mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge branch 'fix/telegram-mcp' into develop
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)_
|
||||
|
||||
@@ -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
@@ -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_
|
||||
|
||||
@@ -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)_
|
||||
|
||||
@@ -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
@@ -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)_
|
||||
|
||||
@@ -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)
|
||||
- **Don’t 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; don’t place bot tokens / api hashes there.
|
||||
- the desktop app typically only needs `VITE_BACKEND_URL`; Telegram verification secrets should live on the backend.
|
||||
|
||||
|
||||
Generated
+520
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user