feat: add message persistence support to MtcuteAdapter

- Add MessagePersistenceOptions interface with getPreviousMessageId/setPreviousMessageId callbacks
- Add optional MtcuteAdapterOptions parameter to constructor
- Make containerId stable using chatId and optional key instead of timestamp
- Implement message ID persistence logic to edit existing messages after bot restarts
- Handle MESSAGE_ID_INVALID errors gracefully by creating new messages
- Simplify initialization by removing unnecessary messageIdInitialized flag

feat: implement message persistence in persist-todo-bot example

- Create MessageIdStorage class for managing message IDs on disk
- Store message IDs in separate file (message-ids.json)
- Wire up message persistence callbacks in the adapter
- Update documentation to reflect message persistence feature

BREAKING CHANGE: sendReactMessage now strictly accepts chatId as number (not number | string)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kyle Fang
2025-07-08 00:36:03 +08:00
parent 926559540d
commit 9729c1a829
6 changed files with 168 additions and 67 deletions

View File

@@ -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
- Export/import functionality
- Backup to cloud storage

View File

@@ -12,32 +12,17 @@ type View = 'welcome' | 'todo' | 'help';
export const PersistentTodoBot = observer(({ store }: PersistentTodoBotProps) => {
const [view, setView] = useState<View>('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) =>
<button onClick={() => setView('welcome')}>🏠 Home</button>
<button onClick={() => setView('todo')}>📝 Todos</button>
</row>
{waitingForCommand && (
<>
<br />
<br />
<i>Type a command:</i>
<br />
<input onSubmit={handleCommand} autoDelete />
</>
)}
<input onSubmit={handleCommand} autoDelete />
</>
);
}
@@ -116,7 +93,7 @@ export const PersistentTodoBot = observer(({ store }: PersistentTodoBotProps) =>
<br />
<i>Or type a command:</i>
<br />
{waitingForCommand && <input onSubmit={handleCommand} autoDelete />}
<input onSubmit={handleCommand} autoDelete />
</>
);
});

View File

@@ -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) => (
<PersistentTodoBot store={store.todoStore} />
));
@@ -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);
main().catch(console.error);

View File

@@ -0,0 +1,18 @@
import { IStorage } from './IStorage';
export class MessageIdStorage {
constructor(private storage: IStorage) {}
async getMessageId(containerId: string): Promise<number | null> {
const value = await this.storage.get(`message_${containerId}`);
return value ? parseInt(value, 10) : null;
}
async setMessageId(containerId: string, messageId: number): Promise<void> {
await this.storage.set(`message_${containerId}`, messageId.toString());
}
async deleteMessageId(containerId: string): Promise<void> {
await this.storage.delete(`message_${containerId}`);
}
}

View File

@@ -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<string, number>();
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', () => <Bot />);
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<number | null>;
setPreviousMessageId: (containerId: string, messageId: number) => Promise<void>;
};
}
```
### 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

View File

@@ -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<number | null>;
setPreviousMessageId: (containerId: string, messageId: number) => Promise<void>;
}
export interface MtcuteAdapterOptions {
messagePersistence?: MessagePersistenceOptions;
}
export class MtcuteAdapter {
private client: TelegramClient;
private dispatcher: Dispatcher;
private activeContainers: Map<string, ReturnType<typeof createContainer>> = new Map();
private commandHandlers: Map<string, (ctx: MessageContext) => 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;
});
}
})
};