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:
committed by
GitHub
parent
8cf9bd230e
commit
28adc22388
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
251
docs/telegram-mtproto-setup.md
Normal file
251
docs/telegram-mtproto-setup.md
Normal 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
1324
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
311
src/channels/telegram-mtproto-format.ts
Normal file
311
src/channels/telegram-mtproto-format.ts
Normal 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' }
|
||||
};
|
||||
}
|
||||
929
src/channels/telegram-mtproto.ts
Normal file
929
src/channels/telegram-mtproto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/channels/tests/telegram-mtproto-format.test.ts
Normal file
242
src/channels/tests/telegram-mtproto-format.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
108
src/channels/tests/telegram-mtproto-pairing.test.ts
Normal file
108
src/channels/tests/telegram-mtproto-pairing.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
61
src/core/group-batching-config.test.ts
Normal file
61
src/core/group-batching-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
src/core/group-batching-config.ts
Normal file
58
src/core/group-batching-config.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
77
src/main.ts
77
src/main.ts
@@ -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
2
src/types/optional-modules.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'tdl';
|
||||
declare module 'prebuilt-tdlib';
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user