Wrap outbound sendMessage calls with parse_mode: 'Markdown' so that Claude's natural formatting (*bold*, _italic_, `code`, etc.) renders correctly in Telegram instead of showing raw asterisks and underscores. Falls back to plain text if Telegram rejects the Markdown formatting.
282 lines
8.7 KiB
TypeScript
282 lines
8.7 KiB
TypeScript
import { Api, Bot } from 'grammy';
|
|
|
|
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
|
|
import { readEnvFile } from '../env.js';
|
|
import { logger } from '../logger.js';
|
|
import { registerChannel, ChannelOpts } from './registry.js';
|
|
import {
|
|
Channel,
|
|
OnChatMetadata,
|
|
OnInboundMessage,
|
|
RegisteredGroup,
|
|
} from '../types.js';
|
|
|
|
export interface TelegramChannelOpts {
|
|
onMessage: OnInboundMessage;
|
|
onChatMetadata: OnChatMetadata;
|
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
|
}
|
|
|
|
/**
|
|
* Send a message with Telegram Markdown parse mode, falling back to plain text.
|
|
* Claude's output naturally matches Telegram's Markdown v1 format:
|
|
* *bold*, _italic_, `code`, ```code blocks```, [links](url)
|
|
*/
|
|
async function sendTelegramMessage(
|
|
api: { sendMessage: Api['sendMessage'] },
|
|
chatId: string | number,
|
|
text: string,
|
|
options: { message_thread_id?: number } = {},
|
|
): Promise<void> {
|
|
try {
|
|
await api.sendMessage(chatId, text, {
|
|
...options,
|
|
parse_mode: 'Markdown',
|
|
});
|
|
} catch (err) {
|
|
// Fallback: send as plain text if Markdown parsing fails
|
|
logger.debug({ err }, 'Markdown send failed, falling back to plain text');
|
|
await api.sendMessage(chatId, text, options);
|
|
}
|
|
}
|
|
|
|
export class TelegramChannel implements Channel {
|
|
name = 'telegram';
|
|
|
|
private bot: Bot | null = null;
|
|
private opts: TelegramChannelOpts;
|
|
private botToken: string;
|
|
|
|
constructor(botToken: string, opts: TelegramChannelOpts) {
|
|
this.botToken = botToken;
|
|
this.opts = opts;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
this.bot = new Bot(this.botToken);
|
|
|
|
// Command to get chat ID (useful for registration)
|
|
this.bot.command('chatid', (ctx) => {
|
|
const chatId = ctx.chat.id;
|
|
const chatType = ctx.chat.type;
|
|
const chatName =
|
|
chatType === 'private'
|
|
? ctx.from?.first_name || 'Private'
|
|
: (ctx.chat as any).title || 'Unknown';
|
|
|
|
ctx.reply(
|
|
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
|
|
{ parse_mode: 'Markdown' },
|
|
);
|
|
});
|
|
|
|
// Command to check bot status
|
|
this.bot.command('ping', (ctx) => {
|
|
ctx.reply(`${ASSISTANT_NAME} is online.`);
|
|
});
|
|
|
|
this.bot.on('message:text', async (ctx) => {
|
|
// Skip commands
|
|
if (ctx.message.text.startsWith('/')) return;
|
|
|
|
const chatJid = `tg:${ctx.chat.id}`;
|
|
let content = ctx.message.text;
|
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
|
const senderName =
|
|
ctx.from?.first_name ||
|
|
ctx.from?.username ||
|
|
ctx.from?.id.toString() ||
|
|
'Unknown';
|
|
const sender = ctx.from?.id.toString() || '';
|
|
const msgId = ctx.message.message_id.toString();
|
|
|
|
// Determine chat name
|
|
const chatName =
|
|
ctx.chat.type === 'private'
|
|
? senderName
|
|
: (ctx.chat as any).title || chatJid;
|
|
|
|
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
|
|
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
|
|
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
|
|
const botUsername = ctx.me?.username?.toLowerCase();
|
|
if (botUsername) {
|
|
const entities = ctx.message.entities || [];
|
|
const isBotMentioned = entities.some((entity) => {
|
|
if (entity.type === 'mention') {
|
|
const mentionText = content
|
|
.substring(entity.offset, entity.offset + entity.length)
|
|
.toLowerCase();
|
|
return mentionText === `@${botUsername}`;
|
|
}
|
|
return false;
|
|
});
|
|
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
|
|
content = `@${ASSISTANT_NAME} ${content}`;
|
|
}
|
|
}
|
|
|
|
// Store chat metadata for discovery
|
|
const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
|
|
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup);
|
|
|
|
// Only deliver full message for registered groups
|
|
const group = this.opts.registeredGroups()[chatJid];
|
|
if (!group) {
|
|
logger.debug(
|
|
{ chatJid, chatName },
|
|
'Message from unregistered Telegram chat',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Deliver message — startMessageLoop() will pick it up
|
|
this.opts.onMessage(chatJid, {
|
|
id: msgId,
|
|
chat_jid: chatJid,
|
|
sender,
|
|
sender_name: senderName,
|
|
content,
|
|
timestamp,
|
|
is_from_me: false,
|
|
});
|
|
|
|
logger.info(
|
|
{ chatJid, chatName, sender: senderName },
|
|
'Telegram message stored',
|
|
);
|
|
});
|
|
|
|
// Handle non-text messages with placeholders so the agent knows something was sent
|
|
const storeNonText = (ctx: any, placeholder: string) => {
|
|
const chatJid = `tg:${ctx.chat.id}`;
|
|
const group = this.opts.registeredGroups()[chatJid];
|
|
if (!group) return;
|
|
|
|
const timestamp = new Date(ctx.message.date * 1000).toISOString();
|
|
const senderName =
|
|
ctx.from?.first_name ||
|
|
ctx.from?.username ||
|
|
ctx.from?.id?.toString() ||
|
|
'Unknown';
|
|
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
|
|
|
|
const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
|
|
this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup);
|
|
this.opts.onMessage(chatJid, {
|
|
id: ctx.message.message_id.toString(),
|
|
chat_jid: chatJid,
|
|
sender: ctx.from?.id?.toString() || '',
|
|
sender_name: senderName,
|
|
content: `${placeholder}${caption}`,
|
|
timestamp,
|
|
is_from_me: false,
|
|
});
|
|
};
|
|
|
|
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
|
|
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
|
|
this.bot.on('message:voice', (ctx) =>
|
|
storeNonText(ctx, '[Voice message]'),
|
|
);
|
|
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
|
|
this.bot.on('message:document', (ctx) => {
|
|
const name = ctx.message.document?.file_name || 'file';
|
|
storeNonText(ctx, `[Document: ${name}]`);
|
|
});
|
|
this.bot.on('message:sticker', (ctx) => {
|
|
const emoji = ctx.message.sticker?.emoji || '';
|
|
storeNonText(ctx, `[Sticker ${emoji}]`);
|
|
});
|
|
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
|
|
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
|
|
|
|
// Handle errors gracefully
|
|
this.bot.catch((err) => {
|
|
logger.error({ err: err.message }, 'Telegram bot error');
|
|
});
|
|
|
|
// Start polling — returns a Promise that resolves when started
|
|
return new Promise<void>((resolve) => {
|
|
this.bot!.start({
|
|
onStart: (botInfo) => {
|
|
logger.info(
|
|
{ username: botInfo.username, id: botInfo.id },
|
|
'Telegram bot connected',
|
|
);
|
|
console.log(`\n Telegram bot: @${botInfo.username}`);
|
|
console.log(
|
|
` Send /chatid to the bot to get a chat's registration ID\n`,
|
|
);
|
|
resolve();
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
async sendMessage(jid: string, text: string): Promise<void> {
|
|
if (!this.bot) {
|
|
logger.warn('Telegram bot not initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const numericId = jid.replace(/^tg:/, '');
|
|
|
|
// Telegram has a 4096 character limit per message — split if needed
|
|
const MAX_LENGTH = 4096;
|
|
if (text.length <= MAX_LENGTH) {
|
|
await sendTelegramMessage(this.bot.api, numericId, text);
|
|
} else {
|
|
for (let i = 0; i < text.length; i += MAX_LENGTH) {
|
|
await sendTelegramMessage(
|
|
this.bot.api,
|
|
numericId,
|
|
text.slice(i, i + MAX_LENGTH),
|
|
);
|
|
}
|
|
}
|
|
logger.info({ jid, length: text.length }, 'Telegram message sent');
|
|
} catch (err) {
|
|
logger.error({ jid, err }, 'Failed to send Telegram message');
|
|
}
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return this.bot !== null;
|
|
}
|
|
|
|
ownsJid(jid: string): boolean {
|
|
return jid.startsWith('tg:');
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
if (this.bot) {
|
|
this.bot.stop();
|
|
this.bot = null;
|
|
logger.info('Telegram bot stopped');
|
|
}
|
|
}
|
|
|
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
|
if (!this.bot || !isTyping) return;
|
|
try {
|
|
const numericId = jid.replace(/^tg:/, '');
|
|
await this.bot.api.sendChatAction(numericId, 'typing');
|
|
} catch (err) {
|
|
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
|
|
}
|
|
}
|
|
}
|
|
|
|
registerChannel('telegram', (opts: ChannelOpts) => {
|
|
const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']);
|
|
const token =
|
|
process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || '';
|
|
if (!token) {
|
|
logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set');
|
|
return null;
|
|
}
|
|
return new TelegramChannel(token, opts);
|
|
});
|