diff --git a/packages/examples/PERSIST_README.md b/packages/examples/PERSIST_README.md index effad94..9c0f89a 100644 --- a/packages/examples/PERSIST_README.md +++ b/packages/examples/PERSIST_README.md @@ -5,6 +5,7 @@ This example demonstrates how to build a Telegram bot with persistent state usin ## Features - **State Persistence**: Todos are automatically saved to disk and restored on bot restart +- **Message Persistence**: Bot messages are edited instead of recreated after restarts - **MobX Integration**: Reactive state management with MobX - **Custom Storage**: Flexible storage layer with file-based and memory implementations - **Full CRUD Operations**: Add, complete, edit, and delete todos @@ -38,6 +39,7 @@ The bot now uses a single `/start` command with an interactive interface: ### Adapter Configuration - Uses the new MtcuteAdapter API that accepts a TelegramClient instance +- Message persistence to edit existing messages after bot restarts - Single `/start` command with all functionality in one React component - Interactive navigation between views using buttons and text commands @@ -56,6 +58,7 @@ The bot now uses a single `/start` command with an interactive interface: - **IStorage**: Abstract storage interface - **FileStorage**: JSON file-based storage - **MemoryStorage**: In-memory storage for testing +- **MessageIdStorage**: Manages Telegram message IDs for persistence ## Testing @@ -89,15 +92,16 @@ src/ ## How It Works -1. **Persistence**: Every state change is automatically saved to disk -2. **Restoration**: On bot restart, todos are loaded from storage -3. **Reactive UI**: MobX ensures UI updates when state changes -4. **Input Handling**: User messages are captured as todo input +1. **State Persistence**: Every state change is automatically saved to disk +2. **Message Persistence**: Message IDs are saved so bot can edit existing messages after restart +3. **Restoration**: On bot restart, todos and message IDs are loaded from storage +4. **Reactive UI**: MobX ensures UI updates when state changes +5. **Input Handling**: User messages are captured as todo input ## Future Enhancements -- Message ID persistence for seamless UI restoration - Multi-user support with separate storage - Todo categories and tags - Due dates and reminders -- Export/import functionality \ No newline at end of file +- Export/import functionality +- Backup to cloud storage \ No newline at end of file diff --git a/packages/examples/src/components/PersistentTodoBot.tsx b/packages/examples/src/components/PersistentTodoBot.tsx index c866f35..b4611e7 100644 --- a/packages/examples/src/components/PersistentTodoBot.tsx +++ b/packages/examples/src/components/PersistentTodoBot.tsx @@ -12,32 +12,17 @@ type View = 'welcome' | 'todo' | 'help'; export const PersistentTodoBot = observer(({ store }: PersistentTodoBotProps) => { const [view, setView] = useState('welcome'); - const [waitingForCommand, setWaitingForCommand] = useState(false); const handleCommand = (text: string) => { const command = text.trim().toLowerCase(); if (command === '/help') { setView('help'); - setWaitingForCommand(false); } else if (command === '/todo') { setView('todo'); - setWaitingForCommand(false); - } else if (command === '/start') { - setView('welcome'); - setWaitingForCommand(false); } }; - useEffect(() => { - // Set up command input when in welcome or help view - if (view === 'welcome' || view === 'help') { - setWaitingForCommand(true); - } else { - setWaitingForCommand(false); - } - }, [view]); - if (view === 'todo') { return ( <> @@ -83,15 +68,7 @@ export const PersistentTodoBot = observer(({ store }: PersistentTodoBotProps) => - {waitingForCommand && ( - <> -
-
- Type a command: -
- - - )} + ); } @@ -116,7 +93,7 @@ export const PersistentTodoBot = observer(({ store }: PersistentTodoBotProps) =>
Or type a command:
- {waitingForCommand && } + ); }); \ No newline at end of file diff --git a/packages/examples/src/persist-todo-bot.tsx b/packages/examples/src/persist-todo-bot.tsx index dd77c85..5a6a973 100644 --- a/packages/examples/src/persist-todo-bot.tsx +++ b/packages/examples/src/persist-todo-bot.tsx @@ -3,25 +3,33 @@ import React from 'react'; import { TelegramClient } from '@mtcute/bun'; import { MtcuteAdapter } from '@react-telegram/mtcute-adapter'; import { FileStorage } from './storage/FileStorage'; +import { MessageIdStorage } from './storage/MessageIdStorage'; import { RootStore } from './stores/RootStore'; import { PersistentTodoBot } from './components/PersistentTodoBot'; // Store instances globally to persist across command invocations let rootStore: RootStore | null = null; let storage: FileStorage | null = null; +let messageIdStorage: MessageIdStorage | null = null; async function initializeStore() { if (!storage) { storage = new FileStorage('todos.json'); } + if (!messageIdStorage) { + // Use a separate file for message IDs + const messageStorage = new FileStorage('message-ids.json'); + messageIdStorage = new MessageIdStorage(messageStorage); + } + if (!rootStore) { rootStore = new RootStore(storage); // Wait for the store to hydrate from storage await new Promise(resolve => setTimeout(resolve, 100)); } - return rootStore; + return { rootStore, messageIdStorage }; } async function main() { @@ -44,14 +52,23 @@ async function main() { storage: config.storage, }); - // Create adapter with the client - const adapter = new MtcuteAdapter(client); + // Initialize the store and message storage + const { rootStore: store, messageIdStorage: msgStorage } = await initializeStore(); - // Initialize the store - const store = await initializeStore(); + // Create adapter with message persistence + const adapter = new MtcuteAdapter(client, { + messagePersistence: { + getPreviousMessageId: async (containerId) => { + return await msgStorage.getMessageId(containerId); + }, + setPreviousMessageId: async (containerId, messageId) => { + await msgStorage.setMessageId(containerId, messageId); + } + } + }); // Set up single command handler - adapter.onCommand('start', () => ( + adapter.onCommand('start', (msg) => ( )); @@ -60,7 +77,8 @@ async function main() { console.log('Persistent Todo Bot is running! Send /start to begin.'); console.log('Todos will be saved to ./storage/todos.json'); + console.log('Message IDs will be saved to ./storage/message-ids.json'); } // Run the bot -main().catch(console.error); \ No newline at end of file +main().catch(console.error); diff --git a/packages/examples/src/storage/MessageIdStorage.ts b/packages/examples/src/storage/MessageIdStorage.ts new file mode 100644 index 0000000..31bacd7 --- /dev/null +++ b/packages/examples/src/storage/MessageIdStorage.ts @@ -0,0 +1,18 @@ +import { IStorage } from './IStorage'; + +export class MessageIdStorage { + constructor(private storage: IStorage) {} + + async getMessageId(containerId: string): Promise { + const value = await this.storage.get(`message_${containerId}`); + return value ? parseInt(value, 10) : null; + } + + async setMessageId(containerId: string, messageId: number): Promise { + await this.storage.set(`message_${containerId}`, messageId.toString()); + } + + async deleteMessageId(containerId: string): Promise { + await this.storage.delete(`message_${containerId}`); + } +} \ No newline at end of file diff --git a/packages/mtcute-adapter/README.md b/packages/mtcute-adapter/README.md index 79e0ef0..da6e9b3 100644 --- a/packages/mtcute-adapter/README.md +++ b/packages/mtcute-adapter/README.md @@ -54,6 +54,30 @@ async function main() { main().catch(console.error); ``` +### With Message Persistence + +```tsx +// Create a simple file-based storage for message IDs +const messageStorage = new Map(); + +const adapter = new MtcuteAdapter(client, { + messagePersistence: { + getPreviousMessageId: async (containerId) => { + // containerId is either "chatId" or "chatId_key" if key was provided + return messageStorage.get(containerId) ?? null; + }, + setPreviousMessageId: async (containerId, messageId) => { + messageStorage.set(containerId, messageId); + // You could save this to a file or database here + } + } +}); + +// Messages will now be edited instead of recreated after bot restarts +adapter.onCommand('start', () => ); +await adapter.start(process.env.BOT_TOKEN!); +``` + ## Features - Full React component support for Telegram messages @@ -62,6 +86,7 @@ main().catch(console.error); - Text input support with auto-delete option - Command handling - TypeScript support +- Message persistence support for seamless bot restarts ## API @@ -69,21 +94,29 @@ main().catch(console.error); ```typescript // Option 1: Pass a TelegramClient -const adapter = new MtcuteAdapter(telegramClient); +const adapter = new MtcuteAdapter(telegramClient, options?); // Option 2: Pass a config object const adapter = new MtcuteAdapter({ apiId: number, apiHash: string, storage?: string // Default: '.mtcute' -}); +}, options?); + +// Options interface +interface MtcuteAdapterOptions { + messagePersistence?: { + getPreviousMessageId: (containerId: string) => Promise; + setPreviousMessageId: (containerId: string, messageId: number) => Promise; + }; +} ``` ### Methods - `onCommand(command: string, handler: (ctx: MessageContext) => ReactElement)` - Register a command handler - `start(botToken: string)` - Start the bot (botToken is required) -- `sendReactMessage(chatId: number | string, app: ReactElement)` - Send a React-powered message +- `sendReactMessage(chatId: number, app: ReactElement, key?: string)` - Send a React-powered message with optional key for stable container ID - `getClient()` - Get the underlying MTCute client - `getDispatcher()` - Get the MTCute dispatcher diff --git a/packages/mtcute-adapter/src/mtcute-adapter.ts b/packages/mtcute-adapter/src/mtcute-adapter.ts index 16ea2d1..672c762 100644 --- a/packages/mtcute-adapter/src/mtcute-adapter.ts +++ b/packages/mtcute-adapter/src/mtcute-adapter.ts @@ -7,6 +7,7 @@ import type { RowNode } from '@react-telegram/core'; import { createContainer } from '@react-telegram/core'; +import type { TelegramNode } from '@react-telegram/core/src/jsx'; import type { ReactElement } from 'react'; export interface MtcuteAdapterConfig { @@ -15,13 +16,23 @@ export interface MtcuteAdapterConfig { storage?: string; } +export interface MessagePersistenceOptions { + getPreviousMessageId: (containerId: string) => Promise; + setPreviousMessageId: (containerId: string, messageId: number) => Promise; +} + +export interface MtcuteAdapterOptions { + messagePersistence?: MessagePersistenceOptions; +} + export class MtcuteAdapter { private client: TelegramClient; private dispatcher: Dispatcher; private activeContainers: Map> = new Map(); private commandHandlers: Map ReactElement> = new Map(); + private options: MtcuteAdapterOptions; - constructor(clientOrConfig: TelegramClient | MtcuteAdapterConfig) { + constructor(clientOrConfig: TelegramClient | MtcuteAdapterConfig, options: MtcuteAdapterOptions = {}) { if (clientOrConfig instanceof TelegramClient) { this.client = clientOrConfig; } else { @@ -32,6 +43,7 @@ export class MtcuteAdapter { }); } + this.options = options; this.dispatcher = Dispatcher.for(this.client); this.setupHandlers(); } @@ -112,7 +124,7 @@ export class MtcuteAdapter { const text: string[] = []; const entities: tl.TypeMessageEntity[] = []; - const processNode = (node: any, parentFormat?: string) => { + const processNode = (node: TelegramNode) => { switch (node.type) { case 'text': text.push(node.content); @@ -120,7 +132,7 @@ export class MtcuteAdapter { case 'formatted': const startOffset = text.join('').length; - node.children.forEach((child: any) => processNode(child, node.format)); + node.children.forEach((child) => processNode(child)); const length = text.join('').length - startOffset; if (length > 0) { @@ -149,7 +161,7 @@ export class MtcuteAdapter { case 'link': const linkStartOffset = text.join('').length; - node.children.forEach((child: any) => processNode(child)); + node.children.forEach((child) => processNode(child)); const linkLength = text.join('').length - linkStartOffset; if (linkLength > 0) { @@ -187,7 +199,7 @@ export class MtcuteAdapter { case 'blockquote': const quoteStartOffset = text.join('').length; - node.children.forEach((child: any) => processNode(child)); + node.children.forEach((child) => processNode(child)); const quoteLength = text.join('').length - quoteStartOffset; if (quoteLength > 0) { @@ -235,8 +247,9 @@ export class MtcuteAdapter { } // Create a React-powered message - async sendReactMessage(chatId: number | string, app: ReactElement) { - const containerId = `${chatId}_${Date.now()}`; + async sendReactMessage(chatId: number, app: ReactElement, key?: string) { + // Create stable containerId using chatId and optional key + const containerId = key ? `${chatId}_${key}` : `${chatId}`; const container = createContainer(); // Store the container for button click handling @@ -245,6 +258,18 @@ export class MtcuteAdapter { // Track the message ID for editing let messageId: number | null = null; + // Initialize messageId from persistence if available + if (this.options.messagePersistence) { + try { + const persistedId = await this.options.messagePersistence.getPreviousMessageId(containerId); + if (persistedId !== null) { + messageId = persistedId; + } + } catch (error) { + console.error('Failed to get persisted message ID:', error); + } + } + // Set up re-render callback container.container.onRenderContainer = async (root) => { const textWithEntities = this.rootNodeToTextWithEntities(root); @@ -252,26 +277,52 @@ export class MtcuteAdapter { await retryOnRpcError(async () => { - if (messageId === null) { - // First render: send a new message - const sentMessage = await this.client.sendText(chatId, textWithEntities, { - replyMarkup - }); - messageId = sentMessage.id; - } else { - // Subsequent renders: edit the existing message - await this.client.editMessage({ - chatId, - message: messageId, - text: textWithEntities, - replyMarkup - }).catch(e => { - if (tl.RpcError.is(e) && e.code === 400 && e.text === "MESSAGE_NOT_MODIFIED") { - return; + if (messageId === null) { + // First render: send a new message + const sentMessage = await this.client.sendText(chatId, textWithEntities, { + replyMarkup + }); + messageId = sentMessage.id; + + // Persist the message ID if persistence is available + if (this.options.messagePersistence) { + try { + await this.options.messagePersistence.setPreviousMessageId(containerId, messageId); + } catch (error) { + console.error('Failed to persist message ID:', error); + } } - throw e; - }); - } + } else { + // Subsequent renders: edit the existing message + await this.client.editMessage({ + chatId, + message: 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 + }); + messageId = sentMessage.id; + + // Update persisted message ID + if (this.options.messagePersistence) { + try { + await this.options.messagePersistence.setPreviousMessageId(containerId, messageId); + } catch (error) { + console.error('Failed to persist message ID:', error); + } + } + return; + } + throw e; + }); + } }) };