mirror of
https://github.com/lockin-bot/react-telegram.git
synced 2026-01-12 15:13:56 +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/tl": "*",
|
||||
"@react-telegram/core": "workspace:*",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^19.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -405,6 +406,8 @@
|
||||
|
||||
"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-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",
|
||||
"br-demo": "bun run src/br-demo.tsx",
|
||||
"persist": "bun run src/persist-todo-bot.tsx",
|
||||
"spa": "bun run src/spa-todo-bot.tsx",
|
||||
"test": "vitest",
|
||||
"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.
|
||||
|
||||
This package includes two adapters:
|
||||
- **MtcuteAdapter** - Traditional command-based bot adapter
|
||||
- **MtcuteSPAAdapter** - Single Page Application adapter for app-like experiences
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -120,6 +124,64 @@ interface MtcuteAdapterOptions {
|
||||
- `getClient()` - Get the underlying MTCute client
|
||||
- `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
|
||||
|
||||
MIT
|
||||
@@ -30,6 +30,7 @@
|
||||
"@mtcute/dispatcher": "^0.24.3",
|
||||
"@mtcute/tl": "*",
|
||||
"@react-telegram/core": "workspace:*",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^19.1.0"
|
||||
},
|
||||
"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