mirror of
https://github.com/lockin-bot/react-telegram.git
synced 2026-04-30 02:15:33 +08:00
wip: refactor spa-hooks to use useSyncExternalStore and immer
- Replace context-based state management with useSyncExternalStore - Add immer for immutable state updates - Implement selector-based API for better performance - Add TgProvider to track current chat context - Strong typing support with TgGlobalState interface 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -58,6 +58,7 @@
|
|||||||
"@mtcute/dispatcher": "^0.24.3",
|
"@mtcute/dispatcher": "^0.24.3",
|
||||||
"@mtcute/tl": "*",
|
"@mtcute/tl": "*",
|
||||||
"@react-telegram/core": "workspace:*",
|
"@react-telegram/core": "workspace:*",
|
||||||
|
"immer": "^10.1.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -405,6 +406,8 @@
|
|||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|||||||
115
packages/examples/SPA_README.md
Normal file
115
packages/examples/SPA_README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# SPA Todo Bot Example
|
||||||
|
|
||||||
|
This example demonstrates the new MtcuteSPAAdapter which provides a true Single Page Application experience for Telegram bots.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. **Single Message Paradigm**
|
||||||
|
- All interactions happen within a single message per chat
|
||||||
|
- Commands don't create new messages - they update the existing one
|
||||||
|
- Provides a seamless, app-like experience
|
||||||
|
|
||||||
|
### 2. **Per-Chat State Management**
|
||||||
|
- Each chat has its own isolated state
|
||||||
|
- State persists across bot restarts
|
||||||
|
- Built-in React hooks for state management
|
||||||
|
|
||||||
|
### 3. **Button Persistence**
|
||||||
|
- Buttons continue to work after bot restarts
|
||||||
|
- No "session expired" errors
|
||||||
|
- Automatic session restoration
|
||||||
|
|
||||||
|
### 4. **React Hooks API**
|
||||||
|
- `useTgState()` - Similar to useState but with automatic persistence
|
||||||
|
- `useTgChatId()` - Get the current chat ID
|
||||||
|
- State changes are automatically saved
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { MtcuteSPAAdapter, useTgState } from '@react-telegram/mtcute-adapter';
|
||||||
|
|
||||||
|
const TodoApp = () => {
|
||||||
|
// State is automatically persisted per chat
|
||||||
|
const [todos, setTodos] = useTgState<Todo[]>('todos', []);
|
||||||
|
const [view, setView] = useTgState<string>('view', 'list');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<b>My SPA Bot</b>
|
||||||
|
{/* Your UI here */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up the adapter
|
||||||
|
const adapter = new MtcuteSPAAdapter(client, {
|
||||||
|
storageAdapter: myStorageAdapter
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter.registerApp(<TodoApp />);
|
||||||
|
await adapter.start(botToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### State Storage
|
||||||
|
The adapter uses a pluggable storage system:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SPAStorageAdapter {
|
||||||
|
getState(chatId: string): Promise<SPAState | null>;
|
||||||
|
setState(chatId: string, state: SPAState): Promise<void>;
|
||||||
|
deleteState(chatId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Structure
|
||||||
|
Each chat's state includes:
|
||||||
|
- `messageId` - The Telegram message ID to edit
|
||||||
|
- `lastContent` - The last rendered content
|
||||||
|
- `lastKeyboard` - The last rendered keyboard
|
||||||
|
- `appState` - Your application's state
|
||||||
|
|
||||||
|
### TgStateProvider
|
||||||
|
The adapter automatically wraps your app with `TgStateProvider`:
|
||||||
|
- Provides React context for state management
|
||||||
|
- Handles state persistence automatically
|
||||||
|
- Isolates state per chat
|
||||||
|
|
||||||
|
## Running the Example
|
||||||
|
|
||||||
|
1. Set environment variables:
|
||||||
|
```bash
|
||||||
|
export API_ID=your_api_id
|
||||||
|
export API_HASH=your_api_hash
|
||||||
|
export BOT_TOKEN=your_bot_token
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the bot:
|
||||||
|
```bash
|
||||||
|
bun run spa
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Send any message to your bot to start
|
||||||
|
|
||||||
|
## Differences from Traditional Bots
|
||||||
|
|
||||||
|
1. **No Command Registration** - Just send any message to interact
|
||||||
|
2. **Stateful** - The bot remembers your state between messages
|
||||||
|
3. **Single Message** - All updates happen in one message
|
||||||
|
4. **Session Persistence** - Works seamlessly across restarts
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. Use `useTgState` for any data that should persist
|
||||||
|
2. Keep state minimal for better performance
|
||||||
|
3. Handle loading states while data is being restored
|
||||||
|
4. Test button functionality after restarts
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Multi-step flows with navigation
|
||||||
|
- Animation support
|
||||||
|
- File upload handling
|
||||||
|
- Inline query support
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"autodelete": "bun run src/input-autodelete-demo.tsx",
|
"autodelete": "bun run src/input-autodelete-demo.tsx",
|
||||||
"br-demo": "bun run src/br-demo.tsx",
|
"br-demo": "bun run src/br-demo.tsx",
|
||||||
"persist": "bun run src/persist-todo-bot.tsx",
|
"persist": "bun run src/persist-todo-bot.tsx",
|
||||||
|
"spa": "bun run src/spa-todo-bot.tsx",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui"
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
|
|||||||
189
packages/examples/src/spa-todo-bot.tsx
Normal file
189
packages/examples/src/spa-todo-bot.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/// <reference types="@react-telegram/core" />
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { TelegramClient } from '@mtcute/bun';
|
||||||
|
import { MtcuteSPAAdapter, useTgState, type SPAStorageAdapter } from '@react-telegram/mtcute-adapter';
|
||||||
|
import { FileStorage } from './storage/FileStorage';
|
||||||
|
|
||||||
|
// Create storage adapter for SPA state
|
||||||
|
class FileSPAStorage implements SPAStorageAdapter {
|
||||||
|
constructor(private storage: FileStorage) {}
|
||||||
|
|
||||||
|
async getState(chatId: string) {
|
||||||
|
const data = await this.storage.get(`spa_${chatId}`);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setState(chatId: string, state: any) {
|
||||||
|
await this.storage.set(`spa_${chatId}`, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteState(chatId: string) {
|
||||||
|
await this.storage.delete(`spa_${chatId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo item component
|
||||||
|
interface TodoItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TodoApp = () => {
|
||||||
|
const [todos = [], setTodos] = useTgState<TodoItem[]>('todos', []);
|
||||||
|
const [view = 'list', setView] = useTgState<'list' | 'add'>('view', 'list');
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
|
||||||
|
const addTodo = (text: string) => {
|
||||||
|
if (text.trim().length < 3) return;
|
||||||
|
|
||||||
|
const newTodo: TodoItem = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
text: text.trim(),
|
||||||
|
completed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
setTodos([...todos, newTodo]);
|
||||||
|
setView('list');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTodo = (id: string) => {
|
||||||
|
setTodos(todos.map(todo =>
|
||||||
|
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTodo = (id: string) => {
|
||||||
|
setTodos(todos.filter(todo => todo.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCompleted = () => {
|
||||||
|
setTodos(todos.filter(todo => !todo.completed));
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTodos = todos.filter(t => !t.completed);
|
||||||
|
const completedTodos = todos.filter(t => t.completed);
|
||||||
|
|
||||||
|
if (view === 'add') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<b>➕ Add New Todo</b>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<i>Enter todo text (min 3 characters):</i>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<input
|
||||||
|
onSubmit={(text) => {
|
||||||
|
addTodo(text);
|
||||||
|
}}
|
||||||
|
autoDelete
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<row>
|
||||||
|
<button onClick={() => setView('list')}>⬅️ Back</button>
|
||||||
|
</row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<b>📝 SPA Todo List ({activeTodos.length} active, {completedTodos.length} completed)</b>
|
||||||
|
<br />
|
||||||
|
<code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{todos.length === 0 ? (
|
||||||
|
<i>No todos yet! Add your first todo.</i>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTodos.map(todo => (
|
||||||
|
<React.Fragment key={todo.id}>
|
||||||
|
<row>
|
||||||
|
<button onClick={() => toggleTodo(todo.id)}>
|
||||||
|
☐ {todo.text}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteTodo(todo.id)}>🗑️</button>
|
||||||
|
</row>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{completedTodos.length > 0 && activeTodos.length > 0 && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<code>───── Completed ─────</code>
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{completedTodos.map(todo => (
|
||||||
|
<React.Fragment key={todo.id}>
|
||||||
|
<row>
|
||||||
|
<button onClick={() => toggleTodo(todo.id)}>
|
||||||
|
☑️ <s>{todo.text}</s>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteTodo(todo.id)}>🗑️</button>
|
||||||
|
</row>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<row>
|
||||||
|
<button onClick={() => setView('add')}>➕ Add Todo</button>
|
||||||
|
{completedTodos.length > 0 && (
|
||||||
|
<button onClick={clearCompleted}>🧹 Clear Completed</button>
|
||||||
|
)}
|
||||||
|
</row>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<i>💡 Send any message to refresh the UI</i>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const config = {
|
||||||
|
apiId: parseInt(process.env.API_ID || '0'),
|
||||||
|
apiHash: process.env.API_HASH || '',
|
||||||
|
botToken: process.env.BOT_TOKEN || '',
|
||||||
|
storage: process.env.STORAGE_PATH || '.mtcute'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.apiId || !config.apiHash || !config.botToken) {
|
||||||
|
console.error('Please set API_ID, API_HASH, and BOT_TOKEN environment variables');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create storage
|
||||||
|
const fileStorage = new FileStorage('spa-states.json');
|
||||||
|
const spaStorage = new FileSPAStorage(fileStorage);
|
||||||
|
|
||||||
|
// Create client
|
||||||
|
const client = new TelegramClient({
|
||||||
|
apiId: config.apiId,
|
||||||
|
apiHash: config.apiHash,
|
||||||
|
storage: config.storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create SPA adapter
|
||||||
|
const adapter = new MtcuteSPAAdapter(client, {
|
||||||
|
storageAdapter: spaStorage
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the app
|
||||||
|
adapter.registerApp(<TodoApp />);
|
||||||
|
|
||||||
|
// Start the bot
|
||||||
|
await adapter.start(config.botToken);
|
||||||
|
|
||||||
|
console.log('SPA Todo Bot is running!');
|
||||||
|
console.log('Send any message to start using the bot.');
|
||||||
|
console.log('State will persist across restarts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the bot
|
||||||
|
main().catch(console.error);
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
MTCute adapter for React Telegram bots. This package provides the integration between React Telegram's core reconciler and the MTCute Telegram client library.
|
MTCute adapter for React Telegram bots. This package provides the integration between React Telegram's core reconciler and the MTCute Telegram client library.
|
||||||
|
|
||||||
|
This package includes two adapters:
|
||||||
|
- **MtcuteAdapter** - Traditional command-based bot adapter
|
||||||
|
- **MtcuteSPAAdapter** - Single Page Application adapter for app-like experiences
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -120,6 +124,64 @@ interface MtcuteAdapterOptions {
|
|||||||
- `getClient()` - Get the underlying MTCute client
|
- `getClient()` - Get the underlying MTCute client
|
||||||
- `getDispatcher()` - Get the MTCute dispatcher
|
- `getDispatcher()` - Get the MTCute dispatcher
|
||||||
|
|
||||||
|
## MtcuteSPAAdapter
|
||||||
|
|
||||||
|
The SPA adapter provides a single-page application experience where all interactions happen within one message per chat.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { MtcuteSPAAdapter, useTgState } from '@react-telegram/mtcute-adapter';
|
||||||
|
|
||||||
|
const MyApp = () => {
|
||||||
|
// State is automatically persisted per chat
|
||||||
|
const [count, setCount] = useTgState('count', 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<b>Count: {count}</b>
|
||||||
|
<br />
|
||||||
|
<row>
|
||||||
|
<button onClick={() => setCount(count + 1)}>Increment</button>
|
||||||
|
<button onClick={() => setCount(0)}>Reset</button>
|
||||||
|
</row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create adapter
|
||||||
|
const adapter = new MtcuteSPAAdapter({
|
||||||
|
apiId: YOUR_API_ID,
|
||||||
|
apiHash: 'YOUR_API_HASH'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register your app
|
||||||
|
adapter.registerApp(<MyApp />);
|
||||||
|
|
||||||
|
// Start the bot
|
||||||
|
await adapter.start(process.env.BOT_TOKEN!);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
1. **Single Message Interface** - All updates happen in one message
|
||||||
|
2. **Automatic State Persistence** - State is saved and restored across restarts
|
||||||
|
3. **Per-Chat Isolation** - Each chat has its own state
|
||||||
|
4. **React Hooks** - `useTgState` and `useTgChatId` for easy state management
|
||||||
|
|
||||||
|
### Storage Adapter
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const adapter = new MtcuteSPAAdapter(client, {
|
||||||
|
storageAdapter: {
|
||||||
|
getState: async (chatId) => { /* load state */ },
|
||||||
|
setState: async (chatId, state) => { /* save state */ },
|
||||||
|
deleteState: async (chatId) => { /* delete state */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@mtcute/dispatcher": "^0.24.3",
|
"@mtcute/dispatcher": "^0.24.3",
|
||||||
"@mtcute/tl": "*",
|
"@mtcute/tl": "*",
|
||||||
"@react-telegram/core": "workspace:*",
|
"@react-telegram/core": "workspace:*",
|
||||||
|
"immer": "^10.1.1",
|
||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1 +1,20 @@
|
|||||||
export { MtcuteAdapter, type MtcuteAdapterConfig } from './mtcute-adapter';
|
export { MtcuteAdapter, type MtcuteAdapterConfig, type MtcuteAdapterOptions, type MessagePersistenceOptions } from './mtcute-adapter';
|
||||||
|
export { MtcuteSPAAdapter } from './mtcute-spa-adapter';
|
||||||
|
export {
|
||||||
|
TgProvider,
|
||||||
|
useTgSelector,
|
||||||
|
useTgUpdater,
|
||||||
|
useTgState,
|
||||||
|
useTgStore,
|
||||||
|
useTgChatId,
|
||||||
|
clearTgStore,
|
||||||
|
clearAllTgStores
|
||||||
|
} from './spa-hooks';
|
||||||
|
export type { TgGlobalState } from './spa-hooks';
|
||||||
|
export type {
|
||||||
|
MtcuteSPAAdapterConfig,
|
||||||
|
MtcuteSPAAdapterOptions,
|
||||||
|
SPAState,
|
||||||
|
SPAStorageAdapter,
|
||||||
|
TgStateContextValue
|
||||||
|
} from './mtcute-spa-adapter-types';
|
||||||
30
packages/mtcute-adapter/src/mtcute-spa-adapter-types.ts
Normal file
30
packages/mtcute-adapter/src/mtcute-spa-adapter-types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export interface SPAState {
|
||||||
|
messageId: number | null;
|
||||||
|
lastContent: string | null;
|
||||||
|
lastKeyboard: any | null;
|
||||||
|
appState: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SPAStorageAdapter {
|
||||||
|
getState(chatId: string): Promise<SPAState | null>;
|
||||||
|
setState(chatId: string, state: SPAState): Promise<void>;
|
||||||
|
deleteState(chatId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MtcuteSPAAdapterConfig {
|
||||||
|
apiId: number;
|
||||||
|
apiHash: string;
|
||||||
|
storage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MtcuteSPAAdapterOptions {
|
||||||
|
storageAdapter?: SPAStorageAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TgStateContextValue {
|
||||||
|
chatId: number;
|
||||||
|
getState: <T = any>(key: string) => T | undefined;
|
||||||
|
setState: <T = any>(key: string, value: T | ((prev: T | undefined) => T)) => void;
|
||||||
|
}
|
||||||
218
packages/mtcute-adapter/src/mtcute-spa-adapter.tsx
Normal file
218
packages/mtcute-adapter/src/mtcute-spa-adapter.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { type ReactElement } from 'react';
|
||||||
|
import { TelegramClient } from '@mtcute/bun';
|
||||||
|
import { Dispatcher, type MessageContext } from '@mtcute/dispatcher';
|
||||||
|
import { tl } from '@mtcute/tl';
|
||||||
|
import { createContainer } from '@react-telegram/core';
|
||||||
|
import type { RootNode } from '@react-telegram/core';
|
||||||
|
import { rootNodeToTextWithEntities, rootNodeToInlineKeyboard } from './shared/telegram-node-converter';
|
||||||
|
import { retryOnRpcError } from './shared/retry-utils';
|
||||||
|
import { TgProvider } from './spa-hooks';
|
||||||
|
import type {
|
||||||
|
MtcuteSPAAdapterConfig,
|
||||||
|
MtcuteSPAAdapterOptions,
|
||||||
|
SPAState,
|
||||||
|
SPAStorageAdapter
|
||||||
|
} from './mtcute-spa-adapter-types';
|
||||||
|
|
||||||
|
export class MtcuteSPAAdapter {
|
||||||
|
private client: TelegramClient;
|
||||||
|
private dispatcher: Dispatcher;
|
||||||
|
private options: MtcuteSPAAdapterOptions;
|
||||||
|
private app: ReactElement | null = null;
|
||||||
|
private activeChats: Map<number, {
|
||||||
|
container: ReturnType<typeof createContainer>;
|
||||||
|
state: SPAState;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
constructor(clientOrConfig: TelegramClient | MtcuteSPAAdapterConfig, options: MtcuteSPAAdapterOptions = {}) {
|
||||||
|
if (clientOrConfig instanceof TelegramClient) {
|
||||||
|
this.client = clientOrConfig;
|
||||||
|
} else {
|
||||||
|
this.client = new TelegramClient({
|
||||||
|
apiId: clientOrConfig.apiId,
|
||||||
|
apiHash: clientOrConfig.apiHash,
|
||||||
|
storage: clientOrConfig.storage || '.mtcute',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options = options;
|
||||||
|
this.dispatcher = Dispatcher.for(this.client);
|
||||||
|
this.setupHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(botToken: string) {
|
||||||
|
await this.client.start({ botToken });
|
||||||
|
console.log('SPA Bot started successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
registerApp(app: ReactElement) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getChatState(chatId: number): Promise<SPAState> {
|
||||||
|
const chatIdStr = chatId.toString();
|
||||||
|
|
||||||
|
// Try to get from active chats first
|
||||||
|
const active = this.activeChats.get(chatId);
|
||||||
|
if (active) {
|
||||||
|
return active.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load from storage
|
||||||
|
if (this.options.storageAdapter) {
|
||||||
|
const saved = await this.options.storageAdapter.getState(chatIdStr);
|
||||||
|
if (saved) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default state
|
||||||
|
return {
|
||||||
|
messageId: null,
|
||||||
|
lastContent: null,
|
||||||
|
lastKeyboard: null,
|
||||||
|
appState: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveChatState(chatId: number, state: SPAState) {
|
||||||
|
if (this.options.storageAdapter) {
|
||||||
|
await this.options.storageAdapter.setState(chatId.toString(), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderApp(chatId: number) {
|
||||||
|
if (!this.app) {
|
||||||
|
throw new Error('No app registered. Call registerApp() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let chatData = this.activeChats.get(chatId);
|
||||||
|
|
||||||
|
if (!chatData) {
|
||||||
|
const state = await this.getChatState(chatId);
|
||||||
|
const container = createContainer();
|
||||||
|
|
||||||
|
chatData = { container, state };
|
||||||
|
this.activeChats.set(chatId, chatData);
|
||||||
|
|
||||||
|
// Set up render callback
|
||||||
|
container.container.onRenderContainer = async (root) => {
|
||||||
|
await this.handleRender(chatId, root);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore button handlers if we have a keyboard
|
||||||
|
if (state.lastKeyboard) {
|
||||||
|
this.restoreButtonHandlers(chatId, container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container, state } = chatData;
|
||||||
|
|
||||||
|
// Render the app wrapped with TgStateProvider
|
||||||
|
container.render(
|
||||||
|
<TgProvider
|
||||||
|
chatId={chatId}
|
||||||
|
initialState={state.appState}
|
||||||
|
>
|
||||||
|
{this.app}
|
||||||
|
</TgProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRender(chatId: number, root: RootNode) {
|
||||||
|
const chatData = this.activeChats.get(chatId);
|
||||||
|
if (!chatData) return;
|
||||||
|
|
||||||
|
const { state } = chatData;
|
||||||
|
const textWithEntities = rootNodeToTextWithEntities(root);
|
||||||
|
const replyMarkup = rootNodeToInlineKeyboard(root, chatId.toString());
|
||||||
|
|
||||||
|
// Store the content for restoration
|
||||||
|
state.lastContent = textWithEntities.text;
|
||||||
|
state.lastKeyboard = replyMarkup;
|
||||||
|
|
||||||
|
await retryOnRpcError(async () => {
|
||||||
|
if (state.messageId === null) {
|
||||||
|
// First render: send a new message
|
||||||
|
const sentMessage = await this.client.sendText(chatId, textWithEntities, {
|
||||||
|
replyMarkup
|
||||||
|
});
|
||||||
|
state.messageId = sentMessage.id;
|
||||||
|
await this.saveChatState(chatId, state);
|
||||||
|
} else {
|
||||||
|
// Subsequent renders: edit the existing message
|
||||||
|
await this.client.editMessage({
|
||||||
|
chatId,
|
||||||
|
message: state.messageId,
|
||||||
|
text: textWithEntities,
|
||||||
|
replyMarkup
|
||||||
|
}).catch(async e => {
|
||||||
|
if (tl.RpcError.is(e) && e.code === 400 && e.text === "MESSAGE_NOT_MODIFIED") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If message not found, send a new one
|
||||||
|
if (tl.RpcError.is(e) && e.code === 400 && e.text === "MESSAGE_ID_INVALID") {
|
||||||
|
const sentMessage = await this.client.sendText(chatId, textWithEntities, {
|
||||||
|
replyMarkup
|
||||||
|
});
|
||||||
|
state.messageId = sentMessage.id;
|
||||||
|
await this.saveChatState(chatId, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreButtonHandlers(chatId: number, container: ReturnType<typeof createContainer>) {
|
||||||
|
// The container already has button click handling built in
|
||||||
|
// We just need to make sure the buttons are mapped correctly
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers() {
|
||||||
|
// Handle all messages (including commands)
|
||||||
|
this.dispatcher.onNewMessage(async (msg) => {
|
||||||
|
if (!msg.text) return;
|
||||||
|
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
|
||||||
|
// Always update the existing message instead of creating new ones
|
||||||
|
await this.renderApp(chatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle callback queries (button clicks)
|
||||||
|
this.dispatcher.onCallbackQuery(async (query) => {
|
||||||
|
if (!query.data) {
|
||||||
|
await query.answer({ text: 'No data provided' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = typeof query.data === 'string' ? query.data : new TextDecoder().decode(query.data);
|
||||||
|
const [chatIdStr, buttonId] = dataStr.split(':');
|
||||||
|
const chatId = parseInt(chatIdStr);
|
||||||
|
|
||||||
|
const chatData = this.activeChats.get(chatId);
|
||||||
|
|
||||||
|
if (chatData && buttonId) {
|
||||||
|
// Click the button in our React app
|
||||||
|
chatData.container.clickButton(buttonId);
|
||||||
|
|
||||||
|
// Answer the callback query
|
||||||
|
await query.answer({ text: '' });
|
||||||
|
} else {
|
||||||
|
// Try to restore the session
|
||||||
|
await this.renderApp(chatId);
|
||||||
|
await query.answer({ text: 'Session restored. Please try again.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDispatcher() {
|
||||||
|
return this.dispatcher;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/mtcute-adapter/src/shared/retry-utils.ts
Normal file
21
packages/mtcute-adapter/src/shared/retry-utils.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { tl } from '@mtcute/tl';
|
||||||
|
|
||||||
|
const RETRY_ERRORS = [
|
||||||
|
'MSG_WAIT_FAILED',
|
||||||
|
'MSG_ID_INVALID'
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function retryOnRpcError<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (tl.RpcError.is(error) && RETRY_ERRORS.includes(error.text) && i < maxRetries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Max retries exceeded');
|
||||||
|
}
|
||||||
130
packages/mtcute-adapter/src/shared/telegram-node-converter.ts
Normal file
130
packages/mtcute-adapter/src/shared/telegram-node-converter.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { TextWithEntities } from '@mtcute/bun';
|
||||||
|
import { tl } from '@mtcute/tl';
|
||||||
|
import type { RootNode, RowNode } from '@react-telegram/core';
|
||||||
|
|
||||||
|
export function rootNodeToTextWithEntities(root: RootNode): TextWithEntities {
|
||||||
|
const text: string[] = [];
|
||||||
|
const entities: tl.TypeMessageEntity[] = [];
|
||||||
|
|
||||||
|
const processNode = (node: any) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'text':
|
||||||
|
text.push(node.content);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'formatted':
|
||||||
|
const startOffset = text.join('').length;
|
||||||
|
node.children.forEach((child: any) => processNode(child));
|
||||||
|
const length = text.join('').length - startOffset;
|
||||||
|
|
||||||
|
if (length > 0) {
|
||||||
|
switch (node.format) {
|
||||||
|
case 'bold':
|
||||||
|
entities.push({ _: 'messageEntityBold', offset: startOffset, length });
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
entities.push({ _: 'messageEntityItalic', offset: startOffset, length });
|
||||||
|
break;
|
||||||
|
case 'underline':
|
||||||
|
entities.push({ _: 'messageEntityUnderline', offset: startOffset, length });
|
||||||
|
break;
|
||||||
|
case 'strikethrough':
|
||||||
|
entities.push({ _: 'messageEntityStrike', offset: startOffset, length });
|
||||||
|
break;
|
||||||
|
case 'spoiler':
|
||||||
|
entities.push({ _: 'messageEntitySpoiler', offset: startOffset, length });
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
entities.push({ _: 'messageEntityCode', offset: startOffset, length });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'link':
|
||||||
|
const linkStartOffset = text.join('').length;
|
||||||
|
node.children.forEach((child: any) => processNode(child));
|
||||||
|
const linkLength = text.join('').length - linkStartOffset;
|
||||||
|
|
||||||
|
if (linkLength > 0) {
|
||||||
|
entities.push({
|
||||||
|
_: 'messageEntityTextUrl',
|
||||||
|
offset: linkStartOffset,
|
||||||
|
length: linkLength,
|
||||||
|
url: node.href
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'custom-emoji':
|
||||||
|
text.push(node.emoji);
|
||||||
|
entities.push({
|
||||||
|
_: 'messageEntityCustomEmoji',
|
||||||
|
offset: text.join('').length - node.emoji.length,
|
||||||
|
length: node.emoji.length,
|
||||||
|
documentId: node.documentId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'br':
|
||||||
|
text.push('\n');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pre':
|
||||||
|
const preStartOffset = text.join('').length;
|
||||||
|
text.push(node.content);
|
||||||
|
entities.push({
|
||||||
|
_: 'messageEntityPre',
|
||||||
|
offset: preStartOffset,
|
||||||
|
length: node.content.length,
|
||||||
|
language: node.language || ''
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'blockquote':
|
||||||
|
const quoteStartOffset = text.join('').length;
|
||||||
|
node.children.forEach((child: any) => processNode(child));
|
||||||
|
const quoteLength = text.join('').length - quoteStartOffset;
|
||||||
|
|
||||||
|
if (quoteLength > 0) {
|
||||||
|
entities.push({
|
||||||
|
_: 'messageEntityBlockquote',
|
||||||
|
offset: quoteStartOffset,
|
||||||
|
length: quoteLength,
|
||||||
|
collapsed: node.expandable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'row':
|
||||||
|
// Rows are handled separately for inline keyboard
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process all non-row children
|
||||||
|
root.children
|
||||||
|
.filter(child => child.type !== 'row')
|
||||||
|
.forEach(child => processNode(child));
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: text.join(''),
|
||||||
|
entities
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootNodeToInlineKeyboard(root: RootNode, containerId: string): tl.RawReplyInlineMarkup | undefined {
|
||||||
|
const rows = root.children.filter(child => child.type === 'row') as RowNode[];
|
||||||
|
|
||||||
|
if (rows.length === 0) return undefined;
|
||||||
|
|
||||||
|
const keyboard: tl.TypeKeyboardButton[][] = rows.map(row =>
|
||||||
|
row.children.map(button => ({
|
||||||
|
_: 'keyboardButtonCallback',
|
||||||
|
text: button.text,
|
||||||
|
data: Buffer.from(`${containerId}:${button.id}`)
|
||||||
|
}) as tl.TypeKeyboardButton)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { _: 'replyInlineMarkup', rows: keyboard.map(row => ({ _: 'keyboardButtonRow', buttons: row })) };
|
||||||
|
}
|
||||||
154
packages/mtcute-adapter/src/spa-hooks.tsx
Normal file
154
packages/mtcute-adapter/src/spa-hooks.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { createContext, useContext, useCallback, useSyncExternalStore } from 'react';
|
||||||
|
import { produce, Draft } from 'immer';
|
||||||
|
|
||||||
|
// Base interface that users can extend
|
||||||
|
export interface TgGlobalState {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store implementation
|
||||||
|
class TgStore<T extends TgGlobalState = TgGlobalState> {
|
||||||
|
private state: T;
|
||||||
|
private listeners: Set<() => void> = new Set();
|
||||||
|
private chatId: number;
|
||||||
|
|
||||||
|
constructor(chatId: number, initialState: T = {} as T) {
|
||||||
|
this.chatId = chatId;
|
||||||
|
this.state = initialState;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState = (): T => {
|
||||||
|
return this.state;
|
||||||
|
};
|
||||||
|
|
||||||
|
setState = (updater: (draft: Draft<T>) => void) => {
|
||||||
|
this.state = produce(this.state, updater);
|
||||||
|
this.listeners.forEach(listener => listener());
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribe = (listener: () => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getChatId = () => {
|
||||||
|
return this.chatId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global store registry
|
||||||
|
const stores = new Map<number, TgStore<any>>();
|
||||||
|
|
||||||
|
// Get or create store for a specific chat
|
||||||
|
function getStore<T extends TgGlobalState>(chatId: number, initialState?: T): TgStore<T> {
|
||||||
|
if (!stores.has(chatId)) {
|
||||||
|
stores.set(chatId, new TgStore(chatId, initialState));
|
||||||
|
}
|
||||||
|
return stores.get(chatId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for current chat
|
||||||
|
interface TgChatContextValue {
|
||||||
|
chatId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TgChatContext = createContext<TgChatContextValue | null>(null);
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
interface TgProviderProps {
|
||||||
|
chatId: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
initialState?: TgGlobalState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TgProvider<T extends TgGlobalState = TgGlobalState>({
|
||||||
|
chatId,
|
||||||
|
children,
|
||||||
|
initialState
|
||||||
|
}: TgProviderProps & { initialState?: T }) {
|
||||||
|
// Initialize store for this chat if needed
|
||||||
|
React.useEffect(() => {
|
||||||
|
getStore<T>(chatId, initialState);
|
||||||
|
}, [chatId, initialState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TgChatContext.Provider value={{ chatId }}>
|
||||||
|
{children}
|
||||||
|
</TgChatContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to get current store
|
||||||
|
function useCurrentStore<T extends TgGlobalState>(): TgStore<T> {
|
||||||
|
const context = useContext(TgChatContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Tg hooks must be used within TgProvider');
|
||||||
|
}
|
||||||
|
return getStore<T>(context.chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to use a selector from the state
|
||||||
|
export function useTgSelector<T extends TgGlobalState, R>(
|
||||||
|
selector: (state: T) => R
|
||||||
|
): R {
|
||||||
|
const store = useCurrentStore<T>();
|
||||||
|
|
||||||
|
const selectValue = useCallback(() => {
|
||||||
|
return selector(store.getState());
|
||||||
|
}, [selector, store]);
|
||||||
|
|
||||||
|
return useSyncExternalStore(
|
||||||
|
store.subscribe,
|
||||||
|
selectValue,
|
||||||
|
selectValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to get the state updater
|
||||||
|
export function useTgUpdater<T extends TgGlobalState>(): (updater: (draft: Draft<T>) => void) => void {
|
||||||
|
const store = useCurrentStore<T>();
|
||||||
|
return store.setState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined hook for both reading and updating
|
||||||
|
export function useTgState<T extends TgGlobalState, R>(
|
||||||
|
selector: (state: T) => R
|
||||||
|
): [R, (updater: (draft: Draft<T>) => void) => void] {
|
||||||
|
const value = useTgSelector(selector);
|
||||||
|
const updater = useTgUpdater<T>();
|
||||||
|
return [value, updater];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to get the entire state (use sparingly)
|
||||||
|
export function useTgStore<T extends TgGlobalState>(): [T, (updater: (draft: Draft<T>) => void) => void] {
|
||||||
|
const store = useCurrentStore<T>();
|
||||||
|
|
||||||
|
const state = useSyncExternalStore(
|
||||||
|
store.subscribe,
|
||||||
|
store.getState,
|
||||||
|
store.getState
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, store.setState];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to get the current chat ID
|
||||||
|
export function useTgChatId(): number {
|
||||||
|
const context = useContext(TgChatContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTgChatId must be used within TgProvider');
|
||||||
|
}
|
||||||
|
return context.chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility to clear a store (useful for cleanup)
|
||||||
|
export function clearTgStore(chatId: number) {
|
||||||
|
stores.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility to clear all stores
|
||||||
|
export function clearAllTgStores() {
|
||||||
|
stores.clear();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user