diff --git a/packages/channel-connector/src/ConfigStore.ts b/packages/channel-connector/src/ChannelConfigRepository.ts similarity index 98% rename from packages/channel-connector/src/ConfigStore.ts rename to packages/channel-connector/src/ChannelConfigRepository.ts index e3becb8ae..7f6d5b239 100644 --- a/packages/channel-connector/src/ConfigStore.ts +++ b/packages/channel-connector/src/ChannelConfigRepository.ts @@ -11,7 +11,7 @@ const DEFAULT_CONFIG: ChannelConfig = { channels: {} }; * Default location: ~/.ai-devkit/channels.json * File permissions are set to 0600 to protect tokens. */ -export class ConfigStore { +export class ChannelConfigRepository { private configPath: string; constructor(configPath?: string) { diff --git a/packages/channel-connector/src/__tests__/ConfigStore.test.ts b/packages/channel-connector/src/__tests__/ChannelConfigRepository.test.ts similarity index 94% rename from packages/channel-connector/src/__tests__/ConfigStore.test.ts rename to packages/channel-connector/src/__tests__/ChannelConfigRepository.test.ts index 9a7e9af87..d68844777 100644 --- a/packages/channel-connector/src/__tests__/ConfigStore.test.ts +++ b/packages/channel-connector/src/__tests__/ChannelConfigRepository.test.ts @@ -1,19 +1,19 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { ConfigStore } from '../ConfigStore.js'; +import { ChannelConfigRepository } from '../ChannelConfigRepository.js'; import { isSlackEntry } from '../types.js'; import type { ChannelEntry } from '../types.js'; -describe('ConfigStore', () => { +describe('ChannelConfigRepository', () => { let tmpDir: string; let configPath: string; - let store: ConfigStore; + let store: ChannelConfigRepository; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-connector-test-')); configPath = path.join(tmpDir, 'channels.json'); - store = new ConfigStore(configPath); + store = new ChannelConfigRepository(configPath); }); afterEach(() => { @@ -48,7 +48,7 @@ describe('ConfigStore', () => { describe('constructor', () => { it('should use default path when no configPath provided', async () => { - const defaultStore = new ConfigStore(); + const defaultStore = new ChannelConfigRepository(); // Should not throw — just uses default path const config = await defaultStore.getConfig(); expect(config).toBeDefined(); @@ -95,7 +95,7 @@ describe('ConfigStore', () => { it('should create parent directory if missing', async () => { const nestedPath = path.join(tmpDir, 'nested', 'dir', 'channels.json'); - const nestedStore = new ConfigStore(nestedPath); + const nestedStore = new ChannelConfigRepository(nestedPath); await nestedStore.saveChannel('telegram', sampleEntry); diff --git a/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts b/packages/channel-connector/src/__tests__/adapters/slack/SlackAdapter.test.ts similarity index 98% rename from packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts rename to packages/channel-connector/src/__tests__/adapters/slack/SlackAdapter.test.ts index 8f6b2e965..229f2aee7 100644 --- a/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts +++ b/packages/channel-connector/src/__tests__/adapters/slack/SlackAdapter.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; -import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../adapters/SlackAdapter.js'; -import type { SlackConfig } from '../../types.js'; +import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../../adapters/slack/SlackAdapter.js'; +import type { SlackConfig } from '../../../types.js'; class FakeSocketClient extends EventEmitter { start = vi.fn().mockResolvedValue(undefined); diff --git a/packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts b/packages/channel-connector/src/__tests__/adapters/slack/SlackMessageDelivery.test.ts similarity index 82% rename from packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts rename to packages/channel-connector/src/__tests__/adapters/slack/SlackMessageDelivery.test.ts index dc970ce47..47c0195ca 100644 --- a/packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts +++ b/packages/channel-connector/src/__tests__/adapters/slack/SlackMessageDelivery.test.ts @@ -1,11 +1,11 @@ -import { SlackDeliveryQueue } from '../../utils/SlackDeliveryQueue.js'; +import { SlackMessageDelivery } from '../../../adapters/slack/SlackMessageDelivery.js'; -describe('SlackDeliveryQueue', () => { +describe('SlackMessageDelivery', () => { it('serializes long output as a parent followed by threaded continuations', async () => { const postMessage = vi.fn() .mockResolvedValueOnce({ ok: true, ts: '100.1' }) .mockResolvedValue({ ok: true, ts: '100.2' }); - const queue = new SlackDeliveryQueue({ postMessage }, { maxMessageLength: 20 }); + const queue = new SlackMessageDelivery({ postMessage }, { maxMessageLength: 20 }); const result = await queue.send('D123', 'first paragraph\n\nsecond paragraph that is long'); expect(result).toEqual({ messageId: '100.1', threadId: '100.1' }); expect(postMessage).toHaveBeenCalledTimes(3); @@ -18,7 +18,7 @@ describe('SlackDeliveryQueue', () => { const error = Object.assign(new Error('rate limited'), { retryAfter: 2 }); const postMessage = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ ok: true, ts: '200.1' }); const sleep = vi.fn().mockResolvedValue(undefined); - const queue = new SlackDeliveryQueue({ postMessage }, { sleep }); + const queue = new SlackMessageDelivery({ postMessage }, { sleep }); await expect(queue.send('D123', 'hello')).resolves.toEqual({ messageId: '200.1', threadId: '200.1' }); expect(sleep).toHaveBeenCalledWith(2000); expect(postMessage).toHaveBeenCalledTimes(2); @@ -28,7 +28,7 @@ describe('SlackDeliveryQueue', () => { const error = Object.assign(new Error('rate limited'), { retryAfter: 86_400 }); const postMessage = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ ok: true, ts: '200.1' }); const sleep = vi.fn().mockResolvedValue(undefined); - const queue = new SlackDeliveryQueue({ postMessage }, { sleep }); + const queue = new SlackMessageDelivery({ postMessage }, { sleep }); await queue.send('D123', 'hello'); expect(sleep).toHaveBeenCalledWith(60_000); }); @@ -39,16 +39,16 @@ describe('SlackDeliveryQueue', () => { release = () => resolve({ ok: true, ts: '300.1' }); }); const postMessage = vi.fn().mockReturnValue(pending); - const queue = new SlackDeliveryQueue({ postMessage }, { maxQueueSize: 1 }); + const queue = new SlackMessageDelivery({ postMessage }, { maxQueueSize: 1 }); const first = queue.send('D123', 'first'); - await expect(queue.send('D123', 'second')).rejects.toThrow('Slack delivery queue is full'); + await expect(queue.send('D123', 'second')).rejects.toThrow('Slack message delivery queue is full'); release(); await first; }); it('rejects empty output and responses without a timestamp', async () => { const postMessage = vi.fn().mockResolvedValue({ ok: true }); - const queue = new SlackDeliveryQueue({ postMessage }); + const queue = new SlackMessageDelivery({ postMessage }); await expect(queue.send('D123', '')).rejects.toThrow('Cannot send an empty Slack message'); await expect(queue.send('D123', 'hello')).rejects.toThrow('Slack did not return a message timestamp'); }); @@ -57,14 +57,14 @@ describe('SlackDeliveryQueue', () => { const sleep = vi.fn(); const permanent = new Error('invalid_auth'); const postMessage = vi.fn().mockRejectedValue(permanent); - const queue = new SlackDeliveryQueue({ postMessage }, { sleep }); + const queue = new SlackMessageDelivery({ postMessage }, { sleep }); await expect(queue.send('D123', 'hello')).rejects.toBe(permanent); expect(sleep).not.toHaveBeenCalled(); }); it('allows separate conversation queues to progress independently', async () => { const postMessage = vi.fn().mockImplementation(async ({ channel }) => ({ ok: true, ts: `${channel}.1` })); - const queue = new SlackDeliveryQueue({ postMessage }); + const queue = new SlackMessageDelivery({ postMessage }); await expect(Promise.all([queue.send('D1', 'one'), queue.send('D2', 'two')])).resolves.toHaveLength(2); expect(postMessage).toHaveBeenCalledTimes(2); }); diff --git a/packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts b/packages/channel-connector/src/__tests__/adapters/slack/slackMarkdown.test.ts similarity index 98% rename from packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts rename to packages/channel-connector/src/__tests__/adapters/slack/slackMarkdown.test.ts index 8e1fb58b4..83e7db3b7 100644 --- a/packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts +++ b/packages/channel-connector/src/__tests__/adapters/slack/slackMarkdown.test.ts @@ -2,7 +2,7 @@ import { SLACK_MAX_MESSAGE_LENGTH, chunkMarkdownForSlack, markdownToSlackMrkdwn, -} from '../../utils/slackMarkdown.js'; +} from '../../../adapters/slack/slackMarkdown.js'; describe('Slack Markdown', () => { it('renders common Markdown without activating broad mentions', () => { diff --git a/packages/channel-connector/src/__tests__/adapters/slack/slackQuestion.test.ts b/packages/channel-connector/src/__tests__/adapters/slack/slackQuestion.test.ts new file mode 100644 index 000000000..04d4bdb2a --- /dev/null +++ b/packages/channel-connector/src/__tests__/adapters/slack/slackQuestion.test.ts @@ -0,0 +1,67 @@ +import { buildSlackQuestionMessage } from '../../../adapters/slack/slackQuestion.js'; + +describe('buildSlackQuestionMessage', () => { + it('builds an accessible Block Kit question with optional skip action', () => { + const message = buildSlackQuestionMessage('D123', { + id: 'q1', + header: 'Scope', + question: 'Choose one', + allowSkip: true, + options: [{ label: 'Safe', description: 'Read only', value: '1' }], + }); + + expect(message).toMatchObject({ + channel: 'D123', + text: 'Scope: Choose one', + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: '*Scope*\nChoose one' }, + }, + { + type: 'actions', + elements: [ + { + type: 'button', + action_id: 'ai_devkit_question', + text: { type: 'plain_text', text: 'Safe' }, + value: 'q1:1', + }, + { + type: 'button', + action_id: 'ai_devkit_question', + text: { type: 'plain_text', text: 'Skip' }, + value: 'q1:skip', + }, + ], + }, + ], + }); + }); + + it('escapes Slack control syntax in fallback and mrkdwn text', () => { + const message = buildSlackQuestionMessage('D123', { + id: 'q1', + header: '', + question: 'Choose & continue', + allowSkip: false, + options: [{ label: 'Safe', value: '1' }], + }); + + expect(message.text).toBe('<!channel>: Choose <one> & continue'); + expect(message.blocks[0].text.text).toBe('*<!channel>*\nChoose <one> & continue'); + }); + + it('limits button labels and values to Slack bounds', () => { + const message = buildSlackQuestionMessage('D123', { + id: 'q1', + question: 'Choose', + allowSkip: false, + options: [{ label: 'A'.repeat(100), value: 'v'.repeat(3000) }], + }); + + const button = message.blocks[1].elements[0]; + expect(button.text.text).toHaveLength(75); + expect(button.value).toHaveLength(2000); + }); +}); diff --git a/packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts b/packages/channel-connector/src/__tests__/adapters/telegram/TelegramAdapter.test.ts similarity index 98% rename from packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts rename to packages/channel-connector/src/__tests__/adapters/telegram/TelegramAdapter.test.ts index 94019e0b0..c5d8ad8b6 100644 --- a/packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts +++ b/packages/channel-connector/src/__tests__/adapters/telegram/TelegramAdapter.test.ts @@ -1,7 +1,7 @@ import * as telegrafModule from 'telegraf'; -import * as telegramHtml from '../../utils/telegramHtml.js'; -import { TelegramAdapter } from '../../adapters/TelegramAdapter.js'; -import type { IncomingMessage } from '../../types.js'; +import * as telegramHtml from '../../../adapters/telegram/telegramHtml.js'; +import { TelegramAdapter } from '../../../adapters/telegram/TelegramAdapter.js'; +import type { IncomingMessage } from '../../../types.js'; // Mock telegraf vi.mock('telegraf', () => { diff --git a/packages/channel-connector/src/__tests__/utils/telegramHtml.test.ts b/packages/channel-connector/src/__tests__/adapters/telegram/telegramHtml.test.ts similarity index 98% rename from packages/channel-connector/src/__tests__/utils/telegramHtml.test.ts rename to packages/channel-connector/src/__tests__/adapters/telegram/telegramHtml.test.ts index d3448dd4f..6c56e7a51 100644 --- a/packages/channel-connector/src/__tests__/utils/telegramHtml.test.ts +++ b/packages/channel-connector/src/__tests__/adapters/telegram/telegramHtml.test.ts @@ -1,4 +1,4 @@ -import { markdownToTelegramHtml } from '../../utils/telegramHtml.js'; +import { markdownToTelegramHtml } from '../../../adapters/telegram/telegramHtml.js'; describe('markdownToTelegramHtml', () => { it('renders bold, italic, strikethrough', () => { diff --git a/packages/channel-connector/src/adapters/SlackAdapter.ts b/packages/channel-connector/src/adapters/slack/SlackAdapter.ts similarity index 86% rename from packages/channel-connector/src/adapters/SlackAdapter.ts rename to packages/channel-connector/src/adapters/slack/SlackAdapter.ts index b4c2783ef..fe5a844f6 100644 --- a/packages/channel-connector/src/adapters/SlackAdapter.ts +++ b/packages/channel-connector/src/adapters/slack/SlackAdapter.ts @@ -1,15 +1,15 @@ import { SocketModeClient } from '@slack/socket-mode'; import { WebClient } from '@slack/web-api'; -import type { InteractiveChannelAdapter } from './ChannelAdapter.js'; +import type { InteractiveChannelAdapter } from '../ChannelAdapter.js'; import type { ChannelQuestion, IncomingInteraction, IncomingMessage, SentMessage, SlackConfig, -} from '../types.js'; -import { SlackDeliveryQueue } from '../utils/SlackDeliveryQueue.js'; -import { escapeSlackText } from '../utils/slackMarkdown.js'; +} from '../../types.js'; +import { SlackMessageDelivery } from './SlackMessageDelivery.js'; +import { buildSlackQuestionMessage } from './slackQuestion.js'; export const SLACK_CHANNEL_TYPE = 'slack'; @@ -83,7 +83,7 @@ export class SlackAdapter implements InteractiveChannelAdapter { readonly type = SLACK_CHANNEL_TYPE; private readonly socket: SocketClientLike; private readonly web: WebClientLike; - private readonly delivery: SlackDeliveryQueue; + private readonly delivery: SlackMessageDelivery; private readonly recentEventIds = new Map(); private messageHandler: ((message: IncomingMessage) => Promise) | null = null; private interactionHandler: ((interaction: IncomingInteraction) => Promise) | null = null; @@ -93,7 +93,7 @@ export class SlackAdapter implements InteractiveChannelAdapter { constructor(private readonly config: SlackConfig, dependencies: SlackAdapterDependencies = {}) { this.socket = dependencies.socketClient ?? new SocketModeClient({ appToken: config.appToken }); this.web = dependencies.webClient ?? new WebClient(config.botToken) as unknown as WebClientLike; - this.delivery = new SlackDeliveryQueue({ + this.delivery = new SlackMessageDelivery({ postMessage: (input) => this.web.chat.postMessage(input), }); } @@ -136,30 +136,7 @@ export class SlackAdapter implements InteractiveChannelAdapter { } async sendQuestion(chatId: string, question: ChannelQuestion): Promise { - const fallback = escapeSlackText(`${question.header ? `${question.header}: ` : ''}${question.question}`); - const elements = question.options.map((option) => ({ - type: 'button', - action_id: 'ai_devkit_question', - text: { type: 'plain_text', text: option.label.slice(0, 75) }, - value: `${question.id}:${option.value}`.slice(0, 2000), - })); - if (question.allowSkip) { - elements.push({ - type: 'button', action_id: 'ai_devkit_question', - text: { type: 'plain_text', text: 'Skip' }, value: `${question.id}:skip`, - }); - } - const response = await this.web.chat.postMessage({ - channel: chatId, - text: fallback, - blocks: [ - { - type: 'section', - text: { type: 'mrkdwn', text: `*${escapeSlackText(question.header ?? 'Question')}*\n${escapeSlackText(question.question)}` }, - }, - { type: 'actions', elements }, - ], - }); + const response = await this.web.chat.postMessage(buildSlackQuestionMessage(chatId, question)); if (!response.ts) throw new Error('Slack did not return a question message timestamp'); return { messageId: response.ts }; } diff --git a/packages/channel-connector/src/utils/SlackDeliveryQueue.ts b/packages/channel-connector/src/adapters/slack/SlackMessageDelivery.ts similarity index 96% rename from packages/channel-connector/src/utils/SlackDeliveryQueue.ts rename to packages/channel-connector/src/adapters/slack/SlackMessageDelivery.ts index 39109d2c3..34f6a10fb 100644 --- a/packages/channel-connector/src/utils/SlackDeliveryQueue.ts +++ b/packages/channel-connector/src/adapters/slack/SlackMessageDelivery.ts @@ -1,4 +1,4 @@ -import type { SentMessage } from '../types.js'; +import type { SentMessage } from '../../types.js'; import { chunkMarkdownForSlack, SLACK_MAX_MESSAGE_LENGTH } from './slackMarkdown.js'; interface SlackPoster { @@ -27,7 +27,7 @@ interface ConversationState { const defaultSleep = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); -export class SlackDeliveryQueue { +export class SlackMessageDelivery { private readonly states = new Map(); private readonly maxMessageLength: number; private readonly maxQueueSize: number; @@ -42,7 +42,7 @@ export class SlackDeliveryQueue { async send(channel: string, markdown: string): Promise { const state = this.states.get(channel) ?? { tail: Promise.resolve(), pending: 0 }; if (state.pending >= this.maxQueueSize) { - throw new Error('Slack delivery queue is full'); + throw new Error('Slack message delivery queue is full'); } state.pending += 1; this.states.set(channel, state); diff --git a/packages/channel-connector/src/utils/slackMarkdown.ts b/packages/channel-connector/src/adapters/slack/slackMarkdown.ts similarity index 100% rename from packages/channel-connector/src/utils/slackMarkdown.ts rename to packages/channel-connector/src/adapters/slack/slackMarkdown.ts diff --git a/packages/channel-connector/src/adapters/slack/slackQuestion.ts b/packages/channel-connector/src/adapters/slack/slackQuestion.ts new file mode 100644 index 000000000..b01af943f --- /dev/null +++ b/packages/channel-connector/src/adapters/slack/slackQuestion.ts @@ -0,0 +1,62 @@ +import type { ChannelQuestion } from '../../types.js'; +import { escapeSlackText } from './slackMarkdown.js'; + +interface SlackQuestionButton { + type: 'button'; + action_id: 'ai_devkit_question'; + text: { type: 'plain_text'; text: string }; + value: string; +} + +type SlackQuestionMessage = Record & { + channel: string; + text: string; + blocks: [ + { + type: 'section'; + text: { type: 'mrkdwn'; text: string }; + }, + { + type: 'actions'; + elements: SlackQuestionButton[]; + }, + ]; +}; + +export function buildSlackQuestionMessage(chatId: string, question: ChannelQuestion): SlackQuestionMessage { + const fallbackPrefix = question.header ? `${question.header}: ` : ''; + const fallback = escapeSlackText(`${fallbackPrefix}${question.question}`); + const buttons = question.options.map((option): SlackQuestionButton => ({ + type: 'button', + action_id: 'ai_devkit_question', + text: { type: 'plain_text', text: option.label.slice(0, 75) }, + value: `${question.id}:${option.value}`.slice(0, 2000), + })); + + if (question.allowSkip) { + buttons.push({ + type: 'button', + action_id: 'ai_devkit_question', + text: { type: 'plain_text', text: 'Skip' }, + value: `${question.id}:skip`, + }); + } + + return { + channel: chatId, + text: fallback, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${escapeSlackText(question.header ?? 'Question')}*\n${escapeSlackText(question.question)}`, + }, + }, + { + type: 'actions', + elements: buttons, + }, + ], + }; +} diff --git a/packages/channel-connector/src/adapters/telegram/TelegramAdapter.ts b/packages/channel-connector/src/adapters/telegram/TelegramAdapter.ts new file mode 100644 index 000000000..d3a3d95df --- /dev/null +++ b/packages/channel-connector/src/adapters/telegram/TelegramAdapter.ts @@ -0,0 +1,147 @@ +import { Telegraf } from 'telegraf'; +import type { ChannelAdapter } from '../ChannelAdapter.js'; +import { TelegramMessageDelivery } from './TelegramMessageDelivery.js'; +import { toTelegrafKeyboard } from './telegramKeyboard.js'; +import type { IncomingMessage, InlineKeyboard, IncomingCallback, CallbackHandler } from '../../types.js'; + +export const TELEGRAM_CHANNEL_TYPE = 'telegram'; +const TELEGRAM_PARSE_MODE = 'HTML' as const; + +export interface TelegramAdapterOptions { + botToken: string; +} + +/** + * Telegram Bot API adapter using telegraf with long polling. + */ +export class TelegramAdapter implements ChannelAdapter { + readonly type = TELEGRAM_CHANNEL_TYPE; + + private bot: Telegraf; + private readonly delivery: TelegramMessageDelivery; + private messageHandler: ((msg: IncomingMessage) => Promise) | null = null; + private callbackHandler: CallbackHandler | null = null; + private running = false; + + constructor(options: TelegramAdapterOptions) { + this.bot = new Telegraf(options.botToken); + this.delivery = new TelegramMessageDelivery(this.bot.telegram); + } + + async start(): Promise { + this.bot.on('text', async (ctx) => { + if (!this.messageHandler) return; + + const msg: IncomingMessage = { + channelType: TELEGRAM_CHANNEL_TYPE, + chatId: String(ctx.message.chat.id), + userId: String(ctx.message.from.id), + text: ctx.message.text, + timestamp: new Date(ctx.message.date * 1000), + }; + + try { + await this.messageHandler(msg); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await ctx.reply(`Error processing message: ${errorMessage}`); + } + }); + + this.bot.on('callback_query', async (ctx) => { + if (!this.callbackHandler) { + try { await ctx.answerCbQuery(); } catch { /* ignore */ } + return; + } + + const query = ctx.callbackQuery as { + id: string; + data?: string; + message?: { message_id: number; chat: { id: number | string } }; + from: { id: number | string }; + }; + + const data = typeof query.data === 'string' ? query.data : ''; + if (!query.message) { + try { await ctx.answerCbQuery(); } catch { /* ignore */ } + return; + } + + const cb: IncomingCallback = { + channelType: TELEGRAM_CHANNEL_TYPE, + chatId: String(query.message.chat.id), + userId: String(query.from.id), + messageId: query.message.message_id, + callbackData: data, + callbackQueryId: query.id, + timestamp: new Date(), + }; + + try { + await this.callbackHandler(cb); + } catch { + try { await ctx.answerCbQuery('Error'); } catch { /* ignore */ } + } + }); + + await this.bot.launch(); + this.running = true; + } + + async stop(): Promise { + this.running = false; + await this.bot.stop(); + } + + /** + * Input is treated as markdown and rendered as Telegram-compatible HTML. + * Long messages are chunked as markdown source before rendering so each + * Telegram HTML payload is independently valid. + */ + async sendMessage(chatId: string, text: string): Promise { + await this.delivery.send(chatId, text); + } + + onMessage(handler: (msg: IncomingMessage) => Promise): void { + this.messageHandler = handler; + } + + onCallback(handler: CallbackHandler): void { + this.callbackHandler = handler; + } + + /** + * Send a message with an inline keyboard. `html` is sent verbatim with + * parse_mode=HTML - callers must pre-escape any user-controlled fields. + * Returns the Telegram message_id of the sent message. + */ + async sendInlineKeyboard(chatId: string, html: string, keyboard: InlineKeyboard): Promise { + const result = await this.bot.telegram.sendMessage(chatId, html, { + parse_mode: TELEGRAM_PARSE_MODE, + reply_markup: { inline_keyboard: toTelegrafKeyboard(keyboard) }, + }) as { message_id: number }; + return result.message_id; + } + + /** + * Replace the inline keyboard on an existing message. Pass `null` to remove + * the keyboard entirely. + */ + async editInlineKeyboard(chatId: string, messageId: number, keyboard: InlineKeyboard | null): Promise { + await this.bot.telegram.editMessageReplyMarkup(chatId, messageId, undefined, keyboard + ? { inline_keyboard: toTelegrafKeyboard(keyboard) } + : undefined); + } + + /** + * Acknowledge a callback_query. Without this Telegram leaves a spinner on + * the tapped button. Pass `text` to show a transient toast. + */ + async answerCallback(callbackQueryId: string, text?: string): Promise { + await this.bot.telegram.answerCbQuery(callbackQueryId, text); + } + + async isHealthy(): Promise { + return this.running; + } +} diff --git a/packages/channel-connector/src/adapters/telegram/TelegramMessageDelivery.ts b/packages/channel-connector/src/adapters/telegram/TelegramMessageDelivery.ts new file mode 100644 index 000000000..55e95104d --- /dev/null +++ b/packages/channel-connector/src/adapters/telegram/TelegramMessageDelivery.ts @@ -0,0 +1,46 @@ +import { + chunkMarkdownForTelegram, + chunkMessage, + htmlToPlainText, + isParseEntitiesError, + type TelegramMessageChunk, +} from './telegramMarkdown.js'; + +export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; +const TELEGRAM_PARSE_MODE = 'HTML' as const; + +interface TelegramSender { + sendMessage(chatId: string, text: string, extra?: Record): Promise; +} + +export class TelegramMessageDelivery { + constructor(private readonly sender: TelegramSender) {} + + async send(chatId: string, text: string): Promise { + let chunks: TelegramMessageChunk[]; + try { + chunks = chunkMarkdownForTelegram(text, TELEGRAM_MAX_MESSAGE_LENGTH); + } catch { + for (const chunk of chunkMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH)) { + await this.sender.sendMessage(chatId, chunk); + } + return; + } + + for (const chunk of chunks) { + if (!chunk.html) { + await this.sender.sendMessage(chatId, chunk.text); + continue; + } + + try { + await this.sender.sendMessage(chatId, chunk.text, { parse_mode: TELEGRAM_PARSE_MODE }); + } catch (error) { + if (!isParseEntitiesError(error)) throw error; + // Telegram rejected the rendered HTML - fall back to plain text + // so the user still gets the content (just unformatted). + await this.sender.sendMessage(chatId, htmlToPlainText(chunk.text)); + } + } + } +} diff --git a/packages/channel-connector/src/utils/telegramHtml.ts b/packages/channel-connector/src/adapters/telegram/telegramHtml.ts similarity index 100% rename from packages/channel-connector/src/utils/telegramHtml.ts rename to packages/channel-connector/src/adapters/telegram/telegramHtml.ts diff --git a/packages/channel-connector/src/adapters/telegram/telegramKeyboard.ts b/packages/channel-connector/src/adapters/telegram/telegramKeyboard.ts new file mode 100644 index 000000000..ea3db9883 --- /dev/null +++ b/packages/channel-connector/src/adapters/telegram/telegramKeyboard.ts @@ -0,0 +1,10 @@ +import type { InlineKeyboard } from '../../types.js'; + +export interface TelegrafInlineKeyboardButton { + text: string; + callback_data: string; +} + +export function toTelegrafKeyboard(keyboard: InlineKeyboard): TelegrafInlineKeyboardButton[][] { + return keyboard.map((row) => row.map((btn) => ({ text: btn.text, callback_data: btn.callbackData }))); +} diff --git a/packages/channel-connector/src/adapters/TelegramAdapter.ts b/packages/channel-connector/src/adapters/telegram/telegramMarkdown.ts similarity index 57% rename from packages/channel-connector/src/adapters/TelegramAdapter.ts rename to packages/channel-connector/src/adapters/telegram/telegramMarkdown.ts index ff8846826..6ae523021 100644 --- a/packages/channel-connector/src/adapters/TelegramAdapter.ts +++ b/packages/channel-connector/src/adapters/telegram/telegramMarkdown.ts @@ -1,192 +1,21 @@ -import { Telegraf } from 'telegraf'; import { Marked, type Token, type Tokens } from 'marked'; -import type { ChannelAdapter } from './ChannelAdapter.js'; -import { markdownToTelegramHtml } from '../utils/telegramHtml.js'; -import type { IncomingMessage, InlineKeyboard, IncomingCallback, CallbackHandler } from '../types.js'; +import { markdownToTelegramHtml } from './telegramHtml.js'; -export const TELEGRAM_CHANNEL_TYPE = 'telegram'; -export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; -const TELEGRAM_PARSE_MODE = 'HTML' as const; const markdownLexer = new Marked(); -type TelegramMessageChunk = { +export type TelegramMessageChunk = { text: string; html: boolean; }; -export interface TelegramAdapterOptions { - botToken: string; -} - -/** - * Telegram Bot API adapter using telegraf with long polling. - */ -export class TelegramAdapter implements ChannelAdapter { - readonly type = TELEGRAM_CHANNEL_TYPE; - - private bot: Telegraf; - private messageHandler: ((msg: IncomingMessage) => Promise) | null = null; - private callbackHandler: CallbackHandler | null = null; - private running = false; - - constructor(options: TelegramAdapterOptions) { - this.bot = new Telegraf(options.botToken); - } - - async start(): Promise { - this.bot.on('text', async (ctx) => { - if (!this.messageHandler) return; - - const msg: IncomingMessage = { - channelType: TELEGRAM_CHANNEL_TYPE, - chatId: String(ctx.message.chat.id), - userId: String(ctx.message.from.id), - text: ctx.message.text, - timestamp: new Date(ctx.message.date * 1000), - }; - - try { - await this.messageHandler(msg); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - await ctx.reply(`Error processing message: ${errorMessage}`); - } - }); - - this.bot.on('callback_query', async (ctx) => { - if (!this.callbackHandler) { - try { await ctx.answerCbQuery(); } catch { /* ignore */ } - return; - } - - const query = ctx.callbackQuery as { - id: string; - data?: string; - message?: { message_id: number; chat: { id: number | string } }; - from: { id: number | string }; - }; - - const data = typeof query.data === 'string' ? query.data : ''; - if (!query.message) { - try { await ctx.answerCbQuery(); } catch { /* ignore */ } - return; - } - - const cb: IncomingCallback = { - channelType: TELEGRAM_CHANNEL_TYPE, - chatId: String(query.message.chat.id), - userId: String(query.from.id), - messageId: query.message.message_id, - callbackData: data, - callbackQueryId: query.id, - timestamp: new Date(), - }; - - try { - await this.callbackHandler(cb); - } catch { - try { await ctx.answerCbQuery('Error'); } catch { /* ignore */ } - } - }); - - await this.bot.launch(); - this.running = true; - } - - async stop(): Promise { - this.running = false; - await this.bot.stop(); - } - - /** - * Input is treated as markdown and rendered as Telegram-compatible HTML. - * Long messages are chunked as markdown source before rendering so each - * Telegram HTML payload is independently valid. - */ - async sendMessage(chatId: string, text: string): Promise { - let chunks: TelegramMessageChunk[]; - try { - chunks = chunkMarkdownForTelegram(text, TELEGRAM_MAX_MESSAGE_LENGTH); - } catch { - for (const chunk of chunkMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH)) { - await this.bot.telegram.sendMessage(chatId, chunk); - } - return; - } - - for (const chunk of chunks) { - if (!chunk.html) { - await this.bot.telegram.sendMessage(chatId, chunk.text); - continue; - } - - try { - await this.bot.telegram.sendMessage(chatId, chunk.text, { parse_mode: TELEGRAM_PARSE_MODE }); - } catch (error) { - if (!isParseEntitiesError(error)) throw error; - // Telegram rejected the rendered HTML — fall back to plain text - // so the user still gets the content (just unformatted). - await this.bot.telegram.sendMessage(chatId, htmlToPlainText(chunk.text)); - } - } - } - - onMessage(handler: (msg: IncomingMessage) => Promise): void { - this.messageHandler = handler; - } - - onCallback(handler: CallbackHandler): void { - this.callbackHandler = handler; - } - - /** - * Send a message with an inline keyboard. `html` is sent verbatim with - * parse_mode=HTML — callers must pre-escape any user-controlled fields. - * Returns the Telegram message_id of the sent message. - */ - async sendInlineKeyboard(chatId: string, html: string, keyboard: InlineKeyboard): Promise { - const result = await this.bot.telegram.sendMessage(chatId, html, { - parse_mode: TELEGRAM_PARSE_MODE, - reply_markup: { inline_keyboard: toTelegrafKeyboard(keyboard) }, - }) as { message_id: number }; - return result.message_id; - } - - /** - * Replace the inline keyboard on an existing message. Pass `null` to remove - * the keyboard entirely. - */ - async editInlineKeyboard(chatId: string, messageId: number, keyboard: InlineKeyboard | null): Promise { - await this.bot.telegram.editMessageReplyMarkup(chatId, messageId, undefined, keyboard - ? { inline_keyboard: toTelegrafKeyboard(keyboard) } - : undefined); - } - - /** - * Acknowledge a callback_query. Without this Telegram leaves a spinner on - * the tapped button. Pass `text` to show a transient toast. - */ - async answerCallback(callbackQueryId: string, text?: string): Promise { - await this.bot.telegram.answerCbQuery(callbackQueryId, text); - } - - async isHealthy(): Promise { - return this.running; - } -} - -function toTelegrafKeyboard(keyboard: InlineKeyboard): { text: string; callback_data: string }[][] { - return keyboard.map((row) => row.map((btn) => ({ text: btn.text, callback_data: btn.callbackData }))); -} - -function isParseEntitiesError(error: unknown): boolean { +export function isParseEntitiesError(error: unknown): boolean { if (!error || typeof error !== 'object') return false; const description = (error as { description?: string }).description; const message = (error as { message?: string }).message; return ((description ?? '') + (message ?? '')).includes("can't parse entities"); } -function htmlToPlainText(html: string): string { +export function htmlToPlainText(html: string): string { return html .replace(/<[^>]+>/g, '') .replace(/</g, '<') @@ -195,7 +24,7 @@ function htmlToPlainText(html: string): string { .replace(/&/g, '&'); } -function chunkMarkdownForTelegram(markdown: string, maxLen: number): TelegramMessageChunk[] { +export function chunkMarkdownForTelegram(markdown: string, maxLen: number): TelegramMessageChunk[] { const markdownChunks = splitMarkdownSource(markdown, maxLen); const chunks: TelegramMessageChunk[] = []; @@ -445,7 +274,7 @@ function renderedLengthFits(markdown: string, maxLen: number): boolean { * Split text into chunks of maxLen or fewer characters. Prefers paragraph * boundaries (\n\n), then single newlines (\n), then hard-splits at maxLen. */ -function chunkMessage(text: string, maxLen: number): string[] { +export function chunkMessage(text: string, maxLen: number): string[] { const chunks: string[] = []; let remaining = text; diff --git a/packages/channel-connector/src/index.ts b/packages/channel-connector/src/index.ts index 9d9701e79..692162a4b 100644 --- a/packages/channel-connector/src/index.ts +++ b/packages/channel-connector/src/index.ts @@ -1,21 +1,14 @@ export { ChannelManager } from './ChannelManager.js'; -export { ConfigStore } from './ConfigStore.js'; -export { TelegramAdapter, TELEGRAM_CHANNEL_TYPE, TELEGRAM_MAX_MESSAGE_LENGTH } from './adapters/TelegramAdapter.js'; +export { ChannelConfigRepository } from './ChannelConfigRepository.js'; +export { TelegramAdapter, TELEGRAM_CHANNEL_TYPE } from './adapters/telegram/TelegramAdapter.js'; export { SlackAdapter, SLACK_CHANNEL_TYPE, validateSlackAppToken, validateSlackCredentials, -} from './adapters/SlackAdapter.js'; -export type { SlackIdentity } from './adapters/SlackAdapter.js'; -export { - SLACK_MAX_MESSAGE_LENGTH, - chunkMarkdownForSlack, - escapeSlackText, - markdownToSlackMrkdwn, -} from './utils/slackMarkdown.js'; -export { SlackDeliveryQueue } from './utils/SlackDeliveryQueue.js'; -export type { TelegramAdapterOptions } from './adapters/TelegramAdapter.js'; +} from './adapters/slack/SlackAdapter.js'; +export type { SlackIdentity } from './adapters/slack/SlackAdapter.js'; +export type { TelegramAdapterOptions } from './adapters/telegram/TelegramAdapter.js'; export { isInteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; export type { ChannelAdapter, InteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index a462daf32..d0d4b7e0c 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -66,7 +66,7 @@ const mockChannelService = { vi.mock('@ai-devkit/channel-connector', () => ({ ChannelManager: vi.fn(function () { return mockChannelManager; }), - ConfigStore: vi.fn(function () { return mockConfigStore; }), + ChannelConfigRepository: vi.fn(function () { return mockConfigStore; }), TelegramAdapter: vi.fn(function () { return mockTelegramAdapter; }), TELEGRAM_CHANNEL_TYPE: 'telegram', SLACK_CHANNEL_TYPE: 'slack', diff --git a/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts b/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts index 2321aee0a..f3dd889ca 100644 --- a/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts +++ b/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts @@ -3,7 +3,7 @@ import React from 'react'; import { render, Text } from 'ink'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentManager } from '@ai-devkit/agent-manager'; -import type { ConfigStore } from '@ai-devkit/channel-connector'; +import type { ChannelConfigRepository } from '@ai-devkit/channel-connector'; import type { ChannelService } from '../../../services/channel/channel.service.js'; import { ConsoleProvider, @@ -32,7 +32,7 @@ describe('ConsoleProvider subscriptions', () => { } as unknown as ChannelService; const configStore = { getConfig: vi.fn().mockResolvedValue({ channels: {} }), - } as unknown as ConfigStore; + } as unknown as ChannelConfigRepository; let agentConsumerRenders = 0; let agentListLoading = true; const AgentOnlyConsumer = () => { @@ -85,7 +85,7 @@ describe('ConsoleProvider subscriptions', () => { } as unknown as ChannelService; const configStore = { getConfig: vi.fn().mockResolvedValue({ channels: {} }), - } as unknown as ConfigStore; + } as unknown as ChannelConfigRepository; const output = new PassThrough(); const renderProvider = (paused: boolean) => React.createElement( ConsoleProvider, diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 5de4c0332..1c33c398c 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -8,7 +8,7 @@ import { SLACK_CHANNEL_TYPE, validateSlackAppToken, validateSlackCredentials, - ConfigStore, + ChannelConfigRepository, type ChannelEntry, type SlackConfig, type TelegramConfig, @@ -72,7 +72,7 @@ export function registerChannelCommand(program: Command): void { } const channelName = channelService.resolveConnectChannelName(options.name); - const configStore = new ConfigStore(); + const configStore = new ChannelConfigRepository(); const existing = await configStore.getChannel(channelName); if (type === SLACK_CHANNEL_TYPE) { @@ -170,7 +170,7 @@ export function registerChannelCommand(program: Command): void { .command('list') .description('List configured channels') .action(withErrorHandler('list channels', async () => { - const configStore = new ConfigStore(); + const configStore = new ChannelConfigRepository(); const config = await configStore.getConfig(); const channels = Object.entries(config.channels); const liveBridges = await channelService.getLiveBridges(); @@ -212,7 +212,7 @@ export function registerChannelCommand(program: Command): void { .description('Remove a channel configuration') .action(withErrorHandler('disconnect channel', async (name: string) => { const channelName = channelService.resolveConnectChannelName(name); - const configStore = new ConfigStore(); + const configStore = new ChannelConfigRepository(); const existing = await configStore.getChannel(channelName); if (!existing) { @@ -242,8 +242,8 @@ export function registerChannelCommand(program: Command): void { enableDebug(); } - const configStore = new ConfigStore(); - debug('Loading channel configuration from ConfigStore'); + const configStore = new ChannelConfigRepository(); + debug('Loading channel configuration from ChannelConfigRepository'); const config = await configStore.getConfig(); const channelName = channelService.resolveStartChannelName(config, name); debug(`Starting channel bridge: channel=${channelName}, agent=${options.agent}`); @@ -319,7 +319,7 @@ export function registerChannelCommand(program: Command): void { .command('status [name]') .description('Show channel bridge status') .action(withErrorHandler('channel status', async (name: string | undefined) => { - const configStore = new ConfigStore(); + const configStore = new ChannelConfigRepository(); const config = await configStore.getConfig(); const channelFilter = name ? channelService.resolveConnectChannelName(name) : undefined; const channels = Object.entries(config.channels) diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index 40c74d613..2038761c0 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -18,7 +18,7 @@ import { import { ChannelManager, type ChannelAdapter, - ConfigStore, + ChannelConfigRepository, SlackAdapter, TelegramAdapter, SLACK_CHANNEL_TYPE, @@ -38,7 +38,7 @@ const AGENT_POLL_INTERVAL_MS = 2000; export interface RunChannelBridgeInput { channelName: string; agentName: string; - configStore?: ConfigStore; + configStore?: ChannelConfigRepository; channelService?: ChannelService; } @@ -277,7 +277,7 @@ function setupGracefulShutdown( } export async function runChannelBridge(input: RunChannelBridgeInput): Promise { - const configStore = input.configStore ?? new ConfigStore(); + const configStore = input.configStore ?? new ChannelConfigRepository(); const channelService = input.channelService ?? new ChannelService(); debug(`Starting channel bridge: channel=${input.channelName}, agent=${input.agentName}`); diff --git a/packages/cli/src/tui/console/hooks/useChannelState.ts b/packages/cli/src/tui/console/hooks/useChannelState.ts index f2cf991d6..b17c9f2fc 100644 --- a/packages/cli/src/tui/console/hooks/useChannelState.ts +++ b/packages/cli/src/tui/console/hooks/useChannelState.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { ConfigStore, type ChannelConfig, type TelegramConfig } from '@ai-devkit/channel-connector'; +import { ChannelConfigRepository, type ChannelConfig, type TelegramConfig } from '@ai-devkit/channel-connector'; import { ChannelService, type ChannelBridgeProcess } from '../../../services/channel/channel.service.js'; import type { AgentChannelStatusMap, ConfiguredChannel } from '../types.js'; @@ -69,12 +69,12 @@ export function configuredChannelsEqual(a: ConfiguredChannel[], b: ConfiguredCha export function useChannelState( channelService?: ChannelService, - configStore?: ConfigStore, + configStore?: ChannelConfigRepository, intervalMs = 3000, paused = false, ): UseChannelStateResult { const serviceRef = useRef(channelService ?? new ChannelService()); - const configStoreRef = useRef(configStore ?? new ConfigStore()); + const configStoreRef = useRef(configStore ?? new ChannelConfigRepository()); const [channelStatuses, setChannelStatuses] = useState({}); const [configuredChannels, setConfiguredChannels] = useState([]); diff --git a/packages/cli/src/tui/console/state/ConsoleContext.tsx b/packages/cli/src/tui/console/state/ConsoleContext.tsx index a59737f10..71be0b19b 100644 --- a/packages/cli/src/tui/console/state/ConsoleContext.tsx +++ b/packages/cli/src/tui/console/state/ConsoleContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { AgentManager } from '@ai-devkit/agent-manager'; -import { ConfigStore } from '@ai-devkit/channel-connector'; +import { ChannelConfigRepository } from '@ai-devkit/channel-connector'; import { ChannelService } from '../../../services/channel/channel.service.js'; import { useAgentList, type UseAgentListResult } from '../hooks/useAgentList.js'; import { @@ -47,7 +47,7 @@ interface ConsoleProviderProps { manager: AgentManager; inputFocused: boolean; channelService?: ChannelService; - configStore?: ConfigStore; + configStore?: ChannelConfigRepository; children: React.ReactNode; }