Files
lettabot/src/channels/telegram.ts
Gabriele Sarti 66e8c462bf feat: group message batching + Telegram group gating + instantGroups (#187)
* feat: add group message batching, Telegram group gating, and instantGroups

Group Message Batcher:
- New GroupBatcher buffers group chat messages and flushes on timer or @mention
- Channel-agnostic: works with any ChannelAdapter
- Configurable per-channel via groupPollIntervalMin (default: 10min, 0 = immediate)
- formatGroupBatchEnvelope formats batched messages as chat logs for the agent
- Single-message batches unwrapped to use DM-style formatMessageEnvelope

Telegram Group Gating:
- my_chat_member handler: bot leaves groups when added by unpaired users
- Groups added by paired users are auto-approved via group-store
- Group messages bypass DM pairing (middleware skips group/supergroup chats)
- Mention detection for @bot in group messages

Channel Group Support:
- All adapters: getDmPolicy() interface method
- Discord: serverId (guildId), wasMentioned, pairing bypass for guilds
- Signal: group messages bypass pairing
- Slack: wasMentioned field on messages

instantGroups Config:
- Per-channel instantGroups config to bypass batching for specific groups
- For Discord, checked against both serverId and chatId
- YAML config → env vars → parsed in main.ts → Set passed to bot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: preserve large numeric IDs in instantGroups YAML config

Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses
unquoted IDs as lossy JavaScript numbers. Use the document AST to
extract the original string representation and avoid precision loss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Slack dmPolicy, Telegram group gating check

- Add dmPolicy to SlackConfig and wire through config/env/adapter
  (was hardcoded to 'open', now reads from config like other adapters)
- Check isGroupApproved() in Telegram middleware before processing
  group messages (approveGroup was called but never checked)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:47:22 -08:00

816 lines
27 KiB
TypeScript

/**
* Telegram Channel Adapter
*
* Uses grammY for Telegram Bot API.
* Supports DM pairing for secure access control.
*/
import { Bot, InputFile } from 'grammy';
import type { ChannelAdapter } from './types.js';
import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js';
import type { DmPolicy } from '../pairing/types.js';
import {
isUserAllowed,
upsertPairingRequest,
formatPairingMessage,
} from '../pairing/store.js';
import { isGroupApproved, approveGroup } from '../pairing/group-store.js';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
export interface TelegramConfig {
token: string;
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: number[]; // Telegram user IDs (config allowlist)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
}
export class TelegramAdapter implements ChannelAdapter {
readonly id = 'telegram' as const;
readonly name = 'Telegram';
private bot: Bot;
private config: TelegramConfig;
private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>;
constructor(config: TelegramConfig) {
this.config = {
...config,
dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
};
this.bot = new Bot(config.token);
this.attachmentsDir = config.attachmentsDir;
this.attachmentsMaxBytes = config.attachmentsMaxBytes;
this.setupHandlers();
}
/**
* Check if a user is authorized based on dmPolicy
* Returns true if allowed, false if blocked, 'pairing' if pending pairing
*/
private async checkAccess(userId: string, username?: string, firstName?: string): Promise<'allowed' | 'blocked' | 'pairing'> {
const policy = this.config.dmPolicy || 'pairing';
const userIdStr = userId;
// Open policy: everyone allowed
if (policy === 'open') {
return 'allowed';
}
// Check if already allowed (config or store)
const configAllowlist = this.config.allowedUsers?.map(String);
const allowed = await isUserAllowed('telegram', userIdStr, configAllowlist);
if (allowed) {
return 'allowed';
}
// Allowlist policy: not allowed if not in list
if (policy === 'allowlist') {
return 'blocked';
}
// Pairing policy: create/update pairing request
return 'pairing';
}
private setupHandlers(): void {
// Detect when bot is added/removed from groups (proactive group gating)
this.bot.on('my_chat_member', async (ctx) => {
const chatMember = ctx.myChatMember;
if (!chatMember) return;
const chatType = chatMember.chat.type;
if (chatType !== 'group' && chatType !== 'supergroup') return;
const newStatus = chatMember.new_chat_member.status;
if (newStatus !== 'member' && newStatus !== 'administrator') return;
const chatId = String(chatMember.chat.id);
const fromId = String(chatMember.from.id);
const dmPolicy = this.config.dmPolicy || 'pairing';
// No gating when policy is not pairing
if (dmPolicy !== 'pairing') {
await approveGroup('telegram', chatId);
console.log(`[Telegram] Group ${chatId} auto-approved (dmPolicy=${dmPolicy})`);
return;
}
// Check if the user who added the bot is paired
const configAllowlist = this.config.allowedUsers?.map(String);
const allowed = await isUserAllowed('telegram', fromId, configAllowlist);
if (allowed) {
await approveGroup('telegram', chatId);
console.log(`[Telegram] Group ${chatId} approved by paired user ${fromId}`);
} else {
console.log(`[Telegram] Unpaired user ${fromId} tried to add bot to group ${chatId}, leaving`);
try {
await ctx.api.sendMessage(chatId, 'This bot can only be added to groups by paired users.');
await ctx.api.leaveChat(chatId);
} catch (err) {
console.error('[Telegram] Failed to leave group:', err);
}
}
});
// Middleware: Check access based on dmPolicy (bypass for groups)
this.bot.use(async (ctx, next) => {
const userId = ctx.from?.id;
if (!userId) return;
// Group gating: check if group is approved before processing
const chatType = ctx.chat?.type;
if (chatType === 'group' || chatType === 'supergroup') {
const dmPolicy = this.config.dmPolicy || 'pairing';
if (dmPolicy === 'open' || await isGroupApproved('telegram', String(ctx.chat!.id))) {
await next();
}
// Silently drop messages from unapproved groups
return;
}
const access = await this.checkAccess(
String(userId),
ctx.from?.username,
ctx.from?.first_name
);
if (access === 'allowed') {
await next();
return;
}
if (access === 'blocked') {
await ctx.reply("Sorry, you're not authorized to use this bot.");
return;
}
// Pairing flow
const { code, created } = await upsertPairingRequest('telegram', String(userId), {
username: ctx.from?.username,
firstName: ctx.from?.first_name,
lastName: ctx.from?.last_name,
});
if (!code) {
// Too many pending requests
await ctx.reply(
"Too many pending pairing requests. Please try again later."
);
return;
}
// Only send pairing message on first contact (created=true)
// or if this is a new message (not just middleware check)
if (created) {
console.log(`[Telegram] New pairing request from ${userId} (${ctx.from?.username || 'no username'}): ${code}`);
await ctx.reply(formatPairingMessage(code), { parse_mode: 'Markdown' });
}
// Don't process the message further
return;
});
// Handle /start and /help
this.bot.command(['start', 'help'], async (ctx) => {
await ctx.reply(
"*LettaBot* - AI assistant with persistent memory\n\n" +
"*Commands:*\n" +
"/status - Show current status\n" +
"/help - Show this message\n\n" +
"Just send me a message to get started!",
{ parse_mode: 'Markdown' }
);
});
// Handle /status
this.bot.command('status', async (ctx) => {
if (this.onCommand) {
const result = await this.onCommand('status');
await ctx.reply(result || 'No status available');
}
});
// Handle /heartbeat - trigger heartbeat manually (silent - no reply)
this.bot.command('heartbeat', async (ctx) => {
if (this.onCommand) {
await this.onCommand('heartbeat');
}
});
// Handle text messages
this.bot.on('message:text', async (ctx) => {
const userId = ctx.from?.id;
const chatId = ctx.chat.id;
const text = ctx.message.text;
if (!userId) return;
if (text.startsWith('/')) return; // Skip other commands
// Group detection
const chatType = ctx.chat.type;
const isGroup = chatType === 'group' || chatType === 'supergroup';
const groupName = isGroup && 'title' in ctx.chat ? ctx.chat.title : undefined;
// Mention detection for groups
let wasMentioned = false;
if (isGroup) {
const botUsername = this.bot.botInfo?.username;
if (botUsername) {
// Check entities for bot_command or mention matching our username
const entities = ctx.message.entities || [];
wasMentioned = entities.some((e) => {
if (e.type === 'mention') {
const mentioned = text.substring(e.offset, e.offset + e.length);
return mentioned.toLowerCase() === `@${botUsername.toLowerCase()}`;
}
return false;
});
// Fallback: text-based check
if (!wasMentioned) {
wasMentioned = text.toLowerCase().includes(`@${botUsername.toLowerCase()}`);
}
}
}
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: ctx.from.username || ctx.from.first_name,
userHandle: ctx.from.username,
messageId: String(ctx.message.message_id),
text,
timestamp: new Date(),
isGroup,
groupName,
wasMentioned,
});
}
});
// Handle message reactions (Bot API >= 7.0)
this.bot.on('message_reaction', async (ctx) => {
const reaction = ctx.update.message_reaction;
if (!reaction) return;
const userId = reaction.user?.id;
if (!userId) return;
const access = await this.checkAccess(
String(userId),
reaction.user?.username,
reaction.user?.first_name
);
if (access !== 'allowed') {
return;
}
const chatId = reaction.chat?.id;
const messageId = reaction.message_id;
if (!chatId || !messageId) return;
const newEmoji = extractTelegramReaction(reaction.new_reaction?.[0]);
const oldEmoji = extractTelegramReaction(reaction.old_reaction?.[0]);
const emoji = newEmoji || oldEmoji;
if (!emoji) return;
const action: InboundReaction['action'] = newEmoji ? 'added' : 'removed';
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: reaction.user?.username || reaction.user?.first_name || undefined,
messageId: String(messageId),
text: '',
timestamp: new Date(),
reaction: {
emoji,
messageId: String(messageId),
action,
},
});
}
});
// Handle voice messages (must be registered before generic 'message' handler)
this.bot.on('message:voice', async (ctx) => {
const userId = ctx.from?.id;
const chatId = ctx.chat.id;
if (!userId) return;
// Check if transcription is configured (config or env)
const { loadConfig } = await import('../config/index.js');
const config = loadConfig();
if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) {
await ctx.reply('Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages');
return;
}
try {
// Get file link
const voice = ctx.message.voice;
const file = await ctx.api.getFile(voice.file_id);
const fileUrl = `https://api.telegram.org/file/bot${this.config.token}/${file.file_path}`;
// Download audio
const response = await fetch(fileUrl);
const buffer = Buffer.from(await response.arrayBuffer());
// Transcribe
const { transcribeAudio } = await import('../transcription/index.js');
const result = await transcribeAudio(buffer, 'voice.ogg');
let messageText: string;
if (result.success && result.text) {
console.log(`[Telegram] Transcribed voice message: "${result.text.slice(0, 50)}..."`);
messageText = `[Voice message]: ${result.text}`;
} else {
console.error(`[Telegram] Transcription failed: ${result.error}`);
messageText = `[Voice message - transcription failed: ${result.error}]`;
}
// Send to agent
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: ctx.from.username || ctx.from.first_name,
messageId: String(ctx.message.message_id),
text: messageText,
timestamp: new Date(),
});
}
} catch (error) {
console.error('[Telegram] Error processing voice message:', error);
// Send error to agent so it can explain
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: ctx.from?.username || ctx.from?.first_name,
messageId: String(ctx.message.message_id),
text: `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`,
timestamp: new Date(),
});
}
}
});
// Handle non-text messages with attachments (excluding voice - handled above)
this.bot.on('message', async (ctx) => {
if (!ctx.message || ctx.message.text || ctx.message.voice) return;
const userId = ctx.from?.id;
const chatId = ctx.chat.id;
if (!userId) return;
const { attachments, caption } = await this.collectAttachments(ctx.message, String(chatId));
if (attachments.length === 0 && !caption) return;
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: ctx.from.username || ctx.from.first_name,
messageId: String(ctx.message.message_id),
text: caption || '',
timestamp: new Date(),
attachments,
});
}
});
// Error handler
this.bot.catch((err) => {
console.error('[Telegram] Bot error:', err);
});
}
async start(): Promise<void> {
if (this.running) return;
// Don't await - bot.start() never resolves (it's a long-polling loop)
// The onStart callback fires when polling begins
// Must catch errors: on deploy, the old instance's getUpdates long-poll may still
// be active, causing a 409 Conflict. grammY retries internally but can throw.
this.bot.start({
onStart: (botInfo) => {
console.log(`[Telegram] Bot started as @${botInfo.username}`);
console.log(`[Telegram] DM policy: ${this.config.dmPolicy}`);
this.running = true;
},
}).catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('terminated by other getUpdates request') || msg.includes('409')) {
console.error(`[Telegram] getUpdates conflict (likely old instance still polling). Retrying in 5s...`);
setTimeout(() => {
this.running = false;
this.start().catch(e => console.error('[Telegram] Retry failed:', e));
}, 5000);
} else {
console.error('[Telegram] Bot polling error:', err);
}
});
// Give it a moment to connect before returning
await new Promise(resolve => setTimeout(resolve, 1000));
}
async stop(): Promise<void> {
if (!this.running) return;
await this.bot.stop();
this.running = false;
}
isRunning(): boolean {
return this.running;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
const { markdownToTelegramV2 } = await import('./telegram-format.js');
// Split long messages into chunks (Telegram limit: 4096 chars)
const chunks = splitMessageText(msg.text);
let lastMessageId = '';
for (const chunk of chunks) {
// Only first chunk replies to the original message
const replyId = !lastMessageId && msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined;
// Try MarkdownV2 first
try {
const formatted = await markdownToTelegramV2(chunk);
// MarkdownV2 escaping can expand text beyond 4096 - re-split if needed
if (formatted.length > TELEGRAM_MAX_LENGTH) {
const subChunks = splitFormattedText(formatted);
for (const sub of subChunks) {
const result = await this.bot.api.sendMessage(msg.chatId, sub, {
parse_mode: 'MarkdownV2',
reply_to_message_id: replyId,
});
lastMessageId = String(result.message_id);
}
} else {
const result = await this.bot.api.sendMessage(msg.chatId, formatted, {
parse_mode: 'MarkdownV2',
reply_to_message_id: replyId,
});
lastMessageId = String(result.message_id);
}
} catch (e) {
// If MarkdownV2 fails, send raw text (also split if needed)
console.warn('[Telegram] MarkdownV2 send failed, falling back to raw text:', e);
const plainChunks = splitFormattedText(chunk);
for (const plain of plainChunks) {
const result = await this.bot.api.sendMessage(msg.chatId, plain, {
reply_to_message_id: replyId,
});
lastMessageId = String(result.message_id);
}
}
}
return { messageId: lastMessageId };
}
async sendFile(file: OutboundFile): Promise<{ messageId: string }> {
const input = new InputFile(file.filePath);
const caption = file.caption || undefined;
if (file.kind === 'image') {
const result = await this.bot.api.sendPhoto(file.chatId, input, { caption });
return { messageId: String(result.message_id) };
}
const result = await this.bot.api.sendDocument(file.chatId, input, { caption });
return { messageId: String(result.message_id) };
}
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
const { markdownToTelegramV2 } = await import('./telegram-format.js');
const formatted = await markdownToTelegramV2(text);
await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' });
}
async addReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
const resolved = resolveTelegramEmoji(emoji);
if (!TELEGRAM_REACTION_SET.has(resolved)) {
throw new Error(`Unsupported Telegram reaction emoji: ${resolved}`);
}
await this.bot.api.setMessageReaction(chatId, Number(messageId), [
{ type: 'emoji', emoji: resolved as TelegramReactionEmoji },
]);
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
async sendTypingIndicator(chatId: string): Promise<void> {
await this.bot.api.sendChatAction(chatId, 'typing');
}
/**
* Get the underlying bot instance (for commands, etc.)
*/
getBot(): Bot {
return this.bot;
}
private async collectAttachments(
message: any,
chatId: string
): Promise<{ attachments: InboundAttachment[]; caption?: string }> {
const attachments: InboundAttachment[] = [];
const caption = message.caption as string | undefined;
if (message.photo && message.photo.length > 0) {
const photo = message.photo[message.photo.length - 1];
const attachment = await this.fetchTelegramFile({
fileId: photo.file_id,
fileName: `photo-${photo.file_unique_id}.jpg`,
mimeType: 'image/jpeg',
size: photo.file_size,
kind: 'image',
chatId,
});
if (attachment) attachments.push(attachment);
}
if (message.document) {
const doc = message.document;
const attachment = await this.fetchTelegramFile({
fileId: doc.file_id,
fileName: doc.file_name,
mimeType: doc.mime_type,
size: doc.file_size,
kind: 'file',
chatId,
});
if (attachment) attachments.push(attachment);
}
if (message.video) {
const video = message.video;
const attachment = await this.fetchTelegramFile({
fileId: video.file_id,
fileName: video.file_name || `video-${video.file_unique_id}.mp4`,
mimeType: video.mime_type,
size: video.file_size,
kind: 'video',
chatId,
});
if (attachment) attachments.push(attachment);
}
if (message.audio) {
const audio = message.audio;
const attachment = await this.fetchTelegramFile({
fileId: audio.file_id,
fileName: audio.file_name || `audio-${audio.file_unique_id}.mp3`,
mimeType: audio.mime_type,
size: audio.file_size,
kind: 'audio',
chatId,
});
if (attachment) attachments.push(attachment);
}
if (message.voice) {
const voice = message.voice;
const attachment = await this.fetchTelegramFile({
fileId: voice.file_id,
fileName: `voice-${voice.file_unique_id}.ogg`,
mimeType: voice.mime_type,
size: voice.file_size,
kind: 'audio',
chatId,
});
if (attachment) attachments.push(attachment);
}
if (message.animation) {
const animation = message.animation;
const attachment = await this.fetchTelegramFile({
fileId: animation.file_id,
fileName: animation.file_name || `animation-${animation.file_unique_id}.mp4`,
mimeType: animation.mime_type,
size: animation.file_size,
kind: 'video',
chatId,
});
if (attachment) attachments.push(attachment);
}
if (message.sticker) {
const sticker = message.sticker;
const attachment = await this.fetchTelegramFile({
fileId: sticker.file_id,
fileName: `sticker-${sticker.file_unique_id}.${sticker.is_animated ? 'tgs' : 'webp'}`,
mimeType: sticker.mime_type,
size: sticker.file_size,
kind: 'image',
chatId,
});
if (attachment) attachments.push(attachment);
}
return { attachments, caption };
}
private async fetchTelegramFile(options: {
fileId: string;
fileName?: string;
mimeType?: string;
size?: number;
kind?: InboundAttachment['kind'];
chatId: string;
}): Promise<InboundAttachment | null> {
const { fileId, fileName, mimeType, size, kind, chatId } = options;
const attachment: InboundAttachment = {
id: fileId,
name: fileName,
mimeType,
size,
kind,
};
if (!this.attachmentsDir) {
return attachment;
}
if (this.attachmentsMaxBytes === 0) {
return attachment;
}
if (this.attachmentsMaxBytes && size && size > this.attachmentsMaxBytes) {
console.warn(`[Telegram] Attachment ${fileName || fileId} exceeds size limit, skipping download.`);
return attachment;
}
try {
const file = await this.bot.api.getFile(fileId);
const remotePath = file.file_path;
if (!remotePath) return attachment;
const resolvedName = fileName || basename(remotePath) || fileId;
const target = buildAttachmentPath(this.attachmentsDir, 'telegram', chatId, resolvedName);
const url = `https://api.telegram.org/file/bot${this.config.token}/${remotePath}`;
await downloadToFile(url, target);
attachment.localPath = target;
console.log(`[Telegram] Attachment saved to ${target}`);
} catch (err) {
console.warn('[Telegram] Failed to download attachment:', err);
}
return attachment;
}
}
function extractTelegramReaction(reaction?: {
type?: string;
emoji?: string;
custom_emoji_id?: string;
}): string | null {
if (!reaction) return null;
if ('emoji' in reaction && reaction.emoji) {
return reaction.emoji;
}
if ('custom_emoji_id' in reaction && reaction.custom_emoji_id) {
return `custom:${reaction.custom_emoji_id}`;
}
return null;
}
const TELEGRAM_EMOJI_ALIAS_TO_UNICODE: Record<string, string> = {
eyes: '👀',
thumbsup: '👍',
thumbs_up: '👍',
'+1': '👍',
heart: '❤️',
fire: '🔥',
smile: '😄',
laughing: '😆',
tada: '🎉',
clap: '👏',
ok_hand: '👌',
};
function resolveTelegramEmoji(input: string): string {
const match = input.match(/^:([^:]+):$/);
const alias = match ? match[1] : null;
if (alias && TELEGRAM_EMOJI_ALIAS_TO_UNICODE[alias]) {
return TELEGRAM_EMOJI_ALIAS_TO_UNICODE[alias];
}
if (TELEGRAM_EMOJI_ALIAS_TO_UNICODE[input]) {
return TELEGRAM_EMOJI_ALIAS_TO_UNICODE[input];
}
return input;
}
const TELEGRAM_REACTION_EMOJIS = [
'👍', '👎', '❤', '🔥', '🥰', '👏', '😁', '🤔', '🤯', '😱', '🤬', '😢',
'🎉', '🤩', '🤮', '💩', '🙏', '👌', '🕊', '🤡', '🥱', '🥴', '😍', '🐳',
'❤‍🔥', '🌚', '🌭', '💯', '🤣', '⚡', '🍌', '🏆', '💔', '🤨', '😐', '🍓',
'🍾', '💋', '🖕', '😈', '😴', '😭', '🤓', '👻', '👨‍💻', '👀', '🎃', '🙈',
'😇', '😨', '🤝', '✍', '🤗', '🫡', '🎅', '🎄', '☃', '💅', '🤪', '🗿',
'🆒', '💘', '🙉', '🦄', '😘', '💊', '🙊', '😎', '👾', '🤷‍♂', '🤷',
'🤷‍♀', '😡',
] as const;
type TelegramReactionEmoji = typeof TELEGRAM_REACTION_EMOJIS[number];
const TELEGRAM_REACTION_SET = new Set<string>(TELEGRAM_REACTION_EMOJIS);
// Telegram message length limit
const TELEGRAM_MAX_LENGTH = 4096;
// Leave room for MarkdownV2 escaping overhead when splitting raw text
const TELEGRAM_SPLIT_THRESHOLD = 3800;
/**
* Split raw markdown text into chunks that will fit within Telegram's limit
* after MarkdownV2 formatting. Splits at paragraph boundaries (double newlines),
* falling back to single newlines, then hard-splitting at the threshold.
*/
function splitMessageText(text: string): string[] {
if (text.length <= TELEGRAM_SPLIT_THRESHOLD) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
while (remaining.length > TELEGRAM_SPLIT_THRESHOLD) {
let splitIdx = -1;
// Try paragraph boundary (double newline)
const searchRegion = remaining.slice(0, TELEGRAM_SPLIT_THRESHOLD);
const lastParagraph = searchRegion.lastIndexOf('\n\n');
if (lastParagraph > TELEGRAM_SPLIT_THRESHOLD * 0.3) {
splitIdx = lastParagraph;
}
// Fall back to single newline
if (splitIdx === -1) {
const lastNewline = searchRegion.lastIndexOf('\n');
if (lastNewline > TELEGRAM_SPLIT_THRESHOLD * 0.3) {
splitIdx = lastNewline;
}
}
// Hard split as last resort
if (splitIdx === -1) {
splitIdx = TELEGRAM_SPLIT_THRESHOLD;
}
chunks.push(remaining.slice(0, splitIdx).trimEnd());
remaining = remaining.slice(splitIdx).trimStart();
}
if (remaining.trim()) {
chunks.push(remaining.trim());
}
return chunks;
}
/**
* Split already-formatted text (MarkdownV2 or plain) at the hard 4096 limit.
* Used as a safety net when formatting expands text beyond the limit.
* Tries to split at newlines to avoid breaking mid-word.
*/
function splitFormattedText(text: string): string[] {
if (text.length <= TELEGRAM_MAX_LENGTH) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
while (remaining.length > TELEGRAM_MAX_LENGTH) {
const searchRegion = remaining.slice(0, TELEGRAM_MAX_LENGTH);
let splitIdx = searchRegion.lastIndexOf('\n');
if (splitIdx < TELEGRAM_MAX_LENGTH * 0.3) {
// No good newline found - hard split
splitIdx = TELEGRAM_MAX_LENGTH;
}
chunks.push(remaining.slice(0, splitIdx));
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
}