feat: add telegram-mtproto channel for user account messaging (#189)

Co-authored-by: Kai <noreply@gtb.ai>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
This commit is contained in:
ghosttigerllc-bit
2026-02-10 17:25:44 -08:00
committed by GitHub
parent 8cf9bd230e
commit 28adc22388
17 changed files with 2231 additions and 1230 deletions

6
.gitignore vendored
View File

@@ -45,8 +45,14 @@ letta-code-sdk/
# WhatsApp session (contains credentials)
data/whatsapp-session/
# Telegram MTProto session (contains authenticated session data)
data/telegram-mtproto/
# Config with secrets
lettabot.yaml
lettabot.yml
bun.lock
.tool-versions
# Telegram MTProto session data (contains auth secrets)
data/telegram-mtproto/

View File

@@ -0,0 +1,251 @@
# Telegram MTProto (User Account) Setup Guide
This guide explains how to run LettaBot as a Telegram **user account** instead of a bot. This uses the MTProto protocol via TDLib, giving you full user capabilities.
## Overview: Bot API vs MTProto
| Feature | Bot API | MTProto (User) |
|---------|---------|----------------|
| **Setup** | Simple (BotFather token) | Phone + API credentials |
| **DM users first** | No (must wait for user) | Yes |
| **File size limit** | 50 MB | 2 GB |
| **Privacy mode** | Restricted in groups | Full access |
| **Rate limits** | 30 req/sec | Higher limits |
| **Appears as** | Bot account | Regular user |
**Choose MTProto if you need:** User-first DMs, larger files, or full group access.
## Prerequisites
1. **Telegram account** with a phone number
2. **API credentials** from my.telegram.org (see below)
3. **LettaBot** installed with dependencies
## Getting API Credentials
1. Go to [my.telegram.org](https://my.telegram.org)
2. Log in with your phone number
3. Click **"API development tools"**
4. Fill in the form:
- **App title**: LettaBot (or any name)
- **Short name**: lettabot
- **Platform**: Desktop
- **Description**: AI assistant
5. Click **"Create application"**
6. Note your **API ID** and **API Hash**
> **Security Note**: Keep your API credentials secret. Never commit them to git or share them publicly. They are tied to your Telegram account.
## Configuration
Add these to your `.env` file:
```bash
# Telegram MTProto User Mode
TELEGRAM_PHONE_NUMBER=+1234567890
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=abcdef1234567890abcdef1234567890
# Optional: Custom database directory (default: ./data/telegram-mtproto)
# TELEGRAM_MTPROTO_DB_DIR=./data/telegram-mtproto
# DM policy (same as bot mode)
TELEGRAM_DM_POLICY=pairing
# TELEGRAM_ALLOWED_USERS=123456789,987654321
```
**Important**: Do NOT set `TELEGRAM_BOT_TOKEN` at the same time. You must choose one mode or the other.
## First Run Authentication
On first run, you'll see prompts for authentication:
```
$ lettabot server
[Telegram MTProto] Starting authentication...
[Telegram MTProto] Sending phone number...
[Telegram MTProto] Verification code sent to your Telegram app
Enter verification code: █
```
1. Open your Telegram app on another device
2. You'll receive a login code message
3. Enter the code in the terminal
If you have 2FA enabled:
```
[Telegram MTProto] 2FA password required
Enter 2FA password: █
```
Enter your Telegram 2FA password.
On success:
```
[Telegram MTProto] Authenticated successfully!
[Telegram MTProto] Session saved to ./data/telegram-mtproto/
```
## Subsequent Runs
After initial authentication, the session is saved. You won't need to enter codes again:
```
$ lettabot server
[Telegram MTProto] Starting adapter...
[Telegram MTProto] Authenticated successfully!
[Telegram MTProto] Session saved to ./data/telegram-mtproto/
[Telegram MTProto] Adapter started
```
## Troubleshooting
### "Phone number banned" or "PHONE_NUMBER_BANNED"
Your phone number may be flagged by Telegram. This can happen if:
- The number was recently used for spam
- Too many failed login attempts
- Account previously terminated
**Solution**: Contact Telegram support or use a different number.
### "FLOOD_WAIT_X" errors
You're sending too many requests. TDLib handles this automatically by waiting, but you'll see delay messages in logs.
**Solution**: This is normal - TDLib will retry automatically.
### Session keeps asking for code
The session database may be corrupted.
**Solution**: Delete the database directory and re-authenticate:
```bash
rm -rf ./data/telegram-mtproto
lettabot server
```
### "API_ID_INVALID" or "API_HASH_INVALID"
Your API credentials are incorrect.
**Solution**: Double-check the values from my.telegram.org.
### Database grows very large
TDLib caches data locally, which can grow to 50+ MB quickly.
**Solution**: This is normal. For very long sessions, you may want to periodically clear the database and re-authenticate.
## Switching Between Bot and MTProto
To switch modes:
1. **Stop LettaBot**
2. **Edit `.env`**:
- For Bot mode: Set `TELEGRAM_BOT_TOKEN`, remove/comment `TELEGRAM_PHONE_NUMBER`
- For MTProto: Set `TELEGRAM_PHONE_NUMBER` + API credentials, remove/comment `TELEGRAM_BOT_TOKEN`
3. **Start LettaBot**
You cannot run both modes simultaneously.
## Security Notes
1. **API credentials**: Treat like passwords. They can be used to access your Telegram account.
2. **Session files**: The `./data/telegram-mtproto/` directory contains your authenticated session. Anyone with these files can act as your Telegram account.
3. **gitignore**: The session directory is automatically gitignored. Never commit it.
4. **Account security**: Consider using a dedicated phone number for bots rather than your personal number.
5. **Logout**: To revoke the session:
- Go to Telegram Settings → Devices
- Find "TDLib" or the session
- Click "Terminate Session"
## Using with DM Policy
MTProto mode supports the same DM policies as bot mode:
- **pairing** (default): Unknown users must be approved before chatting
- **allowlist**: Only users in `TELEGRAM_ALLOWED_USERS` can message
- **open**: Anyone can message
```bash
# Pairing mode (recommended for most users)
TELEGRAM_DM_POLICY=pairing
# Or pre-approve specific users
TELEGRAM_ALLOWED_USERS=123456789,987654321
```
### Admin Notifications for Pairing
When using pairing mode, you can set up an admin chat to receive pairing requests:
```bash
# Your Telegram user ID or a group chat ID for admin notifications
TELEGRAM_ADMIN_CHAT_ID=137747014
```
**How it works:**
1. Unknown user sends a message
2. User sees: *"Your request has been passed on to the admin."*
3. Admin chat receives notification with username and user ID
4. Admin replies **"approve"** or **"deny"** to the notification
5. Both user and admin get confirmation
**Approve/Deny keywords:**
- Approve: `approve`, `yes`, `y`
- Deny: `deny`, `no`, `n`, `reject`
If no admin chat is configured, pairing codes are logged to the console instead.
**Pairing request behavior:**
- Repeated messages from the same unapproved user do not create duplicate admin notifications.
- If the pending pairing queue is full, the user gets: *"Too many pending pairing requests. Please try again later."*
## Group Chat Policy
Since MTProto gives you full group access, you need to control when the agent responds in groups. The **group policy** determines this:
| Policy | Behavior |
|--------|----------|
| **mention** | Only respond when @mentioned by username |
| **reply** | Only respond when someone replies to agent's message |
| **both** | Respond to mentions OR replies (default) |
| **off** | Never respond in groups, DMs only |
```bash
# Only respond when @mentioned (recommended for busy groups)
TELEGRAM_GROUP_POLICY=mention
# Only respond to replies
TELEGRAM_GROUP_POLICY=reply
# Respond to either mentions or replies (default)
TELEGRAM_GROUP_POLICY=both
# Never respond in groups
TELEGRAM_GROUP_POLICY=off
```
**Note**: Group policy does NOT affect DMs - direct messages always work based on your DM policy.
### How Mentions Work
The agent responds when:
- Someone types `@yourusername` in their message
- Someone uses Telegram's mention feature (clicking your name in the member list)
### How Reply Detection Works
The agent tracks messages it sends. When someone replies to one of those messages (using Telegram's reply feature), the agent will respond.
**Tip**: For busy groups, use `mention` policy. For small groups or channels, `both` works well.

1324
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -89,7 +89,9 @@
"@slack/bolt": "^4.6.0",
"@whiskeysockets/baileys": "6.7.21",
"discord.js": "^14.25.1",
"slackify-markdown": "^5.0.0"
"prebuilt-tdlib": "^0.1008060.0",
"slackify-markdown": "^5.0.0",
"tdl": "^8.0.2"
},
"devDependencies": {
"@types/update-notifier": "^6.0.8",

View File

@@ -5,6 +5,7 @@
export * from './types.js';
export * from './setup.js';
export * from './telegram.js';
export * from './telegram-mtproto.js';
export * from './slack.js';
export * from './whatsapp/index.js';
export * from './signal.js';

View File

@@ -0,0 +1,311 @@
/**
* Telegram MTProto Text Formatting
*
* Converts markdown to TDLib formattedText format with proper UTF-16 entity offsets.
*
* CRITICAL: TDLib uses UTF-16 code units for entity offsets, not byte offsets or
* Unicode code points. JavaScript's string.length already returns UTF-16 code units,
* so we can use it directly. However, emoji and other characters outside the BMP
* take 2 UTF-16 code units (surrogate pairs).
*
* Entity types supported:
* - Bold: **text** or __text__
* - Italic: *text* or _text_
* - Code: `code`
* - Pre: ```code block```
* - Strikethrough: ~~text~~
*/
export interface TdlibTextEntity {
_: 'textEntity';
offset: number; // UTF-16 code units from start
length: number; // UTF-16 code units
type: TdlibTextEntityType;
}
export type TdlibTextEntityType =
| { _: 'textEntityTypeBold' }
| { _: 'textEntityTypeItalic' }
| { _: 'textEntityTypeCode' }
| { _: 'textEntityTypePre'; language?: string }
| { _: 'textEntityTypeStrikethrough' }
| { _: 'textEntityTypeUnderline' }
| { _: 'textEntityTypeTextUrl'; url: string };
export interface TdlibFormattedText {
_: 'formattedText';
text: string;
entities: TdlibTextEntity[];
}
/**
* Calculate UTF-16 length of a string.
* JavaScript strings are UTF-16 encoded, so string.length gives UTF-16 code units.
*/
export function utf16Length(str: string): number {
return str.length;
}
/**
* Convert markdown text to TDLib formattedText structure.
* Handles bold, italic, code, pre, and strikethrough.
*/
export function markdownToTdlib(markdown: string): TdlibFormattedText {
const entities: TdlibTextEntity[] = [];
let plainText = '';
let i = 0;
while (i < markdown.length) {
// Code block: ```language\ncode``` or ```code```
if (markdown.slice(i, i + 3) === '```') {
const blockStart = i;
i += 3;
// Check for language specifier
let language = '';
const langMatch = markdown.slice(i).match(/^(\w+)\n/);
if (langMatch) {
language = langMatch[1];
i += langMatch[0].length;
} else if (markdown[i] === '\n') {
i++; // Skip newline after ```
}
// Find closing ```
const closeIdx = markdown.indexOf('```', i);
if (closeIdx !== -1) {
const content = markdown.slice(i, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: language
? { _: 'textEntityTypePre', language }
: { _: 'textEntityTypePre' }
});
plainText += content;
i = closeIdx + 3;
continue;
}
// No closing ```, treat as literal
plainText += '```';
i = blockStart + 3;
continue;
}
// Inline code: `code`
if (markdown[i] === '`') {
const closeIdx = markdown.indexOf('`', i + 1);
if (closeIdx !== -1 && closeIdx > i + 1) {
const content = markdown.slice(i + 1, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: { _: 'textEntityTypeCode' }
});
plainText += content;
i = closeIdx + 1;
continue;
}
}
// Bold: **text** (check before single *)
if (markdown.slice(i, i + 2) === '**') {
const closeIdx = markdown.indexOf('**', i + 2);
if (closeIdx !== -1) {
const content = markdown.slice(i + 2, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: { _: 'textEntityTypeBold' }
});
plainText += content;
i = closeIdx + 2;
continue;
}
}
// Bold alternate: __text__ (check before single _)
if (markdown.slice(i, i + 2) === '__') {
const closeIdx = markdown.indexOf('__', i + 2);
if (closeIdx !== -1) {
const content = markdown.slice(i + 2, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: { _: 'textEntityTypeBold' }
});
plainText += content;
i = closeIdx + 2;
continue;
}
}
// Strikethrough: ~~text~~
if (markdown.slice(i, i + 2) === '~~') {
const closeIdx = markdown.indexOf('~~', i + 2);
if (closeIdx !== -1) {
const content = markdown.slice(i + 2, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: { _: 'textEntityTypeStrikethrough' }
});
plainText += content;
i = closeIdx + 2;
continue;
}
}
// Italic: *text* (single asterisk)
if (markdown[i] === '*' && markdown[i + 1] !== '*') {
const closeIdx = findClosingMark(markdown, i + 1, '*');
if (closeIdx !== -1) {
const content = markdown.slice(i + 1, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: { _: 'textEntityTypeItalic' }
});
plainText += content;
i = closeIdx + 1;
continue;
}
}
// Italic alternate: _text_ (single underscore)
if (markdown[i] === '_' && markdown[i + 1] !== '_') {
const closeIdx = findClosingMark(markdown, i + 1, '_');
if (closeIdx !== -1) {
const content = markdown.slice(i + 1, closeIdx);
const entityOffset = utf16Length(plainText);
const entityLength = utf16Length(content);
entities.push({
_: 'textEntity',
offset: entityOffset,
length: entityLength,
type: { _: 'textEntityTypeItalic' }
});
plainText += content;
i = closeIdx + 1;
continue;
}
}
// Regular character - copy to output
plainText += markdown[i];
i++;
}
return {
_: 'formattedText',
text: plainText,
entities
};
}
/**
* Find closing mark that isn't preceded by backslash and isn't part of a double mark.
*/
function findClosingMark(str: string, startIdx: number, mark: string): number {
for (let i = startIdx; i < str.length; i++) {
if (str[i] === mark) {
// Check it's not escaped
if (i > 0 && str[i - 1] === '\\') continue;
// Check it's not part of a double mark (** or __)
if (str[i + 1] === mark) continue;
return i;
}
}
return -1;
}
/**
* Convert plain text to formattedText with no entities.
*/
export function plainToTdlib(text: string): TdlibFormattedText {
return {
_: 'formattedText',
text,
entities: []
};
}
/**
* Create a bold text entity for a portion of text.
*/
export function createBoldEntity(offset: number, length: number): TdlibTextEntity {
return {
_: 'textEntity',
offset,
length,
type: { _: 'textEntityTypeBold' }
};
}
/**
* Create an italic text entity.
*/
export function createItalicEntity(offset: number, length: number): TdlibTextEntity {
return {
_: 'textEntity',
offset,
length,
type: { _: 'textEntityTypeItalic' }
};
}
/**
* Create a code entity (inline code).
*/
export function createCodeEntity(offset: number, length: number): TdlibTextEntity {
return {
_: 'textEntity',
offset,
length,
type: { _: 'textEntityTypeCode' }
};
}
/**
* Create a pre entity (code block).
*/
export function createPreEntity(offset: number, length: number, language?: string): TdlibTextEntity {
return {
_: 'textEntity',
offset,
length,
type: language ? { _: 'textEntityTypePre', language } : { _: 'textEntityTypePre' }
};
}

View File

@@ -0,0 +1,929 @@
/**
* Telegram MTProto Channel Adapter
*
* Uses TDLib/MTProto for Telegram user account messaging.
* Allows Letta agents to operate as Telegram users (not bots).
*
* Key differences from Bot API:
* - Full user capabilities (DM anyone first, larger files, no privacy mode)
* - Phone number authentication (not bot token)
* - Session persistence via TDLib database
* - UTF-16 entity offsets for text formatting
*
* Requirements:
* - npm install tdl prebuilt-tdlib
* - Telegram API credentials from https://my.telegram.org
*/
import type { ChannelAdapter } from './types.js';
import type { InboundMessage, OutboundMessage } from '../core/types.js';
import type { DmPolicy } from '../pairing/types.js';
import { isUserAllowed, upsertPairingRequest, approvePairingCode } from '../pairing/store.js';
import { markdownToTdlib } from './telegram-mtproto-format.js';
import * as readline from 'node:readline';
// TDLib imports - configured at runtime
let tdlModule: typeof import('tdl');
let getTdjson: () => string;
export type GroupPolicy = 'mention' | 'reply' | 'both' | 'off';
export interface TelegramMTProtoConfig {
phoneNumber: string; // E.164 format: +1234567890
apiId: number; // From my.telegram.org
apiHash: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
// Security
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: number[]; // Telegram user IDs (config allowlist)
// Group behavior
groupPolicy?: GroupPolicy; // 'mention', 'reply', 'both' (default), or 'off'
// Admin notifications
adminChatId?: number; // Chat ID for pairing request notifications
}
// TDLib client type (simplified for our needs)
interface TdlibClient {
invoke(method: object): Promise<any>;
iterUpdates(): AsyncIterable<any>;
close(): Promise<void>;
on(event: 'error', handler: (err: Error) => void): void;
}
export class TelegramMTProtoAdapter implements ChannelAdapter {
readonly id = 'telegram-mtproto' as const;
readonly name = 'Telegram (MTProto)';
private config: TelegramMTProtoConfig;
private running = false;
private client: TdlibClient | null = null;
private updateLoopPromise: Promise<void> | null = null;
private stopRequested = false;
// Auth state machine (single update loop handles both auth and runtime)
private authState: 'initializing' | 'waiting_phone' | 'waiting_code' | 'waiting_password' | 'ready' = 'initializing';
private authResolve: ((value: void) => void) | null = null;
private authReject: ((error: Error) => void) | null = null;
// For group policy - track our identity and sent messages
private myUserId: number | null = null;
private myUsername: string | null = null;
private sentMessageIds = new Set<number>(); // Track our messages for reply detection
// For pairing approval via reply - track admin notification messages
// Maps admin notification messageId -> { code, userId, username }
private pendingPairingApprovals = new Map<number, { code: string; userId: string; username: string }>();
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>;
constructor(config: TelegramMTProtoConfig) {
this.config = {
...config,
dmPolicy: config.dmPolicy || 'pairing',
databaseDirectory: config.databaseDirectory || './data/telegram-mtproto',
groupPolicy: config.groupPolicy || 'both',
};
}
/**
* Check if a user is authorized based on dmPolicy
*/
private async checkAccess(userId: number): Promise<'allowed' | 'blocked' | 'pairing'> {
const policy = this.config.dmPolicy || 'pairing';
if (policy === 'open') {
return 'allowed';
}
const allowed = await isUserAllowed(
'telegram-mtproto',
String(userId),
this.config.allowedUsers?.map(String)
);
if (allowed) {
return 'allowed';
}
if (policy === 'allowlist') {
return 'blocked';
}
return 'pairing';
}
/**
* Format user-facing pairing message (simple, no implementation details)
*/
private formatUserPairingMessage(): string {
return `Your request has been passed on to the admin.`;
}
/**
* Format admin notification for pairing request
*/
private formatAdminPairingNotification(username: string, userId: string, code: string, messageText?: string): string {
const userDisplay = username ? `@${username}` : `User`;
const messagePreview = messageText
? `\n\n💬 Message:\n${messageText.slice(0, 500)}${messageText.length > 500 ? '...' : ''}`
: '';
return `🔔 **New pairing request**
${userDisplay} (ID: ${userId}) wants to chat.${messagePreview}
Reply **approve** or **deny** to this message.`;
}
/**
* Get user info (username, first name) from Telegram
*/
private async getUserInfo(userId: number): Promise<{ username: string | null; firstName: string | null }> {
if (!this.client) return { username: null, firstName: null };
try {
const user = await this.client.invoke({ _: 'getUser', user_id: userId });
return {
username: user.usernames?.editable_username || user.username || null,
firstName: user.first_name || null,
};
} catch (err) {
console.warn(`[Telegram MTProto] Could not get user info for ${userId}:`, err);
return { username: null, firstName: null };
}
}
/**
* Get the private chat ID for a user (TDLib chat_id != user_id)
*/
private async getPrivateChatId(userId: number): Promise<number | null> {
if (!this.client) return null;
try {
const chat = await this.client.invoke({ _: 'createPrivateChat', user_id: userId, force: false });
return chat.id;
} catch (err) {
console.warn(`[Telegram MTProto] Could not get private chat for user ${userId}:`, err);
return null;
}
}
/**
* Prompt user for input (verification code or 2FA password)
*/
private async promptForInput(type: 'code' | 'password'): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = type === 'code'
? '[Telegram MTProto] Enter verification code: '
: '[Telegram MTProto] Enter 2FA password: ';
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Initialize TDLib client
*/
private async initializeClient(): Promise<void> {
// Dynamic import to avoid issues if packages aren't installed
try {
tdlModule = await import('tdl');
const prebuiltModule = await import('prebuilt-tdlib');
getTdjson = prebuiltModule.getTdjson;
} catch (err: any) {
if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND') {
throw new Error(
'Telegram MTProto adapter requires tdl and prebuilt-tdlib packages.\n' +
'Install them with: npm install tdl prebuilt-tdlib\n' +
'See: https://github.com/Bannerets/tdl#installation'
);
}
throw err;
}
// CRITICAL: Configure tdl BEFORE creating client
tdlModule.configure({ tdjson: getTdjson() });
this.client = tdlModule.createClient({
apiId: this.config.apiId,
apiHash: this.config.apiHash,
databaseDirectory: this.config.databaseDirectory,
filesDirectory: `${this.config.databaseDirectory}/files`,
}) as TdlibClient;
// CRITICAL: Always attach error handler
this.client.on('error', (err) => {
console.error('[Telegram MTProto] Client error:', err);
});
}
/**
* Single update loop - handles both auth and runtime updates
* This ensures we only consume iterUpdates() once
*/
private async runUpdateLoop(): Promise<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
console.log('[Telegram MTProto] Starting update loop...');
for await (const update of this.client.iterUpdates()) {
if (this.stopRequested) {
console.log('[Telegram MTProto] Stop requested, exiting update loop');
if (this.authReject) {
this.authReject(new Error('Stop requested'));
}
break;
}
try {
// Handle auth updates until ready
if (this.authState !== 'ready' && update._ === 'updateAuthorizationState') {
await this.handleAuthUpdate(update);
} else if (this.authState === 'ready') {
// Normal runtime updates
await this.handleUpdate(update);
}
// Ignore non-auth updates before we're ready
} catch (err) {
console.error('[Telegram MTProto] Error handling update:', err);
// If auth fails, reject the auth promise
if (this.authState !== 'ready' && this.authReject) {
this.authReject(err as Error);
break;
}
}
}
}
/**
* Handle authorization state updates
*/
private async handleAuthUpdate(update: any): Promise<void> {
const state = update.authorization_state;
switch (state._) {
case 'authorizationStateWaitTdlibParameters':
this.authState = 'initializing';
// TDLib handles this automatically with createClient options
break;
case 'authorizationStateWaitPhoneNumber':
this.authState = 'waiting_phone';
console.log('[Telegram MTProto] Sending phone number...');
await this.client!.invoke({
_: 'setAuthenticationPhoneNumber',
phone_number: this.config.phoneNumber,
});
break;
case 'authorizationStateWaitCode':
this.authState = 'waiting_code';
console.log('[Telegram MTProto] Verification code sent to your Telegram app');
const code = await this.promptForInput('code');
if (this.stopRequested) throw new Error('Stop requested');
await this.client!.invoke({
_: 'checkAuthenticationCode',
code,
});
break;
case 'authorizationStateWaitPassword':
this.authState = 'waiting_password';
console.log('[Telegram MTProto] 2FA password required');
const password = await this.promptForInput('password');
if (this.stopRequested) throw new Error('Stop requested');
await this.client!.invoke({
_: 'checkAuthenticationPassword',
password,
});
break;
case 'authorizationStateReady':
this.authState = 'ready';
console.log('[Telegram MTProto] Authenticated successfully!');
console.log(`[Telegram MTProto] Session saved to ${this.config.databaseDirectory}/`);
// Get our own user info for mention/reply detection
try {
const me = await this.client!.invoke({ _: 'getMe' });
this.myUserId = me.id;
this.myUsername = me.usernames?.editable_username || me.username || null;
console.log(`[Telegram MTProto] Logged in as: ${this.myUsername || this.myUserId}`);
} catch (err) {
console.warn('[Telegram MTProto] Could not fetch user info:', err);
}
// Signal that auth is complete
if (this.authResolve) {
this.authResolve();
this.authResolve = null;
this.authReject = null;
}
break;
case 'authorizationStateClosed':
case 'authorizationStateClosing':
throw new Error('Client is closing');
case 'authorizationStateLoggingOut':
throw new Error('Client is logging out');
}
}
/**
* Handle a single TDLib update
*/
private async handleUpdate(update: any): Promise<void> {
switch (update._) {
case 'updateNewMessage':
await this.handleNewMessage(update.message);
break;
case 'updateMessageSendSucceeded':
// Track the real message ID for reply detection
// old_message_id is the temp ID, message.id is the real server ID
if (update.old_message_id && update.message?.id) {
this.sentMessageIds.add(update.message.id);
// Also update pending pairing approvals if this was an admin notification
const pending = this.pendingPairingApprovals.get(update.old_message_id);
if (pending) {
this.pendingPairingApprovals.delete(update.old_message_id);
this.pendingPairingApprovals.set(update.message.id, pending);
}
}
break;
case 'updateConnectionState':
this.handleConnectionState(update.state);
break;
// Add other update types as needed
}
}
/**
* Handle incoming message
*/
private async handleNewMessage(message: any): Promise<void> {
// Skip outgoing messages (messages we sent)
if (message.is_outgoing) return;
// Check for pairing approval reply from admin
const replyToId = message.reply_to?.message_id;
if (replyToId && this.pendingPairingApprovals.has(replyToId)) {
await this.handlePairingApprovalReply(message, replyToId);
return;
}
// Skip ALL messages from admin chat (don't trigger agent)
const msgChatId = message.chat_id;
if (this.config.adminChatId && msgChatId === this.config.adminChatId) {
// Only process replies to pairing notifications (handled above)
// All other messages in admin chat are ignored
return;
}
// Skip if no handler (for normal messages)
if (!this.onMessage) return;
// Skip non-text messages for now
if (message.content?._ !== 'messageText') return;
// Get sender ID - must be a user
const senderId = message.sender_id;
if (!senderId || senderId._ !== 'messageSenderUser') return;
const userId = senderId.user_id;
const chatId = message.chat_id;
const text = message.content?.text?.text || '';
const messageId = String(message.id);
// Check if this is a group chat and apply group policy
const isGroup = await this.isGroupChat(chatId);
if (isGroup) {
const shouldRespond = await this.shouldRespondInGroup(message, chatId);
if (!shouldRespond) {
return;
}
}
// Check access (DM policy)
const access = await this.checkAccess(userId);
if (access === 'blocked') {
console.log(`[Telegram MTProto] Blocked message from user ${userId}`);
return;
}
if (access === 'pairing') {
// Create pairing request
const { code, created } = await upsertPairingRequest('telegram-mtproto', String(userId));
// Pairing queue is full: notify user and stop
if (!code) {
await this.sendMessage({
chatId: String(chatId),
text: 'Too many pending pairing requests. Please try again later.',
});
return;
}
// Existing pending request: don't send duplicate notifications
if (!created) {
return;
}
// Send simple acknowledgment to user (no implementation details)
await this.sendMessage({ chatId: String(chatId), text: this.formatUserPairingMessage() });
// Send admin notification if admin chat is configured
if (this.config.adminChatId) {
const userInfo = await this.getUserInfo(userId);
const adminMsg = this.formatAdminPairingNotification(
userInfo.username || userInfo.firstName || '',
String(userId),
code,
text
);
try {
const result = await this.sendMessage({ chatId: String(this.config.adminChatId), text: adminMsg });
// Track this notification for reply-based approval
this.pendingPairingApprovals.set(Number(result.messageId), {
code,
userId: String(userId),
username: userInfo.username || userInfo.firstName || String(userId),
});
// Clean up old entries (keep last 100)
if (this.pendingPairingApprovals.size > 100) {
const oldest = this.pendingPairingApprovals.keys().next().value;
if (oldest !== undefined) {
this.pendingPairingApprovals.delete(oldest);
}
}
} catch (err) {
console.error(`[Telegram MTProto] Failed to send admin notification:`, err);
// Fall back to console
console.log(`[Telegram MTProto] Pairing request from ${userInfo.username || userId}: ${code}`);
console.log(`[Telegram MTProto] To approve: lettabot pairing approve telegram-mtproto ${code}`);
}
} else {
// No admin chat configured, log to console
const userInfo = await this.getUserInfo(userId);
console.log(`[Telegram MTProto] Pairing request from ${userInfo.username || userId}: ${code}`);
console.log(`[Telegram MTProto] To approve: lettabot pairing approve telegram-mtproto ${code}`);
}
return;
}
// Build inbound message
const inboundMsg: InboundMessage = {
channel: 'telegram-mtproto',
chatId: String(chatId),
userId: String(userId),
text,
messageId,
timestamp: new Date(message.date * 1000),
};
// Call handler
await this.onMessage(inboundMsg);
}
/**
* Handle pairing approval/denial via reply to admin notification
*/
private async handlePairingApprovalReply(message: any, replyToId: number): Promise<void> {
const pending = this.pendingPairingApprovals.get(replyToId);
if (!pending) return;
const text = (message.content?.text?.text || '').toLowerCase().trim();
const chatId = message.chat_id;
if (text === 'approve' || text === 'yes' || text === 'y') {
// Approve the pairing
const result = await approvePairingCode('telegram-mtproto', pending.code);
if (result) {
// Notify admin
await this.sendMessage({
chatId: String(chatId),
text: `✅ Approved! ${pending.username} can now chat.`,
});
// Notify user (need to get their chat ID, not user ID)
const userChatId = await this.getPrivateChatId(Number(pending.userId));
if (userChatId) {
await this.sendMessage({
chatId: String(userChatId),
text: `You've been approved! You can now chat.`,
});
}
console.log(`[Telegram MTProto] Approved pairing for ${pending.username} (${pending.userId})`);
} else {
await this.sendMessage({
chatId: String(chatId),
text: `❌ Could not approve: Code not found or expired.`,
});
}
// Remove from pending
this.pendingPairingApprovals.delete(replyToId);
} else if (text === 'deny' || text === 'no' || text === 'n' || text === 'reject') {
// Deny the pairing (just remove from pending, don't add to allowlist)
// Silent denial - don't notify the user (security/privacy)
await this.sendMessage({
chatId: String(chatId),
text: `❌ Denied. ${pending.username} will not be able to chat.`,
});
console.log(`[Telegram MTProto] Denied pairing for ${pending.username} (${pending.userId})`);
// Remove from pending
this.pendingPairingApprovals.delete(replyToId);
}
// If text is something else, just ignore (don't process as regular message)
}
/**
* Handle connection state changes
*/
private handleConnectionState(state: any): void {
switch (state._) {
case 'connectionStateReady':
console.log('[Telegram MTProto] Connected');
break;
case 'connectionStateConnecting':
console.log('[Telegram MTProto] Connecting...');
break;
case 'connectionStateUpdating':
console.log('[Telegram MTProto] Updating...');
break;
case 'connectionStateWaitingForNetwork':
console.log('[Telegram MTProto] Waiting for network...');
break;
}
}
// ==================== Group Policy Helpers ====================
/**
* Check if a chat is a group (basic group or supergroup)
*/
private async isGroupChat(chatId: number): Promise<boolean> {
if (!this.client) return false;
try {
const chat = await this.client.invoke({ _: 'getChat', chat_id: chatId });
const chatType = chat.type?._;
return chatType === 'chatTypeBasicGroup' || chatType === 'chatTypeSupergroup';
} catch (err) {
console.warn('[Telegram MTProto] Could not determine chat type:', err);
return false;
}
}
/**
* Check if we are mentioned in the message
* Checks for @username mentions and user ID mentions
*/
private isMentioned(message: any): boolean {
if (!this.myUserId) return false;
const text = message.content?.text?.text || '';
const entities = message.content?.text?.entities || [];
for (const entity of entities) {
const entityType = entity.type?._;
// Check for @username mention
if (entityType === 'textEntityTypeMention') {
const mentionText = text.substring(entity.offset, entity.offset + entity.length);
// Compare without @ prefix, case-insensitive
if (this.myUsername && mentionText.toLowerCase() === `@${this.myUsername.toLowerCase()}`) {
return true;
}
}
// Check for mention by user ID (textEntityTypeMentionName)
if (entityType === 'textEntityTypeMentionName' && entity.type.user_id === this.myUserId) {
return true;
}
}
return false;
}
/**
* Check if message is a reply to one of our messages
*/
private isReplyToUs(message: any): boolean {
const replyTo = message.reply_to?.message_id || message.reply_to_message_id;
if (!replyTo) return false;
return this.sentMessageIds.has(replyTo);
}
/**
* Apply group policy to determine if we should respond
* Returns true if we should process the message, false to ignore
*/
private async shouldRespondInGroup(message: any, chatId: number): Promise<boolean> {
const policy = this.config.groupPolicy || 'both';
// 'off' means never respond in groups
if (policy === 'off') {
console.log('[Telegram MTProto] Group policy is off, ignoring group message');
return false;
}
const mentioned = this.isMentioned(message);
const isReply = this.isReplyToUs(message);
switch (policy) {
case 'mention':
if (!mentioned) {
console.log('[Telegram MTProto] Not mentioned in group, ignoring');
return false;
}
return true;
case 'reply':
if (!isReply) {
console.log('[Telegram MTProto] Not a reply to us in group, ignoring');
return false;
}
return true;
case 'both':
default:
if (!mentioned && !isReply) {
// Silent ignore - don't log every message in busy groups
return false;
}
return true;
}
}
// ==================== ChannelAdapter Interface ====================
async start(): Promise<void> {
if (this.running) return;
console.log('[Telegram MTProto] Starting adapter...');
this.stopRequested = false;
this.authState = 'initializing';
try {
// Initialize TDLib client
await this.initializeClient();
// Create auth promise - will be resolved when authorizationStateReady is received
const authPromise = new Promise<void>((resolve, reject) => {
this.authResolve = resolve;
this.authReject = reject;
});
// Start single update loop in background (handles both auth and runtime)
this.updateLoopPromise = this.runUpdateLoop().catch((err) => {
if (this.running && !this.stopRequested) {
console.error('[Telegram MTProto] Update loop error:', err);
this.running = false;
}
});
// Wait for auth to complete
await authPromise;
this.running = true;
console.log('[Telegram MTProto] Adapter started');
} catch (err) {
console.error('[Telegram MTProto] Failed to start:', err);
throw err;
}
}
async stop(): Promise<void> {
// Always allow stop, even during auth (handles ctrl+c during code/password prompt)
console.log('[Telegram MTProto] Stopping adapter...');
this.stopRequested = true;
this.running = false;
if (this.client) {
try {
await this.client.close();
} catch (err) {
// Ignore errors during shutdown (client may already be closing)
if (!String(err).includes('closed')) {
console.error('[Telegram MTProto] Error closing client:', err);
}
}
this.client = null;
}
console.log('[Telegram MTProto] Adapter stopped');
}
isRunning(): boolean {
return this.running;
}
supportsEditing(): boolean {
// Disabled for now: TDLib sendMessage returns temporary IDs,
// and editMessage fails with "Message not found" until
// updateMessageSendSucceeded provides the real ID.
// TODO: Implement message ID tracking to enable streaming edits
return false;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.client) {
throw new Error('Client not initialized');
}
const formatted = markdownToTdlib(msg.text);
const result = await this.client.invoke({
_: 'sendMessage',
chat_id: this.safeChatId(msg.chatId),
input_message_content: {
_: 'inputMessageText',
text: formatted,
link_preview_options: null,
clear_draft: false,
},
});
// Track this message ID for reply detection in groups
// Note: This is the temp ID; the real ID comes via updateMessageSendSucceeded
// For reply detection, we track both temp and real IDs
this.sentMessageIds.add(result.id);
// Limit set size to prevent memory leak (keep last 1000 messages)
// Delete 100 oldest entries at once to avoid constant single deletions
if (this.sentMessageIds.size > 1000) {
const iterator = this.sentMessageIds.values();
for (let i = 0; i < 100; i++) {
const oldest = iterator.next().value;
if (oldest !== undefined) {
this.sentMessageIds.delete(oldest);
}
}
}
return { messageId: String(result.id) };
}
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
const formatted = markdownToTdlib(text);
await this.client.invoke({
_: 'editMessageText',
chat_id: this.safeChatId(chatId),
message_id: this.safeMessageId(messageId),
input_message_content: {
_: 'inputMessageText',
text: formatted,
},
});
}
async sendTypingIndicator(chatId: string): Promise<void> {
if (!this.client) return;
try {
await this.client.invoke({
_: 'sendChatAction',
chat_id: this.safeChatId(chatId),
action: { _: 'chatActionTyping' },
});
} catch (err) {
// Typing indicators are best-effort, don't throw
console.warn('[Telegram MTProto] Failed to send typing indicator:', err);
}
}
// ==================== Helpers ====================
/**
* Safely convert chatId to number, checking for safe integer bounds
* @throws Error if chatId exceeds JavaScript safe integer bounds
*/
private safeChatId(chatId: string): number {
const num = Number(chatId);
if (!Number.isSafeInteger(num)) {
throw new Error(`Chat ID ${chatId} exceeds safe integer bounds (max: ${Number.MAX_SAFE_INTEGER}). This chat cannot be used with TDLib's number-based API.`);
}
return num;
}
/**
* Safely convert messageId to number
* @throws Error if messageId exceeds JavaScript safe integer bounds
*/
private safeMessageId(messageId: string): number {
const num = Number(messageId);
if (!Number.isSafeInteger(num)) {
throw new Error(`Message ID ${messageId} exceeds safe integer bounds (max: ${Number.MAX_SAFE_INTEGER}).`);
}
return num;
}
// ==================== Public API for Letta Tools ====================
/**
* Get public user info (for Letta telegram_get_user_info tool)
*/
async getPublicUserInfo(userId: number): Promise<{ username: string | null; firstName: string | null; lastName: string | null }> {
if (!this.client) throw new Error('Client not initialized');
try {
const user = await this.client.invoke({ _: 'getUser', user_id: userId });
return {
username: user.usernames?.editable_username || user.username || null,
firstName: user.first_name || null,
lastName: user.last_name || null,
};
} catch (err) {
console.warn(`[Telegram MTProto] Could not get user info for ${userId}:`, err);
throw err;
}
}
/**
* Initiate a direct message to a user (for Letta telegram_send_dm tool)
* Creates a private chat if needed, then sends the message.
*/
async initiateDirectMessage(userId: number, text: string): Promise<{ chatId: string; messageId: string }> {
if (!this.client) throw new Error('Client not initialized');
// Create private chat (or get existing)
const chat = await this.client.invoke({ _: 'createPrivateChat', user_id: userId, force: false });
const chatId = chat.id;
// Send the message
const formatted = markdownToTdlib(text);
const result = await this.client.invoke({
_: 'sendMessage',
chat_id: chatId,
input_message_content: {
_: 'inputMessageText',
text: formatted,
link_preview_options: null,
clear_draft: false,
},
});
// Track message for reply detection
this.sentMessageIds.add(result.id);
if (this.sentMessageIds.size > 1000) {
const oldest = this.sentMessageIds.values().next().value;
if (oldest !== undefined) {
this.sentMessageIds.delete(oldest);
}
}
return { chatId: String(chatId), messageId: String(result.id) };
}
/**
* Search for a user by username (for Letta telegram_find_user tool)
*/
async searchUser(username: string): Promise<{ userId: number; username: string | null; firstName: string | null } | null> {
if (!this.client) throw new Error('Client not initialized');
try {
// Remove @ prefix if present
const cleanUsername = username.replace(/^@/, '');
const result = await this.client.invoke({ _: 'searchPublicChat', username: cleanUsername });
if (result.type?._ === 'chatTypePrivate') {
const userId = result.type.user_id;
const userInfo = await this.getPublicUserInfo(userId);
return {
userId,
username: userInfo.username,
firstName: userInfo.firstName,
};
}
return null;
} catch (err) {
console.warn(`[Telegram MTProto] Could not find user @${username}:`, err);
return null;
}
}
}

View File

@@ -0,0 +1,242 @@
/**
* Tests for telegram-mtproto-format.ts
*
* CRITICAL: These tests verify UTF-16 offset calculations.
* TDLib uses UTF-16 code units, and emoji/surrogate pairs take 2 units.
*/
import { describe, it, expect } from 'vitest';
import {
markdownToTdlib,
plainToTdlib,
utf16Length,
TdlibFormattedText
} from '../telegram-mtproto-format.js';
describe('utf16Length', () => {
it('returns correct length for ASCII text', () => {
expect(utf16Length('hello')).toBe(5);
expect(utf16Length('')).toBe(0);
expect(utf16Length('a')).toBe(1);
});
it('returns correct length for basic emoji (BMP)', () => {
// Most common emoji are actually outside BMP
expect(utf16Length('☺')).toBe(1); // U+263A is in BMP
});
it('returns correct length for emoji with surrogate pairs', () => {
// 👋 U+1F44B is outside BMP, takes 2 UTF-16 code units
expect(utf16Length('👋')).toBe(2);
expect(utf16Length('Hello 👋')).toBe(8); // 6 + 2
expect(utf16Length('👋👋')).toBe(4); // 2 + 2
});
it('returns correct length for complex emoji sequences', () => {
// 👨‍👩‍👧 family emoji (multiple code points joined with ZWJ)
const family = '👨‍👩‍👧';
expect(utf16Length(family)).toBe(8); // Each person is 2, ZWJ is 1 each
});
it('returns correct length for mixed content', () => {
expect(utf16Length('Hi 👋 there!')).toBe(12); // 3 + 2 + 7
});
});
describe('plainToTdlib', () => {
it('creates formattedText with no entities', () => {
const result = plainToTdlib('Hello world');
expect(result._).toBe('formattedText');
expect(result.text).toBe('Hello world');
expect(result.entities).toEqual([]);
});
it('handles empty string', () => {
const result = plainToTdlib('');
expect(result.text).toBe('');
expect(result.entities).toEqual([]);
});
});
describe('markdownToTdlib', () => {
describe('plain text', () => {
it('passes through plain text unchanged', () => {
const result = markdownToTdlib('Hello world');
expect(result.text).toBe('Hello world');
expect(result.entities).toEqual([]);
});
it('handles empty string', () => {
const result = markdownToTdlib('');
expect(result.text).toBe('');
expect(result.entities).toEqual([]);
});
});
describe('bold formatting', () => {
it('handles **bold** syntax', () => {
const result = markdownToTdlib('Hello **world**');
expect(result.text).toBe('Hello world');
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).toEqual({
_: 'textEntity',
offset: 6,
length: 5,
type: { _: 'textEntityTypeBold' }
});
});
it('handles __bold__ syntax', () => {
const result = markdownToTdlib('Hello __world__');
expect(result.text).toBe('Hello world');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].type._).toBe('textEntityTypeBold');
});
it('handles bold with emoji', () => {
const result = markdownToTdlib('Hello **👋 wave**');
expect(result.text).toBe('Hello 👋 wave');
expect(result.entities[0].offset).toBe(6);
expect(result.entities[0].length).toBe(7); // 2 (emoji) + 5 (space + wave)
});
});
describe('italic formatting', () => {
it('handles *italic* syntax', () => {
const result = markdownToTdlib('Hello *world*');
expect(result.text).toBe('Hello world');
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).toEqual({
_: 'textEntity',
offset: 6,
length: 5,
type: { _: 'textEntityTypeItalic' }
});
});
it('handles _italic_ syntax', () => {
const result = markdownToTdlib('Hello _world_');
expect(result.text).toBe('Hello world');
expect(result.entities[0].type._).toBe('textEntityTypeItalic');
});
});
describe('code formatting', () => {
it('handles `inline code`', () => {
const result = markdownToTdlib('Use `npm install`');
expect(result.text).toBe('Use npm install');
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).toEqual({
_: 'textEntity',
offset: 4,
length: 11,
type: { _: 'textEntityTypeCode' }
});
});
it('handles code blocks without language', () => {
const result = markdownToTdlib('```\nconst x = 1;\n```');
expect(result.text).toBe('const x = 1;\n');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].type).toEqual({ _: 'textEntityTypePre' });
});
it('handles code blocks with language', () => {
const result = markdownToTdlib('```typescript\nconst x: number = 1;\n```');
expect(result.text).toBe('const x: number = 1;\n');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].type).toEqual({
_: 'textEntityTypePre',
language: 'typescript'
});
});
});
describe('strikethrough formatting', () => {
it('handles ~~strikethrough~~', () => {
const result = markdownToTdlib('This is ~~deleted~~ text');
expect(result.text).toBe('This is deleted text');
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).toEqual({
_: 'textEntity',
offset: 8,
length: 7,
type: { _: 'textEntityTypeStrikethrough' }
});
});
});
describe('multiple entities', () => {
it('handles multiple formatting types', () => {
const result = markdownToTdlib('**bold** and *italic*');
expect(result.text).toBe('bold and italic');
expect(result.entities).toHaveLength(2);
expect(result.entities[0].type._).toBe('textEntityTypeBold');
expect(result.entities[1].type._).toBe('textEntityTypeItalic');
});
it('calculates correct offsets for sequential entities', () => {
const result = markdownToTdlib('**A** **B** **C**');
expect(result.text).toBe('A B C');
expect(result.entities).toHaveLength(3);
expect(result.entities[0].offset).toBe(0); // A
expect(result.entities[1].offset).toBe(2); // B
expect(result.entities[2].offset).toBe(4); // C
});
});
describe('UTF-16 offset edge cases', () => {
it('calculates correct offset after emoji', () => {
// "👋 **bold**" - emoji takes 2 units, space is 1
const result = markdownToTdlib('👋 **bold**');
expect(result.text).toBe('👋 bold');
expect(result.entities[0].offset).toBe(3); // 2 (emoji) + 1 (space)
expect(result.entities[0].length).toBe(4);
});
it('handles emoji inside formatted text', () => {
const result = markdownToTdlib('**Hello 👋 World**');
expect(result.text).toBe('Hello 👋 World');
expect(result.entities[0].offset).toBe(0);
expect(result.entities[0].length).toBe(14); // 6 + 2 + 6
});
it('handles multiple emoji', () => {
const result = markdownToTdlib('👋👋 **test** 👋');
expect(result.text).toBe('👋👋 test 👋');
// Offset: 4 (two emoji) + 1 (space) = 5
expect(result.entities[0].offset).toBe(5);
expect(result.entities[0].length).toBe(4);
});
it('handles flag emoji (multi-codepoint)', () => {
// 🇺🇸 is two regional indicator symbols
const flag = '🇺🇸';
expect(utf16Length(flag)).toBe(4); // Each regional indicator is 2 units
const result = markdownToTdlib(`${flag} **USA**`);
expect(result.text).toBe('🇺🇸 USA');
expect(result.entities[0].offset).toBe(5); // 4 (flag) + 1 (space)
});
});
describe('unclosed formatting', () => {
it('treats unclosed ** as literal', () => {
const result = markdownToTdlib('Hello **world');
expect(result.text).toBe('Hello **world');
expect(result.entities).toEqual([]);
});
it('treats unclosed ` as literal', () => {
const result = markdownToTdlib('Hello `world');
expect(result.text).toBe('Hello `world');
expect(result.entities).toEqual([]);
});
it('treats unclosed ``` as literal', () => {
const result = markdownToTdlib('```code without close');
expect(result.text).toBe('```code without close');
expect(result.entities).toEqual([]);
});
});
});

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../pairing/store.js', () => ({
isUserAllowed: vi.fn(),
upsertPairingRequest: vi.fn(),
approvePairingCode: vi.fn(),
}));
import { TelegramMTProtoAdapter } from '../telegram-mtproto.js';
import { isUserAllowed, upsertPairingRequest } from '../../pairing/store.js';
const mockedIsUserAllowed = vi.mocked(isUserAllowed);
const mockedUpsertPairingRequest = vi.mocked(upsertPairingRequest);
function makeAdapter(overrides: Partial<ConstructorParameters<typeof TelegramMTProtoAdapter>[0]> = {}) {
return new TelegramMTProtoAdapter({
phoneNumber: '+15551234567',
apiId: 12345,
apiHash: 'test-hash',
dmPolicy: 'pairing',
...overrides,
});
}
function makeIncomingTextMessage(overrides: Record<string, unknown> = {}) {
return {
is_outgoing: false,
chat_id: 1001,
id: 5001,
date: Math.floor(Date.now() / 1000),
sender_id: { _: 'messageSenderUser', user_id: 42 },
content: {
_: 'messageText',
text: { text: 'hello', entities: [] },
},
...overrides,
};
}
describe('TelegramMTProtoAdapter pairing flow', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedIsUserAllowed.mockResolvedValue(false);
});
it('sends queue-full notice when no pairing code can be allocated', async () => {
const adapter = makeAdapter();
adapter.onMessage = vi.fn().mockResolvedValue(undefined);
mockedUpsertPairingRequest.mockResolvedValue({ code: '', created: false });
const sendSpy = vi.spyOn(adapter, 'sendMessage').mockResolvedValue({ messageId: '1' });
await (adapter as any).handleNewMessage(makeIncomingTextMessage());
expect(mockedUpsertPairingRequest).toHaveBeenCalledWith('telegram-mtproto', '42');
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(sendSpy).toHaveBeenCalledWith({
chatId: '1001',
text: 'Too many pending pairing requests. Please try again later.',
});
expect(adapter.onMessage).not.toHaveBeenCalled();
});
it('silently deduplicates existing pending pairing requests', async () => {
const adapter = makeAdapter();
adapter.onMessage = vi.fn().mockResolvedValue(undefined);
mockedUpsertPairingRequest.mockResolvedValue({ code: 'ABC123', created: false });
const sendSpy = vi.spyOn(adapter, 'sendMessage').mockResolvedValue({ messageId: '1' });
await (adapter as any).handleNewMessage(makeIncomingTextMessage());
expect(mockedUpsertPairingRequest).toHaveBeenCalledWith('telegram-mtproto', '42');
expect(sendSpy).not.toHaveBeenCalled();
expect(adapter.onMessage).not.toHaveBeenCalled();
});
it('notifies user and admin once for newly created pairing requests', async () => {
const adapter = makeAdapter({ adminChatId: 9999 });
adapter.onMessage = vi.fn().mockResolvedValue(undefined);
mockedUpsertPairingRequest.mockResolvedValue({ code: 'ABC123', created: true });
vi.spyOn(adapter as any, 'getUserInfo').mockResolvedValue({ username: 'alice', firstName: null });
const sendSpy = vi.spyOn(adapter, 'sendMessage')
.mockResolvedValueOnce({ messageId: '100' }) // user notice
.mockResolvedValueOnce({ messageId: '200' }); // admin notice
await (adapter as any).handleNewMessage(
makeIncomingTextMessage({
content: {
_: 'messageText',
text: { text: 'can you help?', entities: [] },
},
})
);
expect(sendSpy).toHaveBeenCalledTimes(2);
expect(sendSpy.mock.calls[0][0]).toEqual({
chatId: '1001',
text: 'Your request has been passed on to the admin.',
});
expect(sendSpy.mock.calls[1][0].chatId).toBe('9999');
const pendingApprovals = (adapter as any).pendingPairingApprovals as Map<number, { code: string }>;
expect(pendingApprovals.size).toBe(1);
expect([...pendingApprovals.values()][0].code).toBe('ABC123');
});
});

View File

@@ -131,6 +131,32 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
env.TELEGRAM_DM_POLICY = config.channels.telegram.dmPolicy;
}
}
// Telegram MTProto (user account mode)
const mtproto = config.channels['telegram-mtproto'];
if (mtproto?.enabled && mtproto.phoneNumber) {
env.TELEGRAM_MTPROTO_PHONE = mtproto.phoneNumber;
if (mtproto.apiId) {
env.TELEGRAM_MTPROTO_API_ID = String(mtproto.apiId);
}
if (mtproto.apiHash) {
env.TELEGRAM_MTPROTO_API_HASH = mtproto.apiHash;
}
if (mtproto.databaseDirectory) {
env.TELEGRAM_MTPROTO_DB_DIR = mtproto.databaseDirectory;
}
if (mtproto.dmPolicy) {
env.TELEGRAM_MTPROTO_DM_POLICY = mtproto.dmPolicy;
}
if (mtproto.allowedUsers?.length) {
env.TELEGRAM_MTPROTO_ALLOWED_USERS = mtproto.allowedUsers.join(',');
}
if (mtproto.groupPolicy) {
env.TELEGRAM_MTPROTO_GROUP_POLICY = mtproto.groupPolicy;
}
if (mtproto.adminChatId) {
env.TELEGRAM_MTPROTO_ADMIN_CHAT_ID = String(mtproto.adminChatId);
}
}
if (config.channels.slack?.appToken) {
env.SLACK_APP_TOKEN = config.channels.slack.appToken;
}

View File

@@ -22,6 +22,7 @@ export interface AgentConfig {
/** Channels this agent connects to */
channels: {
telegram?: TelegramConfig;
'telegram-mtproto'?: TelegramMTProtoConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
@@ -76,6 +77,7 @@ export interface LettaBotConfig {
// Channel configurations
channels: {
telegram?: TelegramConfig;
'telegram-mtproto'?: TelegramMTProtoConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
@@ -168,6 +170,18 @@ export interface TelegramConfig {
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
}
export interface TelegramMTProtoConfig {
enabled: boolean;
phoneNumber?: string; // E.164 format: +1234567890
apiId?: number; // From my.telegram.org
apiHash?: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: number[]; // Telegram user IDs
groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
adminChatId?: number; // Chat ID for pairing request notifications
}
export interface SlackConfig {
enabled: boolean;
appToken?: string;
@@ -223,6 +237,25 @@ export interface DiscordConfig {
groups?: Record<string, GroupConfig>; // Per-guild/channel settings, "*" for defaults
}
/**
* Telegram MTProto (user account) configuration.
* Uses TDLib for user account mode instead of Bot API.
* Cannot be used simultaneously with TelegramConfig (bot mode).
*/
export interface TelegramMTProtoConfig {
enabled: boolean;
phoneNumber?: string; // E.164 format: +1234567890
apiId?: number; // From my.telegram.org
apiHash?: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: number[]; // Telegram user IDs
groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
adminChatId?: number; // Chat ID for pairing request notifications
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
instantGroups?: string[]; // Chat IDs that bypass batching
}
export interface GoogleAccountConfig {
account: string;
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
@@ -352,6 +385,10 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
normalized.telegram = telegram;
}
// telegram-mtproto: check apiId as the key credential
if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) {
normalized['telegram-mtproto'] = channels['telegram-mtproto'];
}
if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
const slack = { ...channels.slack };
normalizeLegacyGroupFields(slack, `${sourcePath}.slack`);
@@ -406,6 +443,20 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS),
};
}
// telegram-mtproto env var fallback (only if telegram bot not configured)
if (!channels.telegram && !channels['telegram-mtproto'] && process.env.TELEGRAM_API_ID && process.env.TELEGRAM_API_HASH && process.env.TELEGRAM_PHONE_NUMBER) {
channels['telegram-mtproto'] = {
enabled: true,
apiId: parseInt(process.env.TELEGRAM_API_ID, 10),
apiHash: process.env.TELEGRAM_API_HASH,
phoneNumber: process.env.TELEGRAM_PHONE_NUMBER,
databaseDirectory: process.env.TELEGRAM_MTPROTO_DB_DIR || './data/telegram-mtproto',
dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS)?.map(s => parseInt(s, 10)).filter(n => !isNaN(n)),
groupPolicy: (process.env.TELEGRAM_GROUP_POLICY as 'mention' | 'reply' | 'both' | 'off') || 'both',
adminChatId: process.env.TELEGRAM_ADMIN_CHAT_ID ? parseInt(process.env.TELEGRAM_ADMIN_CHAT_ID, 10) : undefined,
};
}
if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
channels.slack = {
enabled: true,

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { AgentConfig } from '../config/types.js';
import { collectGroupBatchingConfig, resolveDebounceMs } from './group-batching-config.js';
describe('resolveDebounceMs', () => {
it('prefers groupDebounceSec over deprecated groupPollIntervalMin', () => {
expect(resolveDebounceMs({ groupDebounceSec: 2, groupPollIntervalMin: 9 })).toBe(2000);
});
it('falls back to default when no debounce config is provided', () => {
expect(resolveDebounceMs({})).toBe(5000);
});
});
describe('collectGroupBatchingConfig', () => {
it('uses telegram-mtproto key for mtproto debounce settings', () => {
const channels: AgentConfig['channels'] = {
'telegram-mtproto': {
enabled: true,
apiId: 12345,
groupDebounceSec: 1,
},
};
const { intervals } = collectGroupBatchingConfig(channels);
expect(intervals.get('telegram-mtproto')).toBe(1000);
expect(intervals.has('telegram')).toBe(false);
});
it('prefixes mtproto instant groups with telegram-mtproto channel id', () => {
const channels: AgentConfig['channels'] = {
'telegram-mtproto': {
enabled: true,
apiId: 12345,
instantGroups: ['-1001', '-1002'],
},
};
const { instantIds } = collectGroupBatchingConfig(channels);
expect(instantIds.has('telegram-mtproto:-1001')).toBe(true);
expect(instantIds.has('telegram-mtproto:-1002')).toBe(true);
expect(instantIds.has('telegram:-1001')).toBe(false);
});
it('collects listening groups for supported channels', () => {
const channels: AgentConfig['channels'] = {
slack: {
enabled: true,
botToken: 'xoxb-test',
appToken: 'xapp-test',
listeningGroups: ['C001'],
},
};
const { listeningIds } = collectGroupBatchingConfig(channels);
expect(listeningIds.has('slack:C001')).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import type { AgentConfig } from '../config/types.js';
type DebounceConfig = { groupDebounceSec?: number; groupPollIntervalMin?: number };
type GroupBatchingConfig = DebounceConfig & {
instantGroups?: string[];
listeningGroups?: string[];
};
/**
* Resolve group debounce value to milliseconds.
* Prefers groupDebounceSec, falls back to deprecated groupPollIntervalMin.
* Default: 5 seconds (5000ms).
*/
export function resolveDebounceMs(channel: DebounceConfig): number {
if (channel.groupDebounceSec !== undefined) return channel.groupDebounceSec * 1000;
if (channel.groupPollIntervalMin !== undefined) return channel.groupPollIntervalMin * 60 * 1000;
return 5000;
}
/**
* Build per-channel group batching configuration for an agent.
*/
export function collectGroupBatchingConfig(
channels: AgentConfig['channels'],
): { intervals: Map<string, number>; instantIds: Set<string>; listeningIds: Set<string> } {
const intervals = new Map<string, number>();
const instantIds = new Set<string>();
const listeningIds = new Set<string>();
const addChannel = (channel: string, config?: GroupBatchingConfig): void => {
if (!config) return;
intervals.set(channel, resolveDebounceMs(config));
for (const id of config.instantGroups || []) {
instantIds.add(`${channel}:${id}`);
}
for (const id of config.listeningGroups || []) {
listeningIds.add(`${channel}:${id}`);
}
};
addChannel('telegram', channels.telegram);
const mtprotoConfig = channels['telegram-mtproto'];
if (mtprotoConfig) {
// MTProto does not currently support listeningGroups, only instant/debounce behavior.
intervals.set('telegram-mtproto', resolveDebounceMs(mtprotoConfig));
for (const id of mtprotoConfig.instantGroups || []) {
instantIds.add(`telegram-mtproto:${id}`);
}
}
addChannel('slack', channels.slack);
addChannel('whatsapp', channels.whatsapp);
addChannel('signal', channels.signal);
addChannel('discord', channels.discord);
return { intervals, instantIds, listeningIds };
}

View File

@@ -43,7 +43,7 @@ export interface TriggerContext {
// Original Types
// =============================================================================
export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal' | 'discord' | 'mock';
export type ChannelId = 'telegram' | 'telegram-mtproto' | 'slack' | 'whatsapp' | 'signal' | 'discord' | 'mock';
export interface InboundAttachment {
id?: string;

View File

@@ -148,11 +148,13 @@ import { normalizeAgents } from './config/types.js';
import { LettaGateway } from './core/gateway.js';
import { LettaBot } from './core/bot.js';
import { TelegramAdapter } from './channels/telegram.js';
import { TelegramMTProtoAdapter } from './channels/telegram-mtproto.js';
import { SlackAdapter } from './channels/slack.js';
import { WhatsAppAdapter } from './channels/whatsapp/index.js';
import { SignalAdapter } from './channels/signal.js';
import { DiscordAdapter } from './channels/discord.js';
import { GroupBatcher } from './core/group-batcher.js';
import { collectGroupBatchingConfig } from './core/group-batching-config.js';
import { CronService } from './cron/service.js';
import { HeartbeatService } from './cron/heartbeat.js';
import { PollingService, parseGmailAccounts } from './polling/service.js';
@@ -272,17 +274,44 @@ function createChannelsForAgent(
): import('./channels/types.js').ChannelAdapter[] {
const adapters: import('./channels/types.js').ChannelAdapter[] = [];
if (agentConfig.channels.telegram?.token) {
// Mutual exclusion: cannot use both Telegram Bot API and MTProto simultaneously
const hasTelegramBot = !!agentConfig.channels.telegram?.token;
const hasTelegramMtproto = !!(agentConfig.channels['telegram-mtproto'] as any)?.apiId;
if (hasTelegramBot && hasTelegramMtproto) {
console.error(`\n Error: Agent "${agentConfig.name}" has both telegram and telegram-mtproto configured.`);
console.error(' The Bot API adapter and MTProto adapter cannot run together.');
console.error(' Choose one: telegram (bot token) or telegram-mtproto (user account).\n');
process.exit(1);
}
if (hasTelegramBot) {
adapters.push(new TelegramAdapter({
token: agentConfig.channels.telegram.token,
dmPolicy: agentConfig.channels.telegram.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.telegram.allowedUsers && agentConfig.channels.telegram.allowedUsers.length > 0
? agentConfig.channels.telegram.allowedUsers.map(u => typeof u === 'string' ? parseInt(u, 10) : u)
token: agentConfig.channels.telegram!.token!,
dmPolicy: agentConfig.channels.telegram!.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.telegram!.allowedUsers && agentConfig.channels.telegram!.allowedUsers.length > 0
? agentConfig.channels.telegram!.allowedUsers.map(u => typeof u === 'string' ? parseInt(u, 10) : u)
: undefined,
attachmentsDir,
attachmentsMaxBytes,
groups: agentConfig.channels.telegram.groups,
mentionPatterns: agentConfig.channels.telegram.mentionPatterns,
groups: agentConfig.channels.telegram!.groups,
mentionPatterns: agentConfig.channels.telegram!.mentionPatterns,
}));
}
if (hasTelegramMtproto) {
const mtprotoConfig = agentConfig.channels['telegram-mtproto'] as any;
adapters.push(new TelegramMTProtoAdapter({
apiId: mtprotoConfig.apiId,
apiHash: mtprotoConfig.apiHash,
phoneNumber: mtprotoConfig.phoneNumber,
databaseDirectory: mtprotoConfig.databaseDirectory || './data/telegram-mtproto',
dmPolicy: mtprotoConfig.dmPolicy || 'pairing',
allowedUsers: mtprotoConfig.allowedUsers && mtprotoConfig.allowedUsers.length > 0
? mtprotoConfig.allowedUsers.map((u: string | number) => typeof u === 'string' ? parseInt(u, 10) : u)
: undefined,
groupPolicy: mtprotoConfig.groupPolicy || 'both',
adminChatId: mtprotoConfig.adminChatId,
}));
}
@@ -359,17 +388,6 @@ function createChannelsForAgent(
return adapters;
}
/**
* Resolve group debounce value to milliseconds.
* Prefers groupDebounceSec, falls back to deprecated groupPollIntervalMin.
* Default: 5 seconds (5000ms).
*/
function resolveDebounceMs(channel: { groupDebounceSec?: number; groupPollIntervalMin?: number }): number {
if (channel.groupDebounceSec !== undefined) return channel.groupDebounceSec * 1000;
if (channel.groupPollIntervalMin !== undefined) return channel.groupPollIntervalMin * 60 * 1000;
return 5000; // 5 seconds default
}
/**
* Create and configure a group batcher for an agent
*/
@@ -377,22 +395,7 @@ function createGroupBatcher(
agentConfig: import('./config/types.js').AgentConfig,
bot: import('./core/interfaces.js').AgentSession,
): { batcher: GroupBatcher | null; intervals: Map<string, number>; instantIds: Set<string>; listeningIds: Set<string> } {
const intervals = new Map<string, number>(); // channel -> debounce ms
const instantIds = new Set<string>();
const listeningIds = new Set<string>();
const channelNames = ['telegram', 'slack', 'whatsapp', 'signal', 'discord'] as const;
for (const channel of channelNames) {
const cfg = agentConfig.channels[channel];
if (!cfg) continue;
intervals.set(channel, resolveDebounceMs(cfg));
for (const id of (cfg as any).instantGroups || []) {
instantIds.add(`${channel}:${id}`);
}
for (const id of (cfg as any).listeningGroups || []) {
listeningIds.add(`${channel}:${id}`);
}
}
const { intervals, instantIds, listeningIds } = collectGroupBatchingConfig(agentConfig.channels);
if (instantIds.size > 0) {
console.log(`[Groups] Instant groups: ${[...instantIds].join(', ')}`);
@@ -523,7 +526,7 @@ async function main() {
initialStatus = bot.getStatus();
}
}
// Container deploy: discover by name
if (!initialStatus.agentId && isContainerDeploy) {
const found = await findAgentByName(agentConfig.name);
@@ -533,7 +536,7 @@ async function main() {
initialStatus = bot.getStatus();
}
}
if (!initialStatus.agentId) {
console.log(`[Agent:${agentConfig.name}] No agent found - will create on first message`);
}
@@ -642,7 +645,7 @@ async function main() {
host: apiHost,
corsOrigin: apiCorsOrigin,
});
// Status logging
console.log('\n=================================');
console.log(`LettaBot is running! (${gateway.size} agent${gateway.size > 1 ? 's' : ''})`);

2
src/types/optional-modules.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare module 'tdl';
declare module 'prebuilt-tdlib';

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: ['dist/**', 'node_modules/**'],
},
});