mirror of
https://github.com/lockin-bot/react-telegram.git
synced 2026-01-12 15:13:56 +08:00
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:
@@ -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
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
18
packages/examples/src/storage/MessageIdStorage.ts
Normal file
18
packages/examples/src/storage/MessageIdStorage.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user