diff --git a/assistant/src/daemon/lifecycle.ts b/assistant/src/daemon/lifecycle.ts index 8a22d5a0fe4..57b6d4c2b21 100644 --- a/assistant/src/daemon/lifecycle.ts +++ b/assistant/src/daemon/lifecycle.ts @@ -41,6 +41,7 @@ import { slackProvider as slackWatcherProvider } from '../watcher/providers/slac import { registerMessagingProvider } from '../messaging/registry.js'; import { slackProvider as slackMessagingProvider } from '../messaging/providers/slack/adapter.js'; import { gmailMessagingProvider } from '../messaging/providers/gmail/adapter.js'; +import { telegramBotMessagingProvider } from '../messaging/providers/telegram-bot/adapter.js'; import { browserManager } from '../tools/browser/browser-manager.js'; import { RuntimeHttpServer } from '../runtime/http-server.js'; import { getHookManager } from '../hooks/manager.js'; @@ -384,6 +385,7 @@ export async function runDaemon(): Promise { // Register messaging providers registerMessagingProvider(slackMessagingProvider); registerMessagingProvider(gmailMessagingProvider); + registerMessagingProvider(telegramBotMessagingProvider); const scheduler = startScheduler( async (conversationId, message) => { diff --git a/assistant/src/messaging/provider.ts b/assistant/src/messaging/provider.ts index 898c91d58cd..f856e617a0b 100644 --- a/assistant/src/messaging/provider.ts +++ b/assistant/src/messaging/provider.ts @@ -38,6 +38,15 @@ export interface MessagingProvider { getThreadReplies?(token: string, conversationId: string, threadId: string, options?: HistoryOptions): Promise; markRead?(token: string, conversationId: string, messageId?: string): Promise; + /** + * Override the default credential check used by getConnectedProviders(). + * When present, the registry calls this instead of looking for + * credential:${credentialService}:access_token. Useful for providers + * that don't use OAuth (e.g. Telegram bot tokens stored under a + * non-standard key). + */ + isConnected?(): boolean; + /** Platform-specific capabilities for tool routing (e.g. 'reactions', 'threads', 'labels'). */ capabilities: Set; } diff --git a/assistant/src/messaging/providers/telegram-bot/adapter.ts b/assistant/src/messaging/providers/telegram-bot/adapter.ts new file mode 100644 index 00000000000..cb293a36915 --- /dev/null +++ b/assistant/src/messaging/providers/telegram-bot/adapter.ts @@ -0,0 +1,128 @@ +/** + * Telegram Bot messaging provider adapter. + * + * Enables proactive outbound messaging to Telegram chats via the gateway's + * /deliver/telegram endpoint. Unlike Slack/Gmail which use direct API calls + * with OAuth tokens, Telegram delivery is proxied through the gateway which + * owns the bot token and handles Telegram API retries. + * + * The `token` parameter in MessagingProvider methods is unused for Telegram + * because delivery is authenticated via the gateway's bearer token, not + * a per-user OAuth token. + */ + +import type { MessagingProvider } from '../../provider.js'; +import type { + Conversation, + Message, + SearchResult, + SendResult, + ConnectionInfo, + ListOptions, + HistoryOptions, + SearchOptions, + SendOptions, +} from '../../provider-types.js'; +import { getSecureKey } from '../../../security/secure-keys.js'; +import { readHttpToken } from '../../../util/platform.js'; +import * as telegram from './client.js'; + +/** Resolve the local gateway base URL from GATEWAY_PORT (default 7830). */ +function getGatewayUrl(): string { + const port = Number(process.env.GATEWAY_PORT) || 7830; + return `http://127.0.0.1:${port}`; +} + +/** Read the runtime HTTP bearer token used to authenticate with the gateway. */ +function getBearerToken(): string { + const token = readHttpToken(); + if (!token) { + throw new Error('No runtime HTTP bearer token available — is the daemon running?'); + } + return token; +} + +/** Read the Telegram bot token from the credential vault. */ +function getBotToken(): string | undefined { + return getSecureKey('credential:telegram:bot_token'); +} + +export const telegramBotMessagingProvider: MessagingProvider = { + id: 'telegram', + displayName: 'Telegram', + credentialService: 'telegram', + capabilities: new Set(['send']), + + /** + * Custom connectivity check. The standard registry check looks for + * credential:telegram:access_token, but the Telegram bot token is + * stored as credential:telegram:bot_token. This method lets the + * registry detect that Telegram credentials exist. + */ + isConnected(): boolean { + return getBotToken() !== undefined; + }, + + async testConnection(_token: string): Promise { + const botToken = getBotToken(); + if (!botToken) { + return { + connected: false, + user: 'unknown', + platform: 'telegram', + metadata: { error: 'No bot token found. Run the telegram-setup skill.' }, + }; + } + + const resp = await telegram.getMe(botToken); + if (!resp.ok || !resp.result) { + return { + connected: false, + user: 'unknown', + platform: 'telegram', + metadata: { error: resp.description ?? 'getMe failed' }, + }; + } + + return { + connected: true, + user: resp.result.username ?? resp.result.first_name, + platform: 'telegram', + metadata: { + botId: resp.result.id, + botUsername: resp.result.username, + botName: resp.result.first_name, + }, + }; + }, + + async sendMessage(_token: string, conversationId: string, text: string, _options?: SendOptions): Promise { + const gatewayUrl = getGatewayUrl(); + const bearerToken = getBearerToken(); + + await telegram.sendMessage(gatewayUrl, bearerToken, conversationId, text); + + return { + id: `tg-${Date.now()}`, + timestamp: Date.now(), + conversationId, + }; + }, + + // Telegram Bot API does not support listing conversations. Bots only + // interact with chats where users have initiated contact or the bot + // has been added to a group. + async listConversations(_token: string, _options?: ListOptions): Promise { + return []; + }, + + // Telegram Bot API does not provide message history retrieval. + async getHistory(_token: string, _conversationId: string, _options?: HistoryOptions): Promise { + return []; + }, + + // Telegram Bot API does not support message search. + async search(_token: string, _query: string, _options?: SearchOptions): Promise { + return { total: 0, messages: [], hasMore: false }; + }, +}; diff --git a/assistant/src/messaging/providers/telegram-bot/client.ts b/assistant/src/messaging/providers/telegram-bot/client.ts new file mode 100644 index 00000000000..cd2befbf783 --- /dev/null +++ b/assistant/src/messaging/providers/telegram-bot/client.ts @@ -0,0 +1,104 @@ +/** + * Low-level Telegram operations. + * + * Outbound message delivery routes through the gateway's /deliver/telegram + * endpoint, which handles bot token management and Telegram API retries. + * Connection verification calls the Telegram Bot API directly with the + * stored bot token. + */ + +import type { TelegramGetMeResponse } from './types.js'; + +const TELEGRAM_API_BASE = 'https://api.telegram.org'; +const DELIVERY_TIMEOUT_MS = 30_000; + +export class TelegramApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'TelegramApiError'; + } +} + +/** + * Verify a bot token by calling Telegram's getMe API directly. + * Used for testConnection() — the only operation that bypasses the gateway. + */ +export async function getMe(botToken: string): Promise { + const resp = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/getMe`, { + method: 'POST', + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + + if (!resp.ok) { + throw new TelegramApiError( + resp.status, + `Telegram getMe failed with status ${resp.status}`, + ); + } + + return resp.json() as Promise; +} + +/** + * Send a text message to a Telegram chat via the gateway's deliver endpoint. + */ +export async function sendMessage( + gatewayUrl: string, + bearerToken: string, + chatId: string, + text: string, +): Promise { + await deliverToGateway(gatewayUrl, bearerToken, { chatId, text }); +} + +/** + * Send a message with attachments to a Telegram chat via the gateway. + */ +export async function sendMessageWithAttachments( + gatewayUrl: string, + bearerToken: string, + chatId: string, + text: string | undefined, + attachmentIds: string[], +): Promise { + await deliverToGateway(gatewayUrl, bearerToken, { + chatId, + text, + attachments: attachmentIds.map((id) => ({ id })), + }); +} + +/** Payload accepted by the gateway's /deliver/telegram endpoint. */ +interface DeliverPayload { + chatId: string; + text?: string; + attachments?: Array<{ id: string }>; +} + +async function deliverToGateway( + gatewayUrl: string, + bearerToken: string, + payload: DeliverPayload, +): Promise { + const url = `${gatewayUrl}/deliver/telegram`; + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new TelegramApiError( + resp.status, + `Gateway /deliver/telegram failed (${resp.status}): ${body}`, + ); + } +} diff --git a/assistant/src/messaging/providers/telegram-bot/types.ts b/assistant/src/messaging/providers/telegram-bot/types.ts new file mode 100644 index 00000000000..d64532860a2 --- /dev/null +++ b/assistant/src/messaging/providers/telegram-bot/types.ts @@ -0,0 +1,15 @@ +/** Telegram Bot API types used by the messaging provider. */ + +export interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; +} + +export interface TelegramGetMeResponse { + ok: boolean; + result?: TelegramUser; + description?: string; +} diff --git a/assistant/src/messaging/registry.ts b/assistant/src/messaging/registry.ts index dde82208686..e0620ef6502 100644 --- a/assistant/src/messaging/registry.ts +++ b/assistant/src/messaging/registry.ts @@ -23,6 +23,7 @@ export function getMessagingProvider(id: string): MessagingProvider { /** Return all registered providers that have stored credentials. */ export function getConnectedProviders(): MessagingProvider[] { return Array.from(providers.values()).filter((p) => { + if (p.isConnected) return p.isConnected(); const token = getSecureKey(`credential:${p.credentialService}:access_token`); return token !== undefined; });