Merge branch 'fix/telegram-mcp' into develop

This commit is contained in:
Steven Enamakel
2026-01-30 22:40:00 +05:30
224 changed files with 13173 additions and 3579 deletions
+8 -8
View File
@@ -94,7 +94,7 @@ adb kill-server # Stop ADB server
adb start-server # Restart ADB server
# Force stop app on device:
adb shell am force-stop com.megamind.tauri-app
adb shell am force-stop com.alphahuman.app
# Kill Gradle daemon if stuck:
# macOS/Linux:
@@ -156,10 +156,10 @@ Get-Process | Where-Object {$_.ProcessName -match "tauri|vite|cargo|node"} | Sto
## Build Targets
| Platform | Command | Output |
|----------|---------|--------|
| Windows | `npm run tauri build` | `.msi`, `.exe` |
| macOS | `npm run tauri build` | `.dmg`, `.app` |
| Linux | `npm run tauri build` | `.deb`, `.AppImage` |
| Android | `npm run tauri android build` | `.apk`, `.aab` |
| iOS | `npm run tauri ios build` | `.ipa` |
| Platform | Command | Output |
| -------- | ----------------------------- | ------------------- |
| Windows | `npm run tauri build` | `.msi`, `.exe` |
| macOS | `npm run tauri build` | `.dmg`, `.app` |
| Linux | `npm run tauri build` | `.deb`, `.AppImage` |
| Android | `npm run tauri android build` | `.apk`, `.aab` |
| iOS | `npm run tauri ios build` | `.ipa` |
+157 -129
View File
@@ -1,11 +1,13 @@
# Frontend Development Guide - Crypto Community Platform
## Overview
Frontend development guide for crypto-focused communication platform using modern React ecosystem with Tauri.
## ✅ CURRENT IMPLEMENTATION STATUS
### Design System (FULLY IMPLEMENTED)
- **Glass Morphism UI**: Enhanced frosted glass effects with 16px backdrop blur throughout interface
- **Crypto Price Ticker**: Animated scrolling ticker with BTC/ETH brand colors and JetBrains Mono font
- **Navigation System**: Complete nav bar with active states (Dashboard, Portfolio, Chat, Markets)
@@ -67,6 +69,7 @@ src/
```
### Recent Architecture Changes
- **Settings Modal System**: Complete URL-based modal system with clean white design
- Modal routes: `/settings`, `/settings/connections` overlaying existing content
- Component structure: SettingsModal, SettingsLayout, ConnectionsPanel, hooks
@@ -82,23 +85,23 @@ src/
### Basic Component
```tsx
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
function App() {
const [result, setResult] = useState('');
const [result, setResult] = useState("");
async function handleClick() {
const greeting = await invoke<string>('greet', { name: 'User' });
setResult(greeting);
}
async function handleClick() {
const greeting = await invoke<string>("greet", { name: "User" });
setResult(greeting);
}
return (
<div>
<button onClick={handleClick}>Greet</button>
<p>{result}</p>
</div>
);
return (
<div>
<button onClick={handleClick}>Greet</button>
<p>{result}</p>
</div>
);
}
export default App;
@@ -109,7 +112,7 @@ export default App;
### Window Management
```typescript
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrentWindow } from "@tauri-apps/api/window";
const appWindow = getCurrentWindow();
@@ -123,73 +126,82 @@ await appWindow.maximize();
await appWindow.close();
// Set title
await appWindow.setTitle('New Title');
await appWindow.setTitle("New Title");
```
### File System
First, add the plugin:
```bash
npm run tauri add fs
```
```typescript
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
import {
readTextFile,
writeTextFile,
BaseDirectory,
} from "@tauri-apps/plugin-fs";
// Read file
const content = await readTextFile('config.json', {
baseDir: BaseDirectory.AppData
const content = await readTextFile("config.json", {
baseDir: BaseDirectory.AppData,
});
// Write file
await writeTextFile('config.json', JSON.stringify(data), {
baseDir: BaseDirectory.AppData
await writeTextFile("config.json", JSON.stringify(data), {
baseDir: BaseDirectory.AppData,
});
```
### Dialogs
First, add the plugin:
```bash
npm run tauri add dialog
```
```typescript
import { open, save, message } from '@tauri-apps/plugin-dialog';
import { open, save, message } from "@tauri-apps/plugin-dialog";
// Open file picker
const filePath = await open({
multiple: false,
filters: [{
name: 'Text',
extensions: ['txt', 'md']
}]
multiple: false,
filters: [
{
name: "Text",
extensions: ["txt", "md"],
},
],
});
// Save dialog
const savePath = await save({
defaultPath: 'document.txt'
defaultPath: "document.txt",
});
// Message box
await message('Operation completed!', { title: 'Success' });
await message("Operation completed!", { title: "Success" });
```
### HTTP Requests
First, add the plugin:
```bash
npm run tauri add http
```
```typescript
import { fetch } from '@tauri-apps/plugin-http';
import { fetch } from "@tauri-apps/plugin-http";
const response = await fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
const response = await fetch("https://api.example.com/data", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
@@ -198,26 +210,26 @@ const data = await response.json();
## Platform Detection
```typescript
import { platform } from '@tauri-apps/plugin-os';
import { platform } from "@tauri-apps/plugin-os";
const currentPlatform = await platform();
switch (currentPlatform) {
case 'windows':
// Windows-specific UI
break;
case 'macos':
// macOS-specific UI
break;
case 'linux':
// Linux-specific UI
break;
case 'android':
// Android-specific UI
break;
case 'ios':
// iOS-specific UI
break;
case "windows":
// Windows-specific UI
break;
case "macos":
// macOS-specific UI
break;
case "linux":
// Linux-specific UI
break;
case "android":
// Android-specific UI
break;
case "ios":
// iOS-specific UI
break;
}
```
@@ -226,43 +238,45 @@ switch (currentPlatform) {
```css
/* Base styles for mobile-first */
.container {
padding: 16px;
font-size: 16px;
padding: 16px;
font-size: 16px;
}
/* Tablet and larger */
@media (min-width: 768px) {
.container {
padding: 24px;
max-width: 720px;
margin: 0 auto;
}
.container {
padding: 24px;
max-width: 720px;
margin: 0 auto;
}
}
/* Desktop */
@media (min-width: 1024px) {
.container {
max-width: 960px;
}
.container {
max-width: 960px;
}
}
/* Safe areas for notched devices (iOS) */
.app {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
```
## Recommended Tech Stack
### UI & Styling
- **Tailwind CSS** - Utility-first CSS framework
- **Headless UI** - Accessible, unstyled UI components
- **Framer Motion** - Animation library for React
### State & Data Management (Current Implementation)
- **Redux Toolkit** - Currently implemented state management with persistence
- **Redux Persist** - Persists auth and telegram state to localStorage
- **Socket.io** - Real-time communication via socketService singleton
@@ -273,7 +287,7 @@ switch (currentPlatform) {
```typescript
// Current implementation uses Redux Toolkit with these slices:
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { useAppSelector, useAppDispatch } from "../store/hooks";
// Auth state (persisted)
const authState = useAppSelector((state) => state.auth);
@@ -293,106 +307,120 @@ const telegramState = useAppSelector((state) => state.telegram);
// Usage in component
function ChatHeader() {
const { token } = useAppSelector((state) => state.auth);
const { profile } = useAppSelector((state) => state.user);
const { isConnected } = useAppSelector((state) => state.socket);
const { token } = useAppSelector((state) => state.auth);
const { profile } = useAppSelector((state) => state.user);
const { isConnected } = useAppSelector((state) => state.socket);
return (
<div className="flex items-center justify-between p-4">
<h1>Chat</h1>
<div className="flex items-center gap-2">
<span>{profile?.username}</span>
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-sage-500' : 'bg-coral-500'}`} />
</div>
</div>
);
return (
<div className="flex items-center justify-between p-4">
<h1>Chat</h1>
<div className="flex items-center gap-2">
<span>{profile?.username}</span>
<div
className={`w-2 h-2 rounded-full ${
isConnected ? "bg-sage-500" : "bg-coral-500"
}`}
/>
</div>
</div>
);
}
```
## Form Handling with React Hook Form
```typescript
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const messageSchema = z.object({
content: z.string().min(1, 'Message cannot be empty').max(1000),
channel: z.string().uuid(),
content: z.string().min(1, "Message cannot be empty").max(1000),
channel: z.string().uuid(),
});
type MessageForm = z.infer<typeof messageSchema>;
function MessageInput() {
const { register, handleSubmit, reset, formState: { errors } } = useForm<MessageForm>({
resolver: zodResolver(messageSchema)
});
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<MessageForm>({
resolver: zodResolver(messageSchema),
});
const onSubmit = (data: MessageForm) => {
// Send message via Tauri IPC
invoke('send_message', data);
reset();
};
const onSubmit = (data: MessageForm) => {
// Send message via Tauri IPC
invoke("send_message", data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex gap-2">
<input
{...register('content')}
placeholder="Type a message..."
className="flex-1 p-2 border rounded"
/>
<button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded">
Send
</button>
{errors.content && <p className="text-red-500">{errors.content.message}</p>}
</form>
);
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex gap-2">
<input
{...register("content")}
placeholder="Type a message..."
className="flex-1 p-2 border rounded"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Send
</button>
{errors.content && (
<p className="text-red-500">{errors.content.message}</p>
)}
</form>
);
}
```
## Data Fetching with TanStack Query
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
// Fetch channels
function useChannels() {
return useQuery({
queryKey: ['channels'],
queryFn: () => invoke<Channel[]>('get_channels'),
staleTime: 5 * 60 * 1000, // 5 minutes
});
return useQuery({
queryKey: ["channels"],
queryFn: () => invoke<Channel[]>("get_channels"),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Send message mutation
function useSendMessage() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (message: NewMessage) => invoke('send_message', message),
onSuccess: () => {
// Invalidate and refetch messages
queryClient.invalidateQueries({ queryKey: ['messages'] });
},
});
return useMutation({
mutationFn: (message: NewMessage) => invoke("send_message", message),
onSuccess: () => {
// Invalidate and refetch messages
queryClient.invalidateQueries({ queryKey: ["messages"] });
},
});
}
// Usage in component
function ChannelList() {
const { data: channels, isLoading, error } = useChannels();
const { data: channels, isLoading, error } = useChannels();
if (isLoading) return <div>Loading channels...</div>;
if (error) return <div>Error loading channels</div>;
if (isLoading) return <div>Loading channels...</div>;
if (error) return <div>Error loading channels</div>;
return (
<div className="space-y-2">
{channels?.map(channel => (
<div key={channel.id} className="p-2 hover:bg-gray-100 rounded">
{channel.name}
</div>
))}
return (
<div className="space-y-2">
{channels?.map((channel) => (
<div key={channel.id} className="p-2 hover:bg-gray-100 rounded">
{channel.name}
</div>
);
))}
</div>
);
}
```
+57 -38
View File
@@ -49,9 +49,11 @@ Web Browser Backend Server Desktop App (Tauri
Initiates Telegram OAuth. The frontend opens this URL in the system browser.
**Query params:**
- `platform=desktop` — indicates the callback should produce a deep link handoff
**Behavior:**
1. Redirect user to Telegram OAuth authorization URL
2. On callback, validate Telegram user data
3. Create or find user in database
@@ -63,6 +65,7 @@ Initiates Telegram OAuth. The frontend opens this URL in the system browser.
Called by the web frontend after phone-based authentication completes.
**Request body:**
```json
{
"method": "phone",
@@ -70,15 +73,20 @@ Called by the web frontend after phone-based authentication completes.
"countryCode": "+1"
}
```
or
```json
{
"method": "telegram",
"telegramUser": { /* Telegram user object */ }
"telegramUser": {
/* Telegram user object */
}
}
```
**Response (200):**
```json
{
"loginToken": "short-lived-opaque-token"
@@ -86,6 +94,7 @@ or
```
**Behavior:**
1. Validate the authentication data (verify OTP for phone, verify Telegram hash)
2. Create or find user in database
3. Generate a short-lived `loginToken`
@@ -101,6 +110,7 @@ Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges
**Note:** The endpoint path is `/auth/desktop-exchange` (no `/api` prefix) — this matches the current frontend implementation.
**Request body:**
```json
{
"token": "loginToken-from-web"
@@ -108,6 +118,7 @@ Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges
```
**Response (200):**
```json
{
"sessionToken": "long-lived-session-token",
@@ -120,6 +131,7 @@ Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges
```
**Error response (401):**
```json
{
"success": false,
@@ -128,6 +140,7 @@ Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges
```
**Behavior:**
1. Look up the `loginToken` in the database
2. Validate: not expired, not already used
3. Mark token as used (single-use enforcement)
@@ -137,35 +150,38 @@ Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges
## Database Schema
### `users` table
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | User ID |
| username | TEXT | Display name |
| first_name | TEXT (nullable) | First name |
| phone_number | TEXT (nullable) | Phone number |
| telegram_id | TEXT (nullable) | Telegram user ID |
| created_at | TIMESTAMP | Account creation |
| updated_at | TIMESTAMP | Last update |
| Column | Type | Description |
| ------------ | --------------- | ---------------- |
| id | UUID (PK) | User ID |
| username | TEXT | Display name |
| first_name | TEXT (nullable) | First name |
| phone_number | TEXT (nullable) | Phone number |
| telegram_id | TEXT (nullable) | Telegram user ID |
| created_at | TIMESTAMP | Account creation |
| updated_at | TIMESTAMP | Last update |
### `login_tokens` table (handoff tokens)
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | Token record ID |
| token | TEXT (unique, indexed) | The opaque token string |
| user_id | UUID (FK -> users) | Associated user |
| created_at | TIMESTAMP | When issued |
| expires_at | TIMESTAMP | Expiration (created_at + 5min) |
| used | BOOLEAN | Whether already exchanged |
| Column | Type | Description |
| ---------- | ---------------------- | ------------------------------ |
| id | UUID (PK) | Token record ID |
| token | TEXT (unique, indexed) | The opaque token string |
| user_id | UUID (FK -> users) | Associated user |
| created_at | TIMESTAMP | When issued |
| expires_at | TIMESTAMP | Expiration (created_at + 5min) |
| used | BOOLEAN | Whether already exchanged |
### `sessions` table
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | Session ID |
| token | TEXT (unique, indexed) | Session token |
| user_id | UUID (FK -> users) | Associated user |
| created_at | TIMESTAMP | When issued |
| expires_at | TIMESTAMP | Expiration (created_at + 30 days) |
| revoked | BOOLEAN | Whether revoked |
| Column | Type | Description |
| ---------- | ---------------------- | --------------------------------- |
| id | UUID (PK) | Session ID |
| token | TEXT (unique, indexed) | Session token |
| user_id | UUID (FK -> users) | Associated user |
| created_at | TIMESTAMP | When issued |
| expires_at | TIMESTAMP | Expiration (created_at + 30 days) |
| revoked | BOOLEAN | Whether revoked |
## Token Generation
@@ -195,16 +211,18 @@ async fn exchange_token(backend_url: String, token: String) -> Result<serde_json
```
Frontend invocation:
```typescript
const data = await invoke<{ sessionToken?: string; user?: object }>(
'exchange_token',
{ backendUrl: BACKEND_URL, token }
"exchange_token",
{ backendUrl: BACKEND_URL, token },
);
```
### Deep Link Listener
Located in `src/utils/desktopDeepLinkListener.ts`. Key behaviors:
- Uses **lazy dynamic import** in `main.tsx` to avoid loading before Tauri IPC is ready
- No `window.__TAURI__` guard — the try/catch handles non-Tauri environments
- Uses `localStorage.setItem('deepLinkHandled', 'true')` to prevent infinite reload loops (since `getCurrent()` returns the same URL after `window.location.replace()`)
@@ -213,23 +231,24 @@ Located in `src/utils/desktopDeepLinkListener.ts`. Key behaviors:
### Backend URL Configuration
Configured in `src/utils/config.ts`:
```typescript
export const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || 'https://2937933edf8a.ngrok-free.app';
import.meta.env.VITE_BACKEND_URL || "https://2937933edf8a.ngrok-free.app";
```
Set `VITE_BACKEND_URL` environment variable for different environments.
## Frontend Integration Points
| File | Role |
|------|------|
| `src/main.tsx` | Lazy-imports and starts the deep link listener |
| `src/utils/desktopDeepLinkListener.ts` | Parses deep link -> invokes Rust `exchange_token` -> stores session -> navigates |
| `src/utils/deeplink.ts` | Web-side: calls `/api/auth/web-complete` and builds `alphahuman://auth?token=...` URL |
| `src/utils/config.ts` | Backend URL configuration |
| `src/pages/Login.tsx` | Opens `GET /auth/telegram?platform=desktop` in browser |
| `src-tauri/src/lib.rs` | Rust `exchange_token` command using `reqwest` (CORS-free) |
| File | Role |
| -------------------------------------- | ------------------------------------------------------------------------------------- |
| `src/main.tsx` | Lazy-imports and starts the deep link listener |
| `src/utils/desktopDeepLinkListener.ts` | Parses deep link -> invokes Rust `exchange_token` -> stores session -> navigates |
| `src/utils/deeplink.ts` | Web-side: calls `/api/auth/web-complete` and builds `alphahuman://auth?token=...` URL |
| `src/utils/config.ts` | Backend URL configuration |
| `src/pages/Login.tsx` | Opens `GET /auth/telegram?platform=desktop` in browser |
| `src-tauri/src/lib.rs` | Rust `exchange_token` command using `reqwest` (CORS-free) |
## Phone OTP Flow (Future)
@@ -245,4 +264,4 @@ See `14-deep-link-platform-guide.md` for detailed platform gotchas.
---
*Last updated: 2026-01-28*
_Last updated: 2026-01-28_
+25 -11
View File
@@ -7,6 +7,7 @@ The `alphahuman://` custom URL scheme is used to hand off authentication from a
## Scheme Registration
Configured in `src-tauri/tauri.conf.json`:
```json
{
"plugins": {
@@ -22,16 +23,19 @@ Configured in `src-tauri/tauri.conf.json`:
## macOS
### How It Works
- URL schemes are registered via `Info.plist` inside the `.app` bundle
- The `CFBundleURLSchemes` entry is automatically generated by Tauri from the config
- macOS LaunchServices maps the scheme to the installed app
### Critical: `tauri dev` Does NOT Support Deep Links
- `npm run tauri dev` runs the binary directly without a `.app` bundle
- No `Info.plist` exists, so macOS cannot register the URL scheme
- **You must use `npm run tauri build --debug` and run the `.app` bundle**
### `register_all()` Is Unsupported on macOS
- The Tauri deep-link plugin's `register_all()` method panics on macOS with `unsupported platform`
- Only call it on Windows and Linux:
```rust
@@ -42,6 +46,7 @@ Configured in `src-tauri/tauri.conf.json`:
```
### Build & Install Workflow
```bash
# Build debug .app bundle
npm run tauri build -- --debug --bundles app
@@ -57,6 +62,7 @@ open "alphahuman://auth?token=YOUR_TOKEN"
```
### Cargo Caching Gotcha
- Cargo may not re-embed updated `dist/` frontend assets on incremental builds
- The `tauri-build` build script embeds frontend assets at compile time
- **If the app shows stale UI, run `cargo clean` before rebuilding:**
@@ -66,15 +72,17 @@ open "alphahuman://auth?token=YOUR_TOKEN"
```
### WebKit Cache
- The macOS WebView (WKWebView) caches aggressively
- Clear caches when debugging stale content:
```bash
rm -rf ~/Library/WebKit/com.megamind.tauri-app
rm -rf ~/Library/Caches/com.megamind.tauri-app
rm -rf ~/Library/Application\ Support/com.megamind.tauri-app
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
rm -rf ~/Library/Application\ Support/com.alphahuman.app
```
### Debug Builds: Secondary Instance Behavior
- In debug mode, clicking a deep link while the app is running may briefly spawn a secondary instance
- The `onOpenUrl` event may fire twice (one may be an empty string)
- The `deepLinkHandled` localStorage flag prevents double-processing
@@ -82,11 +90,13 @@ open "alphahuman://auth?token=YOUR_TOKEN"
## Windows & Linux
### How It Works
- URL schemes are registered at runtime via `register_all()` in the `setup` hook
- This works in both dev mode (`tauri dev`) and production builds
- No special build or install steps needed for deep link testing
### Code
```rust
#[cfg(any(windows, target_os = "linux"))]
{
@@ -97,41 +107,47 @@ open "alphahuman://auth?token=YOUR_TOKEN"
## All Platforms
### `window.__TAURI__` Is Not Available Immediately
- The Tauri IPC bridge (`window.__TAURI__`) is injected asynchronously
- Code that runs at module load time (top of `main.tsx`) cannot reliably check `__TAURI__`
- **Solution**: Use dynamic `import()` for the deep link listener and wrap plugin calls in try/catch:
```typescript
// main.tsx
import('./utils/desktopDeepLinkListener').then(m => {
import("./utils/desktopDeepLinkListener").then((m) => {
m.setupDesktopDeepLinkListener().catch(console.error);
});
```
### `alert()` Is Suppressed in Tauri WebView
- `window.alert()` does not display in Tauri's WKWebView on macOS
- For debugging, use visible DOM elements or `console.log()` (viewable via Safari Web Inspector)
### `document.title` Is Overridden by Tauri
- Tauri sets the window title from `tauri.conf.json` `app.windows[].title`
- Setting `document.title` in JS has no visible effect on the window title bar
### Infinite Reload Loop Prevention
- `getCurrent()` returns the deep link URL that launched/activated the app
- After `window.location.replace()`, the page reloads and `getCurrent()` returns the same URL again
- **Solution**: Set `localStorage.deepLinkHandled = 'true'` before navigating, check and clear it on next load
### CORS: Use Rust for Backend Calls
- Browser `fetch()` from the Tauri WebView is subject to CORS
- External APIs (especially ngrok tunnels) typically don't set `Access-Control-Allow-Origin`
- **Solution**: Use a Rust Tauri command with `reqwest` to make HTTP requests (no CORS):
```typescript
// Instead of fetch():
const data = await invoke('exchange_token', { backendUrl, token });
const data = await invoke("exchange_token", { backendUrl, token });
```
## Tauri Plugin Dependencies
### Cargo.toml
```toml
tauri-plugin-deep-link = "2.0.0"
reqwest = { version = "0.12", features = ["json"] }
@@ -139,21 +155,19 @@ tokio = { version = "1", features = ["full"] }
```
### package.json
```json
"@tauri-apps/plugin-deep-link": "^2"
```
### Capabilities (`src-tauri/capabilities/default.json`)
```json
{
"permissions": [
"core:default",
"opener:default",
"deep-link:default"
]
"permissions": ["core:default", "opener:default", "deep-link:default"]
}
```
---
*Last updated: 2026-01-28*
_Last updated: 2026-01-28_
+51 -22
View File
@@ -7,6 +7,7 @@ Complete implementation of macOS background execution features including system
## Features Implemented
### 1. System Tray (Menu Bar App)
- **Tray icon** appears in macOS menu bar
- **Click to toggle** window visibility (left-click on icon)
- **Context menu** with two options:
@@ -16,6 +17,7 @@ Complete implementation of macOS background execution features including system
- **Close button minimizes to tray** instead of quitting app
### 2. Launch at Login (Autostart)
- **LaunchAgent configuration** for macOS
- **Configurable autostart** via plugin API
- **Command-line flags** support for launch arguments
@@ -26,6 +28,7 @@ Complete implementation of macOS background execution features including system
### Dependencies Added
**Cargo.toml**:
```toml
tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
tauri-plugin-autostart = "2"
@@ -34,15 +37,18 @@ tauri-plugin-autostart = "2"
### Configuration Changes
**tauri.conf.json**:
```json
{
"app": {
"windows": [{
"visible": false, // Start hidden
"decorations": true,
"resizable": true,
"center": true
}],
"windows": [
{
"visible": false, // Start hidden
"decorations": true,
"resizable": true,
"center": true
}
],
"trayIcon": {
"id": "main-tray",
"iconPath": "icons/icon.png",
@@ -50,12 +56,13 @@ tauri-plugin-autostart = "2"
"menuOnLeftClick": false,
"tooltip": "Outsourced - Crypto Community Platform"
},
"macOSPrivateApi": true // Required for advanced tray features
"macOSPrivateApi": true // Required for advanced tray features
}
}
```
**capabilities/default.json**:
```json
{
"permissions": [
@@ -78,6 +85,7 @@ tauri-plugin-autostart = "2"
4. **Autostart Plugin** - Configured with LaunchAgent for macOS
**Code Structure** (`src-tauri/src/lib.rs`):
```rust
// System tray with menu
fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
@@ -108,17 +116,20 @@ pub fn run() {
## Platform-Specific Behavior
### macOS
- Tray icon appears in menu bar
- Close button hides window (minimizes to tray)
- LaunchAgent for autostart integration
- Native macOS menu bar styling
### Windows/Linux
- Tray icon in system tray
- Same menu functionality
- Platform-appropriate autostart mechanisms
### Mobile (iOS/Android)
- Tray features disabled (desktop-only)
- Autostart not applicable on mobile
@@ -129,21 +140,22 @@ pub fn run() {
Control autostart via Tauri commands:
```typescript
import { invoke } from '@tauri-apps/api/core';
import { invoke } from "@tauri-apps/api/core";
// Enable autostart
await invoke('plugin:autostart|enable');
await invoke("plugin:autostart|enable");
// Disable autostart
await invoke('plugin:autostart|disable');
await invoke("plugin:autostart|disable");
// Check if enabled
const isEnabled = await invoke<boolean>('plugin:autostart|is_enabled');
const isEnabled = await invoke<boolean>("plugin:autostart|is_enabled");
```
### Tray Behavior
**User Actions**:
1. **Left-click tray icon** → Toggle window visibility
2. **Right-click tray icon** → Open context menu
3. **"Show/Hide Window"** → Toggle visibility
@@ -153,6 +165,7 @@ const isEnabled = await invoke<boolean>('plugin:autostart|is_enabled');
## Testing
### Build & Test
```bash
# Clean build
cargo clean --manifest-path src-tauri/Cargo.toml
@@ -168,6 +181,7 @@ open /Applications/tauri-app.app
```
### Verification Checklist
- [ ] Tray icon appears in menu bar
- [ ] Left-click toggles window visibility
- [ ] Context menu has "Show/Hide Window" and "Quit"
@@ -180,28 +194,35 @@ open /Applications/tauri-app.app
## Build Requirements
### macOS Deep Link & Tray Testing
- Must use `.app` bundle (not `tauri dev`)
- `tauri dev` does NOT support deep links or full tray functionality
- Use debug build: `npm run tauri build -- --debug --bundles app`
### Cargo Cache Issues
If UI appears outdated after rebuild:
```bash
cargo clean --manifest-path src-tauri/Cargo.toml
npm run tauri build -- --debug --bundles app
```
### WebKit Cache
Clear WebKit cache if needed:
```bash
rm -rf ~/Library/WebKit/com.megamind.tauri-app
rm -rf ~/Library/Caches/com.megamind.tauri-app
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
```
## Configuration Options
### Autostart Arguments
Customize launch arguments in `lib.rs`:
```rust
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
@@ -210,26 +231,32 @@ Customize launch arguments in `lib.rs`:
```
### Tray Icon
Change tray icon path in `tauri.conf.json`:
```json
{
"trayIcon": {
"iconPath": "icons/custom-tray-icon.png",
"iconAsTemplate": true // Adapts to dark/light menu bar
"iconAsTemplate": true // Adapts to dark/light menu bar
}
}
```
### Window Behavior
Adjust window settings:
```json
{
"windows": [{
"visible": false, // Start hidden
"decorations": true, // Show title bar
"resizable": true, // Allow resize
"center": true // Center on screen
}]
"windows": [
{
"visible": false, // Start hidden
"decorations": true, // Show title bar
"resizable": true, // Allow resize
"center": true // Center on screen
}
]
}
```
@@ -253,16 +280,18 @@ Potential improvements for future development:
## Security Considerations
### macOS Private API
- Required for advanced tray features
- Approved by Apple for this use case
- No App Store restrictions for direct distribution
### LaunchAgent
- Installed in user's LaunchAgents directory
- User can disable via System Settings
- Respects macOS security policies
---
*Implementation completed: 2026-01-29*
*Status: Fully functional, tested, and production-ready*
_Implementation completed: 2026-01-29_
_Status: Fully functional, tested, and production-ready_
+20 -9
View File
@@ -11,6 +11,7 @@ Cross-platform crypto community communication platform built with **Tauri v2** (
**Design Philosophy**: Premium, sophisticated crypto platform with calm, trustworthy aesthetic.
### Color Palette
- **Primary**: Ocean blue (`#4A83DD`) optimized for dark backgrounds
- **Sage**: Success green (`#4DC46F`) for growth indicators
- **Amber**: Warning (`#E8A838`) for attention states
@@ -19,18 +20,21 @@ Cross-platform crypto community communication platform built with **Tauri v2** (
- **Market Colors**: Bullish green, bearish red, Bitcoin orange, Ethereum purple
### Typography
- **Primary**: Inter (premium font stack)
- **Display**: Cabinet Grotesk for headings
- **Mono**: JetBrains Mono for code
- **Scale**: Sophisticated sizing with negative letter spacing for elegance
### Component System
- **Shadows**: Glow effects, subtle to float depth levels
- **Animations**: Fade-in, slide-in, scale-in with cubic-bezier easing
- **Border Radius**: Smooth system from `xs` (0.25rem) to `5xl` (2rem)
- **Spacing**: Extended scale including custom values (4.5, 13, 15, etc.)
### Current UI State
- Uses HashRouter (not BrowserRouter) as seen in `App.tsx:1`
- 153 TypeScript files total in src/
- Sophisticated Tailwind config with custom color system and animations
@@ -74,6 +78,7 @@ The app wraps in this order: `Redux Provider` → `PersistGate` → `SocketProvi
### State Management (Redux Toolkit + Persist)
State lives in `src/store/` using Redux Toolkit slices:
- **authSlice** — JWT token, onboarding completion flag (persisted)
- **userSlice** — user profile
- **socketSlice** — connection status, socket ID
@@ -97,6 +102,7 @@ Redux Persist stores auth and telegram state (storage backend is configurable; d
### MCP System (`src/lib/mcp/`)
Model Context Protocol implementation for AI tool execution over Socket.io:
- `transport.ts` — Socket.io JSON-RPC 2.0 transport with 30s timeout
- `telegram/server.ts` — TelegramMCPServer manages 99 tool definitions
- `telegram/tools/` — Individual tool files (one per Telegram API operation)
@@ -117,6 +123,7 @@ Model Context Protocol implementation for AI tool execution over Socket.io:
### Deep Link Auth Flow
Web-to-desktop handoff using `alphahuman://` URL scheme:
1. User authenticates in browser
2. Browser redirects to `alphahuman://auth?token=<loginToken>`
3. Tauri catches the deep link, Rust `exchange_token` command calls backend via `reqwest` (bypasses CORS)
@@ -128,6 +135,7 @@ Key file: `src/utils/desktopDeepLinkListener.ts` (lazy-loaded in `main.tsx`). Us
### Rust Backend (`src-tauri/src/lib.rs`)
Minimal — two Tauri commands:
- `greet` — demo command
- `exchange_token` — CORS-free HTTP POST to backend for token exchange
@@ -137,14 +145,14 @@ Deep link plugin registered at setup. `register_all()` called only on Windows/Li
Set in `.env` (Vite exposes `VITE_*` prefixed vars):
| Variable | Purpose |
|----------|---------|
| `VITE_BACKEND_URL` | Backend API URL (default: `http://localhost:5005`) |
| `VITE_TELEGRAM_API_ID` | Telegram MTProto API ID |
| `VITE_TELEGRAM_API_HASH` | Telegram MTProto API hash |
| `VITE_TELEGRAM_BOT_USERNAME` | Telegram bot username |
| `VITE_TELEGRAM_BOT_ID` | Telegram bot numeric ID |
| `VITE_DEBUG` | Debug mode flag |
| Variable | Purpose |
| ---------------------------- | -------------------------------------------------- |
| `VITE_BACKEND_URL` | Backend API URL (default: `http://localhost:5005`) |
| `VITE_TELEGRAM_API_ID` | Telegram MTProto API ID |
| `VITE_TELEGRAM_API_HASH` | Telegram MTProto API hash |
| `VITE_TELEGRAM_BOT_USERNAME` | Telegram bot username |
| `VITE_TELEGRAM_BOT_ID` | Telegram bot numeric ID |
| `VITE_DEBUG` | Debug mode flag |
Production defaults are in `src/utils/config.ts`.
@@ -153,6 +161,7 @@ Production defaults are in `src/utils/config.ts`.
Key updates from recent commits:
### Major Additions
- **Settings Modal System** (`60054d8`): Complete URL-based settings modal with clean white design
- Modal infrastructure with backdrop blur and center positioning
- User profile integration with Redux state management
@@ -166,6 +175,7 @@ Key updates from recent commits:
- **Big Integer Support**: Consistent handling across all Telegram MCP tools (`0abed4d`)
### Design System Updates
- **Settings Modal UI**: Clean 520px white modal contrasting with glass morphism theme
- **Animations**: 200ms entry animations, 250ms panel transitions, chevron hover effects
- **Lottie Animations**: Integrated into onboarding flow (`334673e`)
@@ -174,6 +184,7 @@ Key updates from recent commits:
- **Theme**: Implemented sophisticated color system with premium crypto aesthetic
### Component Structure
- **165+ TypeScript files** across `src/` directory (added settings modal system)
- **Settings Modal System**: Complete modal infrastructure in `src/components/settings/`
- SettingsModal.tsx - Main container with URL routing
@@ -200,6 +211,6 @@ Key updates from recent commits:
## Platform Gotchas
- **macOS deep links**: Require `.app` bundle (not `tauri dev`). Clear WebKit caches when debugging stale content: `rm -rf ~/Library/WebKit/com.megamind.tauri-app ~/Library/Caches/com.megamind.tauri-app`
- **macOS deep links**: Require `.app` bundle (not `tauri dev`). Clear WebKit caches when debugging stale content: `rm -rf ~/Library/WebKit/com.alphahuman.app ~/Library/Caches/com.alphahuman.app`
- **Cargo caching**: May serve stale frontend assets on incremental builds. Run `cargo clean --manifest-path src-tauri/Cargo.toml` if the app shows outdated UI.
- **`window.__TAURI__`**: Not available at module load time. Use dynamic `import()` and try/catch for Tauri plugin calls.
+35 -28
View File
@@ -3,6 +3,7 @@
## Overview
The Tauri Rust backend provides native functionality for the AlphaHuman desktop application:
- System tray with background execution
- Deep link authentication
- Secure session storage (OS Keychain)
@@ -41,6 +42,7 @@ src-tauri/src/
### lib.rs - Application Entry
The main entry point that:
1. Registers Tauri plugins (opener, deep-link, autostart, notification)
2. Sets up system tray with Show/Hide and Quit menu
3. Handles macOS-specific window close behavior (minimize to tray)
@@ -50,34 +52,36 @@ The main entry point that:
Commands are exposed to the frontend via Tauri's IPC:
| Module | Commands | Purpose |
|--------|----------|---------|
| `auth` | `exchange_token`, `get_auth_state`, `logout`, etc. | Authentication |
| `socket` | `socket_connect`, `report_socket_connected`, etc. | Socket state |
| `telegram` | `start_telegram_login` | Telegram OAuth |
| `window` | `show_window`, `hide_window`, `toggle_window`, etc. | Window control |
| Module | Commands | Purpose |
| ---------- | --------------------------------------------------- | -------------- |
| `auth` | `exchange_token`, `get_auth_state`, `logout`, etc. | Authentication |
| `socket` | `socket_connect`, `report_socket_connected`, etc. | Socket state |
| `telegram` | `start_telegram_login` | Telegram OAuth |
| `window` | `show_window`, `hide_window`, `toggle_window`, etc. | Window control |
### Services Layer
Singleton services providing background functionality:
| Service | Purpose | Storage |
|---------|---------|---------|
| `SessionService` | Secure auth token storage | OS Keychain |
| `SocketService` | Socket.io state management | Memory + Events |
| `NotificationService` | Native notifications | N/A |
| Service | Purpose | Storage |
| --------------------- | -------------------------- | --------------- |
| `SessionService` | Secure auth token storage | OS Keychain |
| `SocketService` | Socket.io state management | Memory + Events |
| `NotificationService` | Native notifications | N/A |
## Architecture Decisions
### Socket.io Strategy
The frontend maintains the actual Socket.io connection, while Rust:
1. Stores connection parameters
2. Tracks connection state
3. Emits events to coordinate with frontend
4. Ensures socket stays connected when window is hidden
This approach is necessary because:
- Rust's Socket.io libraries have API compatibility issues
- The WebView maintains state when hidden (unlike browser tabs)
- Frontend JavaScript is better suited for Socket.io's event-driven model
@@ -85,6 +89,7 @@ This approach is necessary because:
### Keychain Storage
Session tokens are stored in the OS keychain for security:
- **macOS**: Keychain
- **Windows**: Credential Manager
- **Linux**: Secret Service
@@ -105,39 +110,41 @@ listen("socket:should_connect", (event) => {
## Plugin Dependencies
| Plugin | Version | Purpose |
|--------|---------|---------|
| `tauri-plugin-opener` | 2 | Open URLs in browser |
| `tauri-plugin-deep-link` | 2.0.0 | Handle `alphahuman://` URLs |
| `tauri-plugin-autostart` | 2 | Launch at login |
| `tauri-plugin-notification` | 2 | Native notifications |
| Plugin | Version | Purpose |
| --------------------------- | ------- | --------------------------- |
| `tauri-plugin-opener` | 2 | Open URLs in browser |
| `tauri-plugin-deep-link` | 2.0.0 | Handle `alphahuman://` URLs |
| `tauri-plugin-autostart` | 2 | Launch at login |
| `tauri-plugin-notification` | 2 | Native notifications |
## Cargo Dependencies
| Crate | Purpose |
|-------|---------|
| `tauri` | Core framework with tray and macOS APIs |
| `serde`, `serde_json` | Serialization |
| `reqwest` | HTTP client |
| `tokio` | Async runtime |
| `keyring` | Secure credential storage |
| `once_cell` | Lazy static singletons |
| `parking_lot` | Fast mutexes |
| `log`, `env_logger` | Logging |
| Crate | Purpose |
| --------------------- | --------------------------------------- |
| `tauri` | Core framework with tray and macOS APIs |
| `serde`, `serde_json` | Serialization |
| `reqwest` | HTTP client |
| `tokio` | Async runtime |
| `keyring` | Secure credential storage |
| `once_cell` | Lazy static singletons |
| `parking_lot` | Fast mutexes |
| `log`, `env_logger` | Logging |
## Platform-Specific Behavior
### macOS
- Window close button hides instead of quitting
- Dock icon click shows window
- LaunchAgent for autostart
- Keychain for secure storage
### Windows/Linux
- Deep link registration at runtime
- Platform-specific credential storage
- Registry (Windows) or desktop file (Linux) autostart
---
*Next: [Commands Reference](./02-commands.md)*
_Next: [Commands Reference](./02-commands.md)_
+78 -48
View File
@@ -5,204 +5,229 @@ This document lists all Tauri commands available to the frontend.
## Authentication Commands
### `exchange_token`
Exchange a login token for a session token.
```typescript
const result = await invoke('exchange_token', {
backendUrl: 'https://api.example.com',
token: 'login-token-from-deep-link'
const result = await invoke("exchange_token", {
backendUrl: "https://api.example.com",
token: "login-token-from-deep-link",
});
// Returns: { sessionToken: string, user: User }
```
### `get_auth_state`
Get current authentication state.
```typescript
const state = await invoke<AuthState>('get_auth_state');
const state = await invoke<AuthState>("get_auth_state");
// Returns: { is_authenticated: boolean, user: User | null }
```
### `get_session_token`
Get the current session token.
```typescript
const token = await invoke<string | null>('get_session_token');
const token = await invoke<string | null>("get_session_token");
```
### `get_current_user`
Get the current authenticated user.
```typescript
const user = await invoke<User | null>('get_current_user');
const user = await invoke<User | null>("get_current_user");
```
### `is_authenticated`
Check if user is authenticated.
```typescript
const isAuth = await invoke<boolean>('is_authenticated');
const isAuth = await invoke<boolean>("is_authenticated");
```
### `logout`
Clear session and disconnect socket.
```typescript
await invoke('logout');
await invoke("logout");
```
### `store_session`
Manually store a session (usually called automatically by `exchange_token`).
```typescript
await invoke('store_session', {
token: 'session-token',
user: { id: '123', firstName: 'John' }
await invoke("store_session", {
token: "session-token",
user: { id: "123", firstName: "John" },
});
```
## Socket Commands
### `socket_connect`
Request frontend to connect to socket server.
```typescript
await invoke('socket_connect', {
backendUrl: 'https://api.example.com',
token: 'session-token'
await invoke("socket_connect", {
backendUrl: "https://api.example.com",
token: "session-token",
});
// Emits "socket:should_connect" event
```
### `socket_disconnect`
Request frontend to disconnect from socket server.
```typescript
await invoke('socket_disconnect');
await invoke("socket_disconnect");
// Emits "socket:should_disconnect" event
```
### `get_socket_state`
Get current socket state.
```typescript
const state = await invoke<SocketState>('get_socket_state');
const state = await invoke<SocketState>("get_socket_state");
// Returns: { status: 'connected' | 'disconnected' | ..., socket_id: string | null, error: string | null }
```
### `is_socket_connected`
Check if socket is connected.
```typescript
const isConnected = await invoke<boolean>('is_socket_connected');
const isConnected = await invoke<boolean>("is_socket_connected");
```
### `report_socket_connected`
Report that socket connected (called by frontend).
```typescript
await invoke('report_socket_connected', { socketId: 'socket-id' });
await invoke("report_socket_connected", { socketId: "socket-id" });
```
### `report_socket_disconnected`
Report that socket disconnected (called by frontend).
```typescript
await invoke('report_socket_disconnected');
await invoke("report_socket_disconnected");
```
### `report_socket_error`
Report socket error (called by frontend).
```typescript
await invoke('report_socket_error', { error: 'Connection failed' });
await invoke("report_socket_error", { error: "Connection failed" });
```
### `update_socket_status`
Update socket status (called by frontend).
```typescript
await invoke('update_socket_status', {
status: 'connected', // 'connected' | 'connecting' | 'disconnected' | 'reconnecting' | 'error'
socketId: 'socket-id'
await invoke("update_socket_status", {
status: "connected", // 'connected' | 'connecting' | 'disconnected' | 'reconnecting' | 'error'
socketId: "socket-id",
});
```
## Telegram Commands
### `start_telegram_login`
Open Telegram login widget in browser.
```typescript
await invoke('start_telegram_login');
await invoke("start_telegram_login");
// Opens ${DEFAULT_BACKEND_URL}/auth/telegram-widget?redirect=alphahuman://auth
```
### `start_telegram_login_with_url`
Open Telegram login widget with custom backend URL.
```typescript
await invoke('start_telegram_login_with_url', {
backendUrl: 'https://custom-backend.com'
await invoke("start_telegram_login_with_url", {
backendUrl: "https://custom-backend.com",
});
```
## Window Commands
### `show_window`
Show and focus the main window.
```typescript
await invoke('show_window');
await invoke("show_window");
```
### `hide_window`
Hide the main window.
```typescript
await invoke('hide_window');
await invoke("hide_window");
```
### `toggle_window`
Toggle window visibility.
```typescript
await invoke('toggle_window');
await invoke("toggle_window");
```
### `is_window_visible`
Check if window is visible.
```typescript
const isVisible = await invoke<boolean>('is_window_visible');
const isVisible = await invoke<boolean>("is_window_visible");
```
### `minimize_window`
Minimize the window.
```typescript
await invoke('minimize_window');
await invoke("minimize_window");
```
### `maximize_window`
Maximize or unmaximize the window.
```typescript
await invoke('maximize_window');
await invoke("maximize_window");
```
### `close_window`
Close the window (minimizes to tray on macOS).
```typescript
await invoke('close_window');
await invoke("close_window");
```
### `set_window_title`
Set the window title.
```typescript
await invoke('set_window_title', { title: 'New Title' });
await invoke("set_window_title", { title: "New Title" });
```
## Events (Rust → Frontend)
@@ -211,29 +236,29 @@ These events are emitted by Rust and can be listened to in the frontend:
### Socket Events
| Event | Payload | Description |
|-------|---------|-------------|
| `socket:connected` | `()` | Socket connected |
| `socket:disconnected` | `()` | Socket disconnected |
| `socket:error` | `string` | Socket error occurred |
| `socket:message` | `{ event: string, data: any }` | Socket message received |
| `socket:state_changed` | `SocketState` | Socket state changed |
| `socket:should_connect` | `{ backendUrl: string, token: string }` | Request to connect |
| `socket:should_disconnect` | `()` | Request to disconnect |
| Event | Payload | Description |
| -------------------------- | --------------------------------------- | ----------------------- |
| `socket:connected` | `()` | Socket connected |
| `socket:disconnected` | `()` | Socket disconnected |
| `socket:error` | `string` | Socket error occurred |
| `socket:message` | `{ event: string, data: any }` | Socket message received |
| `socket:state_changed` | `SocketState` | Socket state changed |
| `socket:should_connect` | `{ backendUrl: string, token: string }` | Request to connect |
| `socket:should_disconnect` | `()` | Request to disconnect |
### Listening to Events
```typescript
import { listen } from '@tauri-apps/api/event';
import { listen } from "@tauri-apps/api/event";
// Listen for socket connection request
await listen('socket:should_connect', (event) => {
await listen("socket:should_connect", (event) => {
const { backendUrl, token } = event.payload;
socketService.connect(backendUrl, token);
});
// Listen for state changes
await listen('socket:state_changed', (event) => {
await listen("socket:state_changed", (event) => {
const state = event.payload as SocketState;
dispatch(setSocketStatus(state.status));
});
@@ -257,7 +282,12 @@ interface User {
}
interface SocketState {
status: 'connected' | 'connecting' | 'disconnected' | 'reconnecting' | 'error';
status:
| "connected"
| "connecting"
| "disconnected"
| "reconnecting"
| "error";
socket_id: string | null;
error?: string;
}
@@ -265,4 +295,4 @@ interface SocketState {
---
*Previous: [Architecture](./01-architecture.md) | Next: [Services](./03-services.md)*
_Previous: [Architecture](./01-architecture.md) | Next: [Services](./03-services.md)_
+47 -19
View File
@@ -6,11 +6,11 @@ This documentation covers the Tauri Rust backend for the AlphaHuman desktop appl
## Quick Reference
| Document | Description |
|----------|-------------|
| [Architecture](./01-architecture.md) | System architecture and module structure |
| [Commands Reference](./02-commands.md) | All Tauri IPC commands |
| [Services](./03-services.md) | Background services documentation |
| Document | Description |
| -------------------------------------- | ---------------------------------------- |
| [Architecture](./01-architecture.md) | System architecture and module structure |
| [Commands Reference](./02-commands.md) | All Tauri IPC commands |
| [Services](./03-services.md) | Background services documentation |
## Features Implemented
@@ -24,6 +24,7 @@ This documentation covers the Tauri Rust backend for the AlphaHuman desktop appl
## Current State Analysis
### Existing Implementation (`lib.rs`)
- ✅ System tray with show/hide/quit
- ✅ Deep link handling (`alphahuman://` scheme)
- ✅ Token exchange command (CORS bypass)
@@ -31,6 +32,7 @@ This documentation covers the Tauri Rust backend for the AlphaHuman desktop appl
- ✅ Window minimize-to-tray on close (macOS)
### Missing Features
- ❌ Socket.io client in Rust (background persistence)
- ❌ Telegram Widget integration
- ❌ Session management in Rust
@@ -74,6 +76,7 @@ src-tauri/src/
**Goal**: Persistent WebSocket connection even when app is in background
**Dependencies to add:**
```toml
[dependencies]
rust_socketio = "0.6" # Socket.io client
@@ -83,6 +86,7 @@ parking_lot = "0.12" # Fast mutexes
```
**Implementation:**
```rust
// services/socket_service.rs
pub struct SocketService {
@@ -100,6 +104,7 @@ impl SocketService {
```
**Background Persistence:**
- Socket runs on Tokio runtime, independent of window state
- Connection survives window hide/minimize
- Auto-reconnect on network recovery
@@ -110,6 +115,7 @@ impl SocketService {
**Goal**: Web-based Telegram auth → Deep link callback → Native session
**Flow:**
```
1. User clicks "Login with Telegram" in desktop app
@@ -134,12 +140,15 @@ impl SocketService {
```
**Backend Endpoint Needed:**
```
GET /auth/telegram-widget?redirect={deeplink_scheme}
```
Returns HTML page with Telegram Login Widget that redirects to the specified scheme.
**Commands to implement:**
```rust
#[tauri::command]
async fn start_telegram_login(app: AppHandle) -> Result<(), String> {
@@ -165,12 +174,14 @@ async fn logout(app: AppHandle) -> Result<(), String> {
**Goal**: Store auth tokens securely using OS keychain
**Dependencies:**
```toml
[dependencies]
keyring = "3" # Cross-platform keychain access
```
**Implementation:**
```rust
// services/session_service.rs
pub struct SessionService {
@@ -178,7 +189,7 @@ pub struct SessionService {
}
impl SessionService {
const SERVICE: &'static str = "com.megamind.tauri-app";
const SERVICE: &'static str = "com.alphahuman.app";
pub fn store_token(&self, token: &str) -> Result<(), Error>;
pub fn get_token(&self) -> Result<Option<String>, Error>;
@@ -187,6 +198,7 @@ impl SessionService {
```
**Platform Support:**
- macOS: Keychain
- Windows: Credential Manager
- Linux: Secret Service (libsecret)
@@ -196,12 +208,14 @@ impl SessionService {
**Goal**: Show notifications even when app is minimized
**Dependencies:**
```toml
[dependencies]
tauri-plugin-notification = "2"
```
**Capability Addition:**
```json
{
"permissions": [
@@ -213,6 +227,7 @@ tauri-plugin-notification = "2"
```
**Usage:**
```rust
// services/notification_service.rs
pub fn show_notification(title: &str, body: &str) -> Result<(), Error> {
@@ -229,6 +244,7 @@ pub fn show_notification(title: &str, body: &str) -> Result<(), Error> {
**Goal**: Bidirectional communication between Rust services and React frontend
**Events from Rust to Frontend:**
```rust
// Emit to frontend when socket receives message
app.emit("socket:message", payload)?;
@@ -238,10 +254,11 @@ app.emit("telegram:notification", notification)?;
```
**Frontend listening:**
```typescript
import { listen } from '@tauri-apps/api/event';
await listen('socket:message', (event) => {
```typescript
import { listen } from "@tauri-apps/api/event";
await listen("socket:message", (event) => {
// Handle message from Rust socket service
});
```
@@ -251,12 +268,14 @@ await listen('socket:message', (event) => {
**Goal**: Run MCP tools from Rust for performance-critical operations
**Approach:**
- Keep MCP tools in TypeScript for flexibility
- Rust handles socket transport
- Frontend dispatches tool calls
- Rust forwards via socket, returns results
**Alternative (Full Rust MCP):**
- Implement tool handlers in Rust
- Higher performance, but more maintenance
- Consider for v2
@@ -265,15 +284,15 @@ await listen('socket:message', (event) => {
## Implementation Order
| Phase | Priority | Effort | Dependencies |
|-------|----------|--------|--------------|
| 1. Project Structure | High | 2h | None |
| 2. Socket.io Service | High | 4h | Phase 1 |
| 3. Telegram Widget Login | High | 3h | Backend endpoint |
| 4. Secure Storage | High | 2h | Phase 1 |
| 5. Notifications | Medium | 1h | Phase 2 |
| 6. Event Bridge | High | 2h | Phase 2 |
| 7. MCP Integration | Low | 4h+ | Phase 2, 6 |
| Phase | Priority | Effort | Dependencies |
| ------------------------ | -------- | ------ | ---------------- |
| 1. Project Structure | High | 2h | None |
| 2. Socket.io Service | High | 4h | Phase 1 |
| 3. Telegram Widget Login | High | 3h | Backend endpoint |
| 4. Secure Storage | High | 2h | Phase 1 |
| 5. Notifications | Medium | 1h | Phase 2 |
| 6. Event Bridge | High | 2h | Phase 2 |
| 7. MCP Integration | Low | 4h+ | Phase 2, 6 |
**Total Estimated Effort**: 18+ hours
@@ -282,6 +301,7 @@ await listen('socket:message', (event) => {
## Cross-Platform Considerations
### macOS
- ✅ System tray (menu bar)
- ✅ LaunchAgent autostart
- ✅ Keychain storage
@@ -289,6 +309,7 @@ await listen('socket:message', (event) => {
- ⚠️ Notarization for distribution
### Windows
- ✅ System tray
- ✅ Registry autostart
- ✅ Credential Manager storage
@@ -296,12 +317,14 @@ await listen('socket:message', (event) => {
- ⚠️ Code signing for SmartScreen
### Linux
- ✅ System tray (AppIndicator)
- ✅ Desktop file autostart
- ✅ Secret Service storage
- ⚠️ Deep link varies by desktop environment
### Mobile (Future)
- ❌ No system tray
- ❌ Different auth flow
- ❌ Push notifications instead of socket
@@ -312,6 +335,7 @@ await listen('socket:message', (event) => {
## Testing Strategy
### Unit Tests
```rust
#[cfg(test)]
mod tests {
@@ -324,11 +348,13 @@ mod tests {
```
### Integration Tests
- Deep link flow end-to-end
- Socket reconnection scenarios
- Background persistence verification
### Manual Testing
- Build debug `.app` bundle
- Test tray behavior
- Test window minimize/restore
@@ -339,6 +365,7 @@ mod tests {
## Files to Create/Modify
### New Files
- `src-tauri/src/commands/mod.rs`
- `src-tauri/src/commands/auth.rs`
- `src-tauri/src/commands/socket.rs`
@@ -354,6 +381,7 @@ mod tests {
- `src-tauri/src/utils/config.rs`
### Modified Files
- `src-tauri/Cargo.toml` - Add dependencies
- `src-tauri/src/lib.rs` - Refactor, use modules
- `src-tauri/capabilities/default.json` - Add permissions
@@ -371,4 +399,4 @@ mod tests {
---
*Plan created by stevenbaba - 2026-01-29*
_Plan created by stevenbaba - 2026-01-29_
+22 -9
View File
@@ -3,6 +3,7 @@
## System Architecture
The Outsourced platform is built on a layered architecture supporting:
- Redux-based state management with persistence
- Socket.io real-time communication
- Telegram MTProto integration via service layer
@@ -14,12 +15,12 @@ The Outsourced platform is built on a layered architecture supporting:
## Entry Points
| File | Purpose |
|------|---------|
| `main.tsx` | React root, polyfill imports, lazy deep link listener init |
| `App.tsx` | Provider chain: Redux → PersistGate → Socket → Telegram → HashRouter |
| `AppRoutes.tsx` | Route definitions with route guards |
| `polyfills.ts` | Node.js polyfills (Buffer, process, util) for telegram npm package |
| File | Purpose |
| --------------- | -------------------------------------------------------------------- |
| `main.tsx` | React root, polyfill imports, lazy deep link listener init |
| `App.tsx` | Provider chain: Redux → PersistGate → Socket → Telegram → HashRouter |
| `AppRoutes.tsx` | Route definitions with route guards |
| `polyfills.ts` | Node.js polyfills (Buffer, process, util) for telegram npm package |
## Provider Chain
@@ -36,6 +37,7 @@ Redux Provider
```
**Why this order matters:**
1. Redux must be outermost for state access
2. PersistGate rehydrates persisted state before rendering
3. SocketProvider depends on Redux auth token
@@ -101,6 +103,7 @@ MCP System:
## Data Flow
### Authentication Flow (Deep Link)
1. User authenticates in browser → receives `loginToken`
2. Browser redirects to `alphahuman://auth?token=<loginToken>`
3. Desktop app catches deep link via Tauri plugin
@@ -110,6 +113,7 @@ MCP System:
7. App stores session in Redux, navigates to `/onboarding` or `/home`
### Socket.io Connection Flow
1. SocketProvider detects `auth.token` change
2. Calls `socketService.connect(token)`
3. On successful connection, initializes MCP server
@@ -117,6 +121,7 @@ MCP System:
5. Backend can invoke tools via JSON-RPC over Socket.io
### Telegram Connection Flow
1. TelegramProvider detects auth state
2. Calls `mtprotoService.initialize(userId)` and `connect()` in parallel
3. MTProto client authenticates with Telegram servers
@@ -126,50 +131,58 @@ MCP System:
## Key Patterns
### No localStorage Usage
- **Rule**: Avoid `localStorage`; use Redux with persistence instead
- **Exceptions**: Redux-persist's storage adapter (persistence layer)
- **Telegram session**: Stored in `telegram.byUser[userId].sessionString`
### Route Guard Pattern
- **PublicRoute**: Redirects authenticated users away
- **ProtectedRoute**: Requires `token` and optionally `isOnboarded`
- **DefaultRedirect**: Fallback based on auth state
### Settings Modal Pattern
- Renders via `createPortal` when `location.pathname.startsWith('/settings')`
- URL-based navigation without affecting main route
- Uses `useSettingsNavigation` hook
### MCP Tool Pattern
Each tool exports a handler:
```typescript
export const toolName: TelegramMCPToolHandler = {
call: async (args, { telegramClient, userId }) => {
// Perform Telegram API operation
return { success: true, data: result };
}
}
},
};
```
## File Organization
### Feature-Based Structure
- `pages/` - Full-page route components
- `pages/onboarding/` - Onboarding flow with steps
- `components/settings/` - Settings modal system
- `lib/mcp/telegram/tools/` - Individual MCP tools
### Slice-Based State
- `store/authSlice.ts` - Authentication
- `store/socketSlice.ts` - Socket connection
- `store/userSlice.ts` - User profile
- `store/telegram/` - Complex Telegram state (5 files)
### Singleton Services
- `services/socketService.ts` - Socket.io
- `services/mtprotoService.ts` - Telegram MTProto
- `services/apiClient.ts` - HTTP REST
---
*Next: [State Management](./02-state-management.md)*
_Next: [State Management](./02-state-management.md)_
+38 -26
View File
@@ -58,12 +58,12 @@ Redirects authenticated users away from public pages.
export function PublicRoute() {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId)
selectIsOnboarded(state, userId),
);
if (token) {
// Authenticated - redirect to appropriate page
return <Navigate to={isOnboarded ? '/home' : '/onboarding'} replace />;
return <Navigate to={isOnboarded ? "/home" : "/onboarding"} replace />;
}
return <Outlet />;
@@ -82,7 +82,7 @@ interface ProtectedRouteProps {
export function ProtectedRoute({ requireOnboarded = false }) {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId)
selectIsOnboarded(state, userId),
);
if (!token) {
@@ -105,7 +105,7 @@ Fallback route that redirects based on auth state.
export function DefaultRedirect() {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId)
selectIsOnboarded(state, userId),
);
if (!token) {
@@ -127,6 +127,7 @@ export function DefaultRedirect() {
Landing page for unauthenticated users.
**Features:**
- App introduction and branding
- CTA to login/signup
- Public route (redirects if authenticated)
@@ -136,6 +137,7 @@ Landing page for unauthenticated users.
Authentication page.
**Features:**
- Telegram OAuth button
- Opens `/auth/telegram?platform=desktop` in browser
- Handles deep link callback
@@ -160,6 +162,7 @@ export function Login() {
Main dashboard after authentication.
**Features:**
- Protected route (requires auth + onboarded)
- Connection status indicators
- Navigation to settings modal
@@ -170,14 +173,14 @@ export function Home() {
const navigate = useNavigate();
const user = useAppSelector((state) => state.user.profile);
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, user?.id)
selectTelegramConnectionStatus(state, user?.id),
);
return (
<div className="home-page">
<header>
<h1>Welcome, {user?.firstName}</h1>
<button onClick={() => navigate('/settings')}>Settings</button>
<button onClick={() => navigate("/settings")}>Settings</button>
</header>
<TelegramConnectionIndicator status={telegramStatus} />
@@ -194,6 +197,7 @@ export function Home() {
Multi-step onboarding process.
### Structure
```
pages/onboarding/
├── Onboarding.tsx # Flow controller
@@ -209,11 +213,11 @@ pages/onboarding/
```typescript
const STEPS = [
{ id: 'get-started', component: GetStartedStep },
{ id: 'privacy', component: PrivacyStep },
{ id: 'analytics', component: AnalyticsStep },
{ id: 'connect', component: ConnectStep },
{ id: 'features', component: FeaturesStep }
{ id: "get-started", component: GetStartedStep },
{ id: "privacy", component: PrivacyStep },
{ id: "analytics", component: AnalyticsStep },
{ id: "connect", component: ConnectStep },
{ id: "features", component: FeaturesStep },
];
export function Onboarding() {
@@ -227,7 +231,7 @@ export function Onboarding() {
} else {
// Complete onboarding
dispatch(setOnboarded({ userId, isOnboarded: true }));
navigate('/home');
navigate("/home");
}
};
@@ -270,7 +274,7 @@ export function ConnectStep({ onNext, onBack }: StepProps) {
<ConnectionOption
key={option.id}
{...option}
onClick={() => option.id === 'telegram' && setShowModal(true)}
onClick={() => option.id === "telegram" && setShowModal(true)}
/>
))}
@@ -293,13 +297,15 @@ export function ConnectStep({ onNext, onBack }: StepProps) {
The settings modal overlays existing content using URL-based routing.
### Modal Detection
```typescript
// In SettingsModal.tsx
const location = useLocation();
const isOpen = location.pathname.startsWith('/settings');
const isOpen = location.pathname.startsWith("/settings");
```
### Sub-Routes
```
/settings → SettingsHome (main menu)
/settings/connections → ConnectionsPanel
@@ -311,8 +317,9 @@ const isOpen = location.pathname.startsWith('/settings');
```
### Navigation
```typescript
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
import { useSettingsNavigation } from "./hooks/useSettingsNavigation";
function SettingsHome() {
const { navigateTo, closeModal } = useSettingsNavigation();
@@ -321,7 +328,7 @@ function SettingsHome() {
<div>
<SettingsMenuItem
label="Connections"
onClick={() => navigateTo('connections')}
onClick={() => navigateTo("connections")}
/>
<button onClick={closeModal}>Close</button>
</div>
@@ -335,13 +342,14 @@ The app uses HashRouter for desktop compatibility:
```typescript
// App.tsx
import { HashRouter } from 'react-router-dom';
import { HashRouter } from "react-router-dom";
// URLs look like: app://localhost/#/home
// Instead of: app://localhost/home
```
**Why HashRouter:**
1. Tauri deep links work with hash-based URLs
2. No server configuration needed
3. Works with file:// protocol
@@ -353,12 +361,13 @@ Deep links are handled before routing:
```typescript
// main.tsx
import('./utils/desktopDeepLinkListener').then((m) => {
import("./utils/desktopDeepLinkListener").then((m) => {
m.setupDesktopDeepLinkListener().catch(console.error);
});
```
The listener intercepts `alphahuman://auth?token=...` and:
1. Exchanges token via Rust command
2. Stores session in Redux
3. Navigates to `/onboarding` or `/home`
@@ -366,32 +375,35 @@ The listener intercepts `alphahuman://auth?token=...` and:
## Navigation Patterns
### Programmatic Navigation
```typescript
import { useNavigate } from 'react-router-dom';
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
// Navigate to route
navigate('/home');
navigate("/home");
// Replace history entry
navigate('/login', { replace: true });
navigate("/login", { replace: true });
// Go back
navigate(-1);
```
### Link Component
```typescript
import { Link } from 'react-router-dom';
<Link to="/settings">Settings</Link>
```typescript
import { Link } from "react-router-dom";
<Link to="/settings">Settings</Link>;
```
### State Transfer
```typescript
// Pass state to route
navigate('/details', { state: { itemId: 123 } });
navigate("/details", { state: { itemId: 123 } });
// Receive state
const location = useLocation();
@@ -400,4 +412,4 @@ const { itemId } = location.state;
---
*Previous: [MCP System](./04-mcp-system.md) | Next: [Components](./06-components.md)*
_Previous: [MCP System](./04-mcp-system.md) | Next: [Components](./06-components.md)_
+64 -67
View File
@@ -24,21 +24,21 @@ function useSocket(): UseSocketReturn;
**Usage:**
```typescript
import { useSocket } from '../hooks/useSocket';
import { useSocket } from "../hooks/useSocket";
function ChatInput() {
const { emit, isConnected } = useSocket();
const sendMessage = (text: string) => {
if (isConnected) {
emit('chat:message', { text });
emit("chat:message", { text });
}
};
return (
<input
disabled={!isConnected}
onKeyDown={(e) => e.key === 'Enter' && sendMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage(e.target.value)}
/>
);
}
@@ -56,8 +56,8 @@ function Notifications() {
setNotifications((prev) => [...prev, notification]);
};
on('notification', handler);
return () => off('notification', handler);
on("notification", handler);
return () => off("notification", handler);
}, [on, off]);
return <NotificationList items={notifications} />;
@@ -82,7 +82,7 @@ function useUser(): UseUserReturn;
**Usage:**
```typescript
import { useUser } from '../hooks/useUser';
import { useUser } from "../hooks/useUser";
function ProfileHeader() {
const { user, loading, error, refetch } = useUser();
@@ -94,7 +94,9 @@ function ProfileHeader() {
return (
<div className="profile">
<Avatar src={user.avatar} />
<span>{user.firstName} {user.lastName}</span>
<span>
{user.firstName} {user.lastName}
</span>
</div>
);
}
@@ -108,10 +110,10 @@ URL-based navigation for settings modal.
```typescript
interface UseSettingsNavigationReturn {
currentRoute: string; // Current settings path
navigateTo: (panel: string) => void; // Navigate to panel
navigateBack: () => void; // Go back one level
closeModal: () => void; // Close settings entirely
currentRoute: string; // Current settings path
navigateTo: (panel: string) => void; // Navigate to panel
navigateBack: () => void; // Go back one level
closeModal: () => void; // Close settings entirely
}
function useSettingsNavigation(): UseSettingsNavigationReturn;
@@ -120,19 +122,15 @@ function useSettingsNavigation(): UseSettingsNavigationReturn;
**Usage:**
```typescript
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
import { useSettingsNavigation } from "./hooks/useSettingsNavigation";
function SettingsMenu() {
const { navigateTo, closeModal } = useSettingsNavigation();
return (
<nav>
<button onClick={() => navigateTo('connections')}>
Connections
</button>
<button onClick={() => navigateTo('privacy')}>
Privacy
</button>
<button onClick={() => navigateTo("connections")}>Connections</button>
<button onClick={() => navigateTo("privacy")}>Privacy</button>
<button onClick={closeModal}>Close</button>
</nav>
);
@@ -145,9 +143,9 @@ Animation state management for settings modal.
```typescript
interface UseSettingsAnimationReturn {
isEntering: boolean; // Modal is animating in
isExiting: boolean; // Modal is animating out
animationClass: string; // CSS class for current state
isEntering: boolean; // Modal is animating in
isExiting: boolean; // Modal is animating out
animationClass: string; // CSS class for current state
}
function useSettingsAnimation(): UseSettingsAnimationReturn;
@@ -156,16 +154,12 @@ function useSettingsAnimation(): UseSettingsAnimationReturn;
**Usage:**
```typescript
import { useSettingsAnimation } from './hooks/useSettingsAnimation';
import { useSettingsAnimation } from "./hooks/useSettingsAnimation";
function SettingsModal() {
const { animationClass, isExiting } = useSettingsAnimation();
return (
<div className={`modal ${animationClass}`}>
{/* Content */}
</div>
);
return <div className={`modal ${animationClass}`}>{/* Content */}</div>;
}
```
@@ -178,7 +172,7 @@ Environment variable access with defaults.
```typescript
// Backend URL
export const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || 'https://api.example.com';
import.meta.env.VITE_BACKEND_URL || "https://api.example.com";
// Telegram configuration
export const TELEGRAM_API_ID = import.meta.env.VITE_TELEGRAM_API_ID;
@@ -187,18 +181,18 @@ export const TELEGRAM_BOT_USERNAME = import.meta.env.VITE_TELEGRAM_BOT_USERNAME;
export const TELEGRAM_BOT_ID = import.meta.env.VITE_TELEGRAM_BOT_ID;
// Debug mode
export const DEBUG = import.meta.env.VITE_DEBUG === 'true';
export const DEBUG = import.meta.env.VITE_DEBUG === "true";
```
**Usage:**
```typescript
import { BACKEND_URL, DEBUG } from '../utils/config';
import { BACKEND_URL, DEBUG } from "../utils/config";
const response = await fetch(`${BACKEND_URL}/api/users`);
if (DEBUG) {
console.log('Response:', response);
console.log("Response:", response);
}
```
@@ -217,7 +211,7 @@ function parseDeepLink(url: string): { path: string; params: URLSearchParams };
**Usage:**
```typescript
import { buildAuthDeepLink } from '../utils/deeplink';
import { buildAuthDeepLink } from "../utils/deeplink";
// Build URL for browser redirect
const deepLink = buildAuthDeepLink(loginToken);
@@ -240,12 +234,13 @@ async function setupDesktopDeepLinkListener(): Promise<void>;
```typescript
// Lazy import to ensure Tauri IPC is ready
import('./utils/desktopDeepLinkListener').then((m) => {
import("./utils/desktopDeepLinkListener").then((m) => {
m.setupDesktopDeepLinkListener().catch(console.error);
});
```
**What it does:**
1. Listens for `onOpenUrl` events from Tauri deep-link plugin
2. Parses `alphahuman://auth?token=...` URLs
3. Calls Rust `exchange_token` command (bypasses CORS)
@@ -253,14 +248,15 @@ import('./utils/desktopDeepLinkListener').then((m) => {
5. Navigates to `/onboarding` or `/home`
**Loop prevention:**
```typescript
// Set flag before navigation to prevent reprocessing
localStorage.setItem('deepLinkHandled', 'true');
window.location.replace('/');
localStorage.setItem("deepLinkHandled", "true");
window.location.replace("/");
// On next load, clear flag
if (localStorage.getItem('deepLinkHandled') === 'true') {
localStorage.removeItem('deepLinkHandled');
if (localStorage.getItem("deepLinkHandled") === "true") {
localStorage.removeItem("deepLinkHandled");
return; // Don't process again
}
```
@@ -277,22 +273,23 @@ async function openUrl(url: string): Promise<void>;
**Usage:**
```typescript
import { openUrl } from '../utils/openUrl';
import { openUrl } from "../utils/openUrl";
// Opens in system browser (not in-app WebView)
await openUrl('https://telegram.org/auth');
await openUrl("https://telegram.org/auth");
```
**Implementation:**
```typescript
export async function openUrl(url: string): Promise<void> {
try {
// Try Tauri opener plugin first
const { open } = await import('@tauri-apps/plugin-opener');
const { open } = await import("@tauri-apps/plugin-opener");
await open(url);
} catch {
// Fallback to browser API
window.open(url, '_blank');
window.open(url, "_blank");
}
}
```
@@ -305,9 +302,9 @@ The `telegram` npm package requires Node.js APIs. These are polyfilled:
```typescript
// polyfills.ts
import { Buffer } from 'buffer';
import process from 'process';
import util from 'util';
import { Buffer } from "buffer";
import process from "process";
import util from "util";
window.Buffer = Buffer;
window.process = process;
@@ -318,7 +315,7 @@ window.util = util;
```typescript
// main.tsx
import './polyfills';
import "./polyfills";
// ... rest of app
```
@@ -329,15 +326,15 @@ import './polyfills';
export default defineConfig({
resolve: {
alias: {
buffer: 'buffer',
process: 'process/browser',
util: 'util'
}
buffer: "buffer",
process: "process/browser",
util: "util",
},
},
define: {
'process.env': {},
global: 'globalThis'
}
"process.env": {},
global: "globalThis",
},
});
```
@@ -410,10 +407,10 @@ Country list for phone number input.
```typescript
interface Country {
code: string; // "US"
name: string; // "United States"
dialCode: string; // "+1"
flag: string; // "🇺🇸"
code: string; // "US"
name: string; // "United States"
dialCode: string; // "+1"
flag: string; // "🇺🇸"
}
export const countries: Country[];
@@ -422,7 +419,7 @@ export const countries: Country[];
**Usage:**
```typescript
import { countries } from '../data/countries';
import { countries } from "../data/countries";
function PhoneInput() {
const [country, setCountry] = useState(countries[0]);
@@ -431,9 +428,9 @@ function PhoneInput() {
<div>
<select
value={country.code}
onChange={(e) => setCountry(
countries.find((c) => c.code === e.target.value)
)}
onChange={(e) =>
setCountry(countries.find((c) => c.code === e.target.value))
}
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
@@ -456,14 +453,14 @@ Always include dependencies in useEffect:
```typescript
// Good
useEffect(() => {
on('event', handler);
return () => off('event', handler);
on("event", handler);
return () => off("event", handler);
}, [on, off, handler]);
// Bad - missing dependencies
useEffect(() => {
on('event', handler);
return () => off('event', handler);
on("event", handler);
return () => off("event", handler);
}, []);
```
@@ -486,7 +483,7 @@ Wrap utility calls in try-catch:
try {
await openUrl(url);
} catch (error) {
console.error('Failed to open URL:', error);
console.error("Failed to open URL:", error);
// Fallback behavior
}
```
@@ -496,10 +493,10 @@ try {
Use TypeScript generics for API calls:
```typescript
const user = await apiClient.get<User>('/users/me');
const user = await apiClient.get<User>("/users/me");
// user is typed as User
```
---
*Previous: [Providers](./07-providers.md) | [Back to Index](./README.md)*
_Previous: [Providers](./07-providers.md) | [Back to Index](./README.md)_
+18 -10
View File
@@ -33,7 +33,7 @@ The backend base URL is configured here (`src/utils/config.ts`):
```ts
export const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || 'https://2937933edf8a.ngrok-free.app';
import.meta.env.VITE_BACKEND_URL || "https://2937933edf8a.ngrok-free.app";
```
### 2) Deep link registration (Tauri config + Rust)
@@ -77,14 +77,15 @@ pub fn run() {
The listener is lazy-loaded in `src/main.tsx`:
```ts
import('./utils/desktopDeepLinkListener').then(m => {
m.setupDesktopDeepLinkListener().catch(err => {
console.error('[DeepLink] setup error:', err);
import("./utils/desktopDeepLinkListener").then((m) => {
m.setupDesktopDeepLinkListener().catch((err) => {
console.error("[DeepLink] setup error:", err);
});
});
```
The deep link handler:
- Accepts only the `alphahuman:` scheme
- Requires `alphahuman://auth?token=...`
- Calls the Rust command `exchange_token`
@@ -98,17 +99,25 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
try {
const parsed = new URL(url);
<<<<<<< HEAD
if (parsed.protocol !== 'alphahuman:') return;
if (parsed.hostname !== 'auth') return;
=======
if (parsed.protocol !== "outsourced:") return;
if (parsed.hostname !== "auth") return;
>>>>>>> fix/telegram-mcp
const token = parsed.searchParams.get('token');
const token = parsed.searchParams.get("token");
if (!token) return;
const data = await invoke('exchange_token', { backendUrl: BACKEND_URL, token });
const data = await invoke("exchange_token", {
backendUrl: BACKEND_URL,
token,
});
// ... store sessionToken + user ...
window.location.hash = '/onboarding';
window.location.hash = "/onboarding";
} catch (error) {
console.error('[DeepLink] Failed to handle deep link URL:', url, error);
console.error("[DeepLink] Failed to handle deep link URL:", url, error);
}
};
```
@@ -200,8 +209,7 @@ Your backend must implement **both**:
- today it checks only `parsed.protocol === 'alphahuman:'`
- recommended: also require `parsed.hostname === 'auth'` (and optionally a known path)
- **Dont skip Telegram auth in onboarding**:
- `src/pages/onboarding/Step1Phone.tsx` currently has a “Continue with Telegram” button that *only navigates* and does not authenticate.
- `src/pages/onboarding/Step1Phone.tsx` currently has a “Continue with Telegram” button that _only navigates_ and does not authenticate.
- **Remove sensitive Telegram secrets from frontend env**:
- any `VITE_*` variables are bundled into the frontend; dont place bot tokens / api hashes there.
- the desktop app typically only needs `VITE_BACKEND_URL`; Telegram verification secrets should live on the backend.
+520 -1
View File
@@ -38,12 +38,14 @@
"@types/react-dom": "^19.1.6",
"@types/redux-logger": "^3.0.13",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^4.0.18",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vite-plugin-node-polyfills": "^0.25.0"
"vite-plugin-node-polyfills": "^0.25.0",
"vitest": "^4.0.18"
}
},
"node_modules/@alloc/quick-lru": {
@@ -341,6 +343,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@cryptography/aes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@cryptography/aes/-/aes-0.1.1.tgz",
@@ -1640,6 +1652,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1740,6 +1770,158 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
"integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.18",
"ast-v8-to-istanbul": "^0.3.10",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.18",
"vitest": "4.0.18"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1814,6 +1996,45 @@
"util": "^0.12.5"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
"integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/async-mutex": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
@@ -2267,6 +2488,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2747,6 +2978,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -2904,6 +3142,16 @@
"safe-buffer": "^5.1.1"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
@@ -3141,6 +3389,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -3229,6 +3487,13 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
@@ -3491,6 +3756,45 @@
"node": ">=10"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3599,6 +3903,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
"integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3942,6 +4287,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/os-browserify": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
@@ -4026,6 +4382,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pbkdf2": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz",
@@ -4951,6 +5314,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/slide": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz",
@@ -5022,6 +5392,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/store2": {
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz",
@@ -5085,6 +5469,19 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -5222,6 +5619,23 @@
"node": ">=0.6.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5239,6 +5653,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-buffer": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -5526,6 +5950,84 @@
"vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vm-browserify": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
@@ -5586,6 +6088,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/write-file-atomic": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz",
+7 -2
View File
@@ -10,7 +10,10 @@
"tauri": "tauri",
"macos:build": "tauri build --debug --bundles app",
"macos:run": "open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"macos:dev": "tauri build --debug --bundles app && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'"
"macos:dev": "tauri build --debug --bundles app && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@reduxjs/toolkit": "^2.11.2",
@@ -43,11 +46,13 @@
"@types/react-dom": "^19.1.6",
"@types/redux-logger": "^3.0.13",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^4.0.18",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vite-plugin-node-polyfills": "^0.25.0"
"vite-plugin-node-polyfills": "^0.25.0",
"vitest": "^4.0.18"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
name: tauri-app
options:
bundleIdPrefix: com.megamind.tauri-app
bundleIdPrefix: com.alphahuman.app
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
@@ -11,7 +11,7 @@ settingGroups:
app:
base:
PRODUCT_NAME: tauri-app
PRODUCT_BUNDLE_IDENTIFIER: com.megamind.tauri-app
PRODUCT_BUNDLE_IDENTIFIER: com.alphahuman.app
targetTemplates:
app:
type: application
@@ -63,7 +63,7 @@ targets:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
@@ -85,4 +85,4 @@ targets:
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
@@ -273,7 +273,7 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = "com.megamind.tauri-app";
PRODUCT_BUNDLE_IDENTIFIER = "com.alphahuman.app";
PRODUCT_NAME = "tauri-app";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -423,7 +423,7 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = "com.megamind.tauri-app";
PRODUCT_BUNDLE_IDENTIFIER = "com.alphahuman.app";
PRODUCT_NAME = "tauri-app";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
+2 -12
View File
@@ -6,10 +6,10 @@
use std::env;
/// Default backend URL (can be overridden via BACKEND_URL env var)
pub const DEFAULT_BACKEND_URL: &str = "https://api.alphahuman.io";
pub const DEFAULT_BACKEND_URL: &str = "https://api.alphahuman.xyz";
/// Application identifier for keychain storage
pub const APP_IDENTIFIER: &str = "com.megamind.tauri-app";
pub const APP_IDENTIFIER: &str = "com.alphahuman.app";
/// Service name for keychain
pub const KEYCHAIN_SERVICE: &str = "AlphaHuman";
@@ -17,11 +17,6 @@ pub const KEYCHAIN_SERVICE: &str = "AlphaHuman";
/// Deep link scheme
pub const DEEP_LINK_SCHEME: &str = "alphahuman";
/// Socket.io reconnection settings
pub const SOCKET_RECONNECT_ATTEMPTS: u32 = 5;
pub const SOCKET_RECONNECT_DELAY_MS: u64 = 1000;
pub const SOCKET_PING_INTERVAL_MS: u64 = 25000;
/// Get the backend URL from environment or use default
pub fn get_backend_url() -> String {
env::var("BACKEND_URL").unwrap_or_else(|_| DEFAULT_BACKEND_URL.to_string())
@@ -31,8 +26,3 @@ pub fn get_backend_url() -> String {
pub fn get_telegram_widget_url() -> String {
format!("{}/auth/telegram-widget?redirect={}://auth", get_backend_url(), DEEP_LINK_SCHEME)
}
/// Get the token exchange endpoint URL
pub fn get_token_exchange_url() -> String {
format!("{}/auth/desktop-exchange", get_backend_url())
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "AlphaHuman",
"version": "0.1.0",
"identifier": "com.megamind.tauri-app",
"identifier": "com.alphahuman.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
+27
View File
@@ -0,0 +1,27 @@
/**
* Factory builder for TelegramMCPContext (used by tool handlers).
*/
import { vi } from 'vitest';
import type { TelegramMCPContext } from "../../lib/mcp/telegram/types";
import type { TelegramState } from "../../store/telegram/types";
import { initialState } from "../../store/telegram/types";
function createMockTransport() {
return {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
updateSocket: vi.fn(),
connected: true,
} as unknown as TelegramMCPContext["transport"];
}
export function createMockContext(
telegramOverrides: Partial<TelegramState> = {},
): TelegramMCPContext {
return {
telegramState: { ...initialState, ...telegramOverrides },
transport: createMockTransport(),
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Factory builder for a mock RootState matching the Redux store shape.
*/
import type { TelegramState } from "../../store/telegram/types";
import { initialState as telegramInitialState } from "../../store/telegram/types";
/**
* Minimal RootState shape matching the real store (without persist wrappers).
* Only includes the fields tests actually need.
*/
export interface MockRootState {
auth: {
token: string | null;
isOnboardedByUser: Record<string, boolean>;
};
socket: {
byUser: Record<
string,
{ status: "connected" | "disconnected" | "connecting"; socketId: string | null }
>;
};
user: {
user: { _id: string; telegramId: number; [key: string]: unknown } | null;
isLoading: boolean;
error: string | null;
};
telegram: {
byUser: Record<string, TelegramState>;
};
}
const DEFAULT_USER_ID = "user_123";
export function createMockRootState(
telegramOverrides: Partial<TelegramState> = {},
userId = DEFAULT_USER_ID,
): MockRootState {
return {
auth: {
token: "mock-jwt-token",
isOnboardedByUser: { [userId]: true },
},
socket: {
byUser: {
[userId]: { status: "connected", socketId: "sock_1" },
},
},
user: {
user: {
_id: userId,
telegramId: 12345,
},
isLoading: false,
error: null,
},
telegram: {
byUser: {
[userId]: {
...telegramInitialState,
...telegramOverrides,
},
},
},
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Factory builders for Telegram entity fixtures.
*/
import type {
TelegramUser,
TelegramChat,
TelegramMessage,
TelegramState,
} from "../../store/telegram/types";
import { initialState } from "../../store/telegram/types";
let idCounter = 1000;
export function createTelegramUser(
overrides: Partial<TelegramUser> = {},
): TelegramUser {
const id = String(idCounter++);
return {
id,
firstName: "Test",
lastName: "User",
username: `user_${id}`,
isBot: false,
...overrides,
};
}
export function createTelegramChat(
overrides: Partial<TelegramChat> = {},
): TelegramChat {
const id = String(idCounter++);
return {
id,
title: `Chat ${id}`,
type: "private",
unreadCount: 0,
isPinned: false,
...overrides,
};
}
export function createTelegramMessage(
overrides: Partial<TelegramMessage> = {},
): TelegramMessage {
const id = String(idCounter++);
return {
id,
chatId: "1",
date: Math.floor(Date.now() / 1000),
message: `Message ${id}`,
isOutgoing: false,
isEdited: false,
isForwarded: false,
...overrides,
};
}
export function createTelegramState(
overrides: Partial<TelegramState> = {},
): TelegramState {
return {
...initialState,
...overrides,
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Global test setup — runs before every test file.
*
* - Silences console output during tests (unless DEBUG_TESTS=1)
* - Resets rate limiter module-level state between tests
*/
import { beforeEach, vi } from "vitest";
// Silence console during tests to keep output clean
if (!process.env.DEBUG_TESTS) {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
}
// Reset rate limiter per-request counter before each test.
// The module keeps mutable state (callHistory, lastCallTime, callsInCurrentRequest)
// that leaks between tests if not cleared.
beforeEach(async () => {
try {
const { resetRequestCallCount } = await import("../lib/mcp/rateLimiter");
if (typeof resetRequestCallCount === "function") {
resetRequestCallCount();
}
} catch {
// Module may be fully mocked in some test files — safe to skip
}
});
+1 -2
View File
@@ -21,9 +21,8 @@ const TelegramLoginButton = ({
// Desktop (Tauri): use system browser → backend Telegram widget → deep link back to app.
if (isTauri()) await openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}?start=login_desktop`);
// Web fallback: open bot (existing flow).
await openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}?start=login`);
else await openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}?start=login`);
};
const isDisabled = externalDisabled;
+225
View File
@@ -0,0 +1,225 @@
/**
* Tests for error handling utilities
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { logAndFormatError, withErrorHandling, ErrorCategory } from "./errorHandler";
import { ValidationError } from "./validation";
import type { MCPToolResult } from "./types";
describe("logAndFormatError", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it("should pass through ValidationError message directly", () => {
const error = new ValidationError("Invalid user ID format");
const result = logAndFormatError("testFunction", error);
expect(result).toEqual({
content: [{ type: "text", text: "Invalid user ID format" }],
isError: true,
});
expect(consoleErrorSpy).toHaveBeenCalled();
});
it("should generate error code for generic Error", () => {
const error = new Error("Something went wrong");
const result = logAndFormatError("testFunction", error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/An error occurred \(code: GEN-ERR-\d{3}\)/);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it("should use VALIDATION-001 code for ErrorCategory.VALIDATION", () => {
const error = new Error("Validation failed");
const result = logAndFormatError("testFunction", error, ErrorCategory.VALIDATION);
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe("An error occurred (code: VALIDATION-001). Check logs for details.");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Code: VALIDATION-001"),
error
);
});
it("should use category-specific error code", () => {
const error = new Error("Chat not found");
const result = logAndFormatError("getChatInfo", error, ErrorCategory.CHAT);
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/An error occurred \(code: CHAT-ERR-\d{3}\)/);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("CHAT-ERR-"),
error
);
});
it("should include context in console log", () => {
const error = new Error("Test error");
const context = { userId: 123, chatId: "456" };
logAndFormatError("sendMessage", error, ErrorCategory.MSG, context);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("userId=123, chatId=456"),
error
);
});
it("should work without category parameter", () => {
const error = new Error("Generic error");
const result = logAndFormatError("genericFunction", error);
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/An error occurred \(code: GEN-ERR-\d{3}\)/);
});
it("should work without context parameter", () => {
const error = new Error("Test error");
const result = logAndFormatError("testFunction", error, ErrorCategory.CONTACT);
expect(result.isError).toBe(true);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("[MCP] Error in testFunction ()"),
error
);
});
it("should handle different error categories", () => {
const categories = [
ErrorCategory.CHAT,
ErrorCategory.MSG,
ErrorCategory.CONTACT,
ErrorCategory.GROUP,
ErrorCategory.MEDIA,
ErrorCategory.PROFILE,
ErrorCategory.AUTH,
ErrorCategory.ADMIN,
ErrorCategory.SEARCH,
ErrorCategory.DRAFT,
];
categories.forEach((category) => {
const error = new Error("Test");
const result = logAndFormatError("test", error, category);
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(
new RegExp(`An error occurred \\(code: ${category}-ERR-\\d{3}\\)`)
);
});
});
});
describe("withErrorHandling", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it("should return result when async function succeeds", async () => {
const successResult: MCPToolResult = {
content: [{ type: "text", text: "Success" }],
};
const fn = async () => successResult;
const wrapped = withErrorHandling(fn);
const result = await wrapped();
expect(result).toEqual(successResult);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("should catch and format errors from async function", async () => {
const fn = async () => {
throw new Error("Function failed");
};
const wrapped = withErrorHandling(fn as any, ErrorCategory.CHAT);
const result: any = await wrapped();
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/An error occurred \(code: CHAT-ERR-\d{3}\)/);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it("should catch ValidationError and pass through message", async () => {
const fn = async () => {
throw new ValidationError("Invalid input parameter");
};
const wrapped = withErrorHandling(fn as any);
const result: any = await wrapped();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe("Invalid input parameter");
});
it("should handle non-Error throws by converting to Error", async () => {
const fn = async () => {
throw "String error";
};
const wrapped = withErrorHandling(fn as any);
const result: any = await wrapped();
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/An error occurred \(code: GEN-ERR-\d{3}\)/);
});
it("should preserve function arguments in error context", async () => {
const fn = async (_chatId: number, _message: string) => {
throw new Error("Test error");
};
const wrapped = withErrorHandling(fn as any, ErrorCategory.MSG);
await wrapped(123, "Hello");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('args='),
expect.any(Error)
);
});
it("should use function name in error code generation", async () => {
async function namedFunction() {
throw new Error("Test");
}
const wrapped = withErrorHandling(namedFunction as any);
await wrapped();
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("[MCP] Error in namedFunction"),
expect.any(Error)
);
});
it("should handle anonymous functions with 'unknown' name", async () => {
const wrapped = withErrorHandling((() => {
const f = async () => { throw new Error("Test"); };
Object.defineProperty(f, "name", { value: "" });
return f;
})() as any);
await wrapped();
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("[MCP] Error in unknown"),
expect.any(Error)
);
});
});
+284
View File
@@ -0,0 +1,284 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
classifyTool,
isStateOnlyTool,
isHeavyTool,
enforceRateLimit,
resetRequestCallCount,
RATE_LIMIT_CONFIG,
} from "./rateLimiter";
// Mock the logger module to avoid console output during tests
vi.mock("./logger", () => ({
mcpLog: vi.fn(),
mcpWarn: vi.fn(),
}));
describe("RATE_LIMIT_CONFIG", () => {
it("has correct configuration values", () => {
expect(RATE_LIMIT_CONFIG.API_READ_DELAY_MS).toBe(500);
expect(RATE_LIMIT_CONFIG.API_WRITE_DELAY_MS).toBe(1000);
expect(RATE_LIMIT_CONFIG.MAX_CALLS_PER_MINUTE).toBe(30);
expect(RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST).toBe(20);
});
});
describe("classifyTool", () => {
it("classifies known state_only tools correctly", () => {
expect(classifyTool("get_chats")).toBe("state_only");
expect(classifyTool("list_chats")).toBe("state_only");
expect(classifyTool("get_chat")).toBe("state_only");
expect(classifyTool("get_messages")).toBe("state_only");
expect(classifyTool("list_messages")).toBe("state_only");
expect(classifyTool("get_me")).toBe("state_only");
expect(classifyTool("get_message_context")).toBe("state_only");
expect(classifyTool("get_history")).toBe("state_only");
});
it("classifies known api_write tools correctly", () => {
expect(classifyTool("send_message")).toBe("api_write");
expect(classifyTool("edit_message")).toBe("api_write");
expect(classifyTool("delete_message")).toBe("api_write");
expect(classifyTool("forward_message")).toBe("api_write");
expect(classifyTool("create_group")).toBe("api_write");
expect(classifyTool("ban_user")).toBe("api_write");
});
it("classifies known api_read tools correctly", () => {
expect(classifyTool("list_contacts")).toBe("api_read");
expect(classifyTool("search_contacts")).toBe("api_read");
expect(classifyTool("get_participants")).toBe("api_read");
expect(classifyTool("get_admins")).toBe("api_read");
expect(classifyTool("search_messages")).toBe("api_read");
});
it("defaults unknown tools to api_read", () => {
expect(classifyTool("unknown_tool_xyz")).toBe("api_read");
expect(classifyTool("random_tool_123")).toBe("api_read");
});
});
describe("isStateOnlyTool", () => {
it("returns true for state_only tools", () => {
expect(isStateOnlyTool("get_chats")).toBe(true);
expect(isStateOnlyTool("list_messages")).toBe(true);
expect(isStateOnlyTool("get_me")).toBe(true);
});
it("returns false for api_write tools", () => {
expect(isStateOnlyTool("send_message")).toBe(false);
expect(isStateOnlyTool("edit_message")).toBe(false);
});
it("returns false for api_read tools", () => {
expect(isStateOnlyTool("list_contacts")).toBe(false);
expect(isStateOnlyTool("search_contacts")).toBe(false);
});
it("returns false for unknown tools", () => {
expect(isStateOnlyTool("unknown_tool")).toBe(false);
});
});
describe("isHeavyTool", () => {
it("returns true for api_write tools", () => {
expect(isHeavyTool("send_message")).toBe(true);
expect(isHeavyTool("edit_message")).toBe(true);
expect(isHeavyTool("delete_message")).toBe(true);
expect(isHeavyTool("create_group")).toBe(true);
});
it("returns false for state_only tools", () => {
expect(isHeavyTool("get_chats")).toBe(false);
expect(isHeavyTool("list_messages")).toBe(false);
});
it("returns false for api_read tools", () => {
expect(isHeavyTool("list_contacts")).toBe(false);
expect(isHeavyTool("search_contacts")).toBe(false);
});
it("returns false for unknown tools", () => {
expect(isHeavyTool("unknown_tool")).toBe(false);
});
});
describe("enforceRateLimit", () => {
beforeEach(() => {
vi.useFakeTimers();
resetRequestCallCount();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("allows state_only tools without any delay or limit", async () => {
const start = Date.now();
await enforceRateLimit("get_chats");
await enforceRateLimit("list_messages");
await enforceRateLimit("get_me");
const elapsed = Date.now() - start;
expect(elapsed).toBe(0); // No time should have passed
});
it("enforces per-request cap for api_read tools", async () => {
// Call enforceRateLimit 20 times (the max)
for (let i = 0; i < RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST; i++) {
await enforceRateLimit("list_contacts");
vi.advanceTimersByTime(RATE_LIMIT_CONFIG.API_READ_DELAY_MS);
}
// The 21st call should throw
await expect(enforceRateLimit("list_contacts")).rejects.toThrow(
/exceeded 20 API tool calls per request/,
);
});
it("enforces per-request cap for api_write tools", async () => {
// Call enforceRateLimit 20 times (the max)
for (let i = 0; i < RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST; i++) {
const promise = enforceRateLimit("send_message");
await vi.runAllTimersAsync();
await promise;
}
// The 21st call should throw
await expect(enforceRateLimit("send_message")).rejects.toThrow(
/exceeded 20 API tool calls per request/,
);
});
it("does not enforce per-request cap on state_only tools", async () => {
// Call state_only tools many more times than the cap
for (let i = 0; i < 50; i++) {
await enforceRateLimit("get_chats");
}
// Should not throw
expect(true).toBe(true);
});
it("resets per-request counter when resetRequestCallCount is called", async () => {
// Use up the budget
for (let i = 0; i < RATE_LIMIT_CONFIG.MAX_CALLS_PER_REQUEST; i++) {
const promise = enforceRateLimit("list_contacts");
await vi.runAllTimersAsync();
await promise;
}
// Next call should fail
await expect(enforceRateLimit("list_contacts")).rejects.toThrow(
/exceeded 20 API tool calls per request/,
);
// Reset the counter
resetRequestCallCount();
// Now it should work again
const promise = enforceRateLimit("list_contacts");
await vi.runAllTimersAsync();
await expect(promise).resolves.toBeUndefined();
});
it("enforces inter-call delay for api_read tools", async () => {
const promise1 = enforceRateLimit("list_contacts");
await vi.runAllTimersAsync();
await promise1; // First call goes through immediately
const start = Date.now();
const promise2 = enforceRateLimit("list_contacts");
// The second call should be delayed by API_READ_DELAY_MS
expect(Date.now() - start).toBe(0); // Promise created but not resolved yet
// Advance time and run all timers
await vi.runAllTimersAsync();
await promise2;
expect(Date.now() - start).toBeGreaterThanOrEqual(
RATE_LIMIT_CONFIG.API_READ_DELAY_MS,
);
});
it("enforces inter-call delay for api_write tools", async () => {
const promise1 = enforceRateLimit("send_message");
await vi.runAllTimersAsync();
await promise1; // First call goes through immediately
const start = Date.now();
const promise2 = enforceRateLimit("send_message");
// The second call should be delayed by API_WRITE_DELAY_MS
expect(Date.now() - start).toBe(0);
// Advance time and run all timers
await vi.runAllTimersAsync();
await promise2;
expect(Date.now() - start).toBeGreaterThanOrEqual(
RATE_LIMIT_CONFIG.API_WRITE_DELAY_MS,
);
});
it("enforces per-minute sliding window cap", async () => {
// Make MAX_CALLS_PER_MINUTE calls within the window
// Note: We need to reset the per-request counter periodically since MAX_CALLS_PER_MINUTE (30) > MAX_CALLS_PER_REQUEST (20)
for (let i = 0; i < RATE_LIMIT_CONFIG.MAX_CALLS_PER_MINUTE; i++) {
// Reset every 15 calls to avoid hitting per-request limit
if (i > 0 && i % 15 === 0) {
resetRequestCallCount();
}
const promise = enforceRateLimit("list_contacts");
await vi.runAllTimersAsync();
await promise;
}
// Reset again for the final test call
resetRequestCallCount();
// The next call should wait until the oldest entry expires (60 seconds from first call)
// The implementation waits for: oldestTimestamp + 60_000 - now + 50
const promise = enforceRateLimit("list_contacts");
// Check that promise hasn't resolved yet
let resolved = false;
void promise.then(() => {
resolved = true;
});
// Let microtasks run
await Promise.resolve();
expect(resolved).toBe(false);
// Now advance time past the 60-second window and run timers
await vi.runAllTimersAsync();
// Promise should now resolve
await promise;
expect(resolved).toBe(true);
});
it("counts both api_read and api_write towards per-request cap", async () => {
// Mix of read and write calls
for (let i = 0; i < 10; i++) {
const promise = enforceRateLimit("list_contacts");
await vi.runAllTimersAsync();
await promise;
}
for (let i = 0; i < 10; i++) {
const promise = enforceRateLimit("send_message");
await vi.runAllTimersAsync();
await promise;
}
// Total is 20, so the next call should throw
await expect(enforceRateLimit("list_contacts")).rejects.toThrow(
/exceeded 20 API tool calls per request/,
);
});
});
+3 -3
View File
@@ -7,7 +7,7 @@
import type { MCPTool, MCPToolResult } from "../types";
import type { ExtraTool } from "./types";
import * as telegramApi from "../telegram/telegramApi";
import { sendMessage } from "../telegram/api/sendMessage";
// ---------------------------------------------------------------------------
// Constants
@@ -193,7 +193,7 @@ async function executeBulkSendMessage(
for (const chatId of chatIds) {
try {
await telegramApi.sendMessage(chatId, message);
await sendMessage(chatId, message);
successCount++;
await sleep(BATCH_DELAY_MS);
} catch (error) {
@@ -227,7 +227,7 @@ async function executeBulkArchiveChats(
};
}
// Archive is not directly available in telegramApi yet — return planned result
// Archive API integration pending — stubbed for now
return {
content: [
{
@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getChats } from "../getChats";
import type { TelegramChat } from "../../../../../store/telegram/types";
// Mock dependencies
const mockClient = {
invoke: vi.fn(),
getInputEntity: vi.fn(),
getMe: vi.fn(),
sendMessage: vi.fn(),
};
vi.mock("../../../../../services/mtprotoService", () => ({
mtprotoService: {
getClient: vi.fn(() => mockClient),
withFloodWaitHandling: vi.fn(async (fn: any) => await fn()),
isReady: vi.fn(() => true),
isClientConnected: vi.fn(() => true),
sendMessage: vi.fn(),
},
}));
vi.mock("../../../../../store", () => ({
store: {
getState: vi.fn(() => ({
user: { user: { _id: "u1" } },
telegram: {
byUser: {
u1: {
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
},
},
},
})),
},
}));
vi.mock("../../../../../store/telegramSelectors", () => ({
selectTelegramUserState: vi.fn(() => ({
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
})),
selectCurrentUser: vi.fn(() => null),
selectOrderedChats: vi.fn(() => []),
}));
vi.mock("../../../rateLimiter", () => ({
enforceRateLimit: vi.fn(),
resetRequestCallCount: vi.fn(),
}));
vi.mock("../helpers", async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
getOrderedChats: vi.fn(() => []),
apiDialogToTelegramChat: original.apiDialogToTelegramChat,
};
});
describe("getChats", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return cached data when cache hit occurs", async () => {
const mockCachedChats: TelegramChat[] = [
{
id: "1",
title: "Cached Chat 1",
type: "private",
unreadCount: 0,
isPinned: false,
},
{
id: "2",
title: "Cached Chat 2",
type: "group",
unreadCount: 5,
isPinned: true,
},
];
const { getOrderedChats } = await import("../helpers");
vi.mocked(getOrderedChats).mockReturnValueOnce(mockCachedChats);
const result = await getChats(20);
expect(result.data).toEqual(mockCachedChats);
expect(result.fromCache).toBe(true);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should call API when cache is empty", async () => {
const { getOrderedChats } = await import("../helpers");
vi.mocked(getOrderedChats).mockReturnValueOnce([]);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
const mockApiDialogs = {
dialogs: [
{
peer: { className: "PeerChannel", channelId: 123n },
unreadCount: 5,
pinned: false,
},
],
chats: [
{
id: 123n,
title: "API Chat",
accessHash: 456n,
megagroup: false,
},
],
users: [],
};
mockClient.invoke.mockResolvedValueOnce(mockApiDialogs);
const result = await getChats(20);
expect(result.fromCache).toBe(false);
expect(enforceRateLimit).toHaveBeenCalledWith("__api_fallback_chats");
expect(mockClient.invoke).toHaveBeenCalledWith(
expect.objectContaining({
className: "messages.GetDialogs",
})
);
});
it("should return empty array when API fails", async () => {
const { getOrderedChats } = await import("../helpers");
vi.mocked(getOrderedChats).mockReturnValueOnce([]);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
mockClient.invoke.mockRejectedValueOnce(new Error("API Error"));
const result = await getChats(20);
expect(result.data).toEqual([]);
expect(result.fromCache).toBe(false);
});
it("should return empty array when rate limit is rejected", async () => {
const { getOrderedChats } = await import("../helpers");
vi.mocked(getOrderedChats).mockReturnValueOnce([]);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockRejectedValueOnce(
new Error("Rate limit exceeded")
);
const result = await getChats(20);
expect(result.data).toEqual([]);
expect(result.fromCache).toBe(false);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should respect custom limit parameter", async () => {
const mockCachedChats: TelegramChat[] = Array.from({ length: 50 }, (_, i) => ({
id: String(i),
title: `Chat ${i}`,
type: "private",
unreadCount: 0,
isPinned: false,
}));
const { getOrderedChats } = await import("../helpers");
vi.mocked(getOrderedChats).mockReturnValueOnce(mockCachedChats.slice(0, 50));
const result = await getChats(50);
expect(getOrderedChats).toHaveBeenCalledWith(50);
expect(result.data).toHaveLength(50);
});
});
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getCurrentUser } from "../getCurrentUser";
import type { TelegramUser } from "../../../../../store/telegram/types";
// Mock dependencies
const mockClient = {
invoke: vi.fn(),
getInputEntity: vi.fn(),
getMe: vi.fn(),
sendMessage: vi.fn(),
};
vi.mock("../../../../../services/mtprotoService", () => ({
mtprotoService: {
getClient: vi.fn(() => mockClient),
withFloodWaitHandling: vi.fn(async (fn: any) => await fn()),
isReady: vi.fn(() => true),
isClientConnected: vi.fn(() => true),
sendMessage: vi.fn(),
},
}));
vi.mock("../../../../../store", () => ({
store: {
getState: vi.fn(() => ({
user: { user: { _id: "u1" } },
telegram: {
byUser: {
u1: {
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
},
},
},
})),
},
}));
vi.mock("../../../../../store/telegramSelectors", () => ({
selectTelegramUserState: vi.fn(() => ({
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
})),
selectCurrentUser: vi.fn(() => null),
selectOrderedChats: vi.fn(() => []),
}));
vi.mock("../../../rateLimiter", () => ({
enforceRateLimit: vi.fn(),
resetRequestCallCount: vi.fn(),
}));
vi.mock("../helpers", async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
getCurrentUser: vi.fn(() => undefined),
};
});
describe("getCurrentUser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return cached user when cache hit occurs", async () => {
const mockCachedUser: TelegramUser = {
id: "self123",
firstName: "John",
lastName: "Doe",
username: "johndoe",
phoneNumber: "+1234567890",
isBot: false,
};
const { getCurrentUser: getCurrentUserHelper } = await import("../helpers");
vi.mocked(getCurrentUserHelper).mockReturnValueOnce(mockCachedUser);
const result = await getCurrentUser();
expect(result.data).toEqual(mockCachedUser);
expect(result.fromCache).toBe(true);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should call API when cache is empty", async () => {
const { getCurrentUser: getCurrentUserHelper } = await import("../helpers");
vi.mocked(getCurrentUserHelper).mockReturnValueOnce(undefined);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
const mockApiUser = {
id: 123n,
firstName: "API User",
lastName: "Test",
username: "apiuser",
phone: "+9876543210",
};
mockClient.getMe.mockResolvedValueOnce(mockApiUser);
const result = await getCurrentUser();
expect(result.fromCache).toBe(false);
expect(result.data).toBeDefined();
expect(result.data?.id).toBe("123");
expect(result.data?.firstName).toBe("API User");
expect(enforceRateLimit).toHaveBeenCalledWith("__api_fallback_me");
});
it("should return undefined when API returns null", async () => {
const { getCurrentUser: getCurrentUserHelper } = await import("../helpers");
vi.mocked(getCurrentUserHelper).mockReturnValueOnce(undefined);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
mockClient.getMe.mockResolvedValueOnce(null);
const result = await getCurrentUser();
expect(result.data).toBeUndefined();
expect(result.fromCache).toBe(false);
});
it("should return undefined when API fails", async () => {
const { getCurrentUser: getCurrentUserHelper } = await import("../helpers");
vi.mocked(getCurrentUserHelper).mockReturnValueOnce(undefined);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
mockClient.getMe.mockRejectedValueOnce(new Error("API Error"));
const result = await getCurrentUser();
expect(result.data).toBeUndefined();
expect(result.fromCache).toBe(false);
});
it("should return undefined when rate limit is rejected", async () => {
const { getCurrentUser: getCurrentUserHelper } = await import("../helpers");
vi.mocked(getCurrentUserHelper).mockReturnValueOnce(undefined);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockRejectedValueOnce(
new Error("Rate limit exceeded")
);
const result = await getCurrentUser();
expect(result.data).toBeUndefined();
expect(result.fromCache).toBe(false);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should handle user with minimal fields", async () => {
const { getCurrentUser: getCurrentUserHelper } = await import("../helpers");
vi.mocked(getCurrentUserHelper).mockReturnValueOnce(undefined);
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
const mockApiUser = {
id: 456n,
firstName: "MinimalUser",
// No lastName, username, or phone
};
mockClient.getMe.mockResolvedValueOnce(mockApiUser);
const result = await getCurrentUser();
expect(result.data).toBeDefined();
expect(result.data?.id).toBe("456");
expect(result.data?.firstName).toBe("MinimalUser");
expect(result.data?.lastName).toBeUndefined();
expect(result.data?.username).toBeUndefined();
});
});
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getMessages } from "../getMessages";
import type { TelegramMessage } from "../../../../../store/telegram/types";
// Mock dependencies
const mockClient = {
invoke: vi.fn(),
getInputEntity: vi.fn(),
getMe: vi.fn(),
sendMessage: vi.fn(),
};
vi.mock("../../../../../services/mtprotoService", () => ({
mtprotoService: {
getClient: vi.fn(() => mockClient),
withFloodWaitHandling: vi.fn(async (fn: any) => await fn()),
isReady: vi.fn(() => true),
isClientConnected: vi.fn(() => true),
sendMessage: vi.fn(),
},
}));
vi.mock("../../../../../store", () => ({
store: {
getState: vi.fn(() => ({
user: { user: { _id: "u1" } },
telegram: {
byUser: {
u1: {
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
},
},
},
})),
},
}));
vi.mock("../../../../../store/telegramSelectors", () => ({
selectTelegramUserState: vi.fn(() => ({
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
})),
selectCurrentUser: vi.fn(() => null),
selectOrderedChats: vi.fn(() => []),
}));
vi.mock("../../../rateLimiter", () => ({
enforceRateLimit: vi.fn(),
resetRequestCallCount: vi.fn(),
}));
vi.mock("../helpers", async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
getCachedMessages: vi.fn(() => undefined),
getChatById: vi.fn(() => ({
id: "1",
title: "Test Chat",
type: "private",
unreadCount: 0,
isPinned: false,
})),
apiMessageToTelegramMessage: original.apiMessageToTelegramMessage,
};
});
describe("getMessages", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return cached messages when cache hit occurs", async () => {
const mockCachedMessages: TelegramMessage[] = [
{
id: "1",
chatId: "1",
message: "Cached message 1",
date: Date.now(),
fromId: "sender1",
fromName: "John",
isOutgoing: false,
isEdited: false,
isForwarded: false,
},
{
id: "2",
chatId: "1",
message: "Cached message 2",
date: Date.now(),
fromId: "sender2",
fromName: "Jane",
isOutgoing: true,
isEdited: false,
isForwarded: false,
},
];
const { getCachedMessages } = await import("../helpers");
vi.mocked(getCachedMessages).mockReturnValueOnce(mockCachedMessages);
const result = await getMessages("1", 20);
expect(result.data).toEqual(mockCachedMessages);
expect(result.fromCache).toBe(true);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should call API when cache is empty and chat is found", async () => {
const { getCachedMessages, getChatById } = await import("../helpers");
vi.mocked(getCachedMessages).mockReturnValueOnce(undefined);
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
unreadCount: 0,
isPinned: false,
});
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
const mockApiMessages = {
messages: [
{
id: 100,
peerId: { userId: 123n },
message: "API message",
date: Math.floor(Date.now() / 1000),
fromId: { userId: 456n },
out: false,
},
],
chats: [],
users: [
{
id: 456n,
firstName: "API User",
},
],
};
mockClient.getInputEntity.mockResolvedValueOnce({ className: "InputPeerUser", userId: 1n });
mockClient.invoke.mockResolvedValueOnce(mockApiMessages);
const result = await getMessages("1", 20);
expect(result.fromCache).toBe(false);
expect(enforceRateLimit).toHaveBeenCalledWith("__api_fallback_messages");
expect(mockClient.invoke).toHaveBeenCalledWith(
expect.objectContaining({
className: "messages.GetHistory",
})
);
});
it("should return empty array when chat is not found", async () => {
const { getCachedMessages, getChatById } = await import("../helpers");
vi.mocked(getCachedMessages).mockReturnValueOnce(undefined);
vi.mocked(getChatById).mockReturnValueOnce(undefined);
const result = await getMessages("999", 20);
expect(result.data).toEqual([]);
expect(result.fromCache).toBe(false);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should return empty array when API fails", async () => {
const { getCachedMessages, getChatById } = await import("../helpers");
vi.mocked(getCachedMessages).mockReturnValueOnce(undefined);
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
unreadCount: 0,
isPinned: false,
});
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockResolvedValueOnce(undefined);
mockClient.getInputEntity.mockResolvedValueOnce({ className: "InputPeerUser", userId: 1n });
mockClient.invoke.mockRejectedValueOnce(new Error("API Error"));
const result = await getMessages("1", 20);
expect(result.data).toEqual([]);
expect(result.fromCache).toBe(false);
});
it("should return empty array when rate limit is rejected", async () => {
const { getCachedMessages, getChatById } = await import("../helpers");
vi.mocked(getCachedMessages).mockReturnValueOnce(undefined);
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
unreadCount: 0,
isPinned: false,
});
const { enforceRateLimit } = await import("../../../rateLimiter");
vi.mocked(enforceRateLimit).mockRejectedValueOnce(
new Error("Rate limit exceeded")
);
const result = await getMessages("1", 20);
expect(result.data).toEqual([]);
expect(result.fromCache).toBe(false);
const { mtprotoService } = await import(
"../../../../../services/mtprotoService"
);
expect(mtprotoService.getClient).not.toHaveBeenCalled();
});
it("should respect custom limit parameter", async () => {
const mockCachedMessages: TelegramMessage[] = Array.from(
{ length: 100 },
(_, i) => ({
id: String(i),
chatId: "1",
message: `Message ${i}`,
date: Date.now(),
fromId: "sender1",
fromName: "User",
isOutgoing: false,
isEdited: false,
isForwarded: false,
})
);
const { getCachedMessages } = await import("../helpers");
vi.mocked(getCachedMessages).mockReturnValueOnce(
mockCachedMessages.slice(0, 100)
);
const result = await getMessages("1", 100);
expect(getCachedMessages).toHaveBeenCalledWith("1", 100, 0);
expect(result.data).toHaveLength(100);
});
});
@@ -0,0 +1,616 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bigInt from "big-integer";
import {
formatEntity,
formatMessage,
apiMessageToTelegramMessage,
apiDialogToTelegramChat,
} from "../helpers";
import type {
TelegramChat,
TelegramUser,
TelegramMessage,
} from "../../../../../store/telegram/types";
// Mock the store module
vi.mock("../../../../../store", () => ({
store: {
getState: vi.fn(() => ({
telegram: {
byUser: {},
},
})),
},
}));
// Mock the selectors module
vi.mock("../../../../../store/telegramSelectors", () => ({
selectChat: vi.fn(),
selectUser: vi.fn(),
}));
describe("formatEntity", () => {
describe("with TelegramChat", () => {
it("should format a channel chat", () => {
const chat: TelegramChat = {
id: "123",
title: "Test Channel",
type: "channel",
username: "testchannel",
unreadCount: 5,
isPinned: false,
};
const result = formatEntity(chat);
expect(result).toEqual({
id: "123",
name: "Test Channel",
type: "channel",
username: "testchannel",
});
});
it("should map supergroup to group type", () => {
const chat: TelegramChat = {
id: "456",
title: "Test Group",
type: "supergroup",
unreadCount: 0,
isPinned: true,
};
const result = formatEntity(chat);
expect(result).toEqual({
id: "456",
name: "Test Group",
type: "group",
});
});
it("should handle private chat type", () => {
const chat: TelegramChat = {
id: "789",
title: "John Doe",
type: "private",
unreadCount: 2,
isPinned: false,
};
const result = formatEntity(chat);
expect(result).toEqual({
id: "789",
name: "John Doe",
type: "private",
});
});
it("should use 'Unknown' when title is undefined", () => {
const chat: TelegramChat = {
id: "999",
title: undefined,
type: "group",
unreadCount: 0,
isPinned: false,
};
const result = formatEntity(chat);
expect(result).toEqual({
id: "999",
name: "Unknown",
type: "group",
});
});
it("should include username if present", () => {
const chat: TelegramChat = {
id: "111",
title: "Public Group",
type: "group",
username: "publicgroup",
unreadCount: 0,
isPinned: false,
};
const result = formatEntity(chat);
expect(result).toEqual({
id: "111",
name: "Public Group",
type: "group",
username: "publicgroup",
});
});
});
describe("with TelegramUser", () => {
it("should format a user with full name", () => {
const user: TelegramUser = {
id: "123",
firstName: "John",
lastName: "Doe",
username: "johndoe",
isBot: false,
};
const result = formatEntity(user);
expect(result).toEqual({
id: "123",
name: "John Doe",
type: "user",
username: "johndoe",
});
});
it("should format a user with only first name", () => {
const user: TelegramUser = {
id: "456",
firstName: "Alice",
isBot: false,
};
const result = formatEntity(user);
expect(result).toEqual({
id: "456",
name: "Alice",
type: "user",
});
});
it("should include phone number if present", () => {
const user: TelegramUser = {
id: "789",
firstName: "Bob",
lastName: "Smith",
phoneNumber: "+1234567890",
isBot: false,
};
const result = formatEntity(user);
expect(result).toEqual({
id: "789",
name: "Bob Smith",
type: "user",
phone: "+1234567890",
});
});
it("should handle bot users", () => {
const user: TelegramUser = {
id: "999",
firstName: "Bot",
username: "testbot",
isBot: true,
};
const result = formatEntity(user);
expect(result).toEqual({
id: "999",
name: "Bot",
type: "user",
username: "testbot",
});
});
});
});
describe("formatMessage", () => {
it("should format a basic message", () => {
const message: TelegramMessage = {
id: "1",
chatId: "123",
date: 1640000000,
message: "Hello, world!",
isOutgoing: false,
isEdited: false,
isForwarded: false,
};
const result = formatMessage(message);
expect(result).toEqual({
id: "1",
date: new Date(1640000000 * 1000).toISOString(),
text: "Hello, world!",
});
});
it("should include fromId when present", () => {
const message: TelegramMessage = {
id: "2",
chatId: "123",
date: 1640000000,
message: "Test message",
fromId: "456",
isOutgoing: false,
isEdited: false,
isForwarded: false,
};
const result = formatMessage(message);
expect(result).toEqual({
id: "2",
date: new Date(1640000000 * 1000).toISOString(),
text: "Test message",
from_id: "456",
});
});
it("should include media information when media exists", () => {
const message: TelegramMessage = {
id: "3",
chatId: "123",
date: 1640000000,
message: "Photo message",
media: { type: "photo" },
isOutgoing: false,
isEdited: false,
isForwarded: false,
};
const result = formatMessage(message);
expect(result).toEqual({
id: "3",
date: new Date(1640000000 * 1000).toISOString(),
text: "Photo message",
has_media: true,
media_type: "photo",
});
});
it("should not include media fields when media is absent", () => {
const message: TelegramMessage = {
id: "4",
chatId: "123",
date: 1640000000,
message: "Text only",
isOutgoing: false,
isEdited: false,
isForwarded: false,
};
const result = formatMessage(message);
expect(result.has_media).toBeUndefined();
expect(result.media_type).toBeUndefined();
});
});
describe("apiMessageToTelegramMessage", () => {
it("should convert a basic API message", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Hello",
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result).toEqual({
id: "42",
chatId: "123",
date: 1640000000,
message: "Hello",
isOutgoing: false,
isEdited: false,
isForwarded: false,
});
});
it("should extract fromId from userId", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Test",
fromId: { userId: bigInt(456) },
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.fromId).toBe("456");
});
it("should include replyTo message ID", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Reply",
replyTo: { replyToMsgId: 10 },
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.replyToMessageId).toBe("10");
});
it("should detect media presence", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Photo",
media: { className: "MessageMediaPhoto" },
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.media).toEqual({ type: "MessageMediaPhoto" });
});
it("should set isEdited when editDate exists", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Edited",
editDate: 1640001000,
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.isEdited).toBe(true);
});
it("should set isForwarded when fwdFrom exists", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Forwarded",
fwdFrom: { fromId: bigInt(789) },
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.isForwarded).toBe(true);
});
it("should set isOutgoing when out flag is true", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Sent",
out: true,
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.isOutgoing).toBe(true);
});
it("should handle missing optional fields", () => {
const apiMsg = {
id: 42,
date: 1640000000,
message: "Minimal",
};
const result = apiMessageToTelegramMessage(apiMsg as any, "123");
expect(result.fromId).toBeUndefined();
expect(result.replyToMessageId).toBeUndefined();
expect(result.media).toBeUndefined();
});
});
describe("apiDialogToTelegramChat", () => {
const mockChatsById = new Map();
const mockUsersById = new Map();
beforeEach(() => {
mockChatsById.clear();
mockUsersById.clear();
});
it("should convert PeerUser dialog", () => {
const user = {
id: bigInt(123),
firstName: "John",
lastName: "Doe",
username: "johndoe",
};
mockUsersById.set("123", user);
const dialog = {
peer: { className: "PeerUser", userId: bigInt(123) },
unreadCount: 5,
pinned: true,
};
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "123",
title: "John Doe",
type: "private",
username: "johndoe",
unreadCount: 5,
isPinned: true,
});
});
it("should convert PeerChat dialog", () => {
const chat = {
id: bigInt(456),
title: "Test Group",
};
mockChatsById.set("456", chat);
const dialog = {
peer: { className: "PeerChat", chatId: bigInt(456) },
unreadCount: 2,
pinned: false,
};
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "456",
title: "Test Group",
type: "group",
unreadCount: 2,
isPinned: false,
});
});
it("should convert PeerChannel with megagroup flag", () => {
const channel = {
id: bigInt(789),
title: "Supergroup",
username: "mygroup",
megagroup: true,
};
mockChatsById.set("789", channel);
const dialog = {
peer: { className: "PeerChannel", channelId: bigInt(789) },
unreadCount: 0,
pinned: false,
};
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "789",
title: "Supergroup",
type: "supergroup",
username: "mygroup",
unreadCount: 0,
isPinned: false,
});
});
it("should convert PeerChannel without megagroup as channel", () => {
const channel = {
id: bigInt(999),
title: "News Channel",
username: "news",
megagroup: false,
};
mockChatsById.set("999", channel);
const dialog = {
peer: { className: "PeerChannel", channelId: bigInt(999) },
unreadCount: 10,
pinned: true,
};
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "999",
title: "News Channel",
type: "channel",
username: "news",
unreadCount: 10,
isPinned: true,
});
});
it("should return undefined for unknown peer type", () => {
const dialog = {
peer: {}, // No userId, chatId, or channelId
unreadCount: 0,
pinned: false,
};
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toBeUndefined();
});
it("should use 'Unknown' title when raw entity is missing for PeerUser", () => {
const dialog = {
peer: { className: "PeerUser", userId: bigInt(123) },
unreadCount: 0,
pinned: false,
};
// User not in mockUsersById
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "123",
title: "Unknown",
type: "private",
unreadCount: 0,
isPinned: false,
});
});
it("should use 'Unknown' title when raw entity is missing for PeerChat", () => {
const dialog = {
peer: { className: "PeerChat", chatId: bigInt(456) },
unreadCount: 0,
pinned: false,
};
// Chat not in mockChatsById
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "456",
title: "Unknown",
type: "group",
unreadCount: 0,
isPinned: false,
});
});
it("should use 'Unknown' title when raw entity is missing for PeerChannel", () => {
const dialog = {
peer: { className: "PeerChannel", channelId: bigInt(789) },
unreadCount: 0,
pinned: false,
};
// Channel not in mockChatsById
const result = apiDialogToTelegramChat(
dialog as any,
mockChatsById,
mockUsersById
);
expect(result).toEqual({
id: "789",
title: "Unknown",
type: "channel",
unreadCount: 0,
isPinned: false,
});
});
});
@@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendMessage } from "../sendMessage";
// Mock dependencies
vi.mock("../../../../../services/mtprotoService", () => {
const mockClient = {
invoke: vi.fn(),
getInputEntity: vi.fn(),
getMe: vi.fn(),
sendMessage: vi.fn(),
};
return {
mtprotoService: {
getClient: vi.fn(() => mockClient),
withFloodWaitHandling: vi.fn(async (fn: any) => await fn()),
isReady: vi.fn(() => true),
isClientConnected: vi.fn(() => true),
sendMessage: vi.fn(),
},
};
});
vi.mock("../../../../../store", () => ({
store: {
getState: vi.fn(() => ({
user: { user: { _id: "u1" } },
telegram: {
byUser: {
u1: {
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
},
},
},
})),
},
}));
vi.mock("../../../../../store/telegramSelectors", () => ({
selectTelegramUserState: vi.fn(() => ({
chats: {},
chatsOrder: [],
messages: {},
messagesOrder: {},
currentUser: null,
})),
selectCurrentUser: vi.fn(() => null),
selectOrderedChats: vi.fn(() => []),
}));
vi.mock("../../../rateLimiter", () => ({
enforceRateLimit: vi.fn(),
resetRequestCallCount: vi.fn(),
}));
vi.mock("../helpers", async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
getChatById: vi.fn(),
};
});
describe("sendMessage", () => {
let mockMtprotoService: any;
let mockClient: any;
beforeEach(async () => {
vi.clearAllMocks();
const mtproto = await import("../../../../../services/mtprotoService");
mockMtprotoService = mtproto.mtprotoService;
mockClient = mockMtprotoService.getClient();
});
it("should return undefined when chat is not found", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce(undefined);
const result = await sendMessage("999", "Hello");
expect(result.data).toBeUndefined();
expect(result.fromCache).toBe(false);
expect(mockMtprotoService.sendMessage).not.toHaveBeenCalled();
});
it("should use username when chat has username", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
username: "testuser",
unreadCount: 0,
isPinned: false,
});
mockMtprotoService.sendMessage.mockResolvedValueOnce({
id: 100,
randomId: 123n,
} as any);
const result = await sendMessage("1", "Hello with username");
expect(mockMtprotoService.sendMessage).toHaveBeenCalledWith(
"@testuser",
"Hello with username"
);
expect(result.data).toBeDefined();
expect(result.data?.id).toMatch(/^\d+$/); // Timestamp-based ID
expect(result.fromCache).toBe(false);
});
it("should use numeric ID when chat has no username", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "123456",
title: "Test Chat",
type: "private",
unreadCount: 0,
isPinned: false,
});
mockMtprotoService.sendMessage.mockResolvedValueOnce({
id: 200,
randomId: 456n,
} as any);
const result = await sendMessage("123456", "Hello with ID");
expect(mockMtprotoService.sendMessage).toHaveBeenCalledWith(
"123456",
"Hello with ID"
);
expect(result.data).toBeDefined();
expect(result.data?.id).toMatch(/^\d+$/); // Timestamp-based ID
});
it("should use client.sendMessage with replyTo when replyToMessageId is provided", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
username: "testuser",
unreadCount: 0,
isPinned: false,
});
mockClient.sendMessage.mockResolvedValueOnce({
id: 300,
randomId: 789n,
});
const result = await sendMessage("1", "Reply message", 50);
expect(mockClient.sendMessage).toHaveBeenCalledWith("@testuser", {
message: "Reply message",
replyTo: 50,
});
expect(result.data).toBeDefined();
expect(result.data?.id).toMatch(/^\d+$/); // Timestamp-based ID
});
it("should handle API error by propagating exception", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
username: "testuser",
unreadCount: 0,
isPinned: false,
});
mockMtprotoService.sendMessage.mockRejectedValueOnce(
new Error("Network error")
);
await expect(sendMessage("1", "Failed message")).rejects.toThrow(
"Network error"
);
});
it("should handle empty message text", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
username: "testuser",
unreadCount: 0,
isPinned: false,
});
mockMtprotoService.sendMessage.mockResolvedValueOnce({
id: 400,
randomId: 999n,
} as any);
const result = await sendMessage("1", "");
expect(mockMtprotoService.sendMessage).toHaveBeenCalledWith("@testuser", "");
expect(result.data).toBeDefined();
});
it("should handle long message text", async () => {
const longMessage = "A".repeat(4096);
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "1",
title: "Test Chat",
type: "private",
unreadCount: 0,
isPinned: false,
});
mockMtprotoService.sendMessage.mockResolvedValueOnce({
id: 500,
randomId: 111n,
} as any);
const result = await sendMessage("1", longMessage);
expect(mockMtprotoService.sendMessage).toHaveBeenCalledWith("1", longMessage);
expect(result.data).toBeDefined();
});
it("should handle group chat without username", async () => {
const { getChatById } = await import("../helpers");
vi.mocked(getChatById).mockReturnValueOnce({
id: "-1001234567890",
title: "Test Group",
type: "group",
unreadCount: 0,
isPinned: false,
});
mockMtprotoService.sendMessage.mockResolvedValueOnce({
id: 600,
randomId: 222n,
} as any);
const result = await sendMessage("-1001234567890", "Group message");
expect(mockMtprotoService.sendMessage).toHaveBeenCalledWith(
"-1001234567890",
"Group message"
);
expect(result.data).toBeDefined();
expect(result.data?.id).toMatch(/^\d+$/); // Timestamp-based ID
});
});
+36
View File
@@ -0,0 +1,36 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
export async function addContact(
phone: string,
firstName: string,
lastName?: string,
): Promise<ApiResult<void>> {
if (!phone) {
throw new Error("phone is required");
}
if (!firstName) {
throw new Error("first_name is required");
}
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.contacts.ImportContacts({
contacts: [
new Api.InputPhoneContact({
clientId: bigInt(0),
phone,
firstName,
lastName: lastName ?? "",
}),
],
}),
);
});
return { data: undefined, fromCache: false };
}
+30
View File
@@ -0,0 +1,30 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
export async function archiveChat(
chatId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
await client.invoke(
new Api.folders.EditPeerFolders({
folderPeers: [
new Api.InputFolderPeer({
peer: inputPeer,
folderId: 1, // 1 = Archive folder
}),
],
}),
);
});
return { data: undefined as unknown as void, fromCache: false };
}
+46
View File
@@ -0,0 +1,46 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import { toInputChannel, toInputPeer } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export async function banUser(
chatId: string | number,
userId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
if (chat.type !== "channel" && chat.type !== "supergroup") {
throw new Error("Ban is only available for channels/supergroups.");
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
const inputUser = await client.getInputEntity(userId);
await client.invoke(
new Api.channels.EditBanned({
channel: toInputChannel(inputChannel),
participant: toInputPeer(inputUser),
bannedRights: new Api.ChatBannedRights({
viewMessages: true,
sendMessages: true,
sendMedia: true,
sendStickers: true,
sendGifs: true,
sendGames: true,
sendInline: true,
embedLinks: true,
untilDate: 0,
}),
}),
);
});
return { data: undefined, fromCache: false };
}
+15
View File
@@ -0,0 +1,15 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import { toInputPeer } from "./apiCastHelpers";
export async function blockUser(userId: string | number): Promise<ApiResult<void>> {
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
const inputUser = await client.getInputEntity(userId);
await client.invoke(new Api.contacts.Block({ id: toInputPeer(inputUser) }));
});
return { data: undefined, fromCache: false };
}
+34
View File
@@ -0,0 +1,34 @@
/**
* API: Clear draft
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
export async function clearDraft(
chatId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
await client.invoke(
new Api.messages.SaveDraft({
peer: inputPeer,
message: "",
}),
);
});
return { data: undefined as unknown as void, fromCache: false };
}
+30
View File
@@ -0,0 +1,30 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ResultWithChats } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export async function createChannel(
title: string,
about?: string,
megagroup?: boolean,
): Promise<ApiResult<{ id: string; type: string }>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.channels.CreateChannel({
title,
about: about ?? "",
megagroup: megagroup ?? false,
broadcast: !megagroup,
}),
);
});
const channelId =
narrow<ResultWithChats>(result)?.chats?.[0]?.id ?? "unknown";
const type = megagroup ? "Supergroup" : "Channel";
return { data: { id: String(channelId), type }, fromCache: false };
}
+30
View File
@@ -0,0 +1,30 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ResultWithChats } from "./apiResultTypes";
import { toInputUser, narrow } from "./apiCastHelpers";
export async function createGroup(
title: string,
userIds: string[],
): Promise<ApiResult<{ id: string }>> {
if (userIds.length === 0) {
throw new Error("user_ids must not be empty");
}
const client = mtprotoService.getClient();
const users: Api.TypeInputUser[] = [];
for (const uid of userIds) {
const inputUser = await client.getInputEntity(String(uid));
users.push(toInputUser(inputUser));
}
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.messages.CreateChat({ title, users }));
});
const chatId = narrow<ResultWithChats>(result)?.chats?.[0]?.id ?? "unknown";
return { data: { id: String(chatId) }, fromCache: false };
}
+54
View File
@@ -0,0 +1,54 @@
/**
* API: Create poll
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
export async function createPoll(
chatId: string | number,
question: string,
options: string[],
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
await client.invoke(
new Api.messages.SendMedia({
peer: inputPeer,
media: new Api.InputMediaPoll({
poll: new Api.Poll({
id: bigInt(0),
question: new Api.TextWithEntities({
text: question,
entities: [],
}),
answers: options.map(
(opt, i) =>
new Api.PollAnswer({
text: new Api.TextWithEntities({ text: opt, entities: [] }),
option: Buffer.from([i]),
}),
),
}),
}),
message: "",
randomId: bigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
}),
);
});
return { data: undefined as unknown as void, fromCache: false };
}
@@ -0,0 +1,39 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import { toInputChannel } from "./apiCastHelpers";
export async function deleteChatPhoto(
chatId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
if (chat.type === "channel" || chat.type === "supergroup") {
await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
await client.invoke(
new Api.channels.EditPhoto({
channel: toInputChannel(inputChannel),
photo: new Api.InputChatPhotoEmpty(),
}),
);
});
} else {
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.messages.EditChatPhoto({
chatId: bigInt(chat.id),
photo: new Api.InputChatPhotoEmpty(),
}),
);
});
}
return { data: undefined as unknown as void, fromCache: false };
}
+19
View File
@@ -0,0 +1,19 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import { toInputUser } from "./apiCastHelpers";
export async function deleteContact(userId: string | number): Promise<ApiResult<void>> {
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
const inputUser = await client.getInputEntity(userId);
await client.invoke(
new Api.contacts.DeleteContacts({
id: [toInputUser(inputUser)],
}),
);
});
return { data: undefined, fromCache: false };
}
+28
View File
@@ -0,0 +1,28 @@
/**
* API: Delete message
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
export async function deleteMessage(
chatId: string | number,
messageId: number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.deleteMessages(entity, [messageId], { revoke: true });
});
return { data: undefined as unknown as void, fromCache: false };
}
@@ -0,0 +1,48 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiPhoto } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export async function deleteProfilePhoto(): Promise<ApiResult<void>> {
const client = mtprotoService.getClient();
const photos = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.photos.GetUserPhotos({
userId: new Api.InputUserSelf(),
offset: 0,
maxId: bigInt(0),
limit: 1,
}),
);
});
if (
!photos ||
!("photos" in photos) ||
!Array.isArray(photos.photos) ||
photos.photos.length === 0
) {
throw new Error("No profile photo to delete.");
}
const photo = narrow<ApiPhoto>(photos.photos[0]);
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.photos.DeletePhotos({
id: [
new Api.InputPhoto({
id: photo.id,
accessHash: photo.accessHash,
fileReference: photo.fileReference,
}),
],
}),
);
});
return { data: undefined, fromCache: false };
}
+39
View File
@@ -0,0 +1,39 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import { toInputChannel, toInputUser } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export async function demoteAdmin(
chatId: string | number,
userId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
if (chat.type !== "channel" && chat.type !== "supergroup") {
throw new Error(
"Admin demotion is only available for channels/supergroups.",
);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
const inputUser = await client.getInputEntity(userId);
await client.invoke(
new Api.channels.EditAdmin({
channel: toInputChannel(inputChannel),
userId: toInputUser(inputUser),
adminRights: new Api.ChatAdminRights({}),
rank: "",
}),
);
});
return { data: undefined, fromCache: false };
}
+10
View File
@@ -0,0 +1,10 @@
import type { ApiResult } from "./types";
export async function editChatPhoto(
_chatId: string | number,
_filePath: string,
): Promise<ApiResult<void>> {
throw new Error(
"edit_chat_photo requires file upload which is not supported via MCP text interface. Use the Telegram client directly.",
);
}
+40
View File
@@ -0,0 +1,40 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import { toInputChannel } from "./apiCastHelpers";
export async function editChatTitle(
chatId: string | number,
title: string,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
if (chat.type === "channel" || chat.type === "supergroup") {
await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
await client.invoke(
new Api.channels.EditTitle({
channel: toInputChannel(inputChannel),
title,
}),
);
});
} else {
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.messages.EditChatTitle({
chatId: bigInt(chat.id),
title,
}),
);
});
}
return { data: undefined as unknown as void, fromCache: false };
}
+29
View File
@@ -0,0 +1,29 @@
/**
* API: Edit message
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
export async function editMessage(
chatId: string | number,
messageId: number,
newText: string,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.editMessage(entity, { message: messageId, text: newText });
});
return { data: undefined as unknown as void, fromCache: false };
}
@@ -0,0 +1,38 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ChatInviteResult } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export async function exportChatInvite(
chatId: string | number,
title?: string,
expireDate?: number,
usageLimit?: number,
): Promise<ApiResult<{ link: string }>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
return client.invoke(
new Api.messages.ExportChatInvite({
peer: inputPeer,
title: title ?? undefined,
expireDate: expireDate ?? undefined,
usageLimit: usageLimit ?? undefined,
}),
);
});
const link = narrow<ChatInviteResult>(result)?.link;
if (!link) {
throw new Error("Could not create invite link.");
}
return { data: { link }, fromCache: false };
}
@@ -0,0 +1,42 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiUser } from "./apiResultTypes";
export interface ExportedContact {
id: string;
firstName: string;
lastName: string;
username: string;
phone: string;
}
export async function exportContacts(): Promise<
ApiResult<ExportedContact[]>
> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.contacts.GetContacts({ hash: bigInt(0) }));
});
if (
!result ||
!("users" in result) ||
!Array.isArray(result.users) ||
result.users.length === 0
) {
return { data: [], fromCache: false };
}
const contacts: ExportedContact[] = result.users.map((u: ApiUser) => ({
id: String(u.id),
firstName: u.firstName ?? "",
lastName: u.lastName ?? "",
username: u.username ?? "",
phone: u.phone ?? "",
}));
return { data: contacts, fromCache: false };
}
@@ -0,0 +1,40 @@
/**
* API: Forward message
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
export async function forwardMessage(
fromChatId: string | number,
toChatId: string | number,
messageId: number,
): Promise<ApiResult<void>> {
const fromChat = getChatById(fromChatId);
if (!fromChat) {
throw new Error(`Source chat not found: ${fromChatId}`);
}
const toChat = getChatById(toChatId);
if (!toChat) {
throw new Error(`Target chat not found: ${toChatId}`);
}
const fromEntity = fromChat.username
? `@${fromChat.username.replace("@", "")}`
: fromChat.id;
const toEntity = toChat.username
? `@${toChat.username.replace("@", "")}`
: toChat.id;
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.forwardMessages(toEntity, {
messages: [messageId],
fromPeer: fromEntity,
});
});
return { data: undefined as unknown as void, fromCache: false };
}
+60
View File
@@ -0,0 +1,60 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiUser } from "./apiResultTypes";
import { toInputChannel, narrow } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export interface Admin {
id: string;
name: string;
username?: string;
}
export async function getAdmins(chatId: string | number): Promise<ApiResult<Admin[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
let admins: ApiUser[] = [];
if (chat.type === "channel" || chat.type === "supergroup") {
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
return client.invoke(
new Api.channels.GetParticipants({
channel: toInputChannel(inputChannel),
filter: new Api.ChannelParticipantsAdmins(),
offset: 0,
limit: 100,
hash: bigInt(0),
}),
);
});
if (result && "users" in result && Array.isArray(result.users)) {
admins = narrow<ApiUser[]>(result.users);
}
} else {
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.messages.GetFullChat({ chatId: bigInt(chat.id) }),
);
});
if (result && "users" in result && Array.isArray(result.users)) {
admins = narrow<ApiUser[]>(result.users);
}
}
const data: Admin[] = admins.map((u: ApiUser) => ({
id: String(u.id),
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown",
username: u.username,
}));
return { data, fromCache: false };
}
@@ -0,0 +1,62 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiUser } from "./apiResultTypes";
import { toInputChannel } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export interface BannedUser {
id: string;
name: string;
username?: string;
}
export async function getBannedUsers(
chatId: string | number,
limit: number = 50,
): Promise<ApiResult<BannedUser[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
if (chat.type !== "channel" && chat.type !== "supergroup") {
throw new Error(
"Banned users list is only available for channels/supergroups.",
);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
return client.invoke(
new Api.channels.GetParticipants({
channel: toInputChannel(inputChannel),
filter: new Api.ChannelParticipantsKicked({ q: "" }),
offset: 0,
limit,
hash: bigInt(0),
}),
);
});
if (
!result ||
!("users" in result) ||
!Array.isArray(result.users) ||
result.users.length === 0
) {
return { data: [], fromCache: false };
}
const data: BannedUser[] = result.users.map((u: ApiUser) => ({
id: String(u.id),
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown",
username: u.username,
}));
return { data, fromCache: false };
}
@@ -0,0 +1,37 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ApiUser } from "./apiResultTypes";
export interface BlockedUser {
id: string;
name: string;
username?: string;
}
export async function getBlockedUsers(
limit: number = 50,
): Promise<ApiResult<BlockedUser[]>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.contacts.GetBlocked({ offset: 0, limit }));
});
if (
!result ||
!("users" in result) ||
!Array.isArray(result.users) ||
result.users.length === 0
) {
return { data: [], fromCache: false };
}
const blockedUsers: BlockedUser[] = result.users.map((u: ApiUser) => ({
id: String(u.id),
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown",
username: u.username,
}));
return { data: blockedUsers, fromCache: false };
}
+51
View File
@@ -0,0 +1,51 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { FullUserResult } from "./apiResultTypes";
import { toInputUser, narrow } from "./apiCastHelpers";
export interface BotInfo {
name: string;
username: string;
id: string;
isBot: boolean;
about?: string;
botDescription?: string;
commands?: Array<{ command: string; description: string }>;
}
export async function getBotInfo(
botId: string | number,
): Promise<ApiResult<BotInfo>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputUser = await client.getInputEntity(botId);
return client.invoke(
new Api.users.GetFullUser({ id: toInputUser(inputUser) }),
);
});
const fullUser = narrow<FullUserResult>(result)?.fullUser;
const user = narrow<FullUserResult>(result)?.users?.[0];
if (!user) {
throw new Error("Bot not found: " + botId);
}
const name =
[user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown";
return {
data: {
name,
username: user.username ?? "N/A",
id: String(user.id),
isBot: user.bot ?? false,
about: fullUser?.about,
botDescription: fullUser?.botInfo?.description,
commands: fullUser?.botInfo?.commands,
},
fromCache: false,
};
}
+111
View File
@@ -0,0 +1,111 @@
/**
* API: Get chat — cache-first with API fallback (hybrid).
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { enforceRateLimit } from "../../rateLimiter";
import { getChatById, formatEntity } from "./helpers";
import type { ApiResult } from "./types";
export interface ChatInfo {
id: string;
name: string;
type: string;
username?: string;
participantsCount?: number;
unreadCount?: number;
phone?: string;
isBot?: boolean;
lastMessage?: {
from: string;
date: string;
text: string;
};
}
export async function getChat(
chatId: string | number,
): Promise<ApiResult<ChatInfo | undefined>> {
// 1. Try cache
const chat = getChatById(chatId);
if (chat) {
const entity = formatEntity(chat);
const info: ChatInfo = {
id: entity.id,
name: entity.name,
type: entity.type,
username: entity.username,
};
if ("participantsCount" in chat && chat.participantsCount) {
info.participantsCount = chat.participantsCount as number;
}
if ("unreadCount" in chat) {
info.unreadCount = (chat.unreadCount as number) ?? 0;
}
const lastMsg = (
chat as {
lastMessage?: {
fromName?: string;
fromId?: string;
date: number;
message?: string;
};
}
).lastMessage;
if (lastMsg) {
info.lastMessage = {
from: lastMsg.fromName ?? lastMsg.fromId ?? "Unknown",
date: new Date(lastMsg.date * 1000).toISOString(),
text: lastMsg.message || "[Media/No text]",
};
}
return { data: info, fromCache: true };
}
// 2. Cache miss — try Telegram API
try {
await enforceRateLimit("__api_fallback_chat");
const client = mtprotoService.getClient();
const rawEntity = await mtprotoService.withFloodWaitHandling(async () => {
return client.getEntity(chatId);
});
if (!rawEntity) return { data: undefined, fromCache: false };
const raw = rawEntity as unknown as Record<string, unknown>;
const className = raw.className as string | undefined;
const info: ChatInfo = {
id: String(raw.id ?? chatId),
name: "Unknown",
type: "unknown",
};
if (className === "User") {
info.name =
[raw.firstName, raw.lastName].filter(Boolean).join(" ") || "Unknown";
info.type = "user";
if (raw.username) info.username = String(raw.username);
if (raw.phone) info.phone = String(raw.phone);
if (raw.bot) info.isBot = true;
} else if (className === "Channel") {
info.name = (raw.title as string) ?? "Unknown";
info.type = raw.megagroup ? "supergroup" : "channel";
if (raw.username) info.username = String(raw.username);
if (raw.participantsCount)
info.participantsCount = raw.participantsCount as number;
} else if (className === "Chat") {
info.name = (raw.title as string) ?? "Unknown";
info.type = "group";
if (raw.participantsCount)
info.participantsCount = raw.participantsCount as number;
} else {
info.name =
(raw.title as string) ?? (raw.firstName as string) ?? "Unknown";
}
return { data: info, fromCache: false };
} catch {
return { data: undefined, fromCache: false };
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* API: Get chats — cache-first with API fallback.
*/
import type { TelegramChat } from "../../../../store/telegram/types";
import { mtprotoService } from "../../../../services/mtprotoService";
import { Api } from "telegram";
import bigInt from "big-integer";
import { enforceRateLimit } from "../../rateLimiter";
import { getOrderedChats, apiDialogToTelegramChat } from "./helpers";
import type { ApiResult } from "./types";
export async function getChats(
limit = 20,
): Promise<ApiResult<TelegramChat[]>> {
// 1. Try cache
const cached = getOrderedChats(limit);
if (cached.length > 0) return { data: cached, fromCache: true };
// 2. Rate limit before API call
try {
await enforceRateLimit("__api_fallback_chats");
} catch {
return { data: [], fromCache: false };
}
// 3. Fetch from Telegram API
try {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.messages.GetDialogs({
offsetDate: 0,
offsetId: 0,
offsetPeer: new Api.InputPeerEmpty(),
limit,
hash: bigInt(0),
}),
);
});
if (!("dialogs" in result) || !Array.isArray(result.dialogs)) {
return { data: [], fromCache: false };
}
// Index chats and users by ID
const chatsById = new Map<string, Record<string, unknown>>();
const usersById = new Map<string, Record<string, unknown>>();
if ("chats" in result && Array.isArray(result.chats)) {
for (const c of result.chats as unknown as Record<string, unknown>[]) {
if (c.id != null) chatsById.set(String(c.id), c);
}
}
if ("users" in result && Array.isArray(result.users)) {
for (const u of result.users as unknown as Record<string, unknown>[]) {
if (u.id != null) usersById.set(String(u.id), u);
}
}
const data = (result.dialogs as unknown as Record<string, unknown>[])
.map((d) => apiDialogToTelegramChat(d, chatsById, usersById))
.filter((c): c is TelegramChat => c !== undefined)
.slice(0, limit);
return { data, fromCache: false };
} catch {
return { data: [], fromCache: false };
}
}
@@ -0,0 +1,27 @@
/**
* API: Get contact chats — cache-only.
*/
import { store } from "../../../../store";
import { selectOrderedChats } from "../../../../store/telegramSelectors";
import type { ApiResult } from "./types";
export interface ContactChat {
id: string;
title: string;
username?: string;
}
export async function getContactChats(): Promise<ApiResult<ContactChat[]>> {
const state = store.getState();
const chats = selectOrderedChats(state);
const dmChats = chats
.filter((c) => c.type === "private")
.map((c) => ({
id: c.id,
title: c.title ?? "DM",
username: c.username,
}));
return { data: dmChats, fromCache: true };
}
+26
View File
@@ -0,0 +1,26 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ContactIdEntry } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
type ContactIdResult = number | ContactIdEntry;
export async function getContactIds(): Promise<ApiResult<string[]>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.contacts.GetContactIDs({ hash: bigInt(0) }));
});
if (!result || !Array.isArray(result) || result.length === 0) {
return { data: [], fromCache: false };
}
const ids = narrow<ContactIdResult[]>(result).map((c) =>
String(typeof c === "number" ? c : (c.userId ?? c)),
);
return { data: ids, fromCache: false };
}
@@ -0,0 +1,57 @@
/**
* API: Get current user — cache-first with API fallback.
*/
import type { TelegramUser } from "../../../../store/telegram/types";
import { mtprotoService } from "../../../../services/mtprotoService";
import { enforceRateLimit } from "../../rateLimiter";
import { getCurrentUser as getCachedCurrentUser } from "./helpers";
import type { ApiResult } from "./types";
export async function getCurrentUser(): Promise<
ApiResult<TelegramUser | undefined>
> {
// 1. Try cache
const cached = getCachedCurrentUser();
if (cached) return { data: cached, fromCache: true };
// 2. Rate limit before API call
try {
await enforceRateLimit("__api_fallback_me");
} catch {
return { data: undefined, fromCache: false };
}
// 3. Fetch from Telegram API
try {
const client = mtprotoService.getClient();
const me = await mtprotoService.withFloodWaitHandling(async () => {
return client.getMe();
});
if (!me) return { data: undefined, fromCache: false };
const raw = me as unknown as {
id?: unknown;
firstName?: string;
lastName?: string;
username?: string;
phone?: string;
bot?: boolean;
};
return {
data: {
id: String(raw.id ?? ""),
firstName: raw.firstName ?? "",
lastName: raw.lastName,
username: raw.username,
phoneNumber: raw.phone,
isBot: Boolean(raw.bot),
},
fromCache: false,
};
} catch {
return { data: undefined, fromCache: false };
}
}
@@ -0,0 +1,35 @@
/**
* API: Get direct chat by contact — cache-only.
*/
import { store } from "../../../../store";
import { selectOrderedChats } from "../../../../store/telegramSelectors";
import type { ApiResult } from "./types";
export interface DirectChat {
id: string;
title: string;
username?: string;
}
export async function getDirectChatByContact(
userId: string | number,
): Promise<ApiResult<DirectChat | undefined>> {
const state = store.getState();
const chats = selectOrderedChats(state);
const dmChat = chats.find(
(c) => c.type === "private" && String(c.id) === String(userId),
);
if (!dmChat) return { data: undefined, fromCache: true };
return {
data: {
id: dmChat.id,
title: dmChat.title ?? "DM",
username: dmChat.username,
},
fromCache: true,
};
}
+44
View File
@@ -0,0 +1,44 @@
/**
* API: Get all drafts
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import type { UpdatesResult } from "./apiResultTypes";
import { Api } from "telegram";
import { narrow } from "./apiCastHelpers";
export interface DraftData {
peerId: string;
message: string;
}
export async function getDrafts(): Promise<ApiResult<DraftData[]>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.messages.GetAllDrafts());
});
const updates = narrow<UpdatesResult>(result);
if (!updates || !updates.updates || updates.updates.length === 0) {
return { data: [], fromCache: false };
}
const drafts: DraftData[] = [];
for (const update of updates.updates) {
if (update.draft && update.draft.message) {
const peerId =
update.peer?.userId ??
update.peer?.chatId ??
update.peer?.channelId ??
"?";
drafts.push({
peerId: String(peerId),
message: update.draft.message,
});
}
}
return { data: drafts, fromCache: false };
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Get GIF Search API - Search GIFs using Telegram's inline bot
*
* Uses the @gif bot to search for GIFs.
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import type { InlineBotResults } from "./apiResultTypes";
import { toInputUser, narrow } from "./apiCastHelpers";
import { Api } from "telegram";
export interface GifResult {
title: string;
description?: string;
}
export async function getGifSearch(
query: string,
limit: number,
): Promise<ApiResult<GifResult[]>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
const bot = await client.getInputEntity("gif");
return client.invoke(
new Api.messages.GetInlineBotResults({
bot: toInputUser(bot),
peer: new Api.InputPeerSelf(),
query,
offset: "",
}),
);
});
const results = narrow<InlineBotResults>(result)?.results;
if (!results || !Array.isArray(results) || results.length === 0) {
return { data: [], fromCache: false };
}
const gifs: GifResult[] = results.slice(0, limit).map((r) => ({
title: r.title ?? r.description ?? "GIF",
description: r.description,
}));
return { data: gifs, fromCache: false };
}
+33
View File
@@ -0,0 +1,33 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ChatInviteResult } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export async function getInviteLink(
chatId: string | number,
): Promise<ApiResult<{ link: string }>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
return client.invoke(
new Api.messages.ExportChatInvite({
peer: inputPeer,
legacyRevokePermanent: true,
}),
);
});
const link = narrow<ChatInviteResult>(result)?.link;
if (!link) {
throw new Error("Could not generate invite link.");
}
return { data: { link }, fromCache: false };
}
@@ -0,0 +1,38 @@
/**
* API: Get last interaction — cache-only.
*/
import type { ApiResult } from "./types";
import { getChatById, getCachedMessages, formatMessage } from "./helpers";
export interface LastInteraction {
chatTitle: string;
from: string;
date: string;
text: string;
}
export async function getLastInteraction(
chatId: string | number,
): Promise<ApiResult<LastInteraction | undefined>> {
const chat = getChatById(chatId);
if (!chat) return { data: undefined, fromCache: true };
const messages = getCachedMessages(chatId, 1, 0);
if (!messages || messages.length === 0) {
return { data: undefined, fromCache: true };
}
const msg = messages[0];
const f = formatMessage(msg);
return {
data: {
chatTitle: chat.title ?? String(chatId),
from: msg.fromName ?? msg.fromId ?? "Unknown",
date: f.date,
text: f.text || "[Media/No text]",
},
fromCache: true,
};
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Get Media Info API - Get media info from a cached message
*
* Reads from cached messages only, does not make API calls.
*/
import { getChatById, getCachedMessages } from "./helpers";
import type { ApiResult } from "./types";
export interface MediaInfo {
type: string;
[key: string]: unknown;
}
export async function getMediaInfo(
chatId: string | number,
messageId: number,
): Promise<ApiResult<MediaInfo>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error("Chat not found: " + chatId);
}
const messages = getCachedMessages(chatId, 200, 0);
if (!messages || messages.length === 0) {
throw new Error("No messages found in cache.");
}
const msg = messages.find((m) => String(m.id) === String(messageId));
if (!msg) {
throw new Error("Message " + messageId + " not found in cache.");
}
if (!msg.media) {
return {
data: { type: "none" },
fromCache: true,
};
}
const info: MediaInfo = {
...msg.media,
type: msg.media.type ?? "unknown",
};
return { data: info, fromCache: true };
}
@@ -0,0 +1,58 @@
/**
* API: Get message reactions
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import type { UpdatesResult } from "./apiResultTypes";
import { Api } from "telegram";
import { narrow } from "./apiCastHelpers";
export interface ReactionData {
emoji: string;
count: number;
}
export async function getMessageReactions(
chatId: string | number,
messageId: number,
): Promise<ApiResult<ReactionData[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
return client.invoke(
new Api.messages.GetMessagesReactions({
peer: inputPeer,
id: [messageId],
}),
);
});
const updates = narrow<UpdatesResult>(result);
if (!updates || !updates.updates || updates.updates.length === 0) {
return { data: [], fromCache: false };
}
const reactions: ReactionData[] = [];
for (const update of updates.updates) {
if (update.reactions && update.reactions.results) {
for (const r of update.reactions.results) {
const emoji = r.reaction?.emoticon ?? r.reaction?.className ?? "?";
const count = r.count ?? 0;
reactions.push({ emoji, count });
}
}
}
return { data: reactions, fromCache: false };
}
+98
View File
@@ -0,0 +1,98 @@
/**
* API: Get messages — cache-first with API fallback.
*/
import type { TelegramMessage } from "../../../../store/telegram/types";
import { mtprotoService } from "../../../../services/mtprotoService";
import { Api } from "telegram";
import bigInt from "big-integer";
import { enforceRateLimit } from "../../rateLimiter";
import {
getChatById,
getCachedMessages,
apiMessageToTelegramMessage,
} from "./helpers";
import type { ApiResult } from "./types";
export async function getMessages(
chatId: string | number,
limit = 20,
offset = 0,
): Promise<ApiResult<TelegramMessage[]>> {
// 1. Try cache
const cached = getCachedMessages(chatId, limit, offset);
if (cached && cached.length > 0) {
return { data: cached, fromCache: true };
}
// 2. Resolve chat for API call
const chat = getChatById(chatId);
if (!chat) return { data: [], fromCache: false };
// 3. Rate limit before API call
try {
await enforceRateLimit("__api_fallback_messages");
} catch {
return { data: [], fromCache: false };
}
// 4. Fetch from Telegram API
try {
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const inputPeer = await client.getInputEntity(entity);
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.messages.GetHistory({
peer: inputPeer,
offsetId: 0,
offsetDate: 0,
addOffset: offset,
limit,
maxId: 0,
minId: 0,
hash: bigInt(0),
}),
);
});
if ("messages" in result && Array.isArray(result.messages)) {
const messages = (
result.messages as unknown as Record<string, unknown>[]
)
.filter((m) => m.className !== "MessageEmpty")
.map((m) => apiMessageToTelegramMessage(m, chat.id));
// Enrich fromName from users list
if ("users" in result && Array.isArray(result.users)) {
const usersById = new Map<string, string>();
for (const u of result.users as Array<{
id?: unknown;
firstName?: string;
lastName?: string;
}>) {
if (u.id != null) {
const name =
[u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown";
usersById.set(String(u.id), name);
}
}
for (const msg of messages) {
if (msg.fromId && !msg.fromName) {
msg.fromName = usersById.get(msg.fromId);
}
}
}
return {
data: messages.length > 0 ? messages : [],
fromCache: false,
};
}
} catch {
// API failed — return empty
}
return { data: [], fromCache: false };
}
@@ -0,0 +1,63 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiUser } from "./apiResultTypes";
import { toInputChannel, narrow } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export interface Participant {
id: string;
name: string;
username?: string;
}
export async function getParticipants(
chatId: string | number,
limit: number = 50,
): Promise<ApiResult<Participant[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
let participants: ApiUser[] = [];
if (chat.type === "channel" || chat.type === "supergroup") {
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
return client.invoke(
new Api.channels.GetParticipants({
channel: toInputChannel(inputChannel),
filter: new Api.ChannelParticipantsRecent(),
offset: 0,
limit,
hash: bigInt(0),
}),
);
});
if (result && "users" in result && Array.isArray(result.users)) {
participants = narrow<ApiUser[]>(result.users);
}
} else {
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.messages.GetFullChat({ chatId: bigInt(chat.id) }),
);
});
if (result && "users" in result && Array.isArray(result.users)) {
participants = narrow<ApiUser[]>(result.users);
}
}
const data: Participant[] = participants.map((u: ApiUser) => ({
id: String(u.id),
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown",
username: u.username,
}));
return { data, fromCache: false };
}
@@ -0,0 +1,68 @@
/**
* API: Get pinned messages (API-first with cache fallback)
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import type { ApiMessage } from "./apiResultTypes";
import { Api } from "telegram";
import bigInt from "big-integer";
import { narrow } from "./apiCastHelpers";
export interface PinnedMessage {
id: number | string;
date: string;
text: string;
}
export async function getPinnedMessages(
chatId: string | number,
): Promise<ApiResult<PinnedMessage[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const client = mtprotoService.getClient();
// Try API first
try {
const inputPeer = await client.getInputEntity(entity);
const result = await client.invoke(
new Api.messages.Search({
peer: inputPeer,
q: "",
filter: new Api.InputMessagesFilterPinned(),
minDate: 0,
maxDate: 0,
offsetId: 0,
addOffset: 0,
limit: 50,
maxId: 0,
minId: 0,
hash: bigInt(0),
}),
);
if ("messages" in result && Array.isArray(result.messages)) {
const pinnedMessages = narrow<ApiMessage[]>(result.messages).map((msg) => ({
id: msg.id ?? "?",
date: msg.date
? new Date(msg.date * 1000).toISOString()
: "unknown",
text: msg.message ?? "[Media/No text]",
}));
return { data: pinnedMessages, fromCache: false };
}
} catch {
// API failed, fall back to cache below
}
// Fallback: pinned status is not stored in Redux cache (TelegramMessage),
// so we can't provide pinned messages from cache. Return empty result.
return { data: [], fromCache: true };
}
@@ -0,0 +1,62 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { PrivacyResult } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export type PrivacyKey =
| "phone_number"
| "last_seen"
| "profile_photo"
| "forwards"
| "phone_call"
| "chat_invite";
export interface PrivacySettings {
key: PrivacyKey;
rules: string[];
}
const keyMap: Record<PrivacyKey, Api.TypeInputPrivacyKey> = {
phone_number: new Api.InputPrivacyKeyPhoneNumber(),
last_seen: new Api.InputPrivacyKeyStatusTimestamp(),
profile_photo: new Api.InputPrivacyKeyProfilePhoto(),
forwards: new Api.InputPrivacyKeyForwards(),
phone_call: new Api.InputPrivacyKeyPhoneCall(),
chat_invite: new Api.InputPrivacyKeyChatInvite(),
};
export async function getPrivacySettings(
key: string = "last_seen",
): Promise<ApiResult<PrivacySettings>> {
if (!(key in keyMap)) {
throw new Error(
"Unknown privacy key: " +
key +
". Valid keys: " +
Object.keys(keyMap).join(", "),
);
}
const privacyKey = key as PrivacyKey;
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.account.GetPrivacy({ key: keyMap[privacyKey] }));
});
const rules = narrow<PrivacyResult>(result)?.rules;
if (!rules || !Array.isArray(rules)) {
return {
data: { key: privacyKey, rules: [] },
fromCache: false,
};
}
const ruleNames = rules.map((r) => r.className ?? "Unknown rule");
return {
data: { key: privacyKey, rules: ruleNames },
fromCache: false,
};
}
@@ -0,0 +1,63 @@
/**
* Get Recent Actions API - Get recent admin actions in a chat
*
* Fetches admin log from channels/supergroups only.
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import type { AdminLogResult } from "./apiResultTypes";
import { toInputChannel, narrow } from "./apiCastHelpers";
import { Api } from "telegram";
import bigInt from "big-integer";
export interface AdminActionResult {
date: string;
action: string;
}
export async function getRecentActions(
chatId: string | number,
limit: number,
): Promise<ApiResult<AdminActionResult[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error("Chat not found: " + chatId);
}
if (chat.type !== "channel" && chat.type !== "supergroup") {
throw new Error(
"Recent actions are only available for channels/supergroups.",
);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
return client.invoke(
new Api.channels.GetAdminLog({
channel: toInputChannel(inputChannel),
q: "",
maxId: bigInt(0),
minId: bigInt(0),
limit,
}),
);
});
const events = narrow<AdminLogResult>(result)?.events;
if (!events || !Array.isArray(events) || events.length === 0) {
return { data: [], fromCache: false };
}
const actions: AdminActionResult[] = events.map((e) => {
const date = e.date ? new Date(e.date * 1000).toISOString() : "unknown";
const action = e.action?.className ?? "unknown";
return { date, action };
});
return { data: actions, fromCache: false };
}
@@ -0,0 +1,41 @@
/**
* Get Sticker Sets API - Get user's sticker sets
*
* Fetches all available sticker sets for the current user.
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import type { StickerSetsResult } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
import { Api } from "telegram";
import bigInt from "big-integer";
export interface StickerSetResult {
id: string;
title: string;
count: number;
}
export async function getStickerSets(): Promise<
ApiResult<StickerSetResult[]>
> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.messages.GetAllStickers({ hash: bigInt(0) }));
});
const sets = narrow<StickerSetsResult>(result)?.sets;
if (!sets || !Array.isArray(sets) || sets.length === 0) {
return { data: [], fromCache: false };
}
const stickerSets: StickerSetResult[] = sets.map((s) => ({
id: String(s.id),
title: s.title ?? "Untitled",
count: s.count ?? 0,
}));
return { data: stickerSets, fromCache: false };
}
+48
View File
@@ -0,0 +1,48 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiPhoto } from "./apiResultTypes";
import { toInputUser, narrow } from "./apiCastHelpers";
export interface UserPhoto {
id: string;
date: string;
}
export async function getUserPhotos(
userId: string | number,
limit: number = 10,
): Promise<ApiResult<UserPhoto[]>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputUser = await client.getInputEntity(userId);
return client.invoke(
new Api.photos.GetUserPhotos({
userId: toInputUser(inputUser),
offset: 0,
maxId: bigInt(0),
limit,
}),
);
});
if (
!result ||
!("photos" in result) ||
!Array.isArray(result.photos) ||
result.photos.length === 0
) {
return { data: [], fromCache: false };
}
const photos = narrow<ApiPhoto[]>(result.photos).map((photo) => ({
id: String(photo.id),
date: photo.date
? new Date(photo.date * 1000).toISOString()
: "unknown",
}));
return { data: photos, fromCache: false };
}
+66
View File
@@ -0,0 +1,66 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ApiUser } from "./apiResultTypes";
import { toInputUser, narrow } from "./apiCastHelpers";
export interface UserStatus {
userId: string;
name: string;
status: string;
lastSeen?: string;
}
export async function getUserStatus(
userId: string | number,
): Promise<ApiResult<UserStatus>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputUser = await client.getInputEntity(userId);
return client.invoke(
new Api.users.GetUsers({ id: [toInputUser(inputUser)] }),
);
});
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error("User " + userId + " not found.");
}
const user = narrow<ApiUser>(result[0]);
const name =
[user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown";
let statusText = "unknown";
let lastSeen: string | undefined = undefined;
if (user.status) {
const s = user.status;
if (s.className === "UserStatusOnline") {
statusText = "Online";
} else if (s.className === "UserStatusOffline") {
const lastSeenDate = s.wasOnline
? new Date(s.wasOnline * 1000).toISOString()
: "unknown";
statusText = "Offline";
lastSeen = lastSeenDate;
} else if (s.className === "UserStatusRecently") {
statusText = "Recently";
} else if (s.className === "UserStatusLastWeek") {
statusText = "Last week";
} else if (s.className === "UserStatusLastMonth") {
statusText = "Last month";
} else {
statusText = s.className ?? "unknown";
}
}
return {
data: {
userId: String(user.id),
name,
status: statusText,
lastSeen,
},
fromCache: false,
};
}
+292
View File
@@ -0,0 +1,292 @@
/**
* Shared helpers for the Telegram API layer.
*
* Cache lookups, entity formatting, and message formatting live here.
* These are used by both api/ functions and (transitionally) by tool wrappers.
*/
import { store } from "../../../../store";
import {
selectOrderedChats,
selectCurrentUser,
selectTelegramUserState,
} from "../../../../store/telegramSelectors";
import type {
TelegramChat,
TelegramUser,
TelegramMessage,
} from "../../../../store/telegram/types";
// ---------------------------------------------------------------------------
// Redux state helpers
// ---------------------------------------------------------------------------
export function getTelegramState() {
return selectTelegramUserState(store.getState());
}
// ---------------------------------------------------------------------------
// Cache lookups
// ---------------------------------------------------------------------------
/**
* Get chat by ID or username from Redux cache.
*/
export function getChatById(
chatId: string | number,
): TelegramChat | undefined {
const state = getTelegramState();
const idStr = String(chatId);
const chat = state.chats[idStr];
if (chat) return chat;
if (
typeof chatId === "string" &&
(chatId.startsWith("@") || /^[a-zA-Z0-9_]+$/.test(chatId))
) {
const username = chatId.startsWith("@") ? chatId : `@${chatId}`;
return Object.values(state.chats).find(
(c) =>
c.username &&
(c.username === username || c.username === username.slice(1)),
);
}
return undefined;
}
/**
* Get user by ID (current user only; no full user cache).
*/
export function getUserById(
userId: string | number,
): TelegramUser | undefined {
const state = getTelegramState();
const current = state.currentUser;
if (!current) return undefined;
if (String(current.id) === String(userId)) return current;
return undefined;
}
/**
* Get current user from Redux cache.
*/
export function getCurrentUser(): TelegramUser | undefined {
const state = store.getState();
return selectCurrentUser(state) ?? undefined;
}
/**
* Get ordered chats from Redux cache.
*/
export function getOrderedChats(limit = 20): TelegramChat[] {
const state = store.getState();
const ordered = selectOrderedChats(state);
return ordered.slice(0, limit);
}
/**
* Get cached messages for a chat from Redux store.
*/
export function getCachedMessages(
chatId: string | number,
limit = 20,
offset = 0,
): TelegramMessage[] | undefined {
const chat = getChatById(chatId);
if (!chat) return undefined;
const state = getTelegramState();
const order = state.messagesOrder[chat.id] ?? [];
const byId = state.messages[chat.id] ?? {};
const all = order.map((id) => byId[id]).filter(Boolean);
const list = all.slice(offset, offset + limit);
return list.length ? list : undefined;
}
/**
* Search chats by query (filter by title/username from cache).
*/
export function searchChatsInCache(query: string): TelegramChat[] {
const state = store.getState();
const ordered = selectOrderedChats(state);
const q = query.toLowerCase();
return ordered.filter((c) => {
const title = (c.title ?? "").toLowerCase();
const un = (c.username ?? "").toLowerCase();
return title.includes(q) || un.includes(q);
});
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
export interface FormattedEntity {
id: string;
name: string;
type: string;
username?: string;
phone?: string;
}
export interface FormattedMessage {
id: number | string;
date: string;
text: string;
from_id?: string;
has_media?: boolean;
media_type?: string;
}
/**
* Format entity (chat or user) for display.
*/
export function formatEntity(
entity: TelegramChat | TelegramUser,
): FormattedEntity {
if ("title" in entity) {
const chat = entity as TelegramChat;
const type =
chat.type === "channel"
? "channel"
: chat.type === "supergroup"
? "group"
: chat.type;
return {
id: chat.id,
name: chat.title ?? "Unknown",
type,
username: chat.username,
};
}
const user = entity as TelegramUser;
const name =
[user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown";
return {
id: user.id,
name,
type: "user",
username: user.username,
phone: user.phoneNumber,
};
}
/**
* Format message for display.
*/
export function formatMessage(message: TelegramMessage): FormattedMessage {
const result: FormattedMessage = {
id: message.id,
date: new Date(message.date * 1000).toISOString(),
text: message.message ?? "",
};
if (message.fromId) result.from_id = message.fromId;
if (message.media?.type) {
result.has_media = true;
result.media_type = message.media.type;
}
return result;
}
// ---------------------------------------------------------------------------
// GramJS conversion helpers (used by API fallback functions)
// ---------------------------------------------------------------------------
/** Convert a raw GramJS message to our TelegramMessage format */
export function apiMessageToTelegramMessage(
msg: Record<string, unknown>,
chatId: string,
): TelegramMessage {
const fromId =
msg.fromId &&
typeof msg.fromId === "object" &&
"userId" in (msg.fromId as object)
? String((msg.fromId as { userId: unknown }).userId)
: undefined;
const replyTo = msg.replyTo as { replyToMsgId?: number } | undefined;
const media = msg.media as { className?: string } | undefined;
let mediaInfo: TelegramMessage["media"] | undefined;
if (media && media.className && media.className !== "MessageMediaEmpty") {
mediaInfo = { type: media.className };
}
return {
id: String(msg.id ?? ""),
chatId,
date: typeof msg.date === "number" ? msg.date : 0,
message: typeof msg.message === "string" ? msg.message : "",
fromId,
isOutgoing: Boolean(msg.out),
isEdited: msg.editDate != null,
isForwarded: msg.fwdFrom != null,
replyToMessageId:
replyTo?.replyToMsgId != null
? String(replyTo.replyToMsgId)
: undefined,
media: mediaInfo,
};
}
/** Convert a raw GramJS dialog + chat/user to our TelegramChat format */
export function apiDialogToTelegramChat(
dialog: Record<string, unknown>,
chatsById: Map<string, Record<string, unknown>>,
usersById: Map<string, Record<string, unknown>>,
): TelegramChat | undefined {
const peer = dialog.peer as
| {
className?: string;
userId?: unknown;
chatId?: unknown;
channelId?: unknown;
}
| undefined;
if (!peer) return undefined;
let id: string;
let type: TelegramChat["type"];
let raw: Record<string, unknown> | undefined;
if (peer.className === "PeerUser" && peer.userId != null) {
id = String(peer.userId);
type = "private";
raw = usersById.get(id);
} else if (peer.className === "PeerChat" && peer.chatId != null) {
id = String(peer.chatId);
type = "group";
raw = chatsById.get(id);
} else if (peer.className === "PeerChannel" && peer.channelId != null) {
id = String(peer.channelId);
raw = chatsById.get(id);
type = raw && Boolean(raw.megagroup) ? "supergroup" : "channel";
} else {
return undefined;
}
let title: string;
let username: string | undefined;
if (raw) {
title =
(raw.title as string) ??
[raw.firstName, raw.lastName].filter(Boolean).join(" ") ??
"Unknown";
username = raw.username as string | undefined;
} else {
title = "Unknown";
}
return {
id,
title,
type,
username,
unreadCount:
typeof dialog.unreadCount === "number" ? dialog.unreadCount : 0,
isPinned: Boolean(dialog.pinned),
};
}
@@ -0,0 +1,20 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ResultWithChats } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export async function importChatInvite(
hash: string,
): Promise<ApiResult<{ chatTitle: string }>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.messages.ImportChatInvite({ hash }));
});
const chatTitle =
narrow<ResultWithChats>(result)?.chats?.[0]?.title ?? "unknown";
return { data: { chatTitle }, fromCache: false };
}
@@ -0,0 +1,44 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ContactInput, ImportContactsResult } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export interface ImportContactsResponse {
imported: number;
total: number;
}
export async function importContacts(
contacts: ContactInput[],
): Promise<ApiResult<ImportContactsResponse>> {
if (!Array.isArray(contacts) || contacts.length === 0) {
throw new Error("contacts array is required and must not be empty.");
}
const inputContacts = contacts.map(
(c: ContactInput, i: number) =>
new Api.InputPhoneContact({
clientId: bigInt(i),
phone: String(c.phone ?? ""),
firstName: String(c.first_name ?? ""),
lastName: String(c.last_name ?? ""),
}),
);
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.contacts.ImportContacts({ contacts: inputContacts }),
);
});
const imported = narrow<ImportContactsResult>(result)?.imported?.length ?? 0;
return {
data: { imported, total: contacts.length },
fromCache: false,
};
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Barrel export for the Telegram API layer.
*
* Each module exposes a single async function returning ApiResult<T>.
*/
// Foundation
export type { ApiResult } from "./types";
export * from "./helpers";
export * from "./apiResultTypes";
export * from "./apiCastHelpers";
// Cache-first with API fallback
export { getChats } from "./getChats";
export { getMessages } from "./getMessages";
export { getCurrentUser } from "./getCurrentUser";
// Hybrid (API-first with cache fallback)
export { getChat } from "./getChat";
export { resolveUsername } from "./resolveUsername";
export { searchPublicChats } from "./searchPublicChats";
export { searchMessages } from "./searchMessages";
export { getPinnedMessages } from "./getPinnedMessages";
// Cache-only
export { getContactChats } from "./getContactChats";
export { getDirectChatByContact } from "./getDirectChatByContact";
export { getLastInteraction } from "./getLastInteraction";
export { listInlineButtons } from "./listInlineButtons";
export { searchChats } from "./searchChats";
export { getMediaInfo } from "./getMediaInfo";
// Write operations — Chat management
export { sendMessage } from "./sendMessage";
export { archiveChat } from "./archiveChat";
export { unarchiveChat } from "./unarchiveChat";
export { createChannel } from "./createChannel";
export { createGroup } from "./createGroup";
export { editChatTitle } from "./editChatTitle";
export { leaveChat } from "./leaveChat";
export { muteChat } from "./muteChat";
export { unmuteChat } from "./unmuteChat";
export { deleteChatPhoto } from "./deleteChatPhoto";
export { editChatPhoto } from "./editChatPhoto";
export { exportChatInvite } from "./exportChatInvite";
export { importChatInvite } from "./importChatInvite";
export { joinChatByLink } from "./joinChatByLink";
export { subscribePublicChannel } from "./subscribePublicChannel";
export { getInviteLink } from "./getInviteLink";
// Write operations — Message ops
export { editMessage } from "./editMessage";
export { deleteMessage } from "./deleteMessage";
export { forwardMessage } from "./forwardMessage";
export { pinMessage } from "./pinMessage";
export { unpinMessage } from "./unpinMessage";
export { sendReaction } from "./sendReaction";
export { removeReaction } from "./removeReaction";
export { getMessageReactions } from "./getMessageReactions";
export { pressInlineButton } from "./pressInlineButton";
export { saveDraft } from "./saveDraft";
export { getDrafts } from "./getDrafts";
export { clearDraft } from "./clearDraft";
export { createPoll } from "./createPoll";
export { markAsRead } from "./markAsRead";
// Write operations — User/admin
export { getParticipants } from "./getParticipants";
export { getAdmins } from "./getAdmins";
export { getBannedUsers } from "./getBannedUsers";
export { promoteAdmin } from "./promoteAdmin";
export { demoteAdmin } from "./demoteAdmin";
export { banUser } from "./banUser";
export { unbanUser } from "./unbanUser";
export { inviteToGroup } from "./inviteToGroup";
// Write operations — Contacts
export { listContacts } from "./listContacts";
export { searchContacts } from "./searchContacts";
export { addContact } from "./addContact";
export { deleteContact } from "./deleteContact";
export { blockUser } from "./blockUser";
export { unblockUser } from "./unblockUser";
export { getBlockedUsers } from "./getBlockedUsers";
export { getContactIds } from "./getContactIds";
export { importContacts } from "./importContacts";
export { exportContacts } from "./exportContacts";
// Write operations — Profile/settings
export { updateProfile } from "./updateProfile";
export { getUserPhotos } from "./getUserPhotos";
export { getUserStatus } from "./getUserStatus";
export { getPrivacySettings } from "./getPrivacySettings";
export { setPrivacySettings } from "./setPrivacySettings";
export { setProfilePhoto } from "./setProfilePhoto";
export { deleteProfilePhoto } from "./deleteProfilePhoto";
export { setBotCommands } from "./setBotCommands";
export { getBotInfo } from "./getBotInfo";
// Read operations — Search/discovery
export { getRecentActions } from "./getRecentActions";
export { getStickerSets } from "./getStickerSets";
export { getGifSearch } from "./getGifSearch";
export { listTopics } from "./listTopics";
+56
View File
@@ -0,0 +1,56 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import { toInputChannel, toInputUser } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export async function inviteToGroup(
chatId: string | number,
userIds: string[],
): Promise<ApiResult<void>> {
if (userIds.length === 0) {
throw new Error("user_ids must not be empty");
}
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const users: Api.TypeInputUser[] = [];
for (const uid of userIds) {
const inputUser = await client.getInputEntity(String(uid));
users.push(toInputUser(inputUser));
}
const inputPeer = await client.getInputEntity(entity);
if (chat.type === "channel" || chat.type === "supergroup") {
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.channels.InviteToChannel({
channel: toInputChannel(inputPeer),
users,
}),
);
});
} else {
for (const user of users) {
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.messages.AddChatUser({
chatId: bigInt(chat.id),
userId: user,
fwdLimit: 100,
}),
);
});
}
}
return { data: undefined, fromCache: false };
}
@@ -0,0 +1,27 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ResultWithChats } from "./apiResultTypes";
import { narrow } from "./apiCastHelpers";
export async function joinChatByLink(
link: string,
): Promise<ApiResult<{ chatTitle: string }>> {
// Extract hash from link
let hash = link;
const plusMatch = link.match(/t\.me\/\+(.+)/);
const joinMatch = link.match(/t\.me\/joinchat\/(.+)/);
if (plusMatch) hash = plusMatch[1];
else if (joinMatch) hash = joinMatch[1];
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.messages.ImportChatInvite({ hash }));
});
const chatTitle =
narrow<ResultWithChats>(result)?.chats?.[0]?.title ?? "unknown";
return { data: { chatTitle }, fromCache: false };
}
+38
View File
@@ -0,0 +1,38 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import { toInputChannel } from "./apiCastHelpers";
export async function leaveChat(
chatId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
if (chat.type === "channel" || chat.type === "supergroup") {
await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
await client.invoke(
new Api.channels.LeaveChannel({
channel: toInputChannel(inputChannel),
}),
);
});
} else {
await mtprotoService.withFloodWaitHandling(async () => {
await client.invoke(
new Api.messages.DeleteChatUser({
chatId: bigInt(chat.id),
userId: new Api.InputUserSelf(),
}),
);
});
}
return { data: undefined as unknown as void, fromCache: false };
}
+33
View File
@@ -0,0 +1,33 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import bigInt from "big-integer";
import type { ApiUser } from "./apiResultTypes";
export interface Contact {
id: string;
name: string;
username?: string;
phone?: string;
}
export async function listContacts(): Promise<ApiResult<Contact[]>> {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.contacts.GetContacts({ hash: bigInt(0) }));
});
if (!result || !("users" in result) || !Array.isArray(result.users)) {
return { data: [], fromCache: false };
}
const contacts: Contact[] = result.users.map((u: ApiUser) => ({
id: String(u.id),
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown",
username: u.username,
phone: u.phone,
}));
return { data: contacts, fromCache: false };
}
@@ -0,0 +1,45 @@
/**
* API: List inline buttons — cache-only.
*/
import type { ApiResult } from "./types";
import { getChatById, getCachedMessages } from "./helpers";
import { narrow } from "./apiCastHelpers";
import type {
MessageWithReplyMarkup,
ReplyMarkupRow,
} from "./apiResultTypes";
export interface InlineButton {
row: number;
button: number;
text: string;
}
export async function listInlineButtons(
chatId: string | number,
messageId: number,
): Promise<ApiResult<InlineButton[]>> {
const chat = getChatById(chatId);
if (!chat) return { data: [], fromCache: true };
const messages = getCachedMessages(chatId, 200, 0);
if (!messages) return { data: [], fromCache: true };
const found = messages.find((m) => String(m.id) === String(messageId));
const msg = found ? narrow<MessageWithReplyMarkup>(found) : undefined;
if (!msg || !msg.replyMarkup || !msg.replyMarkup.rows) {
return { data: [], fromCache: true };
}
const buttons: InlineButton[] = [];
msg.replyMarkup.rows.forEach((row: ReplyMarkupRow, ri: number) => {
if (row.buttons) {
row.buttons.forEach((btn, bi: number) => {
buttons.push({ row: ri, button: bi, text: btn.text ?? "?" });
});
}
});
return { data: buttons, fromCache: true };
}
+60
View File
@@ -0,0 +1,60 @@
/**
* List Topics API - List forum topics in a chat
*
* Fetches forum topics from channels/supergroups only.
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import type { ForumTopicsResult } from "./apiResultTypes";
import { toInputChannel, narrow } from "./apiCastHelpers";
import { Api } from "telegram";
export interface TopicResult {
id: number;
title: string;
}
export async function listTopics(
chatId: string | number,
): Promise<ApiResult<TopicResult[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error("Chat not found: " + chatId);
}
if (chat.type !== "channel" && chat.type !== "supergroup") {
throw new Error(
"Forum topics are only available for channels/supergroups.",
);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
return client.invoke(
new Api.channels.GetForumTopics({
channel: toInputChannel(inputChannel),
offsetDate: 0,
offsetId: 0,
offsetTopic: 0,
limit: 100,
}),
);
});
const topics = narrow<ForumTopicsResult>(result)?.topics;
if (!topics || !Array.isArray(topics) || topics.length === 0) {
return { data: [], fromCache: false };
}
const topicResults: TopicResult[] = topics.map((t) => ({
id: t.id,
title: t.title ?? "Untitled",
}));
return { data: topicResults, fromCache: false };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* API: Mark as read
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
export async function markAsRead(
chatId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.markAsRead(entity);
});
return { data: undefined as unknown as void, fromCache: false };
}
+34
View File
@@ -0,0 +1,34 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
export async function muteChat(
chatId: string | number,
duration?: number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) throw new Error(`Chat not found: ${chatId}`);
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
const muteUntil =
duration === 0 || duration === undefined
? 2147483647
: Math.floor(Date.now() / 1000) + duration;
await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
await client.invoke(
new Api.account.UpdateNotifySettings({
peer: new Api.InputNotifyPeer({ peer: inputPeer }),
settings: new Api.InputPeerNotifySettings({
muteUntil,
}),
}),
);
});
return { data: undefined as unknown as void, fromCache: false };
}
+28
View File
@@ -0,0 +1,28 @@
/**
* API: Pin message
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
export async function pinMessage(
chatId: string | number,
messageId: number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.pinMessage(entity, messageId, { notify: false });
});
return { data: undefined as unknown as void, fromCache: false };
}
@@ -0,0 +1,43 @@
/**
* API: Press inline button
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import type { BotCallbackAnswer } from "./apiResultTypes";
import { Api } from "telegram";
import { narrow } from "./apiCastHelpers";
export async function pressInlineButton(
chatId: string | number,
messageId: number,
buttonData: string,
): Promise<ApiResult<string>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const result = await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
return client.invoke(
new Api.messages.GetBotCallbackAnswer({
peer: inputPeer,
msgId: messageId,
data: Buffer.from(buttonData, "base64"),
}),
);
});
const answer =
narrow<BotCallbackAnswer>(result)?.message ??
"Button pressed (no response message).";
return { data: answer, fromCache: false };
}
+46
View File
@@ -0,0 +1,46 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import { toInputChannel, toInputUser } from "./apiCastHelpers";
import { getChatById } from "./helpers";
export async function promoteAdmin(
chatId: string | number,
userId: string | number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
if (chat.type !== "channel" && chat.type !== "supergroup") {
throw new Error(
"Admin promotion is only available for channels/supergroups.",
);
}
const client = mtprotoService.getClient();
const entity = chat.username ? chat.username : chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputChannel = await client.getInputEntity(entity);
const inputUser = await client.getInputEntity(userId);
await client.invoke(
new Api.channels.EditAdmin({
channel: toInputChannel(inputChannel),
userId: toInputUser(inputUser),
adminRights: new Api.ChatAdminRights({
changeInfo: true,
deleteMessages: true,
banUsers: true,
inviteUsers: true,
pinMessages: true,
manageCall: true,
}),
rank: "Admin",
}),
);
});
return { data: undefined, fromCache: false };
}
@@ -0,0 +1,36 @@
/**
* API: Remove reaction
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
export async function removeReaction(
chatId: string | number,
messageId: number,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
await client.invoke(
new Api.messages.SendReaction({
peer: inputPeer,
msgId: messageId,
reaction: [],
}),
);
});
return { data: undefined as unknown as void, fromCache: false };
}
@@ -0,0 +1,95 @@
/**
* API: Resolve username — API-first with cache fallback (hybrid).
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { Api } from "telegram";
import { getChatById, formatEntity } from "./helpers";
import type { ApiResult } from "./types";
export interface ResolvedEntity {
id: string;
name: string;
type: string;
username: string;
}
export async function resolveUsername(
username: string,
): Promise<ApiResult<ResolvedEntity | undefined>> {
const clean = username.startsWith("@") ? username.slice(1) : username;
// 1. Try server-side resolution via Telegram API
try {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.contacts.ResolveUsername({ username: clean }),
);
});
if (result && "peer" in result) {
const peer = result.peer;
const peerType =
peer.className === "PeerUser"
? "user"
: peer.className === "PeerChannel"
? "channel"
: peer.className === "PeerChat"
? "chat"
: "unknown";
const peerId =
"userId" in peer
? String(peer.userId)
: "channelId" in peer
? String(peer.channelId)
: "chatId" in peer
? String(peer.chatId)
: "unknown";
let name = clean;
if ("users" in result && Array.isArray(result.users)) {
const user = result.users[0] as {
firstName?: string;
lastName?: string;
};
if (user) {
name =
[user.firstName, user.lastName].filter(Boolean).join(" ") || clean;
}
}
if ("chats" in result && Array.isArray(result.chats)) {
const chat = result.chats[0] as { title?: string };
if (chat?.title) {
name = chat.title;
}
}
return {
data: { id: peerId, name, type: peerType, username: clean },
fromCache: false,
};
}
} catch {
// API call failed — fall back to cached lookup below
}
// 2. Fallback: look up from cached state
const lookupKey = `@${clean}`;
const chat = getChatById(lookupKey);
if (!chat) {
return { data: undefined, fromCache: false };
}
const entity = formatEntity(chat);
return {
data: {
id: entity.id,
name: entity.name,
type: entity.type,
username: entity.username ?? clean,
},
fromCache: true,
};
}
+35
View File
@@ -0,0 +1,35 @@
/**
* API: Save draft
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
export async function saveDraft(
chatId: string | number,
text: string,
): Promise<ApiResult<void>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
await mtprotoService.withFloodWaitHandling(async () => {
const inputPeer = await client.getInputEntity(entity);
await client.invoke(
new Api.messages.SaveDraft({
peer: inputPeer,
message: text,
}),
);
});
return { data: undefined as unknown as void, fromCache: false };
}
+14
View File
@@ -0,0 +1,14 @@
/**
* API: Search chats — cache-only.
*/
import type { TelegramChat } from "../../../../store/telegram/types";
import { searchChatsInCache } from "./helpers";
import type { ApiResult } from "./types";
export async function searchChats(
query: string,
): Promise<ApiResult<TelegramChat[]>> {
const data = searchChatsInCache(query);
return { data, fromCache: true };
}
@@ -0,0 +1,42 @@
import { mtprotoService } from "../../../../services/mtprotoService";
import type { ApiResult } from "./types";
import { Api } from "telegram";
import type { ApiUser } from "./apiResultTypes";
export interface ContactSearchResult {
id: string;
name: string;
username?: string;
}
export async function searchContacts(
query: string,
limit: number = 20,
): Promise<ApiResult<ContactSearchResult[]>> {
if (!query) {
throw new Error("query is required");
}
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.contacts.Search({ q: query, limit }));
});
if (
!result ||
!("users" in result) ||
!Array.isArray(result.users) ||
result.users.length === 0
) {
return { data: [], fromCache: false };
}
const contacts: ContactSearchResult[] = result.users.map((u: ApiUser) => ({
id: String(u.id),
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown",
username: u.username,
}));
return { data: contacts, fromCache: false };
}
@@ -0,0 +1,93 @@
/**
* API: Search messages (API-first with cache fallback)
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById, formatMessage, getCachedMessages } from "./helpers";
import type { ApiResult } from "./types";
import type { ApiMessage } from "./apiResultTypes";
import { Api } from "telegram";
import bigInt from "big-integer";
import { narrow } from "./apiCastHelpers";
export interface SearchedMessage {
id: number | string;
date: string;
text: string;
from?: string;
}
export async function searchMessages(
chatId: string | number,
query: string,
limit: number,
): Promise<ApiResult<SearchedMessage[]>> {
const chat = getChatById(chatId);
if (!chat) {
throw new Error(`Chat not found: ${chatId}`);
}
// Try API first
try {
const client = mtprotoService.getClient();
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
const inputPeer = await client.getInputEntity(entity);
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(
new Api.messages.Search({
peer: inputPeer,
q: query,
filter: new Api.InputMessagesFilterEmpty(),
minDate: 0,
maxDate: 0,
offsetId: 0,
addOffset: 0,
limit,
maxId: 0,
minId: 0,
hash: bigInt(0),
}),
);
});
if ("messages" in result && Array.isArray(result.messages)) {
const messages = narrow<ApiMessage[]>(result.messages);
const searchedMessages = messages.map((msg) => ({
id: msg.id ?? "?",
date: msg.date
? new Date(msg.date * 1000).toISOString()
: "unknown",
text: msg.message ?? "[Media/No text]",
}));
return { data: searchedMessages, fromCache: false };
}
} catch {
// API failed, fall back to cache below
}
// Fallback: search cached messages
const cachedMessages = getCachedMessages(chatId, limit * 3, 0);
if (cachedMessages) {
const q = query.toLowerCase();
const filtered = cachedMessages
.filter((msg) => msg.message.toLowerCase().includes(q))
.slice(0, limit);
const searchedMessages = filtered.map((msg) => {
const f = formatMessage(msg);
return {
id: f.id,
date: f.date,
text: f.text || "[Media]",
from: msg.fromName ?? msg.fromId ?? "Unknown",
};
});
return { data: searchedMessages, fromCache: true };
}
return { data: [], fromCache: true };
}
@@ -0,0 +1,96 @@
/**
* Search Public Chats API - Search for public chats, channels, or bots
*
* Uses Telegram's contacts.Search API for server-side discovery.
* Falls back to filtering cached chats if the API call fails.
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { searchChatsInCache } from "./helpers";
import type { ApiResult } from "./types";
import { Api } from "telegram";
export interface PublicChatResult {
id: string;
name: string;
type: string;
username?: string;
}
export async function searchPublicChats(
query: string,
): Promise<ApiResult<PublicChatResult[]>> {
// Try server-side search via Telegram API
try {
const client = mtprotoService.getClient();
const result = await mtprotoService.withFloodWaitHandling(async () => {
return client.invoke(new Api.contacts.Search({ q: query, limit: 20 }));
});
const entries: PublicChatResult[] = [];
// Process returned chats
if ("chats" in result && Array.isArray(result.chats)) {
for (const chat of result.chats) {
const c = chat as {
id: { toString(): string };
title?: string;
username?: string;
megagroup?: boolean;
broadcast?: boolean;
};
const type = c.broadcast
? "channel"
: c.megagroup
? "group"
: "chat";
entries.push({
id: String(c.id),
name: c.title ?? "Unknown",
type,
username: c.username,
});
}
}
// Process returned users
if ("users" in result && Array.isArray(result.users)) {
for (const user of result.users) {
const u = user as {
id: { toString(): string };
firstName?: string;
lastName?: string;
username?: string;
bot?: boolean;
};
const name =
[u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown";
entries.push({
id: String(u.id),
name,
type: u.bot ? "bot" : "user",
username: u.username,
});
}
}
if (entries.length > 0) {
return { data: entries, fromCache: false };
}
} catch {
// API call failed — fall back to cached search
}
// Fallback: search cached chats locally
const chats = searchChatsInCache(query);
return {
data: chats.map((c) => ({
id: c.id,
name: c.title ?? "Unknown",
type: c.type ?? "chat",
username: c.username,
})),
fromCache: true,
};
}
+34
View File
@@ -0,0 +1,34 @@
/**
* API: Send message — always API (write operation).
*/
import { mtprotoService } from "../../../../services/mtprotoService";
import { getChatById } from "./helpers";
import type { ApiResult } from "./types";
export async function sendMessage(
chatId: string | number,
message: string,
replyToMessageId?: number,
): Promise<ApiResult<{ id: string } | undefined>> {
const chat = getChatById(chatId);
if (!chat) return { data: undefined, fromCache: false };
const entity = chat.username
? `@${chat.username.replace("@", "")}`
: chat.id;
if (replyToMessageId !== undefined) {
const client = mtprotoService.getClient();
await mtprotoService.withFloodWaitHandling(async () => {
await client.sendMessage(entity, {
message,
replyTo: replyToMessageId,
});
});
} else {
await mtprotoService.sendMessage(entity, message);
}
return { data: { id: String(Date.now()) }, fromCache: false };
}

Some files were not shown because too many files have changed in this diff Show More