diff --git a/README.md b/README.md index 367ec32bc..7235f40df 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,11 @@ Happier acts as a secure bridge between your local development environment and y - it communicates with the daemon through the relay server - it receive daemon updates (sessions updates, messages, etc) through the relay server +## Channel Integrations + +- Telegram bi-directional bridge setup (BotFather, topics/DM mapping, optional webhook relay): + - [Telegram channel bridge guide](docs/telegram-channel-bridge.md) + ## Self-Hosting the Server Relay Happier is 100% self-hostable. It's even the most recommended way to run it, even if we also offer an end-to-end encrypted cloud server (app.happier.dev / api.happier.dev). diff --git a/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts b/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts index 785d7b951..d20cc9d79 100644 --- a/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts +++ b/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts @@ -24,5 +24,17 @@ describe('serializeAxiosErrorForLog', () => { expect(serialized).not.toHaveProperty('headers'); expect(serialized).not.toHaveProperty('data'); }); -}); + it('redacts Telegram bot tokens embedded in URL paths', () => { + const err = new AxiosError('boom', 'ECONNABORTED', { + method: 'get', + url: 'https://api.telegram.org/bot123456789:AAABBBccc___-123/getUpdates?offset=123', + headers: { Authorization: 'Bearer SECRET', 'Content-Type': 'application/json' }, + } as any); + + const serialized = serializeAxiosErrorForLog(err); + expect(serialized).toEqual(expect.objectContaining({ + url: 'https://api.telegram.org/botREDACTED/getUpdates', + })); + }); +}); diff --git a/apps/cli/src/api/client/serializeAxiosErrorForLog.ts b/apps/cli/src/api/client/serializeAxiosErrorForLog.ts index de2e3af0d..b7bcd4c1a 100644 --- a/apps/cli/src/api/client/serializeAxiosErrorForLog.ts +++ b/apps/cli/src/api/client/serializeAxiosErrorForLog.ts @@ -1,17 +1,33 @@ import axios from 'axios'; +function redactTelegramBotTokenPathname(pathname: string): string { + const raw = String(pathname ?? ''); + if (!raw) return raw; + const parts = raw.split('/'); + const redacted = parts.map((part) => { + const segment = String(part ?? ''); + if (segment.startsWith('bot') && segment.length > 3) { + return 'botREDACTED'; + } + return segment; + }); + return redacted.join('/'); +} + function redactUrlForLog(raw: unknown): string | undefined { if (typeof raw !== 'string') return undefined; const value = raw.trim(); if (!value) return undefined; try { const parsed = new URL(value); + parsed.pathname = redactTelegramBotTokenPathname(parsed.pathname); parsed.search = ''; parsed.hash = ''; return parsed.toString(); } catch { // Best-effort: strip query/hash to avoid leaking secrets in URLs. - return value.split('?')[0]?.split('#')[0]; + const withoutQuery = value.split('?')[0]?.split('#')[0] ?? value; + return withoutQuery.replace(/\/bot[^/]+\//g, '/botREDACTED/'); } } @@ -34,4 +50,3 @@ export function serializeAxiosErrorForLog(error: unknown): Record { + it('rejects webhook secrets that do not match Telegram-safe token charset', () => { + expect(() => + upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookSecret: 'bad token!', + }, + }) + ).toThrow('Invalid webhookSecret: must match [A-Za-z0-9_-]'); + }); + + it('rejects webhook secrets that exceed Telegram maximum length', () => { + expect(() => + upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookSecret: 'x'.repeat(257), + }, + }) + ).toThrow('Webhook secret token is too long'); + }); + + it('writes scoped telegram config under server/account with secrets in local-only block', () => { + const next = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 2_200, + botToken: 'bot-token', + allowedChatIds: ['-100111'], + requireTopics: true, + webhookEnabled: true, + webhookSecret: 'secret-1', + webhookHost: '127.0.0.1', + webhookPort: 9_000, + }, + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: next, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram).toEqual({ + tickMs: 2_200, + botToken: 'bot-token', + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: true, + secret: 'secret-1', + host: '127.0.0.1', + port: 9_000, + }, + }); + + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.secrets).toEqual({ + botToken: 'bot-token', + webhookSecret: 'secret-1', + }); + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.botToken).toBeUndefined(); + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.webhook.secret).toBeUndefined(); + }); + + it('normalizes allowedChatIds when writing scoped config', () => { + const next = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + allowedChatIds: [' -100111 ', '', ' ', '-100222'], + }, + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: next, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram?.allowedChatIds).toEqual(['-100111', '-100222']); + }); + + it('does not materialize providers.telegram for tick-only scoped updates', () => { + const next = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 2_500, + }, + }); + + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers).toBeUndefined(); + + const telegram = readScopedTelegramBridgeConfig({ + settings: next, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram).toBeNull(); + }); + + it('preserves webhook secret when only webhook host/port settings are updated', () => { + const configured = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookSecret: 'secret-1', + }, + }); + + const updated = upsertScopedTelegramBridgeConfig({ + settings: configured, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookHost: '127.0.0.1', + webhookPort: 8080, + }, + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: updated, + serverId: 'local-3005', + accountId: 'acct-1', + }); + const webhook = telegram?.webhook as { secret?: string; host?: string; port?: number } | undefined; + + expect(webhook?.secret).toBe('secret-1'); + expect(webhook?.host).toBe('127.0.0.1'); + expect(webhook?.port).toBe(8080); + }); + + it('removes scoped telegram config and prunes empty nesting', () => { + const configured = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 2_200, + botToken: 'bot-token', + }, + }); + + const cleared = removeScopedTelegramBridgeConfig({ + settings: configured, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: cleared, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram).toBeNull(); + expect((cleared as any).channelBridge).toBeUndefined(); + }); + + it('removes stale scoped tickMs even when providers.telegram is already missing', () => { + const cleared = removeScopedTelegramBridgeConfig({ + settings: { + channelBridge: { + byServerId: { + 'local-3005': { + byAccountId: { + 'acct-1': { + tickMs: 2_500, + }, + }, + }, + }, + }, + }, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect((cleared as any).channelBridge).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts new file mode 100644 index 000000000..6f2cd5ff2 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -0,0 +1,289 @@ +/** + * Scoped channel bridge account configuration helpers. + * + * Responsibilities: + * - split bridge updates into local-secret and shared KV-safe payloads + * - read normalized account-scoped bridge config from settings + * - upsert/remove account-scoped bridge config trees under server/account scope + * + * Secrets remain local-only and are never emitted into shared payload helpers. + */ +import { assertTelegramWebhookSecretToken } from '@/channels/providers/telegram/telegramWebhookSecretToken'; + +type RecordLike = Record; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function deepCloneRoot(value: unknown): RecordLike { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return JSON.parse(JSON.stringify(value)) as RecordLike; +} + +function ensureRecord(parent: RecordLike, key: string): RecordLike { + const existing = asRecord(parent[key]); + if (existing) return existing; + const created: RecordLike = {}; + parent[key] = created; + return created; +} + +function isEmptyRecord(value: unknown): boolean { + const record = asRecord(value); + if (!record) return false; + return Object.keys(record).length === 0; +} + +function parseStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry.length > 0); +} + +export type ScopedTelegramBridgeUpdate = Readonly<{ + tickMs?: number; + botToken?: string; + allowedChatIds?: string[]; + allowAllSharedChats?: boolean; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookSecret?: string; + webhookHost?: string; + webhookPort?: number; +}>; + +export function readScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; +}>): RecordLike | null { + const root = asRecord(params.settings); + const channelBridge = asRecord(root?.channelBridge); + const byServerId = asRecord(channelBridge?.byServerId); + const serverScope = asRecord(byServerId?.[params.serverId]); + const byAccountId = asRecord(serverScope?.byAccountId); + const accountScope = asRecord(byAccountId?.[params.accountId]); + const providers = asRecord(accountScope?.providers); + const telegram = asRecord(providers?.telegram); + if (!telegram) return null; + + const secrets = asRecord(telegram.secrets); + const botToken = + typeof secrets?.botToken === 'string' + ? secrets.botToken + : typeof telegram.botToken === 'string' + ? telegram.botToken + : undefined; + + const webhook = asRecord(telegram.webhook); + const webhookSecret = + typeof secrets?.webhookSecret === 'string' + ? secrets.webhookSecret + : typeof webhook?.secret === 'string' + ? webhook.secret + : undefined; + + const normalized: RecordLike = {}; + + if (typeof accountScope?.tickMs === 'number' && Number.isFinite(accountScope.tickMs)) { + normalized.tickMs = Math.trunc(accountScope.tickMs); + } + + if (typeof botToken === 'string') { + normalized.botToken = botToken; + } + + const allowedChatIds = parseStringArray(telegram.allowedChatIds); + if (allowedChatIds.length > 0 || Array.isArray(telegram.allowedChatIds)) { + normalized.allowedChatIds = allowedChatIds; + } + + if (typeof telegram.requireTopics === 'boolean') { + normalized.requireTopics = telegram.requireTopics; + } + + if (typeof telegram.allowAllSharedChats === 'boolean') { + normalized.allowAllSharedChats = telegram.allowAllSharedChats; + } + + const normalizedWebhook: RecordLike = {}; + if (typeof webhook?.enabled === 'boolean') { + normalizedWebhook.enabled = webhook.enabled; + } + if (typeof webhookSecret === 'string') { + normalizedWebhook.secret = webhookSecret; + } + if (typeof webhook?.host === 'string') { + normalizedWebhook.host = webhook.host; + } + if (typeof webhook?.port === 'number' && Number.isFinite(webhook.port)) { + normalizedWebhook.port = Math.trunc(webhook.port); + } + + if (Object.keys(normalizedWebhook).length > 0) { + normalized.webhook = normalizedWebhook; + } + + return normalized; +} + +export function upsertScopedTelegramBridgeConfig(params: Readonly<{ + settings: T; + serverId: string; + accountId: string; + update: ScopedTelegramBridgeUpdate; +}>): T; + +export function upsertScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; + update: ScopedTelegramBridgeUpdate; +}>): RecordLike; + +export function upsertScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; + update: ScopedTelegramBridgeUpdate; +}>): RecordLike { + const root = deepCloneRoot(params.settings); + + const channelBridge = ensureRecord(root, 'channelBridge'); + const byServerId = ensureRecord(channelBridge, 'byServerId'); + const serverScope = ensureRecord(byServerId, params.serverId); + const byAccountId = ensureRecord(serverScope, 'byAccountId'); + const accountScope = ensureRecord(byAccountId, params.accountId); + + if (typeof params.update.tickMs === 'number' && Number.isFinite(params.update.tickMs)) { + accountScope.tickMs = Math.trunc(params.update.tickMs); + } + + let cachedTelegramScope: RecordLike | null = null; + const ensureTelegramScope = (): RecordLike => { + if (cachedTelegramScope) return cachedTelegramScope; + const providers = ensureRecord(accountScope, 'providers'); + cachedTelegramScope = ensureRecord(providers, 'telegram'); + return cachedTelegramScope; + }; + + if (Array.isArray(params.update.allowedChatIds)) { + const telegram = ensureTelegramScope(); + telegram.allowedChatIds = parseStringArray(params.update.allowedChatIds); + } + if (typeof params.update.allowAllSharedChats === 'boolean') { + const telegram = ensureTelegramScope(); + telegram.allowAllSharedChats = params.update.allowAllSharedChats; + } + if (typeof params.update.requireTopics === 'boolean') { + const telegram = ensureTelegramScope(); + telegram.requireTopics = params.update.requireTopics; + } + + const hasWebhookUpdate = + typeof params.update.webhookEnabled === 'boolean' + || typeof params.update.webhookHost === 'string' + || (typeof params.update.webhookPort === 'number' && Number.isFinite(params.update.webhookPort)); + + if (hasWebhookUpdate) { + const telegram = ensureTelegramScope(); + const webhook = ensureRecord(telegram, 'webhook'); + if (typeof params.update.webhookEnabled === 'boolean') { + webhook.enabled = params.update.webhookEnabled; + } + if (typeof params.update.webhookHost === 'string') { + webhook.host = params.update.webhookHost; + } + if (typeof params.update.webhookPort === 'number' && Number.isFinite(params.update.webhookPort)) { + webhook.port = Math.trunc(params.update.webhookPort); + } + } + + const hasSecretUpdate = + typeof params.update.botToken === 'string' + || typeof params.update.webhookSecret === 'string'; + + if (hasSecretUpdate) { + const telegram = ensureTelegramScope(); + const secrets = ensureRecord(telegram, 'secrets'); + if (typeof params.update.botToken === 'string') { + secrets.botToken = params.update.botToken; + if ('botToken' in telegram) { + delete telegram.botToken; + } + } + if (typeof params.update.webhookSecret === 'string') { + secrets.webhookSecret = assertTelegramWebhookSecretToken(params.update.webhookSecret, { + empty: 'Invalid webhookSecret: cannot be empty', + invalid: 'Invalid webhookSecret: must match [A-Za-z0-9_-]', + tooLong: 'Webhook secret token is too long', + }); + const webhook = asRecord(telegram.webhook); + if (webhook && 'secret' in webhook) { + delete webhook.secret; + } + } + } + + return root; +} + +export function removeScopedTelegramBridgeConfig(params: Readonly<{ + settings: T; + serverId: string; + accountId: string; +}>): T; + +export function removeScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; +}>): RecordLike; + +export function removeScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; +}>): RecordLike { + const root = deepCloneRoot(params.settings); + + const channelBridge = asRecord(root.channelBridge); + const byServerId = asRecord(channelBridge?.byServerId); + const serverScope = asRecord(byServerId?.[params.serverId]); + const byAccountId = asRecord(serverScope?.byAccountId); + const accountScope = asRecord(byAccountId?.[params.accountId]); + const providers = asRecord(accountScope?.providers); + + if (accountScope && 'tickMs' in accountScope) { + delete accountScope.tickMs; + } + if (providers && 'telegram' in providers) { + delete providers.telegram; + } + + if (providers && isEmptyRecord(providers) && accountScope) { + delete accountScope.providers; + } + if (isEmptyRecord(accountScope) && byAccountId) { + delete byAccountId[params.accountId]; + } + if (isEmptyRecord(byAccountId) && serverScope) { + delete serverScope.byAccountId; + } + if (isEmptyRecord(serverScope) && byServerId) { + delete byServerId[params.serverId]; + } + if (isEmptyRecord(byServerId) && channelBridge) { + delete channelBridge.byServerId; + } + if (isEmptyRecord(channelBridge)) { + delete root.channelBridge; + } + + return root; +} diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts new file mode 100644 index 000000000..fad43292b --- /dev/null +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -0,0 +1,522 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; + +describe('resolveChannelBridgeRuntimeConfig', () => { + it('exposes provider configs under config.providers.', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + tickMs: 3_100, + providers: { + telegram: { + botToken: 'settings-bot-token', + allowedChatIds: ['-100111', '-100222'], + requireTopics: true, + webhook: { + enabled: true, + secret: 'settings-secret', + host: '0.0.0.0', + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(3_100); + expect((config as any).providers?.telegram).toBeTruthy(); + }); + + it('uses settings.json bridge values when env is not set', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + tickMs: 3_100, + providers: { + telegram: { + botToken: 'settings-bot-token', + allowedChatIds: ['-100111', '-100222'], + requireTopics: true, + webhook: { + enabled: true, + secret: 'settings-secret', + host: '0.0.0.0', + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(3_100); + expect(config.providers.telegram.botToken).toBe('settings-bot-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100111', '-100222']); + expect(config.providers.telegram.allowAllSharedChats).toBe(false); + expect(config.providers.telegram.requireTopics).toBe(true); + expect(config.providers.telegram.webhookEnabled).toBe(true); + expect(config.providers.telegram.webhookSecret).toBe('settings-secret'); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.1'); + expect(config.providers.telegram.webhookPort).toBe(9_001); + }); + + it('reads secret fields from telegram.secrets local-only block', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + }, + secrets: { + botToken: 'secret-bot-token', + webhookSecret: 'secret-webhook-token', + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.botToken).toBe('secret-bot-token'); + expect(config.providers.telegram.webhookSecret).toBe('secret-webhook-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100111']); + expect(config.providers.telegram.allowAllSharedChats).toBe(false); + expect(config.providers.telegram.requireTopics).toBe(true); + }); + + it('applies env overrides and falls back to settings for invalid env webhook port', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_CHANNEL_BRIDGE_TICK_MS: '700', + HAPPIER_TELEGRAM_BOT_TOKEN: 'env-token', + HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS: '-100333,-100444', + HAPPIER_TELEGRAM_REQUIRE_TOPICS: '0', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: '1', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'env-secret', + HAPPIER_TELEGRAM_WEBHOOK_HOST: '127.0.0.9', + HAPPIER_TELEGRAM_WEBHOOK_PORT: '8_877', + }, + settings: { + channelBridge: { + tickMs: 5_000, + providers: { + telegram: { + botToken: 'settings-token', + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: false, + secret: 'settings-secret', + host: '0.0.0.0', + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(700); + expect(config.providers.telegram.botToken).toBe('env-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100333', '-100444']); + expect(config.providers.telegram.allowAllSharedChats).toBe(false); + expect(config.providers.telegram.requireTopics).toBe(false); + expect(config.providers.telegram.webhookEnabled).toBe(true); + expect(config.providers.telegram.webhookSecret).toBe('env-secret'); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.9'); + expect(config.providers.telegram.webhookPort).toBe(9_001); + }); + + it('does not override settings bot token when env bot token is empty/whitespace', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: ' ', + }, + settings: { + channelBridge: { + providers: { + telegram: { + botToken: 'settings-token', + }, + }, + }, + }, + }); + + expect(config.providers.telegram.botToken).toBe('settings-token'); + }); + + it('applies a valid env webhook port override', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_PORT: '8877', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.webhookPort).toBe(8_877); + }); + + it('reads allowAllSharedChats from env', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS: '1', + }, + settings: {}, + }); + + expect(config.providers.telegram.allowAllSharedChats).toBe(true); + }); + + it('falls back to settings allowedChatIds when env CSV is effectively empty', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS: ', ,', + }, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100settings'], + }, + }, + }, + }, + }); + + expect(config.providers.telegram.allowedChatIds).toEqual(['-100settings']); + }); + + it('falls back to lower scope when account allowedChatIds is malformed', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100-global'], + }, + }, + byServerId: { + 'local-test': { + providers: { + telegram: { + allowedChatIds: ['-100-server'], + }, + }, + byAccountId: { + 'acct-123': { + providers: { + telegram: { + allowedChatIds: [{}], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.allowedChatIds).toEqual(['-100-server']); + }); + + it('falls back to lower scope when account allowedChatIds string normalizes empty', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + byServerId: { + 'local-test': { + providers: { + telegram: { + allowedChatIds: ['-100-server'], + }, + }, + byAccountId: { + 'acct-123': { + providers: { + telegram: { + allowedChatIds: ', ,', + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.allowedChatIds).toEqual(['-100-server']); + }); + + it('falls back to lower scope when higher-scope string secrets are blank', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + providers: { + telegram: { + botToken: 'global-token', + webhook: { + secret: 'global-secret', + }, + }, + }, + byServerId: { + 'local-test': { + providers: { + telegram: { + botToken: 'server-token', + webhook: { + secret: 'server-secret', + }, + }, + }, + byAccountId: { + 'acct-123': { + providers: { + telegram: { + botToken: ' ', + webhook: { + secret: ' ', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.botToken).toBe('server-token'); + expect(config.providers.telegram.webhookSecret).toBe('server-secret'); + }); + + it('falls back to settings webhook secret when env secret token is invalid', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'bad token!', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + secret: 'settings-secret', + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.webhookSecret).toBe('settings-secret'); + }); + + it('treats invalid settings webhook secret token as missing', () => { + const config = resolveChannelBridgeRuntimeConfig({ + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + secret: 'bad token!', + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.webhookSecret).toBe(''); + }); + + it('ignores non-loopback env webhook host and keeps settings host', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_HOST: '0.0.0.0', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + host: '127.0.0.1', + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.webhookHost).toBe('127.0.0.1'); + }); + + it('rejects webhook port zero and falls back to default', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_PORT: '0', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + port: 0, + }, + }, + }, + }, + }, + }); + + expect(config.providers.telegram.webhookPort).toBe(8_787); + }); + + it('resolves account-scoped bridge config with server/global fallback', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + tickMs: 2_500, + providers: { + telegram: { + botToken: 'global-token', + allowedChatIds: ['-100-global'], + requireTopics: false, + webhook: { + enabled: false, + secret: '', + host: '127.0.0.1', + port: 8_787, + }, + }, + }, + byServerId: { + 'local-test': { + providers: { + telegram: { + allowedChatIds: ['-100-server'], + requireTopics: true, + }, + }, + byAccountId: { + 'acct-123': { + tickMs: 1_800, + providers: { + telegram: { + botToken: 'account-token', + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(1_800); + expect(config.providers.telegram.botToken).toBe('account-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100-server']); + expect(config.providers.telegram.requireTopics).toBe(true); + expect(config.providers.telegram.webhookEnabled).toBe(false); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.1'); + expect(config.providers.telegram.webhookPort).toBe(8_787); + }); + + it('falls back to global settings when scoped bridge config is missing', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'unknown-server', + accountId: 'acct-missing', + settings: { + channelBridge: { + tickMs: 4_200, + providers: { + telegram: { + botToken: 'global-token', + allowedChatIds: [], + requireTopics: true, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(4_200); + expect(config.providers.telegram.botToken).toBe('global-token'); + expect(config.providers.telegram.allowedChatIds).toEqual([]); + expect(config.providers.telegram.requireTopics).toBe(true); + }); + + it('keeps settings allowedChatIds when env override is empty', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS: ' ', + }, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100111'], + }, + }, + }, + }, + }); + + expect(config.providers.telegram.allowedChatIds).toEqual(['-100111']); + }); + + it('accepts numeric allowedChatIds from settings arrays', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: [-1001234567890], + }, + }, + }, + }, + }); + + expect(config.providers.telegram.allowedChatIds).toEqual(['-1001234567890']); + }); +}); diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts new file mode 100644 index 000000000..7d3f02011 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -0,0 +1,211 @@ +import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { readTelegramWebhookSecretToken } from '@/channels/providers/telegram/telegramWebhookSecretToken'; +import { + asRecord, + firstParsed, + parseBoolean, + parseCsv, + parseInteger, + parseStrictInteger, + parseStringArray, + readTrimmedString, +} from '@/channels/channelBridgeConfigParsing'; +import { resolveChannelBridgeSettingsScopes } from '@/channels/channelBridgeSettingsScopes'; + +type RecordLike = Record; + +function readWebhookSecretToken(value: unknown): string | null { + return readTelegramWebhookSecretToken(value); +} + +type TelegramChannelBridgeRuntimeConfig = Readonly<{ + botToken: string; + allowedChatIds: string[]; + allowAllSharedChats: boolean; + requireTopics: boolean; + webhookEnabled: boolean; + webhookSecret: string; + webhookHost: string; + webhookPort: number; +}>; + +export type ChannelBridgeRuntimeConfig = Readonly<{ + tickMs: number; + providers: Readonly<{ + telegram: TelegramChannelBridgeRuntimeConfig; + }>; +}>; + +export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ + env?: NodeJS.ProcessEnv; + settings?: unknown; + serverId?: string | null; + accountId?: string | null; +}>): ChannelBridgeRuntimeConfig { + const env = params.env ?? process.env; + const scopes = resolveChannelBridgeSettingsScopes({ + settings: params.settings, + serverId: params.serverId, + accountId: params.accountId, + }); + const channelBridgeGlobal = scopes.channelBridgeGlobal; + const channelBridgeServer = scopes.channelBridgeServer; + const channelBridgeAccount = scopes.channelBridgeAccount; + + const providersGlobal = asRecord(channelBridgeGlobal?.providers); + const providersServer = asRecord(channelBridgeServer?.providers); + const providersAccount = asRecord(channelBridgeAccount?.providers); + + const telegramGlobal = asRecord(providersGlobal?.telegram); + const telegramServer = asRecord(providersServer?.telegram); + const telegramAccount = asRecord(providersAccount?.telegram); + + const secretsGlobal = asRecord(telegramGlobal?.secrets); + const secretsServer = asRecord(telegramServer?.secrets); + const secretsAccount = asRecord(telegramAccount?.secrets); + + const webhookGlobal = asRecord(telegramGlobal?.webhook); + const webhookServer = asRecord(telegramServer?.webhook); + const webhookAccount = asRecord(telegramAccount?.webhook); + + const settingsTickMs = + firstParsed( + [channelBridgeAccount?.tickMs, channelBridgeServer?.tickMs, channelBridgeGlobal?.tickMs], + (value) => parseInteger(value, 250, 60_000), + ); + const envTickMs = + typeof env.HAPPIER_CHANNEL_BRIDGE_TICK_MS === 'string' + ? parseStrictInteger(env.HAPPIER_CHANNEL_BRIDGE_TICK_MS, 250, 60_000) + : null; + const tickMs = envTickMs ?? settingsTickMs ?? 2_500; + + const settingsBotToken = + firstParsed([ + secretsAccount?.botToken, + telegramAccount?.botToken, + secretsServer?.botToken, + telegramServer?.botToken, + secretsGlobal?.botToken, + telegramGlobal?.botToken, + ], readTrimmedString) + ?? ''; + const envBotToken = readTrimmedString(env.HAPPIER_TELEGRAM_BOT_TOKEN); + const botToken = envBotToken ?? settingsBotToken; + + const settingsAllowedChatIds = + firstParsed( + [telegramAccount?.allowedChatIds, telegramServer?.allowedChatIds, telegramGlobal?.allowedChatIds], + parseStringArray, + ) + ?? []; + const envAllowedChatIdsRaw = + typeof env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS === 'string' + ? env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS.trim() + : null; + const parsedEnvAllowedChatIds = + envAllowedChatIdsRaw && envAllowedChatIdsRaw.length > 0 + ? parseCsv(envAllowedChatIdsRaw) + : null; + const allowedChatIds = + parsedEnvAllowedChatIds && parsedEnvAllowedChatIds.length > 0 + ? parsedEnvAllowedChatIds + : settingsAllowedChatIds; + + const settingsAllowAllSharedChats = + firstParsed( + [telegramAccount?.allowAllSharedChats, telegramServer?.allowAllSharedChats, telegramGlobal?.allowAllSharedChats], + parseBoolean, + ) + ?? false; + const envAllowAllSharedChats = + typeof env.HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS === 'string' + ? parseBoolean(env.HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS) + : null; + const allowAllSharedChats = envAllowAllSharedChats ?? settingsAllowAllSharedChats; + + const settingsRequireTopics = + firstParsed( + [telegramAccount?.requireTopics, telegramServer?.requireTopics, telegramGlobal?.requireTopics], + parseBoolean, + ) + ?? false; + const envRequireTopics = + typeof env.HAPPIER_TELEGRAM_REQUIRE_TOPICS === 'string' + ? parseBoolean(env.HAPPIER_TELEGRAM_REQUIRE_TOPICS) + : null; + const requireTopics = envRequireTopics ?? settingsRequireTopics; + + const settingsWebhookEnabled = + firstParsed( + [webhookAccount?.enabled, webhookServer?.enabled, webhookGlobal?.enabled], + parseBoolean, + ) + ?? false; + const envWebhookEnabled = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_ENABLED === 'string' + ? parseBoolean(env.HAPPIER_TELEGRAM_WEBHOOK_ENABLED) + : null; + const webhookEnabled = envWebhookEnabled ?? settingsWebhookEnabled; + + const settingsWebhookSecret = + firstParsed([ + secretsAccount?.webhookSecret, + webhookAccount?.secret, + secretsServer?.webhookSecret, + webhookServer?.secret, + secretsGlobal?.webhookSecret, + webhookGlobal?.secret, + ], readWebhookSecretToken) + ?? ''; + const envWebhookSecretRaw = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string' + ? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim() + : null; + const envWebhookSecret = readWebhookSecretToken(envWebhookSecretRaw); + const webhookSecret = envWebhookSecret ?? settingsWebhookSecret; + + const settingsWebhookHostRaw = + firstParsed( + [webhookAccount?.host, webhookServer?.host, webhookGlobal?.host], + readTrimmedString, + ) + || '127.0.0.1'; + const settingsWebhookHost = isLoopbackHostname(settingsWebhookHostRaw) ? settingsWebhookHostRaw : '127.0.0.1'; + const envWebhookHostRaw = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_HOST === 'string' + ? env.HAPPIER_TELEGRAM_WEBHOOK_HOST.trim() + : null; + const envWebhookHost = + envWebhookHostRaw && isLoopbackHostname(envWebhookHostRaw) + ? envWebhookHostRaw + : null; + const webhookHost = envWebhookHost ?? settingsWebhookHost; + + const settingsWebhookPort = + firstParsed( + [webhookAccount?.port, webhookServer?.port, webhookGlobal?.port], + (value) => parseInteger(value, 1, 65_535), + ) + ?? 8_787; + const envWebhookPort = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_PORT === 'string' + ? parseStrictInteger(env.HAPPIER_TELEGRAM_WEBHOOK_PORT, 1, 65_535) + : null; + const webhookPort = envWebhookPort ?? settingsWebhookPort; + + return { + tickMs, + providers: { + telegram: { + botToken, + allowedChatIds, + allowAllSharedChats, + requireTopics, + webhookEnabled, + webhookSecret, + webhookHost, + webhookPort, + }, + }, + }; +} diff --git a/apps/cli/src/channels/channelBridgeConfigParsing.ts b/apps/cli/src/channels/channelBridgeConfigParsing.ts new file mode 100644 index 000000000..db86b11e2 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeConfigParsing.ts @@ -0,0 +1,78 @@ +type RecordLike = Record; + +export function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +export function parseBoolean(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return null; + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return null; +} + +export function parseStrictInteger(raw: string, min: number, max: number): number | null { + const trimmed = raw.trim(); + if (!/^[-]?\d+$/.test(trimmed)) return null; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) return null; + return Math.trunc(parsed); +} + +export function parseInteger(value: unknown, min: number, max: number): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + const candidate = Math.trunc(value); + if (candidate < min || candidate > max) return null; + return candidate; + } + if (typeof value === 'string') { + return parseStrictInteger(value, min, max); + } + return null; +} + +export function parseCsv(raw: string): string[] { + return raw + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function parseStringArray(value: unknown): string[] | null { + if (typeof value === 'string') { + const parsed = parseCsv(value); + return parsed.length > 0 ? parsed : null; + } + if (!Array.isArray(value)) return null; + const out = value + .map((entry) => { + if (typeof entry === 'string') return entry.trim(); + if (typeof entry === 'number' && Number.isFinite(entry)) return String(Math.trunc(entry)); + return ''; + }) + .filter((entry) => entry.length > 0); + if (out.length === 0 && value.length > 0) { + return null; + } + return out; +} + +export function readTrimmedString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function firstParsed(values: readonly unknown[], parse: (value: unknown) => T | null): T | null { + for (const value of values) { + const parsed = parse(value); + if (parsed !== null) { + return parsed; + } + } + return null; +} diff --git a/apps/cli/src/channels/channelBridgeSettingsScopes.ts b/apps/cli/src/channels/channelBridgeSettingsScopes.ts new file mode 100644 index 000000000..dcce1128c --- /dev/null +++ b/apps/cli/src/channels/channelBridgeSettingsScopes.ts @@ -0,0 +1,40 @@ +import { asRecord, readTrimmedString } from './channelBridgeConfigParsing'; + +type RecordLike = Record; + +export type ChannelBridgeSettingsScopes = Readonly<{ + channelBridgeGlobal: RecordLike | null; + channelBridgeServer: RecordLike | null; + channelBridgeAccount: RecordLike | null; +}>; + +export function resolveChannelBridgeSettingsScopes(params: Readonly<{ + settings: unknown; + serverId?: string | null; + accountId?: string | null; +}>): ChannelBridgeSettingsScopes { + const settingsRoot = asRecord(params.settings); + const channelBridgeGlobal = asRecord(settingsRoot?.channelBridge); + + const byServerId = asRecord(channelBridgeGlobal?.byServerId); + const scopedServerId = readTrimmedString(params.serverId) ?? ''; + const scopedAccountId = readTrimmedString(params.accountId) ?? ''; + + const channelBridgeServer = + scopedServerId.length > 0 && byServerId + ? asRecord(byServerId[scopedServerId]) + : null; + + const byAccountId = asRecord(channelBridgeServer?.byAccountId); + const channelBridgeAccount = + scopedAccountId.length > 0 && byAccountId + ? asRecord(byAccountId[scopedAccountId]) + : null; + + return { + channelBridgeGlobal, + channelBridgeServer, + channelBridgeAccount, + }; +} + diff --git a/apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts new file mode 100644 index 000000000..74cf00385 --- /dev/null +++ b/apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createInMemoryChannelBindingStore, startChannelBridgeWorker } from './channelBridgeWorker'; + +describe('startChannelBridgeWorker (class-based adapters)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not drop prototype methods when wrapping adapters for single-flight pull', async () => { + class PrototypeAdapter { + providerId = 'proto'; + sent: Array> = []; + + async pullInboundMessages() { + return []; + } + + async sendMessage(params: Readonly<{ conversationId: string; threadId: string | null; text: string }>) { + this.sent.push(params); + } + } + + const adapter = new PrototypeAdapter(); + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'proto', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'owner-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const onWarning = vi.fn(); + + const worker = startChannelBridgeWorker({ + store, + adapters: [adapter], + deps: { + listSessions: async () => [], + resolveSessionIdOrPrefix: async () => ({ ok: false, code: 'unsupported' }), + resolveLatestSessionSeq: async () => 0, + fetchAgentMessagesAfterSeq: async ({ afterSeq }) => { + if (afterSeq >= 1) return { messages: [], highestSeenSeq: afterSeq }; + return { + messages: [{ seq: 1, text: 'hello from agent' }], + highestSeenSeq: 1, + }; + }, + sendUserMessageToSession: async () => {}, + onWarning, + }, + tickMs: 250, + }); + + await worker.stop(); + + expect(onWarning).not.toHaveBeenCalled(); + expect(adapter.sent).toEqual([ + { conversationId: 'conv-1', threadId: null, text: 'hello from agent' }, + ]); + }); +}); + diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts new file mode 100644 index 000000000..e53cc308f --- /dev/null +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -0,0 +1,3221 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createInMemoryChannelBindingStore, + executeChannelBridgeTick, + startChannelBridgeWorker, + createChannelBridgeInboundDeduper, + createChannelBridgeInboundForwardFailureTracker, + ChannelBridgePermanentDeliveryError, + type ChannelBridgeAdapter, + type ChannelBindingStore, + type ChannelBridgeDeps, + type ChannelBridgeInboundMessage, +} from '@/channels/core/channelBridgeWorker'; + +interface SentConversationMessage { + conversationId: string; + threadId: string | null; + text: string; +} + +interface SentSessionMessage { + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + messageId?: string; +} + +interface WarningRecord { + message: string; + error?: unknown; +} + +interface DepsHarness { + deps: ChannelBridgeDeps; + sentToSession: SentSessionMessage[]; + warnings: WarningRecord[]; +} + +interface DeferredPromise { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function createDepsHarness(options?: { + sessions?: Array<{ sessionId: string; label: string | null }>; + listSessions?: ChannelBridgeDeps['listSessions']; + resolveSessionIdOrPrefix?: ChannelBridgeDeps['resolveSessionIdOrPrefix']; + sendUserMessageToSession?: ChannelBridgeDeps['sendUserMessageToSession']; + resolveLatestSessionSeq?: ChannelBridgeDeps['resolveLatestSessionSeq']; + fetchAgentMessagesAfterSeq?: ChannelBridgeDeps['fetchAgentMessagesAfterSeq']; + authorizeCommand?: ChannelBridgeDeps['authorizeCommand']; +}): DepsHarness { + const sentToSession: SentSessionMessage[] = []; + const warnings: WarningRecord[] = []; + const deps: ChannelBridgeDeps = { + listSessions: options?.listSessions ?? (async () => options?.sessions ?? []), + resolveSessionIdOrPrefix: + options?.resolveSessionIdOrPrefix ?? + (async () => ({ ok: false as const, code: 'session_not_found' as const })), + sendUserMessageToSession: + options?.sendUserMessageToSession ?? + (async (params) => { + sentToSession.push({ ...params }); + }), + resolveLatestSessionSeq: options?.resolveLatestSessionSeq ?? (async () => 0), + fetchAgentMessagesAfterSeq: options?.fetchAgentMessagesAfterSeq ?? (async () => []), + authorizeCommand: options?.authorizeCommand, + onWarning: (message, error) => { + warnings.push({ message, error }); + }, + }; + return { deps, sentToSession, warnings }; +} + +function createAdapterHarness(providerId: string = 'telegram'): { + adapter: ChannelBridgeAdapter; + pushInbound: (event: ChannelBridgeInboundMessage) => void; + sent: SentConversationMessage[]; + failPullOnce: (error: Error) => void; + failSendOnce: (error: Error) => void; + stopCalls: () => number; + pendingInboundCount: () => number; +} { + const queue: ChannelBridgeInboundMessage[] = []; + const sent: SentConversationMessage[] = []; + let pullError: Error | null = null; + let sendError: Error | null = null; + let stopCallCount = 0; + + return { + adapter: { + providerId, + pullInboundMessages: async () => { + if (pullError) { + const error = pullError; + pullError = null; + throw error; + } + const items = queue.slice(); + queue.length = 0; + return items; + }, + sendMessage: async (params) => { + if (sendError) { + const error = sendError; + sendError = null; + throw error; + } + sent.push({ + conversationId: params.conversationId, + threadId: params.threadId, + text: params.text, + }); + }, + stop: async () => { + stopCallCount += 1; + }, + }, + pushInbound: (event) => { + const next = typeof event.senderId === 'undefined' ? { ...event, senderId: 'user-1' } : event; + queue.push(next); + }, + sent, + failPullOnce: (error) => { + pullError = error; + }, + failSendOnce: (error) => { + sendError = error; + }, + stopCalls: () => stopCallCount, + pendingInboundCount: () => queue.length, + }; +} + +function createDeferredPromise(): DeferredPromise { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function waitFor(condition: () => boolean, timeoutMs: number = 2_000): Promise { + const startedAt = Date.now(); + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out waiting for condition'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +const DEFAULT_BINDING_POLICY = { + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly' as const, + allowMissingSenderId: false, +}; + +describe('createInMemoryChannelBindingStore', () => { + it('normalizes non-finite cursor values and ignores invalid cursor updates', async () => { + const store = createInMemoryChannelBindingStore(); + const ref = { + providerId: 'telegram', + conversationId: '-100-cursor', + threadId: null, + } as const; + + await store.upsertBinding({ + ...ref, + ...DEFAULT_BINDING_POLICY, + sessionId: 'sess-cursor', + lastForwardedSeq: Number.NaN, + }); + + const created = await store.getBinding(ref); + expect(created?.lastForwardedSeq).toBe(0); + + await store.updateLastForwardedSeq(ref, { + expectedSessionId: 'sess-cursor', + seq: 7, + }); + await store.updateLastForwardedSeq(ref, { + expectedSessionId: 'sess-cursor', + seq: Number.NaN, + }); + + const updated = await store.getBinding(ref); + expect(updated?.lastForwardedSeq).toBe(7); + }); + + it('normalizes binding keys the same way as server-backed store', async () => { + const store = createInMemoryChannelBindingStore(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: ' telegram ', + conversationId: ' room-1 ', + threadId: ' ', + sessionId: ' sess-1 ', + lastForwardedSeq: 1, + }); + + const found = await store.getBinding({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + }); + expect(found).toMatchObject({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + sessionId: 'sess-1', + }); + + const advanced = await store.updateLastForwardedSeq({ + providerId: ' telegram ', + conversationId: ' room-1 ', + threadId: ' ', + }, { + expectedSessionId: ' sess-1 ', + seq: 2, + }); + expect(advanced).toBe(true); + + const removed = await store.removeBinding({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + }); + expect(removed).toBe(true); + }); +}); + +describe('executeChannelBridgeTick', () => { + it('sanitizes non-finite in-memory cursor values in binding writes', async () => { + const store = createInMemoryChannelBindingStore(); + + const upserted = await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-100sanity', + threadId: null, + sessionId: 'sess-sanity', + lastForwardedSeq: Number.NaN, + }); + + expect(upserted.lastForwardedSeq).toBe(0); + + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100sanity', + threadId: null, + }, { + expectedSessionId: 'sess-sanity', + seq: Number.POSITIVE_INFINITY, + }); + + const afterInvalidUpdate = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100sanity', + threadId: null, + }); + + expect(afterInvalidUpdate?.lastForwardedSeq).toBe(0); + }); + + it('returns defensive copies from in-memory binding store reads', async () => { + const store = createInMemoryChannelBindingStore(() => 1_000); + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'room-copy', + threadId: null, + sessionId: 'sess-copy', + lastForwardedSeq: 5, + }); + + const firstRead = await store.getBinding({ + providerId: 'telegram', + conversationId: 'room-copy', + threadId: null, + }); + expect(firstRead).not.toBeNull(); + (firstRead as { sessionId: string }).sessionId = 'mutated-first-read'; + + const listed = await store.listBindings(); + (listed[0] as { sessionId: string }).sessionId = 'mutated-list-read'; + + const secondRead = await store.getBinding({ + providerId: 'telegram', + conversationId: 'room-copy', + threadId: null, + }); + + expect(secondRead?.sessionId).toBe('sess-copy'); + }); + + it('supports /attach then forwards inbound user messages into the bound session', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async (idOrPrefix: string) => { + if (idOrPrefix === 'abc123') { + return { ok: true as const, sessionId: 'sess-abc123' }; + } + return { ok: false as const, code: 'session_not_found' as const }; + }, + resolveLatestSessionSeq: async () => 41, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: '/attach abc123', + messageId: 'm1', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding).toMatchObject({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + sessionId: 'sess-abc123', + lastForwardedSeq: 41, + }); + expect(harness.sent.some((row) => row.text.includes('Attached'))).toBe(true); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: 'Ship it', + messageId: 'm2', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toEqual([ + { + sessionId: 'sess-abc123', + text: 'Ship it', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + messageId: 'm2', + }, + ]); + }); + + it('does not acknowledge inbound messages when forwarding to the session fails (so the adapter can retry)', async () => { + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-abc123', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const inbound: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: 'Hello from Telegram', + messageId: 'm-forward-failure', + }; + + const pending: ChannelBridgeInboundMessage[] = [inbound]; + const ackCalls: Array = []; + const sent: SentConversationMessage[] = []; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => pending.slice(), + ackInboundMessages: async (messages) => { + ackCalls.push(messages); + for (const message of messages) { + const index = pending.findIndex((row) => row.messageId === message.messageId); + if (index >= 0) { + pending.splice(index, 1); + } + } + }, + sendMessage: async (params) => { + sent.push({ conversationId: params.conversationId, threadId: params.threadId, text: params.text }); + }, + }; + + const sendToSessionSpy = async () => { + throw new Error('session forward failed'); + }; + const { deps } = createDepsHarness({ + sendUserMessageToSession: sendToSessionSpy, + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(ackCalls).toEqual([]); + expect(pending).toHaveLength(1); + expect(sent.length).toBeGreaterThan(0); + }); + + it('gives up and acknowledges after repeated session-forward failures to avoid stalling adapter offsets', async () => { + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-abc123', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const inbound: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: 'Hello from Telegram', + messageId: 'm-forward-failure-give-up', + }; + + const pending: ChannelBridgeInboundMessage[] = [inbound]; + const ackCalls: Array = []; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => pending.slice(), + ackInboundMessages: async (messages) => { + ackCalls.push(messages); + for (const message of messages) { + const index = pending.findIndex((row) => row.messageId === message.messageId); + if (index >= 0) { + pending.splice(index, 1); + } + } + }, + sendMessage: async () => {}, + }; + + const { deps } = createDepsHarness({ + sendUserMessageToSession: async () => { + throw new Error('session forward failed'); + }, + }); + + const deduper = createChannelBridgeInboundDeduper(); + const failureTracker = createChannelBridgeInboundForwardFailureTracker({ maxAttempts: 2, maxAgeMs: 60_000 }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + inboundForwardFailureTracker: failureTracker, + }); + + expect(ackCalls).toEqual([]); + expect(pending).toHaveLength(1); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + inboundForwardFailureTracker: failureTracker, + }); + + expect(ackCalls).toHaveLength(1); + expect(pending).toHaveLength(0); + }); + + it('denies /attach when sender identity is missing (safe-by-default)', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + text: '/attach abc123', + messageId: 'm-attach-missing-sender', + senderId: null, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toEqual([]); + expect(harness.sent.some((row) => row.text.toLowerCase().includes('sender'))).toBe(true); + }); + + it('denies forwarding from non-owner senders by default', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: '/attach abc123', + messageId: 'm-attach-owner', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-2', + text: 'hello from non-owner', + messageId: 'm-non-owner', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toEqual([]); + expect(harness.sent.some((row) => row.text.toLowerCase().includes('authorized'))).toBe(true); + }); + + it('allows forwarding from anyone when attached with --anyone', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: '/attach abc123 --anyone', + messageId: 'm-attach-anyone', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-2', + text: 'hello from anyone', + messageId: 'm-anyone', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('hello from anyone'); + }); + + it('allows unsafe forwarding without senderId when attached with --allow-missing-sender-id', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: null, + text: '/attach abc123 --allow-missing-sender-id', + messageId: 'm-attach-unsafe', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: null, + text: 'unsafe no sender', + messageId: 'm-unsafe-forward', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('unsafe no sender'); + }); + + it('includes previous session id when /attach replaces an existing binding', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + sessionId: 'sess-old', + lastForwardedSeq: 12, + }); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-new' }), + resolveLatestSessionSeq: async () => 41, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: '/attach sess-new', + messageId: 'm-attach-replace', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('replaced previous session sess-old'))).toBe(true); + }); + + it('does not retry /attach when success reply delivery fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + let latestSeqCallCount = 0; + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-no-retry' }), + resolveLatestSessionSeq: async () => { + latestSeqCallCount += 1; + return latestSeqCallCount === 1 ? 10 : 25; + }, + }); + + harness.failSendOnce(new Error('temporary command reply failure')); + const deduper = createChannelBridgeInboundDeduper(); + const attachEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: '/attach sess-no-retry', + messageId: 'm-attach-reply-fails-once', + }; + + harness.pushInbound(attachEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(attachEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(latestSeqCallCount).toBe(1); + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(10); + }); + + it('supports /sessions and /detach command flow', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-old', + lastForwardedSeq: 3, + }); + + const { deps } = createDepsHarness({ + sessions: [ + { sessionId: 'sess-1', label: 'build-docs' }, + { sessionId: 'sess-2', label: null }, + ], + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', + text: '/sessions', + messageId: 'm-sessions', + }); + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', + text: '/detach', + messageId: 'm-detach', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Recent sessions'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Detached'))).toBe(true); + + const remaining = await store.listBindings(); + expect(remaining).toHaveLength(0); + }); + + it('blocks /sessions in non-DM conversations', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + sessions: [{ sessionId: 'sess-1', label: 'build-docs' }], + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + conversationKind: 'group', + text: '/sessions', + messageId: 'm-sessions-group', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('For safety, /sessions is only available in direct messages.'))).toBe( + true, + ); + expect(harness.sent.some((row) => row.text.includes('Recent sessions'))).toBe(false); + }); + + it('warns and replies when /sessions fails to list sessions', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + listSessions: async () => { + throw new Error('list unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', + text: '/sessions', + messageId: 'm-sessions-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to list sessions for /sessions command'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to retrieve sessions'))).toBe(true); + }); + + it('supports /session command for attached conversations', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + text: '/session', + messageId: 'm-session-bound', + }); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Attached session: sess-bound'))).toBe(true); + }); + + it('denies /session command for attached conversations when sender is not authorized', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-2', + text: '/session', + messageId: 'm-session-bound-unauthorized', + }); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('not authorized'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Attached session: sess-bound'))).toBe(false); + }); + + it('supports /session command for non-attached conversations', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '100', + text: '/session', + messageId: 'm-session-unbound', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('No session is attached here'))).toBe(true); + }); + + it('warns and replies when /session cannot read binding from store', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('binding read failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/session', + messageId: 'm-session-store-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to read binding for /session command'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to read current session binding'))).toBe(true); + }); + + it('warns and replies when /detach fails to remove a binding from store', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + removeBinding: async () => { + throw new Error('binding remove failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/detach', + messageId: 'm-detach-store-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to remove binding for /detach command'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to detach current session binding'))).toBe(true); + }); + + it('supports /help and /start command aliases', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/help', + messageId: 'm-help', + }); + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/start', + messageId: 'm-start', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const helpReplies = harness.sent.filter((row) => row.text.includes('Happier bridge commands:')); + expect(helpReplies).toHaveLength(2); + for (const reply of helpReplies) { + expect(reply.text.includes('/help - show command help')).toBe(true); + expect(reply.text.includes('/start - alias for /help')).toBe(true); + } + expect(sentToSession).toHaveLength(0); + }); + + it('replies for unknown slash commands instead of forwarding them', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/bogus-command', + messageId: 'm-unknown-command', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Unknown command: /bogus-command'))).toBe(true); + expect(sentToSession).toHaveLength(0); + }); + + it('acks unknown-command events even when reply delivery fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + const deduper = createChannelBridgeInboundDeduper(); + + const event: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/@', + messageId: 'm-unknown-reply-failure', + }; + + harness.failSendOnce(new Error('reply failed')); + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to send unknown-command reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + expect(sentToSession).toHaveLength(0); + }); + + it('replies for malformed slash command tokens instead of forwarding them', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/@', + messageId: 'm-malformed-command', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Unknown command. Use /help'))).toBe(true); + expect(sentToSession).toHaveLength(0); + }); + + it('replies with no-binding hint for non-command inbound text when conversation is unbound', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'unbound-room', + threadId: null, + text: 'hello from unbound thread', + messageId: 'unbound-non-command', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('No session is attached here'))).toBe(true); + expect(sentToSession).toHaveLength(0); + }); + + it('acks non-command unbound events even when no-binding reply delivery fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + const deduper = createChannelBridgeInboundDeduper(); + + const event: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'unbound-room-fail-reply', + threadId: null, + text: 'hello from unbound thread', + messageId: 'm-unbound-reply-failure', + }; + + harness.failSendOnce(new Error('no-binding reply failed')); + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to send no-binding reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + expect(sentToSession).toHaveLength(0); + }); + + it('warns and replies when non-command forwarding cannot read binding from store', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('binding read failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + + const failedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'bound-room', + threadId: null, + text: 'hello from channel', + messageId: 'non-command-getbinding-fail', + }; + + harness.pushInbound(failedEvent); + const deduper = createChannelBridgeInboundDeduper(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(failedEvent); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toBe(true); + expect(warnings.filter((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toHaveLength(1); + expect(harness.sent.filter((row) => row.text.includes('Failed to read current session binding'))).toHaveLength(1); + }); + + it('acks binding-read failures even when failure reply delivery throws', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('binding read failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + + const deduper = createChannelBridgeInboundDeduper(); + const failedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'bound-room-reply-fail', + threadId: null, + text: 'hello from channel', + messageId: 'non-command-getbinding-fail-reply-send', + }; + + harness.failSendOnce(new Error('temporary reply transport failure')); + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(0); + expect(warnings.filter((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toHaveLength(1); + expect(warnings.filter((row) => row.message.includes('Failed to send binding-read-failure reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + }); + + it('indicates when /sessions output is truncated', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness({ + sessions: Array.from({ length: 21 }, (_, index) => ({ + sessionId: `sess-${index + 1}`, + label: null, + })), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', + text: '/sessions', + messageId: 'm-sessions-truncated', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('…and 1 more.'))).toBe(true); + }); + + it('forwards agent replies to the bound conversation and advances cursor', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1005', + threadId: null, + sessionId: 'sess-a', + lastForwardedSeq: 9, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async ({ afterSeq }: { afterSeq: number }) => { + if (afterSeq === 9) { + return [ + { seq: 10, text: 'First agent reply' }, + { seq: 11, text: 'Second agent reply' }, + ]; + } + return []; + }, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toEqual([ + { conversationId: '-1005', threadId: null, text: 'First agent reply' }, + { conversationId: '-1005', threadId: null, text: 'Second agent reply' }, + ]); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(11); + }); + + it('advances outbound cursor when transcript page contains only non-agent rows', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1005b', + threadId: null, + sessionId: 'sess-non-agent-window', + lastForwardedSeq: 9, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async ({ afterSeq }: { afterSeq: number }) => { + if (afterSeq === 9) { + return { + messages: [], + highestSeenSeq: 59, + }; + } + return []; + }, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toEqual([]); + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(59); + }); + + it('skips agent rows with invalid seq values and continues forwarding valid rows', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1006', + threadId: null, + sessionId: 'sess-invalid-seq-row', + lastForwardedSeq: 9, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: Number.NaN, text: 'bad row' }, + { seq: 12, text: 'valid row' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toEqual([ + { conversationId: '-1006', threadId: null, text: 'valid row' }, + ]); + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(12); + expect(warnings.some((row) => row.message.includes('invalid seq'))).toBe(true); + }); + + it('does not attach when latest session sequence cannot be resolved', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-invalid-seq' }), + resolveLatestSessionSeq: async () => { + throw new Error('sequence unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-invalid-seq', + messageId: 'attach-fails', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Failed to attach'))).toBe(true); + }); + + it('does not attach when session id/prefix resolution throws', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => { + throw new Error('resolver unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-resolver-error', + messageId: 'attach-resolver-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to resolve session by id/prefix for attach'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to attach to session sess-resolver-error'))).toBe(true); + }); + + it('does not attach when latest session sequence resolves to an invalid value', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-invalid-seq-value' }), + resolveLatestSessionSeq: async () => Number.NaN, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-invalid-seq-value', + messageId: 'attach-invalid-seq-value', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Failed to attach'))).toBe(true); + expect(warnings.some((row) => row.message.includes('resolveLatestSessionSeq returned an invalid value'))).toBe(true); + }); + + it('does not attach when binding persistence fails', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + upsertBinding: async () => { + throw new Error('binding upsert failed'); + }, + }; + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-upsert-fail' }), + resolveLatestSessionSeq: async () => 10, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-upsert-fail', + messageId: 'attach-upsert-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await baseStore.listBindings()).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to persist binding during /attach'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('unable to persist binding'))).toBe(true); + }); + + it('does not attach when reading an existing binding fails before persistence', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('prior binding read failed'); + }, + }; + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-prior-fail' }), + resolveLatestSessionSeq: async () => 10, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-prior-fail', + messageId: 'attach-getbinding-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await baseStore.listBindings()).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to read existing binding during /attach'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to read current binding before attach'))).toBe(true); + }); + + it('returns ambiguous attach message even when resolver omits candidate ids', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ + ok: false as const, + code: 'session_id_ambiguous' as const, + candidates: [], + }), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-ambiguous', + messageId: 'attach-ambiguous-empty-candidates', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Ambiguous session prefix'))).toBe(true); + }); + + it('returns unsupported attach message when resolver does not support attach by id/prefix', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: false as const, code: 'unsupported' as const }), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-unsupported', + messageId: 'attach-unsupported', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Attaching by session ID or prefix is not supported'))).toBe(true); + }); + + it('returns session-not-found attach message when resolver cannot find target', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: false as const, code: 'session_not_found' as const }), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-missing', + messageId: 'attach-session-not-found', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Session not found'))).toBe(true); + }); + + it('replies with usage hint when /attach is called without arguments', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach', + messageId: 'attach-no-args', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Usage: /attach'))).toBe(true); + }); + + it('enforces sender-scoped command authorization via deps hook', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + authorizeCommand: async ({ commandName, actor }) => { + if (commandName === 'attach' && actor.senderId === 'blocked-user') { + return { allowed: false as const, message: 'Not authorized for attach.' }; + } + return true; + }, + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-new' }), + resolveLatestSessionSeq: async () => 5, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1008', + threadId: null, + senderId: 'blocked-user', + text: '/attach sess-new', + messageId: 'attach-authz-blocked', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Not authorized for attach.'))).toBe(true); + }); + + it('denies command and warns when authorizeCommand hook throws', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + authorizeCommand: async () => { + throw new Error('auth service unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1009', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: '/sessions', + messageId: 'authz-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Unable to authorize this command right now.'))).toBe(true); + expect(warnings.some((row) => row.message.includes('Authorization check failed'))).toBe(true); + }); + + it('normalizes inbound provider identity to adapter provider and warns on mismatch', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('telegram'); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'chat-42', + threadId: null, + sessionId: 'sess-42', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession, warnings } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'discord', + conversationId: 'chat-42', + threadId: null, + text: 'Hello from spoofed provider', + messageId: 'm-spoof', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]).toMatchObject({ + providerId: 'telegram', + sentFrom: 'telegram', + conversationId: 'chat-42', + threadId: null, + }); + expect(warnings.some((row) => row.message.includes('Inbound provider mismatch'))).toBe(true); + }); + + it('continues processing other adapters when one adapter pull fails', async () => { + const store = createInMemoryChannelBindingStore(); + const failing = createAdapterHarness('telegram'); + const healthy = createAdapterHarness('discord'); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'discord', + conversationId: 'discord-room', + threadId: null, + sessionId: 'sess-discord', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession, warnings } = createDepsHarness(); + + failing.failPullOnce(new Error('telegram pull failed')); + healthy.pushInbound({ + providerId: 'discord', + conversationId: 'discord-room', + threadId: null, + text: 'still processed', + messageId: 'discord-message', + }); + + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter, healthy.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toEqual([ + { + sessionId: 'sess-discord', + text: 'still processed', + sentFrom: 'discord', + providerId: 'discord', + conversationId: 'discord-room', + threadId: null, + messageId: 'discord-message', + }, + ]); + expect(warnings.some((row) => row.message.includes('Failed to pull inbound messages for adapter telegram'))).toBe(true); + }); + + it('deduplicates adapter pull failure warnings until the adapter recovers when warnedAdapterPullFailures is provided', async () => { + const store = createInMemoryChannelBindingStore(); + const failing = createAdapterHarness('telegram'); + + const { deps, warnings } = createDepsHarness(); + const inboundDeduper = createChannelBridgeInboundDeduper(); + const warnedAdapterPullFailures = new Set(); + + const warningCount = () => + warnings.filter((row) => row.message.includes('Failed to pull inbound messages for adapter telegram')).length; + + failing.failPullOnce(new Error('telegram pull failed')); + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + expect(warningCount()).toBe(1); + + failing.failPullOnce(new Error('telegram pull failed again')); + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + expect(warningCount()).toBe(1); + + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + + failing.failPullOnce(new Error('telegram pull failed after recovery')); + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + expect(warningCount()).toBe(2); + }); + + it('warns and ignores duplicate adapter provider ids', async () => { + const store = createInMemoryChannelBindingStore(); + const first = createAdapterHarness('telegram'); + const second = createAdapterHarness('telegram'); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + sessionId: 'sess-dup', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession, warnings } = createDepsHarness(); + + first.pushInbound({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + text: 'from first adapter', + messageId: 'dup-1', + }); + second.pushInbound({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + text: 'from second adapter', + messageId: 'dup-2', + }); + + await executeChannelBridgeTick({ + store, + adapters: [first.adapter, second.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('from first adapter'); + expect(warnings.some((row) => row.message.includes('Duplicate adapter providerId detected: telegram'))).toBe(true); + }); + + it('warns and replies when forwarding inbound text into session fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'failing-room', + threadId: null, + sessionId: 'sess-fail', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + sendUserMessageToSession: async () => { + throw new Error('session unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'failing-room', + threadId: null, + text: 'hello', + messageId: 'send-fail-msg', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to forward channel message into session'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to send message to session sess-fail.'))).toBe(true); + }); + + it('retries session-forward failures and deduplicates forwarding warnings and failure replies', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'failing-room-reply-fail', + threadId: null, + sessionId: 'sess-fail-reply', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + sendUserMessageToSession: async () => { + throw new Error('session unavailable'); + }, + }); + + const deduper = createChannelBridgeInboundDeduper(); + const failedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'failing-room-reply-fail', + threadId: null, + text: 'hello', + messageId: 'send-fail-reply-ack', + }; + + harness.failSendOnce(new Error('temporary reply transport failure')); + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to forward channel message into session'))).toHaveLength(1); + expect(warnings.filter((row) => row.message.includes('Failed to send session-forward-failure reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(1); + }); + + it('persists cursor after successful sends when a later outbound row fails', async () => { + const store = createInMemoryChannelBindingStore(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-2002', + threadId: null, + sessionId: 'sess-partial', + lastForwardedSeq: 9, + }); + + let sendCalls = 0; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + sendCalls += 1; + if (sendCalls >= 2) { + throw new Error('simulated send failure'); + } + }, + stop: async () => {}, + }; + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 10, text: 'first row' }, + { seq: 11, text: 'second row fails' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(10); + expect(warnings.some((row) => row.message.includes('Failed to forward agent output to channel'))).toBe(true); + }); + + it('warns when fetching outbound agent rows fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-2003', + threadId: null, + sessionId: 'sess-fetch-fail', + lastForwardedSeq: 3, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => { + throw new Error('transcript read failed'); + }, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to forward agent output to channel'))).toBe(true); + }); + + it('deduplicates repeated inbound messages across direct executeChannelBridgeTick calls', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'direct-dedupe-room', + threadId: null, + sessionId: 'sess-direct-dedupe', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + const repeated = { + providerId: 'telegram' as const, + conversationId: 'direct-dedupe-room', + threadId: null, + text: 'same payload', + messageId: 'direct-dedupe-id-1', + }; + + const deduper = createChannelBridgeInboundDeduper(); + + harness.pushInbound(repeated); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(repeated); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(1); + }); + + it('marks inbound messages as seen after replying on transient binding-read failure', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const ref = { + providerId: 'telegram', + conversationId: 'retry-on-failure-room', + threadId: null, + } as const; + + await baseStore.upsertBinding({ + ...ref, + ...DEFAULT_BINDING_POLICY, + sessionId: 'sess-retry-on-failure', + lastForwardedSeq: 0, + }); + + let failGetBindingOnce = true; + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async (bindingRef) => { + if (failGetBindingOnce) { + failGetBindingOnce = false; + throw new Error('temporary store failure'); + } + return await baseStore.getBinding(bindingRef); + }, + }; + + const repeatedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'retry-on-failure-room', + threadId: null, + senderId: 'user-1', + text: 'retry me', + messageId: 'm-retry-after-failure', + }; + + let pullCount = 0; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => { + pullCount += 1; + if (pullCount <= 2) { + return [repeatedEvent]; + } + return []; + }, + sendMessage: async () => {}, + }; + + const { deps, sentToSession, warnings } = createDepsHarness(); + const deduper = createChannelBridgeInboundDeduper(); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + }); + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toHaveLength(1); + expect(sentToSession).toHaveLength(0); + }); + + it('does not dedupe messages when inbound messageId is empty', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'empty-id-room', + threadId: null, + sessionId: 'sess-empty-id', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + const messageWithEmptyId = { + providerId: 'telegram' as const, + conversationId: 'empty-id-room', + threadId: null, + text: 'same payload', + messageId: ' ', + }; + + const deduper = createChannelBridgeInboundDeduper(); + + harness.pushInbound(messageWithEmptyId); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(messageWithEmptyId); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(2); + }); + + it('skips invalid outbound seq rows and warns without stalling valid cursor updates', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3001', + threadId: null, + sessionId: 'sess-invalid-seq', + lastForwardedSeq: 9, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: Number.NaN, text: 'invalid row' }, + { seq: 10, text: 'valid row' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(10); + expect(harness.sent.some((row) => row.text.includes('valid row'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('invalid row'))).toBe(false); + expect(warnings.some((row) => row.message.includes('Skipped agent output row with invalid seq'))).toBe(true); + }); + + it('forwards outbound agent rows in ascending seq order even when source rows are unsorted', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3002', + threadId: null, + sessionId: 'sess-unsorted-seq', + lastForwardedSeq: 9, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 12, text: 'third by seq' }, + { seq: 10, text: 'first by seq' }, + { seq: 11, text: 'second by seq' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual([ + 'first by seq', + 'second by seq', + 'third by seq', + ]); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(12); + }); + + it('skips outbound rows at or below the current cursor', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3003', + threadId: null, + sessionId: 'sess-cursor-guard', + lastForwardedSeq: 10, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 9, text: 'already forwarded before cursor' }, + { seq: 10, text: 'exactly at cursor' }, + { seq: 11, text: 'new row after cursor' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual(['new row after cursor']); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(11); + }); + + it('warns and stops forwarding remaining rows when cursor persistence fails after send', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + updateLastForwardedSeq: async () => { + throw new Error('cursor store unavailable'); + }, + }; + const harness = createAdapterHarness(); + + await baseStore.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3004', + threadId: null, + sessionId: 'sess-cursor-persist-fail', + lastForwardedSeq: 9, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 10, text: 'first send succeeds' }, + { seq: 11, text: 'should not send after cursor failure' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual(['first send succeeds']); + expect(warnings.some((row) => row.message.includes('Failed to persist channel bridge cursor'))).toBe(true); + expect(warnings.some((row) => row.message.includes('Failed to forward agent output to channel'))).toBe(false); + + const [binding] = await baseStore.listBindings(); + expect(binding?.lastForwardedSeq).toBe(9); + }); + + it('treats typed permanent delivery failures as non-retryable and advances cursor', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], + }); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3004b', + threadId: null, + sessionId: 'sess-typed-permanent', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new ChannelBridgePermanentDeliveryError({ + code: 'forbidden', + message: 'Forbidden: bot was blocked by the user', + }); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(1); + expect( + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), + ).toBe(true); + expect( + warnings.some((row) => row.message.includes('Failed to forward agent output to channel')), + ).toBe(false); + }); + + it('treats typed permanent failures as non-retryable even for non-default provider ids', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], + }); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram-alt', + conversationId: '-3004b-alt', + threadId: null, + sessionId: 'sess-typed-permanent-alt', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram-alt', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new ChannelBridgePermanentDeliveryError({ + code: 'forbidden', + message: 'Forbidden: bot was blocked by the user', + }); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(1); + expect( + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), + ).toBe(true); + }); + + it('treats typed chat-not-found failures as non-retryable', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], + }); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3004c', + threadId: null, + sessionId: 'sess-typed-permanent-chat-not-found', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new ChannelBridgePermanentDeliveryError({ + code: 'conversation_not_found', + message: 'Bad Request: chat not found', + }); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(1); + expect( + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), + ).toBe(true); + }); + + it('does not treat untyped telegram-like send errors as permanent failures', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'must not be treated as permanent' }], + }); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-3004c', + threadId: null, + sessionId: 'sess-untyped-error', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new Error('Telegram sendMessage failed (403): Forbidden'); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(0); + expect( + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), + ).toBe(false); + expect( + warnings.some((row) => row.message.includes('Failed to forward agent output to channel')), + ).toBe(true); + }); + + it('does not advance cursor when conversation is reattached to a different session', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const ref = { + providerId: 'telegram', + conversationId: '-3005', + threadId: null, + } as const; + + await baseStore.upsertBinding({ + ...ref, + ...DEFAULT_BINDING_POLICY, + sessionId: 'sess-old', + lastForwardedSeq: 0, + }); + + const store: ChannelBindingStore = { + ...baseStore, + updateLastForwardedSeq: async (bindingRef, params) => { + await baseStore.upsertBinding({ + ...bindingRef, + ...DEFAULT_BINDING_POLICY, + sessionId: 'sess-new', + lastForwardedSeq: 0, + }); + return await baseStore.updateLastForwardedSeq(bindingRef, params); + }, + }; + + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 1, text: 'first send' }, + { seq: 2, text: 'must not send' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual(['first send']); + + const rebound = await baseStore.getBinding(ref); + expect(rebound?.sessionId).toBe('sess-new'); + expect(rebound?.lastForwardedSeq).toBe(0); + expect(warnings.some((row) => row.message.includes('Skipped cursor advance because binding changed'))).toBe(true); + }); + + it('warns when binding provider has no active adapter for outbound forwarding', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('discord'); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'orphaned-room', + threadId: null, + sessionId: 'sess-orphaned', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'ignored' }], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('No adapter registered for binding providerId=telegram'))).toBe(true); + }); + + it('warns once per binding when missing adapter warnings are tracked across ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('discord'); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'orphaned-room', + threadId: null, + sessionId: 'sess-orphaned', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'ignored' }], + }); + const warnedMissingAdapterBindings = new Set(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + const missingAdapterWarnings = warnings.filter((row) => + row.message.includes('No adapter registered for binding providerId=telegram'), + ); + expect(missingAdapterWarnings).toHaveLength(1); + }); + + it('prunes stale missing-adapter warning keys when bindings are removed', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('discord'); + + const bindingRef = { + providerId: 'telegram', + conversationId: 'orphaned-room', + threadId: null, + } as const; + + await store.upsertBinding({ + ...bindingRef, + ...DEFAULT_BINDING_POLICY, + sessionId: 'sess-orphaned', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'ignored' }], + }); + const warnedMissingAdapterBindings = new Set(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + await store.removeBinding(bindingRef); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + await store.upsertBinding({ + ...bindingRef, + ...DEFAULT_BINDING_POLICY, + sessionId: 'sess-orphaned-2', + lastForwardedSeq: 0, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + const missingAdapterWarnings = warnings.filter((row) => + row.message.includes('No adapter registered for binding providerId=telegram'), + ); + expect(missingAdapterWarnings).toHaveLength(2); + }); + + it('warns and exits tick when listing bindings fails', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + listBindings: async () => { + throw new Error('list bindings unavailable'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to list bindings for outbound forwarding'))).toBe(true); + }); + + it('acknowledges handled inbound messages when adapter exposes ack hook', async () => { + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'room-ack', + threadId: null, + sessionId: 'sess-ack', + lastForwardedSeq: 0, + }); + + const acknowledged: ChannelBridgeInboundMessage[][] = []; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [ + { + providerId: 'telegram', + conversationId: 'room-ack', + threadId: null, + senderId: 'user-1', + text: 'hello from telegram', + messageId: 'm-ack-1', + }, + ], + ackInboundMessages: async (messages) => { + acknowledged.push(messages.map((message) => ({ ...message }))); + }, + sendMessage: async () => {}, + }; + const depsHarness = createDepsHarness(); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps: depsHarness.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(depsHarness.sentToSession).toHaveLength(1); + expect(acknowledged).toHaveLength(1); + expect(acknowledged[0]?.map((message) => message.messageId)).toEqual(['m-ack-1']); + }); + + it('acknowledges slash-command and no-binding inbound messages in the same tick', async () => { + const store = createInMemoryChannelBindingStore(); + + const acknowledged: ChannelBridgeInboundMessage[][] = []; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [ + { + providerId: 'telegram', + conversationId: 'room-cmd-ack', + threadId: null, + senderId: 'user-1', + text: '/sessions', + messageId: 'm-ack-command', + }, + { + providerId: 'telegram', + conversationId: 'room-cmd-ack', + threadId: null, + senderId: 'user-1', + text: 'hello on unbound conversation', + messageId: 'm-ack-unbound', + }, + ], + ackInboundMessages: async (messages) => { + acknowledged.push(messages.map((message) => ({ ...message }))); + }, + sendMessage: async () => {}, + }; + + const depsHarness = createDepsHarness({ + sessions: [{ sessionId: 'sess-1', label: 'demo' }], + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps: depsHarness.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(acknowledged).toHaveLength(1); + expect(acknowledged[0]?.map((message) => message.messageId)).toEqual([ + 'm-ack-command', + 'm-ack-unbound', + ]); + }); +}); +describe('startChannelBridgeWorker', () => { + it('runs the first tick on startup', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness({ + sessions: [{ sessionId: 'sess-1', label: 'demo' }], + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-2001', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: '/sessions', + messageId: 'startup-sessions', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => harness.sent.length > 0); + expect(harness.sent.some((row) => row.text.includes('Recent sessions'))).toBe(true); + } finally { + await worker.stop(); + } + }); + + it('does not start overlapping adapter pulls across ticks when the previous pull times out', async () => { + vi.useFakeTimers(); + const store = createInMemoryChannelBindingStore(); + const { deps } = createDepsHarness(); + const deferred = createDeferredPromise(); + let pullCalls = 0; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => { + pullCalls += 1; + return deferred.promise; + }, + sendMessage: async () => {}, + }; + + const worker = startChannelBridgeWorker({ + store, + adapters: [adapter], + deps, + tickMs: 250, + }); + + try { + // Allow the startup tick to time out without resolving the underlying adapter pulls. + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + expect(pullCalls).toBe(1); + + worker.trigger(); + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + expect(pullCalls).toBe(1); + } finally { + deferred.resolve([]); + await worker.stop(); + vi.useRealTimers(); + } + }); + + it('deduplicates inbound messages across runtime ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + sessionId: 'sess-dedupe', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + text: 'duplicate payload', + messageId: 'dedupe-id-1', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); + expect(sentToSession).toHaveLength(1); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + text: 'duplicate payload', + messageId: 'dedupe-id-1', + }); + worker.trigger(); + + await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); + expect(sentToSession).toHaveLength(1); + } finally { + await worker.stop(); + } + }); + + it('stops idempotently and waits for in-flight tick before stopping adapters', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + let startedTick = false; + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'stop-room', + threadId: null, + sessionId: 'sess-stop', + lastForwardedSeq: 0, + }); + + const gate = createDeferredPromise(); + const { deps } = createDepsHarness({ + sendUserMessageToSession: async () => { + startedTick = true; + await gate.promise; + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'stop-room', + threadId: null, + text: 'block until released', + messageId: 'stop-message', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => startedTick); + + const stopFirst = worker.stop(); + const stopSecond = worker.stop(); + expect(harness.stopCalls()).toBe(0); + + gate.resolve(); + await stopFirst; + await stopSecond; + + expect(harness.stopCalls()).toBe(1); + } finally { + gate.resolve(); + await worker.stop(); + } + }); + + it('waits for startup-triggered tick when stop is called immediately', async () => { + const store = createInMemoryChannelBindingStore(); + const gate = createDeferredPromise(); + let startedTick = false; + let stopCallCount = 0; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => { + startedTick = true; + await gate.promise; + return []; + }, + sendMessage: async () => {}, + stop: async () => { + stopCallCount += 1; + }, + }; + + const { deps } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters: [adapter], + deps, + tickMs: 60_000, + }); + + try { + const stopPromise = worker.stop(); + + await Promise.resolve(); + await waitFor(() => startedTick); + expect(stopCallCount).toBe(0); + + gate.resolve(); + await stopPromise; + + expect(stopCallCount).toBe(1); + } finally { + gate.resolve(); + await worker.stop(); + } + }); + + it('continues stopping remaining adapters if one stop fails', async () => { + const store = createInMemoryChannelBindingStore(); + let secondaryStopped = false; + const adapters: ChannelBridgeAdapter[] = [ + { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + throw new Error('primary stop failed'); + }, + }, + { + providerId: 'discord', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + secondaryStopped = true; + }, + }, + ]; + + const { deps, warnings } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters, + deps, + tickMs: 60_000, + }); + + try { + await worker.stop(); + + expect(secondaryStopped).toBe(true); + expect(warnings.some((row) => row.message.includes('Failed to stop channel adapter telegram'))).toBe(true); + } finally { + await worker.stop(); + } + }); + + it('stops distinct adapter instances even when provider ids are duplicated', async () => { + const store = createInMemoryChannelBindingStore(); + const firstStop = { calls: 0 }; + const duplicateStop = { calls: 0 }; + + const adapters: ChannelBridgeAdapter[] = [ + { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + firstStop.calls += 1; + }, + }, + { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + duplicateStop.calls += 1; + }, + }, + ]; + + const { deps } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters, + deps, + tickMs: 60_000, + }); + + try { + await worker.stop(); + expect(firstStop.calls).toBe(1); + expect(duplicateStop.calls).toBe(1); + } finally { + await worker.stop(); + } + }); + + it('stops shared adapter references once during shutdown', async () => { + const store = createInMemoryChannelBindingStore(); + const stopCounter = { calls: 0 }; + + const sharedAdapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + stopCounter.calls += 1; + }, + }; + + const { deps, warnings } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters: [sharedAdapter, sharedAdapter], + deps, + tickMs: 60_000, + }); + + try { + await worker.stop(); + expect(stopCounter.calls).toBe(1); + expect(warnings.some((row) => row.message.includes('Duplicate adapter providerId detected: telegram'))).toBe(true); + } finally { + await worker.stop(); + } + }); +}); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts new file mode 100644 index 000000000..f939c7d14 --- /dev/null +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -0,0 +1,1582 @@ +/** + * Provider-agnostic channel bridge worker. + * + * Responsibilities: + * - Pull inbound channel messages from adapters + * - Handle slash commands (`/sessions`, `/attach`, `/detach`, `/session`, `/help`, `/start`) + * - Forward non-command inbound messages into attached Happier sessions + * - Forward agent output back into the mapped channel conversation + * + * Cursor semantics: + * - `lastForwardedSeq` tracks the highest transcript sequence that has been delivered + * to the channel for a given binding. + * - `fetchAgentMessagesAfterSeq` is treated as an exclusive cursor (`seq > afterSeq`). + * - `updateLastForwardedSeq` must persist the maximum forwarded sequence. + */ +import { startSingleFlightIntervalLoop, type SingleFlightIntervalLoopHandle } from '@/daemon/lifecycle/singleFlightIntervalLoop'; + +/** + * Logical channel conversation reference. + * + * For thread-capable providers, `threadId` identifies the topic/thread; for non-threaded + * conversations it is `null`. + */ +export type ChannelBridgeConversationRef = Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; +}>; + +export type ChannelBridgeConversationKind = 'dm' | 'group' | 'channel' | 'unknown'; + +/** + * Inbound message event produced by an adapter. + * + * `messageId` is used for best-effort duplicate suppression in the worker runtime. + */ +export type ChannelBridgeInboundMessage = ChannelBridgeConversationRef & Readonly<{ + senderId?: string | null; + conversationKind?: ChannelBridgeConversationKind | null; + text: string; + messageId: string; +}>; + +export type ChannelBridgeActorContext = Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + senderId: string | null; + conversationKind: ChannelBridgeConversationKind; +}>; + +/** + * Adapter contract for a specific provider (Telegram, Discord, etc.). + * + * Expectations: + * - `pullInboundMessages` should return available inbound items without throwing for + * normal empty states (return `[]` instead). + * - `ackInboundMessages` is optional and, when implemented, will be called after + * the worker has fully handled a batch item (including command replies / forward attempts). + * Adapters can use this to implement deferred acknowledgment semantics. + * - `sendMessage` should deliver text into a target conversation/thread. + * - `sendMessage` should tolerate at-least-once delivery attempts. Timeout races may + * trigger retries, so provider adapters should be idempotent when possible. + * - `stop` is optional and should tear down adapter resources. + */ +export type ChannelBridgeAdapter = Readonly<{ + providerId: string; + pullInboundMessages: () => Promise; + ackInboundMessages?: (messages: readonly ChannelBridgeInboundMessage[]) => void | Promise; + sendMessage: (params: Readonly<{ conversationId: string; threadId: string | null; text: string }>) => Promise; + stop?: () => void | Promise; +}>; + +/** + * Persisted conversation -> session mapping and agent cursor state. + */ +export type ChannelBridgeInboundMode = 'ownerOnly' | 'anyone'; + +export type ChannelSessionBinding = ChannelBridgeConversationRef & Readonly<{ + sessionId: string; + lastForwardedSeq: number; + ownerSenderId: string | null; + inboundMode: ChannelBridgeInboundMode; + allowMissingSenderId: boolean; + createdAtMs: number; + updatedAtMs: number; +}>; + +/** + * Resolution result for `/attach `. + */ +export type ResolveSessionIdResult = + | Readonly<{ ok: true; sessionId: string }> + | Readonly<{ ok: false; code: 'session_not_found' | 'session_id_ambiguous' | 'unsupported'; candidates?: string[] }>; + +export type ChannelBridgeAgentMessageRow = Readonly<{ + seq: number; + text: string; +}>; + +export type ChannelBridgeAgentFetchResult = Readonly<{ + messages: readonly ChannelBridgeAgentMessageRow[]; + highestSeenSeq?: number | null; +}>; + +/** + * Bridge dependencies supplied by runtime integration. + * + * - `resolveLatestSessionSeq` should return the latest valid non-negative transcript cursor. + * - `fetchAgentMessagesAfterSeq` should return rows with `seq > afterSeq`. + * Results may be unsorted; the worker enforces ascending `seq` delivery before forwarding. + * - `fetchAgentMessagesAfterSeq` may optionally return `highestSeenSeq` when no agent rows are + * present so the worker can advance cursor windows across non-agent transcript pages. + * - `onWarning` receives non-fatal operational issues; worker continues best-effort. + */ +export type ChannelBridgeDeps = Readonly<{ + listSessions: () => Promise>>; + resolveSessionIdOrPrefix: (idOrPrefix: string) => Promise; + sendUserMessageToSession: (params: Readonly<{ + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + messageId?: string; + }>) => Promise; + resolveLatestSessionSeq: (sessionId: string) => Promise; + fetchAgentMessagesAfterSeq: (params: Readonly<{ sessionId: string; afterSeq: number }>) => Promise< + readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult + >; + authorizeCommand?: (params: Readonly<{ commandName: string; actor: ChannelBridgeActorContext }>) => Promise>; + onWarning?: (message: string, error?: unknown) => void; +}>; + +/** + * Binding persistence contract used by the bridge worker. + * + * `updateLastForwardedSeq` is monotonic: implementations should keep the highest cursor. + */ +export type ChannelBindingStore = Readonly<{ + listBindings: () => Promise; + getBinding: (ref: ChannelBridgeConversationRef) => Promise; + upsertBinding: (binding: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + sessionId: string; + lastForwardedSeq: number; + ownerSenderId: string | null; + inboundMode: ChannelBridgeInboundMode; + allowMissingSenderId: boolean; + }>) => Promise; + updateLastForwardedSeq: ( + ref: ChannelBridgeConversationRef, + params: Readonly<{ expectedSessionId: string; seq: number }>, + ) => Promise; + removeBinding: (ref: ChannelBridgeConversationRef) => Promise; +}>; + +export type ChannelBridgeWorkerHandle = Readonly<{ + /** Stops the worker. Idempotent; safe to call multiple times. */ + stop: () => Promise; + /** Requests an immediate tick; no-op once `stop()` has been called. */ + trigger: () => void; +}>; + +/** + * Key encoding for in-memory binding map. + * + * Uses JSON array encoding to avoid delimiter collision risks. + */ +function bindingKey(ref: ChannelBridgeConversationRef): string { + return JSON.stringify([ref.providerId, ref.conversationId, ref.threadId]); +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const parsed = Math.trunc(value); + if (parsed < 0) return null; + return parsed; +} + +function normalizeSenderId(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeConversationKind(raw: unknown): ChannelBridgeConversationKind { + switch (raw) { + case 'dm': + case 'group': + case 'channel': + return raw; + default: + return 'unknown'; + } +} + +export class ChannelBridgePermanentDeliveryError extends Error { + readonly code: 'forbidden' | 'conversation_not_found' | 'unknown'; + + constructor(params: Readonly<{ code: ChannelBridgePermanentDeliveryError['code']; message: string }>) { + super(params.message); + this.name = 'ChannelBridgePermanentDeliveryError'; + this.code = params.code; + } +} + +function isChannelBridgePermanentDeliveryFailure(error: unknown): error is ChannelBridgePermanentDeliveryError { + return error instanceof ChannelBridgePermanentDeliveryError; +} + +const DEFAULT_EXTERNAL_IO_TIMEOUT_MS = 30_000; + +function resolveExternalIoTimeoutMs(): number { + const raw = (process.env.HAPPIER_CHANNEL_BRIDGE_IO_TIMEOUT_MS ?? '').trim(); + if (!raw) { + return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; + } + + if (!/^\d+$/.test(raw)) { + return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; + } + + return parsed; +} + +const EXTERNAL_IO_TIMEOUT_MS = resolveExternalIoTimeoutMs(); + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + // Promise.race does not cancel the underlying operation. Attach a no-op rejection + // handler so late failures do not surface as unhandled rejections after timeout. + void promise.catch(() => undefined); + + let timeoutHandle: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} + +type ChannelBridgeInboundDeduper = Readonly<{ + hasSeen: (message: ChannelBridgeInboundMessage) => boolean; + markSeen: (message: ChannelBridgeInboundMessage) => void; +}>; + +type ChannelBridgeInboundForwardFailureTracker = Readonly<{ + recordFailure: (message: ChannelBridgeInboundMessage) => Readonly<{ giveUp: boolean }>; + clear: (message: ChannelBridgeInboundMessage) => void; +}>; + +export function createChannelBridgeInboundForwardFailureTracker(params: Readonly<{ + now?: () => number; + maxAttempts?: number; + maxAgeMs?: number; +}> = {}): ChannelBridgeInboundForwardFailureTracker { + const now = params.now ?? (() => Date.now()); + const maxAttempts = + typeof params.maxAttempts === 'number' && Number.isFinite(params.maxAttempts) + ? Math.max(1, Math.trunc(params.maxAttempts)) + : 10; + const maxAgeMs = + typeof params.maxAgeMs === 'number' && Number.isFinite(params.maxAgeMs) + ? Math.max(1_000, Math.trunc(params.maxAgeMs)) + : 5 * 60_000; + + type FailureEntry = Readonly<{ + attempts: number; + firstFailedAtMs: number; + lastFailedAtMs: number; + }>; + + const failures = new Map(); + const maxEntries = 50_000; + + const keyFor = (message: ChannelBridgeInboundMessage): string | null => { + const normalizedMessageId = String(message.messageId ?? '').trim(); + if (!normalizedMessageId) return null; + return JSON.stringify([message.providerId, message.conversationId, message.threadId, normalizedMessageId]); + }; + + const pruneIfNeeded = () => { + if (failures.size <= maxEntries) return; + while (failures.size > maxEntries) { + const [oldest] = failures.keys(); + if (oldest === undefined) break; + failures.delete(oldest); + } + }; + + return { + recordFailure: (message) => { + const key = keyFor(message); + if (!key) { + return { giveUp: true }; + } + pruneIfNeeded(); + const currentNow = now(); + const previous = failures.get(key); + const next: FailureEntry = previous + ? { + attempts: previous.attempts + 1, + firstFailedAtMs: previous.firstFailedAtMs, + lastFailedAtMs: currentNow, + } + : { + attempts: 1, + firstFailedAtMs: currentNow, + lastFailedAtMs: currentNow, + }; + failures.set(key, next); + const tooManyAttempts = next.attempts >= maxAttempts; + const tooOld = currentNow - next.firstFailedAtMs >= maxAgeMs; + return { giveUp: tooManyAttempts || tooOld }; + }, + clear: (message) => { + const key = keyFor(message); + if (!key) return; + failures.delete(key); + }, + }; +} + +/** + * Create an inbound deduper for channel messages. + * + * Use this to isolate dedupe state across independent bridge instances sharing the same process. + */ +export function createChannelBridgeInboundDeduper(now: () => number = () => Date.now()): ChannelBridgeInboundDeduper { + const recent = new Map(); + const ttlMs = 24 * 60 * 60 * 1000; + const maxEntries = 100_000; + // Avoid full TTL scans on every message. If the map exceeds maxEntries, + // prune still runs immediately even within the minimum interval. + const minPruneIntervalMs = 1_000; + let lastPrunedAtMs = 0; + + const prune = (currentNow: number) => { + if (recent.size <= maxEntries && currentNow - lastPrunedAtMs < minPruneIntervalMs) { + return; + } + lastPrunedAtMs = currentNow; + + for (const [key, seenAtMs] of recent) { + if (currentNow - seenAtMs > ttlMs) { + recent.delete(key); + } + } + while (recent.size > maxEntries) { + const [oldest] = recent.keys(); + if (oldest === undefined) break; + recent.delete(oldest); + } + }; + + const dedupeKey = (message: ChannelBridgeInboundMessage): string | null => { + const normalizedMessageId = String(message.messageId).trim(); + if (normalizedMessageId.length === 0) { + return null; + } + return JSON.stringify([message.providerId, message.conversationId, message.threadId, normalizedMessageId]); + }; + + return { + hasSeen: (message) => { + const key = dedupeKey(message); + if (!key) { + return false; + } + const currentNow = now(); + prune(currentNow); + return recent.has(key); + }, + markSeen: (message) => { + const key = dedupeKey(message); + if (!key) { + return; + } + const currentNow = now(); + prune(currentNow); + recent.set(key, currentNow); + }, + }; +} + +function normalizeAgentFetchResult( + fetched: readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult, +): Readonly<{ messages: readonly ChannelBridgeAgentMessageRow[]; highestSeenSeq: number | null }> { + const isMessageRowArray = ( + value: readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult, + ): value is readonly ChannelBridgeAgentMessageRow[] => Array.isArray(value); + + if (isMessageRowArray(fetched)) { + return { + messages: fetched, + highestSeenSeq: null, + }; + } + + return { + messages: fetched.messages, + highestSeenSeq: toNonNegativeInt(fetched.highestSeenSeq ?? null), + }; +} + +/** + * Create an in-memory binding store. + * + * `now` is injectable for deterministic tests. + */ +export function createInMemoryChannelBindingStore(now: () => number = () => Date.now()): ChannelBindingStore { + const byKey = new Map(); + const normalizeRef = (ref: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + }>): ChannelBridgeConversationRef => { + const providerId = ref.providerId.trim(); + const conversationId = ref.conversationId.trim(); + const threadIdRaw = typeof ref.threadId === 'string' ? ref.threadId.trim() : ''; + return { + providerId, + conversationId, + threadId: threadIdRaw.length > 0 ? threadIdRaw : null, + }; + }; + + return { + listBindings: async () => Array.from(byKey.values()).map((binding) => ({ ...binding })), + getBinding: async (ref) => { + const found = byKey.get(bindingKey(normalizeRef(ref))); + return found ? { ...found } : null; + }, + upsertBinding: async (binding) => { + const normalizedRef = normalizeRef(binding); + const key = bindingKey(normalizedRef); + const existing = byKey.get(key); + const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq) ?? 0; + const ownerSenderId = normalizeSenderId(binding.ownerSenderId); + const inboundMode: ChannelBridgeInboundMode = binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const allowMissingSenderId = binding.allowMissingSenderId === true; + const next: ChannelSessionBinding = { + ...normalizedRef, + sessionId: binding.sessionId.trim(), + lastForwardedSeq: normalizedLastForwardedSeq, + ownerSenderId, + inboundMode, + allowMissingSenderId, + createdAtMs: existing?.createdAtMs ?? now(), + updatedAtMs: now(), + }; + byKey.set(key, { ...next }); + return { ...next }; + }, + updateLastForwardedSeq: async (ref, params) => { + const key = bindingKey(normalizeRef(ref)); + const existing = byKey.get(key); + if (!existing) return false; + if (existing.sessionId !== params.expectedSessionId.trim()) return false; + const parsedSeq = toNonNegativeInt(params.seq); + if (parsedSeq === null) return false; + const nextSeq = Math.max(existing.lastForwardedSeq, parsedSeq); + if (nextSeq === existing.lastForwardedSeq) { + return false; + } + byKey.set(key, { + ...existing, + lastForwardedSeq: nextSeq, + updatedAtMs: now(), + }); + return true; + }, + removeBinding: async (ref) => byKey.delete(bindingKey(normalizeRef(ref))), + }; +} + +function parseSlashCommand(text: string): Readonly<{ name: string; args: string[] }> | null { + const trimmed = text.trim(); + if (!trimmed.startsWith('/')) return null; + const [rawName, ...args] = trimmed.slice(1).split(/\s+/g); + const normalized = String(rawName).trim().toLowerCase(); + if (!normalized) return null; + const name = normalized.split('@')[0]!.trim(); + if (!name) return null; + return { name, args }; +} + +function parseAttachFlags(args: readonly string[]): Readonly<{ + allowAnyone: boolean; + allowMissingSenderId: boolean; + unknownFlags: string[]; +}> { + const allowAnyone = args.some((arg) => arg.trim().toLowerCase() === '--anyone'); + const allowMissingSenderId = args.some((arg) => arg.trim().toLowerCase() === '--allow-missing-sender-id'); + const unknownFlags = args + .map((arg) => arg.trim()) + .filter((arg) => arg.startsWith('--')) + .filter((arg) => arg.toLowerCase() !== '--anyone' && arg.toLowerCase() !== '--allow-missing-sender-id'); + return { + allowAnyone, + allowMissingSenderId, + unknownFlags, + }; +} + +function authorizeBindingControl(params: Readonly<{ + binding: ChannelSessionBinding | null; + senderId: string | null; +}>): Readonly<{ allowed: boolean; message: string | null }> { + const { binding, senderId } = params; + + if (!binding) { + if (!senderId) { + return { + allowed: false, + message: 'This command requires a stable sender identity. Try using it in a DM.', + }; + } + return { allowed: true, message: null }; + } + + if (!senderId) { + if (binding.allowMissingSenderId) { + return { allowed: true, message: null }; + } + return { + allowed: false, + message: 'This binding does not allow commands without a sender identity.', + }; + } + + if (!binding.ownerSenderId) { + return { allowed: true, message: null }; + } + + if (binding.ownerSenderId !== senderId) { + return { + allowed: false, + message: 'You are not authorized to control this binding.', + }; + } + + return { allowed: true, message: null }; +} + +function authorizeInboundForwarding(params: Readonly<{ + binding: ChannelSessionBinding; + senderId: string | null; +}>): Readonly<{ allowed: boolean; message: string | null }> { + const { binding, senderId } = params; + + if (!senderId) { + if (binding.allowMissingSenderId) { + return { allowed: true, message: null }; + } + return { + allowed: false, + message: 'Sender identity is missing; forwarding is disabled for safety.', + }; + } + + if (binding.inboundMode === 'anyone') { + return { allowed: true, message: null }; + } + + if (!binding.ownerSenderId) { + return { + allowed: false, + message: 'This binding has no owner identity; reattach to establish one.', + }; + } + + if (binding.ownerSenderId !== senderId) { + return { + allowed: false, + message: 'You are not authorized to send messages to this session from here.', + }; + } + + return { allowed: true, message: null }; +} + +function formatSessionsMessage(rows: Array>): string { + if (rows.length === 0) { + return 'No sessions found.'; + } + const limit = 20; + const truncated = rows.length > limit; + const body = rows + .slice(0, limit) + .map((row) => `• ${row.sessionId}${row.label ? ` (${row.label})` : ''}`) + .join('\n'); + const suffix = truncated ? `\n…and ${rows.length - limit} more.` : ''; + return `Recent sessions:\n${body}${suffix}`; +} + +async function replyToConversation( + adapter: ChannelBridgeAdapter, + conversation: Readonly<{ conversationId: string; threadId: string | null }>, + text: string, +): Promise { + await withTimeout( + adapter.sendMessage({ + conversationId: conversation.conversationId, + threadId: conversation.threadId, + text, + }), + EXTERNAL_IO_TIMEOUT_MS, + `replyToConversation(${adapter.providerId}:${conversation.conversationId})`, + ); +} + +async function authorizeCommand(params: Readonly<{ + commandName: string; + event: ChannelBridgeInboundMessage; + deps: ChannelBridgeDeps; +}>): Promise> { + const senderId = normalizeSenderId(params.event.senderId); + const actor: ChannelBridgeActorContext = { + providerId: params.event.providerId, + conversationId: params.event.conversationId, + threadId: params.event.threadId, + senderId, + conversationKind: normalizeConversationKind(params.event.conversationKind), + }; + + if (params.commandName === 'sessions' && actor.conversationKind !== 'dm') { + return { + allowed: false, + message: 'For safety, /sessions is only available in direct messages.', + }; + } + + const authorize = params.deps.authorizeCommand; + if (!authorize) { + return { allowed: true, message: null }; + } + + try { + const result = await withTimeout( + authorize({ + commandName: params.commandName, + actor, + }), + EXTERNAL_IO_TIMEOUT_MS, + `authorizeCommand(/${params.commandName})`, + ); + if (typeof result === 'boolean') { + return { + allowed: result, + message: null, + }; + } + + const allowed = Boolean(result.allowed); + const message = typeof result.message === 'string' && result.message.trim().length > 0 ? result.message.trim() : null; + return { allowed, message }; + } catch (error) { + params.deps.onWarning?.(`Authorization check failed for command /${params.commandName}`, error); + return { + allowed: false, + message: 'Unable to authorize this command right now.', + }; + } +} + +async function handleCommand(params: Readonly<{ + command: Readonly<{ name: string; args: string[] }>; + event: ChannelBridgeInboundMessage; + adapter: ChannelBridgeAdapter; + store: ChannelBindingStore; + deps: ChannelBridgeDeps; +}>): Promise { + const { command, event, adapter, store, deps } = params; + const ref: ChannelBridgeConversationRef = { + providerId: event.providerId, + conversationId: event.conversationId, + threadId: event.threadId, + }; + + const replyForCommand = async (text: string): Promise => { + try { + await replyToConversation(adapter, ref, text); + } catch (error) { + deps.onWarning?.( + `Failed to send command reply for /${command.name} (provider=${ref.providerId} conversation=${ref.conversationId} thread=${ref.threadId ?? 'null'})`, + error, + ); + } + }; + + if (command.name !== 'help' && command.name !== 'start') { + const authz = await authorizeCommand({ + commandName: command.name, + event, + deps, + }); + if (!authz.allowed) { + await replyForCommand(authz.message ?? 'You are not authorized to run this command here.'); + return; + } + } + + if (command.name === 'help' || command.name === 'start') { + await replyForCommand( + [ + 'Happier bridge commands:', + '/sessions - list recent sessions', + '/attach [--anyone] [--allow-missing-sender-id] - attach this conversation', + '/detach - unbind this DM/topic', + '/session - show current binding', + '/help - show command help', + '/start - alias for /help', + ].join('\n'), + ); + return; + } + + if (command.name === 'sessions') { + let sessions: Array>; + try { + sessions = await withTimeout( + deps.listSessions(), + EXTERNAL_IO_TIMEOUT_MS, + 'listSessions()', + ); + } catch (error) { + deps.onWarning?.('Failed to list sessions for /sessions command', error); + await replyForCommand('Failed to retrieve sessions. Please try again later.'); + return; + } + await replyForCommand(formatSessionsMessage(sessions)); + return; + } + + if (command.name === 'session') { + let existing: ChannelSessionBinding | null; + try { + existing = await withTimeout( + store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + deps.onWarning?.('Failed to read binding for /session command', error); + await replyForCommand('Failed to read current session binding. Please try again later.'); + return; + } + + if (!existing) { + await replyForCommand('No session is attached here. Use /attach .'); + return; + } + + const controlAuthz = authorizeBindingControl({ + binding: existing, + senderId: normalizeSenderId(event.senderId), + }); + if (!controlAuthz.allowed) { + await replyForCommand(controlAuthz.message ?? 'You are not authorized to view this binding.'); + return; + } + await replyForCommand(`Attached session: ${existing.sessionId}`); + return; + } + + if (command.name === 'attach') { + const rawArgs = command.args.map((arg) => String(arg)); + const idOrPrefix = String(rawArgs[0] ?? '').trim(); + if (!idOrPrefix) { + await replyForCommand('Usage: /attach [--anyone] [--allow-missing-sender-id]'); + return; + } + + const flags = parseAttachFlags(rawArgs.slice(1)); + if (flags.unknownFlags.length > 0) { + await replyForCommand( + `Unknown flags: ${flags.unknownFlags.join(', ')}\nUsage: /attach [--anyone] [--allow-missing-sender-id]`, + ); + return; + } + + const senderId = normalizeSenderId(event.senderId); + if (!senderId && !flags.allowMissingSenderId) { + await replyForCommand( + 'Cannot attach: sender identity is missing. Try attaching from a DM, or pass --allow-missing-sender-id (unsafe).', + ); + return; + } + + let resolved: ResolveSessionIdResult; + try { + resolved = await withTimeout( + deps.resolveSessionIdOrPrefix(idOrPrefix), + EXTERNAL_IO_TIMEOUT_MS, + `resolveSessionIdOrPrefix(${idOrPrefix})`, + ); + } catch (error) { + deps.onWarning?.('Failed to resolve session by id/prefix for attach', error); + await replyForCommand( + `Failed to attach to session ${idOrPrefix}: unable to resolve session identifier.`, + ); + return; + } + + if (!resolved.ok) { + if (resolved.code === 'session_id_ambiguous') { + if (resolved.candidates && resolved.candidates.length > 0) { + await replyForCommand( + `Ambiguous session prefix. Candidates:\n${resolved.candidates.map((id) => `• ${id}`).join('\n')}`, + ); + return; + } + + await replyForCommand('Ambiguous session prefix. Use /sessions to list active sessions.'); + return; + } + + if (resolved.code === 'unsupported') { + await replyForCommand('Attaching by session ID or prefix is not supported in this environment.'); + return; + } + await replyForCommand('Session not found. Use /sessions to list recent sessions.'); + return; + } + + let latestSeq: number; + try { + const resolvedSeq = toNonNegativeInt(await withTimeout( + deps.resolveLatestSessionSeq(resolved.sessionId), + EXTERNAL_IO_TIMEOUT_MS, + `resolveLatestSessionSeq(${resolved.sessionId})`, + )); + if (resolvedSeq === null) { + deps.onWarning?.( + `resolveLatestSessionSeq returned an invalid value for session ${resolved.sessionId}; expected a non-negative integer`, + ); + await replyForCommand( + `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, + ); + return; + } + latestSeq = resolvedSeq; + } catch (error) { + deps.onWarning?.('Failed to resolve latest session sequence for attach', error); + await replyForCommand( + `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, + ); + return; + } + + let previousBinding: ChannelSessionBinding | null; + try { + previousBinding = await withTimeout( + store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + deps.onWarning?.('Failed to read existing binding during /attach', error); + await replyForCommand('Failed to read current binding before attach. Please try again later.'); + return; + } + const previousSessionId = previousBinding?.sessionId ?? null; + + if (previousBinding) { + const controlAuthz = authorizeBindingControl({ + binding: previousBinding, + senderId, + }); + if (!controlAuthz.allowed) { + await replyForCommand(controlAuthz.message ?? 'You are not authorized to attach here.'); + return; + } + } + + const ownerSenderId = senderId; + const allowMissingSenderId = flags.allowMissingSenderId === true; + const requestedInboundMode: ChannelBridgeInboundMode = flags.allowAnyone ? 'anyone' : 'ownerOnly'; + const inboundMode: ChannelBridgeInboundMode = + ownerSenderId + ? requestedInboundMode + : allowMissingSenderId + ? 'anyone' + : 'ownerOnly'; + + try { + await withTimeout( + store.upsertBinding({ + providerId: ref.providerId, + conversationId: ref.conversationId, + threadId: ref.threadId, + sessionId: resolved.sessionId, + lastForwardedSeq: latestSeq, + ownerSenderId, + inboundMode, + allowMissingSenderId, + }), + EXTERNAL_IO_TIMEOUT_MS, + `store.upsertBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + deps.onWarning?.('Failed to persist binding during /attach', error); + await replyForCommand(`Failed to attach to session ${resolved.sessionId}: unable to persist binding.`); + return; + } + + const switchedFrom = + previousSessionId && previousSessionId !== resolved.sessionId + ? ` (replaced previous session ${previousSessionId})` + : ''; + await replyForCommand(`Attached this conversation to session ${resolved.sessionId}${switchedFrom}.`); + return; + } + + if (command.name === 'detach') { + let existing: ChannelSessionBinding | null; + try { + existing = await withTimeout( + store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + deps.onWarning?.('Failed to read binding before /detach command', error); + await replyForCommand('Failed to read current binding. Please try again later.'); + return; + } + + const senderId = normalizeSenderId(event.senderId); + const controlAuthz = authorizeBindingControl({ + binding: existing, + senderId, + }); + if (!controlAuthz.allowed) { + await replyForCommand(controlAuthz.message ?? 'You are not authorized to detach here.'); + return; + } + + let removed = false; + try { + removed = await withTimeout( + store.removeBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.removeBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + deps.onWarning?.('Failed to remove binding for /detach command', error); + await replyForCommand('Failed to detach current session binding. Please try again later.'); + return; + } + + if (removed) { + await replyForCommand('Detached this conversation from Happier session.'); + } else { + await replyForCommand('No session was attached here.'); + } + return; + } + + await replyForCommand(`Unknown command: /${command.name}. Use /help for supported commands.`); + return; +} + +/** + * Execute one bridge tick. + * + * Flow: + * 1) Pull inbound messages per adapter + * 2) Handle commands or forward user text to attached session + * 3) Fetch agent output after each binding cursor and send to channel + * 4) Advance cursors monotonically + * + * Deduper behavior: + * - `inboundDeduper` is required to make dedupe-state ownership explicit. + * - Use `createChannelBridgeInboundDeduper()` to construct per-worker dedupe state. + * + * Missing-adapter warning deduplication: + * - `warnedMissingAdapterBindings` is optional. When omitted, missing-adapter + * warnings are emitted per binding on each call. + * - Pass a stable `Set` across calls to dedupe warning spam in + * long-running loops (as `startChannelBridgeWorker` does). + * + * Adapter pull failure warning deduplication: + * - `warnedAdapterPullFailures` is optional. When provided, pull failures for + * a given adapter providerId are warned only on the transition to failing. + * - Once an adapter successfully pulls again, future failures will warn again. + */ +export async function executeChannelBridgeTick(params: Readonly<{ + store: ChannelBindingStore; + adapters: readonly ChannelBridgeAdapter[]; + deps: ChannelBridgeDeps; + inboundDeduper: ChannelBridgeInboundDeduper; + inboundForwardFailureTracker?: ChannelBridgeInboundForwardFailureTracker; + warnedMissingAdapterBindings?: Set; + warnedAdapterPullFailures?: Set; +}>): Promise { + const activeAdapters: ChannelBridgeAdapter[] = []; + const adapterByProvider = new Map(); + for (const adapter of params.adapters) { + if (adapterByProvider.has(adapter.providerId)) { + params.deps.onWarning?.(`Duplicate adapter providerId detected: ${adapter.providerId}; ignoring later adapter instance.`); + continue; + } + adapterByProvider.set(adapter.providerId, adapter); + activeAdapters.push(adapter); + } + + const deduper = params.inboundDeduper; + const inboundForwardFailureTracker = params.inboundForwardFailureTracker; + + for (const adapter of activeAdapters) { + let inbound: ChannelBridgeInboundMessage[]; + try { + inbound = await withTimeout( + adapter.pullInboundMessages(), + EXTERNAL_IO_TIMEOUT_MS, + `pullInboundMessages(${adapter.providerId})`, + ); + params.warnedAdapterPullFailures?.delete(adapter.providerId); + } catch (error) { + const warnedPullFailures = params.warnedAdapterPullFailures; + if (!warnedPullFailures || !warnedPullFailures.has(adapter.providerId)) { + params.deps.onWarning?.(`Failed to pull inbound messages for adapter ${adapter.providerId}`, error); + warnedPullFailures?.add(adapter.providerId); + } + continue; + } + + const ackableInbound: ChannelBridgeInboundMessage[] = []; + + for (const rawEvent of inbound) { + const event: ChannelBridgeInboundMessage = + rawEvent.providerId === adapter.providerId + ? rawEvent + : { + ...rawEvent, + providerId: adapter.providerId, + }; + + if (rawEvent.providerId !== adapter.providerId) { + params.deps.onWarning?.( + `Inbound provider mismatch; using adapter providerId=${adapter.providerId} instead of event providerId=${rawEvent.providerId}`, + ); + } + + if (deduper.hasSeen(event)) { + ackableInbound.push(event); + continue; + } + + let processedSuccessfully = false; + + try { + const command = parseSlashCommand(event.text); + if (command) { + await handleCommand({ + command, + event, + adapter, + store: params.store, + deps: params.deps, + }); + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; + continue; + } + + if (event.text.trim().startsWith('/')) { + try { + await replyToConversation(adapter, { + conversationId: event.conversationId, + threadId: event.threadId, + }, 'Unknown command. Use /help for supported commands.'); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send unknown-command reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; + continue; + } + + const ref: ChannelBridgeConversationRef = { + providerId: adapter.providerId, + conversationId: event.conversationId, + threadId: event.threadId, + }; + let binding: ChannelSessionBinding | null; + try { + binding = await withTimeout( + params.store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + params.deps.onWarning?.( + `Failed to read binding for inbound message forwarding (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'})`, + error, + ); + try { + await replyToConversation( + adapter, + ref, + 'Failed to read current session binding. Please try again later.', + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send binding-read-failure reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; + continue; + } + + if (!binding) { + try { + await replyToConversation( + adapter, + ref, + 'No session is attached here. Use /attach first.', + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send no-binding reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; + continue; + } + + const senderId = normalizeSenderId(event.senderId); + const forwardAuthz = authorizeInboundForwarding({ + binding, + senderId, + }); + if (!forwardAuthz.allowed) { + try { + await replyToConversation( + adapter, + ref, + forwardAuthz.message ?? 'You are not authorized to send messages here.', + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send forwarding-unauthorized reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; + continue; + } + + try { + await withTimeout( + params.deps.sendUserMessageToSession({ + sessionId: binding.sessionId, + text: event.text, + sentFrom: adapter.providerId, + providerId: adapter.providerId, + conversationId: event.conversationId, + threadId: event.threadId, + messageId: event.messageId, + }), + EXTERNAL_IO_TIMEOUT_MS, + `sendUserMessageToSession(${binding.sessionId})`, + ); + inboundForwardFailureTracker?.clear(event); + processedSuccessfully = true; + } catch (error) { + const giveUp = inboundForwardFailureTracker?.recordFailure(event).giveUp ?? false; + const failureWarnKey: ChannelBridgeInboundMessage = { + ...event, + messageId: `forward-failure-warn:${String(event.messageId ?? '').trim()}`, + }; + + if (!deduper.hasSeen(failureWarnKey)) { + params.deps.onWarning?.( + `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId}); message will be retried until it is acknowledged by the adapter`, + error, + ); + deduper.markSeen(failureWarnKey); + } + + const failureReplyKey: ChannelBridgeInboundMessage = { + ...event, + messageId: `forward-failure:${String(event.messageId ?? '').trim()}:${giveUp ? 'give-up' : 'retrying'}`, + }; + + if (!deduper.hasSeen(failureReplyKey)) { + try { + await replyToConversation( + adapter, + ref, + giveUp + ? `Failed to send message to session ${binding.sessionId}. Giving up after repeated failures; please try again.` + : `Failed to send message to session ${binding.sessionId}. Retrying…`, + ); + deduper.markSeen(failureReplyKey); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send session-forward-failure reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + } + + if (giveUp) { + processedSuccessfully = true; + } else { + continue; + } + } + } catch (error) { + params.deps.onWarning?.(`Failed to process inbound message for adapter ${adapter.providerId}`, error); + } + + if (processedSuccessfully) { + deduper.markSeen(event); + ackableInbound.push(event); + } + } + + if (adapter.ackInboundMessages && ackableInbound.length > 0) { + try { + await withTimeout( + Promise.resolve(adapter.ackInboundMessages(ackableInbound)), + EXTERNAL_IO_TIMEOUT_MS, + `ackInboundMessages(${adapter.providerId})`, + ); + } catch (error) { + params.deps.onWarning?.(`Failed to acknowledge inbound messages for adapter ${adapter.providerId}`, error); + } + } + } + + let bindings: ChannelSessionBinding[]; + try { + bindings = await withTimeout( + params.store.listBindings(), + EXTERNAL_IO_TIMEOUT_MS, + 'store.listBindings()', + ); + } catch (error) { + params.deps.onWarning?.('Failed to list bindings for outbound forwarding', error); + return; + } + + const warnedMissingAdapterBindings = params.warnedMissingAdapterBindings; + if (warnedMissingAdapterBindings) { + const activeBindingKeys = new Set(bindings.map((binding) => bindingKey(binding))); + for (const warnedKey of warnedMissingAdapterBindings) { + if (!activeBindingKeys.has(warnedKey)) { + warnedMissingAdapterBindings.delete(warnedKey); + } + } + } + + for (const binding of bindings) { + const missingBindingWarningKey = bindingKey(binding); + const adapter = adapterByProvider.get(binding.providerId); + if (!adapter) { + if (!warnedMissingAdapterBindings || !warnedMissingAdapterBindings.has(missingBindingWarningKey)) { + params.deps.onWarning?.( + `No adapter registered for binding providerId=${binding.providerId} conversationId=${binding.conversationId}; skipping outbound forwarding`, + ); + warnedMissingAdapterBindings?.add(missingBindingWarningKey); + } + continue; + } + + warnedMissingAdapterBindings?.delete(missingBindingWarningKey); + + try { + const fetchedMessages = await withTimeout( + params.deps.fetchAgentMessagesAfterSeq({ + sessionId: binding.sessionId, + afterSeq: binding.lastForwardedSeq, + }), + EXTERNAL_IO_TIMEOUT_MS, + `fetchAgentMessagesAfterSeq(${binding.sessionId})`, + ); + + const { + messages, + highestSeenSeq, + } = normalizeAgentFetchResult(fetchedMessages); + + const orderedMessages: Array> = []; + for (const row of messages) { + const parsedSeq = toNonNegativeInt(row.seq); + if (parsedSeq === null) { + params.deps.onWarning?.( + `Skipped agent output row with invalid seq for session ${binding.sessionId}`, + ); + continue; + } + orderedMessages.push({ + seq: parsedSeq, + text: row.text, + }); + } + + orderedMessages.sort((left, right) => left.seq - right.seq); + + let maxSeq = binding.lastForwardedSeq; + const persistCursor = async (nextSeq: number): Promise => { + try { + maxSeq = nextSeq; + const advanced = await withTimeout( + params.store.updateLastForwardedSeq(binding, { + expectedSessionId: binding.sessionId, + seq: maxSeq, + }), + EXTERNAL_IO_TIMEOUT_MS, + `store.updateLastForwardedSeq(${binding.providerId}:${binding.conversationId}:${binding.threadId ?? 'null'}:${binding.sessionId}:${maxSeq})`, + ); + if (!advanced) { + params.deps.onWarning?.( + `Skipped cursor advance because binding changed or cursor was stale for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + ); + return false; + } + return true; + } catch (error) { + params.deps.onWarning?.( + `Failed to persist channel bridge cursor for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + error, + ); + return false; + } + }; + + if (orderedMessages.length === 0 && highestSeenSeq !== null && highestSeenSeq > maxSeq) { + await persistCursor(highestSeenSeq); + continue; + } + + for (const row of orderedMessages) { + const parsedSeq = row.seq; + if (parsedSeq <= maxSeq) { + continue; + } + + const nextSeq = parsedSeq; + const text = String(row.text).trim(); + if (!text) { + const persisted = await persistCursor(nextSeq); + if (!persisted) { + break; + } + continue; + } + try { + await withTimeout( + adapter.sendMessage({ + conversationId: binding.conversationId, + threadId: binding.threadId, + text, + }), + EXTERNAL_IO_TIMEOUT_MS, + `sendMessage(${adapter.providerId})`, + ); + } catch (error) { + if (isChannelBridgePermanentDeliveryFailure(error)) { + params.deps.onWarning?.( + `Detected permanent delivery failure; advancing outbound cursor without retry for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + error, + ); + } else { + throw error; + } + } + const persisted = await persistCursor(nextSeq); + if (!persisted) { + break; + } + } + } catch (error) { + params.deps.onWarning?.( + `Failed to forward agent output to channel for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId}`, + error, + ); + } + } +} + +/** + * Start the bridge worker loop. + * + * - `tickMs` is clamped to a minimum of 250ms (default 2500ms) + * - Uses single-flight scheduling: only one tick runs at a time + * - `trigger()` requests an immediate tick + * - `stop()` is idempotent, drains in-flight tick, then stops adapters + * - Adapter shutdown deduplicates by object identity, not `providerId` + */ +export function startChannelBridgeWorker(params: Readonly<{ + store: ChannelBindingStore; + adapters: readonly ChannelBridgeAdapter[]; + deps: ChannelBridgeDeps; + tickMs?: number; +}>): ChannelBridgeWorkerHandle { + const tickMs = + typeof params.tickMs === 'number' && Number.isFinite(params.tickMs) && params.tickMs > 0 + ? Math.max(250, Math.trunc(params.tickMs)) + : 2_500; + + const inboundDeduper = createChannelBridgeInboundDeduper(); + const inboundForwardFailureTracker = createChannelBridgeInboundForwardFailureTracker({ + maxAttempts: (() => { + const raw = (process.env.HAPPIER_CHANNEL_BRIDGE_INBOUND_FORWARD_MAX_ATTEMPTS ?? '').trim(); + if (!raw || !/^[0-9]+$/.test(raw)) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isSafeInteger(parsed) ? parsed : undefined; + })(), + maxAgeMs: (() => { + const raw = (process.env.HAPPIER_CHANNEL_BRIDGE_INBOUND_FORWARD_MAX_AGE_MS ?? '').trim(); + if (!raw || !/^[0-9]+$/.test(raw)) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isSafeInteger(parsed) ? parsed : undefined; + })(), + }); + const warnedMissingAdapterBindings = new Set(); + const warnedAdapterPullFailures = new Set(); + const adapterPullSingleFlightByAdapter = new WeakMap>(); + const adaptersForTick: ChannelBridgeAdapter[] = params.adapters.map((adapter) => { + const pullInboundMessages = (): Promise => { + const existing = adapterPullSingleFlightByAdapter.get(adapter); + if (existing) { + return existing; + } + + let inflight: Promise; + inflight = Promise.resolve() + .then(() => adapter.pullInboundMessages()) + .finally(() => { + if (adapterPullSingleFlightByAdapter.get(adapter) === inflight) { + adapterPullSingleFlightByAdapter.delete(adapter); + } + }); + + adapterPullSingleFlightByAdapter.set(adapter, inflight); + return inflight; + }; + + const wrapped: ChannelBridgeAdapter = { + providerId: adapter.providerId, + pullInboundMessages, + sendMessage: async (params) => { + await adapter.sendMessage(params); + }, + ...(adapter.ackInboundMessages + ? { + ackInboundMessages: async (messages) => { + await adapter.ackInboundMessages!(messages); + }, + } + : {}), + }; + + return wrapped; + }); + let inFlightTick: Promise | null = null; + + const runTick = async (): Promise => { + const tickRun = executeChannelBridgeTick({ + store: params.store, + adapters: adaptersForTick, + deps: params.deps, + inboundDeduper, + inboundForwardFailureTracker, + warnedMissingAdapterBindings, + warnedAdapterPullFailures, + }); + inFlightTick = tickRun; + try { + await tickRun; + } finally { + if (inFlightTick === tickRun) { + inFlightTick = null; + } + } + }; + + let loop: SingleFlightIntervalLoopHandle | null = startSingleFlightIntervalLoop({ + intervalMs: tickMs, + task: runTick, + onError: (error) => { + params.deps.onWarning?.('Channel bridge tick failed', error); + }, + }); + + loop.trigger(); + let stopPromise: Promise | null = null; + + return { + stop: async () => { + if (stopPromise) { + await stopPromise; + return; + } + + stopPromise = (async () => { + const activeLoop = loop; + loop = null; + activeLoop?.stop(); + + await Promise.resolve(); + + const currentTick = inFlightTick; + if (currentTick) { + try { + await currentTick; + } catch { + // Tick failures are already surfaced via loop onError while running. + // During shutdown we only drain the in-flight tick before adapter stop. + } + } + + const adaptersToStop: ChannelBridgeAdapter[] = []; + const seenAdapters = new Set(); + for (const adapter of params.adapters) { + if (seenAdapters.has(adapter)) { + continue; + } + seenAdapters.add(adapter); + adaptersToStop.push(adapter); + } + + const stopResults = await Promise.allSettled( + adaptersToStop.map(async (adapter) => { + if (typeof adapter.stop !== 'function') return; + await withTimeout( + Promise.resolve(adapter.stop()), + EXTERNAL_IO_TIMEOUT_MS, + `adapter.stop(${adapter.providerId})`, + ); + }), + ); + + stopResults.forEach((result, index) => { + if (result.status === 'rejected') { + const providerId = adaptersToStop[index]?.providerId ?? 'unknown'; + params.deps.onWarning?.(`Failed to stop channel adapter ${providerId} during shutdown`, result.reason); + } + }); + })(); + + await stopPromise; + }, + trigger: () => loop?.trigger(), + }; +} diff --git a/apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts b/apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts new file mode 100644 index 000000000..fe2625b59 --- /dev/null +++ b/apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts @@ -0,0 +1,26 @@ +import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; + +import { telegramChannelBridgeProvider } from './telegramChannelBridgeProvider'; +import type { ChannelBridgeProviderDefinition } from './types'; + +export type AnyChannelBridgeProviderDefinition = ChannelBridgeProviderDefinition< + string, + ChannelBridgeRuntimeConfig, + unknown +>; + +export const channelBridgeProviderRegistry = { + telegram: telegramChannelBridgeProvider, +} as const satisfies Record; + +export type ChannelBridgeProviderId = keyof typeof channelBridgeProviderRegistry; + +export function listChannelBridgeProviderIds(): readonly ChannelBridgeProviderId[] { + return Object.keys(channelBridgeProviderRegistry) as ChannelBridgeProviderId[]; +} + +export function resolveChannelBridgeProviderDefinition( + providerId: ChannelBridgeProviderId, +): AnyChannelBridgeProviderDefinition { + return channelBridgeProviderRegistry[providerId]; +} diff --git a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts new file mode 100644 index 000000000..037e27578 --- /dev/null +++ b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts @@ -0,0 +1,100 @@ +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { createTelegramChannelAdapter } from '@/channels/providers/telegram/telegramAdapter'; +import { createTelegramPollingCursorStore } from '@/channels/providers/telegram/telegramPollingCursorStore'; +import { createTelegramWebhookUpdateStore } from '@/channels/providers/telegram/telegramWebhookUpdateStore'; +import { + startTelegramWebhookRelay, + type TelegramWebhookRelayHandle, +} from '@/channels/providers/telegram/telegramWebhookRelay'; +import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { logger } from '@/ui/logger'; + +import type { ChannelBridgeProviderDefinition, ChannelBridgeProviderRuntime } from './types'; + +async function stopRelayBestEffort(relayHandle: TelegramWebhookRelayHandle | null): Promise { + if (!relayHandle) return; + try { + await relayHandle.stop(); + } catch (error) { + logger.warn('[channelBridge] Error stopping Telegram webhook relay during shutdown', error); + } +} + +export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< + 'telegram', + ChannelBridgeRuntimeConfig, + ChannelBridgeRuntimeConfig['providers']['telegram'] +> = { + providerId: 'telegram', + readConfig: (root) => root.providers.telegram, + createRuntime: async ({ config, context }) => { + const botToken = config.botToken; + if (!botToken) return null; + + const allowedChatIdsRaw = config.allowedChatIds; + const allowedChatIds = allowedChatIdsRaw.length > 0 ? new Set(allowedChatIdsRaw) : null; + const allowAllSharedChats = config.allowAllSharedChats; + const requireTopics = config.requireTopics; + + const webhookEnabled = config.webhookEnabled; + const webhookSecret = config.webhookSecret; + if (webhookEnabled && webhookSecret.length === 0) { + throw new Error( + 'Telegram webhook mode is enabled but webhook secret is missing. Set HAPPIER_TELEGRAM_WEBHOOK_SECRET (or persist secrets.webhookSecret in settings) and restart the daemon.', + ); + } + + const webhookModeRequested = webhookEnabled; + const pollingCursorStore = context.accountId + ? createTelegramPollingCursorStore({ accountId: context.accountId, botToken }) + : null; + const webhookUpdateStore = webhookModeRequested && context.accountId + ? createTelegramWebhookUpdateStore({ accountId: context.accountId, botToken }) + : null; + + let relayHandle: TelegramWebhookRelayHandle | null = null; + let adapter = createTelegramChannelAdapter({ + botToken, + allowedChatIds, + allowAllSharedChats, + requireTopics, + webhookMode: webhookModeRequested, + pollingCursorStore: pollingCursorStore ?? undefined, + webhookUpdateStore: webhookUpdateStore ?? undefined, + }); + + if (webhookModeRequested) { + const port = config.webhookPort; + const host = config.webhookHost; + try { + relayHandle = await startTelegramWebhookRelay({ + port, + host, + secretToken: webhookSecret, + onUpdate: adapter.enqueueWebhookUpdate, + }); + logger.debug( + `[channelBridge] Telegram webhook relay listening on http://${host}:${relayHandle.port} (path redacted)`, + ); + } catch (error) { + logger.warn('[channelBridge] Failed to start Telegram webhook relay', serializeAxiosErrorForLog(error)); + await stopRelayBestEffort(relayHandle); + relayHandle = null; + throw new Error( + 'Telegram webhook mode is enabled but the local webhook relay failed to start. Fix webhookHost/webhookPort and restart the daemon.', + ); + } + } + + let stopped = false; + const runtime: ChannelBridgeProviderRuntime = { + adapters: [adapter], + stop: async () => { + if (stopped) return; + stopped = true; + await stopRelayBestEffort(relayHandle); + }, + }; + return runtime; + }, +}; diff --git a/apps/cli/src/channels/providers/_registry/types.ts b/apps/cli/src/channels/providers/_registry/types.ts new file mode 100644 index 000000000..56c9eefe2 --- /dev/null +++ b/apps/cli/src/channels/providers/_registry/types.ts @@ -0,0 +1,21 @@ +import type { ChannelBridgeAdapter } from '@/channels/core/channelBridgeWorker'; + +export type ChannelBridgeProviderRuntime = Readonly<{ + adapters: readonly ChannelBridgeAdapter[]; + stop: () => Promise; +}>; + +export type ChannelBridgeProviderRuntimeContext = Readonly<{ + serverId: string | null; + accountId: string | null; +}>; + +export type ChannelBridgeProviderDefinition< + TProviderId extends string, + TRootConfig, + TProviderConfig, +> = Readonly<{ + providerId: TProviderId; + readConfig: (root: TRootConfig) => TProviderConfig; + createRuntime(params: Readonly<{ config: TProviderConfig; context: ChannelBridgeProviderRuntimeContext }>): Promise; +}>; diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts new file mode 100644 index 000000000..1c6cb1528 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -0,0 +1,787 @@ +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { loggerWarn } = vi.hoisted(() => ({ + loggerWarn: vi.fn(), +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + warn: loggerWarn, + }, +})); + +import { createTelegramChannelAdapter } from './telegramAdapter'; + +function createDeferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('createTelegramChannelAdapter', () => { + beforeEach(() => { + loggerWarn.mockReset(); + }); + + it('parses inbound topic updates and ignores self-authored bot messages', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from topic', + message_thread_id: 9001, + chat: { id: -100555, type: 'supergroup' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + { + update_id: 102, + message: { + message_id: 5002, + text: 'echo from self', + chat: { id: -100555, type: 'supergroup' }, + from: { id: 777, is_bot: true, username: 'happier_bot' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowedChatIds: new Set(['-100555']), + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '-100555', + threadId: '9001', + senderId: '42', + conversationKind: 'group', + text: 'hello from topic', + messageId: '5001', + }, + ]); + }); + + it('parses inbound messages from captions when text is missing', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + caption: 'caption says hello', + chat: { id: 1234, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '42', + conversationKind: 'dm', + text: 'caption says hello', + messageId: '5001', + }, + ]); + }); + + it('rejects plain groups when requireTopics is enabled', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from plain group', + chat: { id: -555, type: 'group' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowAllSharedChats: true, + requireTopics: true, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([]); + }); + + it('parses channel_post updates and uses sender_chat id as sender identity', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + channel_post: { + message_id: 5001, + text: 'hello from channel', + chat: { id: -100999, type: 'channel' }, + sender_chat: { id: -100999, type: 'channel', title: 'News Channel' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowAllSharedChats: true, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '-100999', + threadId: null, + senderId: '-100999', + conversationKind: 'channel', + text: 'hello from channel', + messageId: '5001', + }, + ]); + }); + + it('ignores non-private chats by default unless allowlisted or allowAllSharedChats is enabled', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from dm', + chat: { id: 1234, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + { + update_id: 102, + message: { + message_id: 5002, + text: 'hello from group', + chat: { id: -100555, type: 'supergroup' }, + from: { id: 43, is_bot: false, first_name: 'Grace' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '42', + conversationKind: 'dm', + text: 'hello from dm', + messageId: '5001', + }, + ]); + }); + + it('redacts bot token when axios throws a transport error', async () => { + const botToken = 'secret-token-123'; + const axiosPostSpy = vi.spyOn(axios, 'post'); + + axiosPostSpy.mockImplementation(async (urlRaw: unknown) => { + const url = String(urlRaw); + if (url.includes('/getUpdates')) { + return { + status: 200, + data: { ok: true, result: [] }, + } as any; + } + + const error = new Error(`Network failure calling ${url}`); + (error as any).isAxiosError = true; + (error as any).config = { url }; + throw error; + }); + + try { + const adapter = createTelegramChannelAdapter({ + botToken, + }); + + await adapter.pullInboundMessages(); + throw new Error('Expected pullInboundMessages to fail'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).not.toContain(botToken); + expect(message).toContain('botREDACTED'); + } finally { + axiosPostSpy.mockRestore(); + } + }); + + it('allows non-private chats when allowAllSharedChats is enabled', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from group', + chat: { id: -100555, type: 'supergroup' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowAllSharedChats: true, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '-100555', + threadId: null, + senderId: '42', + conversationKind: 'group', + text: 'hello from group', + messageId: '5001', + }, + ]); + }); + + it('sends outbound messages with thread targeting when threadId is present', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + await adapter.sendMessage({ + conversationId: '-100777', + threadId: '451', + text: 'assistant says hi', + }); + + expect(api.sendMessage).toHaveBeenCalledWith({ + chatId: '-100777', + threadId: '451', + text: 'assistant says hi', + }); + }); + + it('accepts webhook updates through enqueueWebhookUpdate without polling', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + adapter.enqueueWebhookUpdate({ + update_id: 777, + message: { + message_id: 88, + text: '/sessions', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '456', + conversationKind: 'dm', + text: '/sessions', + messageId: '88', + }, + ]); + expect(api.getUpdates).not.toHaveBeenCalled(); + + const pendingReplay = await adapter.pullInboundMessages(); + expect(pendingReplay).toHaveLength(1); + + await adapter.ackInboundMessages?.(inbound); + await expect(adapter.pullInboundMessages()).resolves.toEqual([]); + }); + + it('keeps webhook updates queued when parsing fails and retries later', async () => { + const api = { + getMe: vi + .fn() + .mockRejectedValueOnce(new Error('temporary getMe failure')) + .mockResolvedValue({ id: 9, username: 'happier_bot' }), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + adapter.enqueueWebhookUpdate({ + update_id: 801, + message: { + message_id: 9001, + text: 'retry me', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + await expect(adapter.pullInboundMessages()).rejects.toThrow('temporary getMe failure'); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '456', + conversationKind: 'dm', + text: 'retry me', + messageId: '9001', + }, + ]); + }); + + it('persists webhook updates across adapter restarts and ignores duplicate webhook deliveries while queued', async () => { + type TelegramWebhookUpdateStoreSnapshot = Readonly<{ + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: readonly Readonly<{ + id: number; + update: unknown; + }>[]; + }>; + + let storedSnapshot: TelegramWebhookUpdateStoreSnapshot | null = null; + const webhookUpdateStore = { + load: vi.fn(async () => storedSnapshot), + save: vi.fn(async (snapshot: TelegramWebhookUpdateStoreSnapshot) => { + storedSnapshot = snapshot; + }), + }; + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const webhookUpdate = { + update_id: 901, + message: { + message_id: 88, + text: '/sessions', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }; + + const firstAdapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + webhookUpdateStore: webhookUpdateStore as never, + } as never); + + await firstAdapter.enqueueWebhookUpdate(webhookUpdate); + const firstInbound = await firstAdapter.pullInboundMessages(); + expect(firstInbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '456', + conversationKind: 'dm', + text: '/sessions', + messageId: '88', + }, + ]); + expect(webhookUpdateStore.save).toHaveBeenCalledTimes(1); + + const secondAdapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + webhookUpdateStore: webhookUpdateStore as never, + } as never); + + await expect(secondAdapter.pullInboundMessages()).resolves.toEqual(firstInbound); + + await secondAdapter.enqueueWebhookUpdate(webhookUpdate); + await expect(secondAdapter.pullInboundMessages()).resolves.toEqual(firstInbound); + }); + + it('bounds webhook queue size to prevent unbounded growth', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + for (let i = 0; i < 2_100; i += 1) { + adapter.enqueueWebhookUpdate({ + update_id: i, + message: { + message_id: i, + text: `message ${i}`, + chat: { id: 4321, type: 'private' }, + from: { id: 222, is_bot: false, first_name: 'Ada' }, + }, + }); + } + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toHaveLength(2_000); + expect(inbound[0]?.messageId).toBe('100'); + expect(inbound[1_999]?.messageId).toBe('2099'); + expect(loggerWarn).toHaveBeenCalledTimes(1); + expect(loggerWarn.mock.calls[0]?.[0]).toContain('dropped 100 oldest update(s)'); + }); + + it('does not drop updates enqueued while webhook parsing is in flight', async () => { + const gate = createDeferredPromise(); + const api = { + getMe: vi.fn(async () => { + await gate.promise; + return { id: 9, username: 'happier_bot' }; + }), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + adapter.enqueueWebhookUpdate({ + update_id: 901, + message: { + message_id: 901, + text: 'first', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + const firstPull = adapter.pullInboundMessages(); + + adapter.enqueueWebhookUpdate({ + update_id: 902, + message: { + message_id: 902, + text: 'second', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + gate.resolve(); + + const firstInbound = await firstPull; + expect(firstInbound).toHaveLength(1); + expect(firstInbound[0]?.messageId).toBe('901'); + + await adapter.ackInboundMessages?.(firstInbound); + + const secondInbound = await adapter.pullInboundMessages(); + expect(secondInbound).toHaveLength(1); + expect(secondInbound[0]?.messageId).toBe('902'); + }); + + it('truncates outbound telegram messages above provider limit', async () => { + type SendMessageParams = Readonly<{ chatId: string; threadId: string | null; text: string }>; + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async (_params: SendMessageParams) => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const oversized = 'x'.repeat(4_500); + await adapter.sendMessage({ + conversationId: '1234', + threadId: null, + text: oversized, + }); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + const calls = api.sendMessage.mock.calls as SendMessageParams[][]; + expect(calls[0]?.[0]?.text).toHaveLength(4_096); + expect(loggerWarn).toHaveBeenCalledWith( + expect.stringContaining('Truncated Telegram outbound message for conversation 1234 to 4096 characters'), + ); + }); + + it('truncates outbound telegram messages by Unicode code points without splitting surrogate pairs', async () => { + type SendMessageParams = Readonly<{ chatId: string; threadId: string | null; text: string }>; + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async (_params: SendMessageParams) => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const nearBoundary = 'a'.repeat(4_095) + '🎉'; + await adapter.sendMessage({ + conversationId: '1234', + threadId: null, + text: nearBoundary, + }); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + const calls = api.sendMessage.mock.calls as SendMessageParams[][]; + expect(calls[0]?.[0]?.text).toBe(nearBoundary); + expect(Array.from(calls[0]?.[0]?.text ?? '').length).toBe(4_096); + expect(loggerWarn).not.toHaveBeenCalled(); + }); + + it('advances polling offset only after adapter acknowledgements in polling mode', async () => { + const getMe = vi + .fn() + .mockRejectedValueOnce(new Error('temporary getMe failure')) + .mockResolvedValue({ id: 9, username: 'happier_bot' }); + const getUpdates = vi + .fn() + .mockResolvedValueOnce([ + { + update_id: 11, + message: { + message_id: 111, + text: 'hello', + chat: { id: -100555, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ]) + .mockResolvedValueOnce([ + { + update_id: 11, + message: { + message_id: 111, + text: 'hello', + chat: { id: -100555, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ]) + .mockResolvedValueOnce([]); + + const api = { + getMe, + getUpdates, + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: false, + }); + + await expect(adapter.pullInboundMessages()).rejects.toThrow('temporary getMe failure'); + expect(getUpdates).toHaveBeenNthCalledWith(1, { offset: null, limit: 100 }); + + const parsed = await adapter.pullInboundMessages(); + expect(parsed).toHaveLength(1); + expect(getUpdates).toHaveBeenNthCalledWith(2, { offset: null, limit: 100 }); + + const replayBeforeAck = await adapter.pullInboundMessages(); + expect(replayBeforeAck).toEqual(parsed); + expect(getUpdates).toHaveBeenCalledTimes(2); + + await adapter.ackInboundMessages?.(parsed); + + await adapter.pullInboundMessages(); + expect(getUpdates).toHaveBeenNthCalledWith(3, { offset: 12, limit: 100 }); + }); + + it('loads and persists polling offset when a cursor store is provided', async () => { + const getUpdates = vi + .fn() + .mockResolvedValueOnce([ + { + update_id: 50, + message: { + message_id: 111, + text: 'hello', + chat: { id: 1234, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ]) + .mockResolvedValueOnce([]); + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates, + sendMessage: vi.fn(async () => undefined), + }; + + const cursorStore = { + load: vi.fn(async () => 42), + save: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: false, + pollingCursorStore: cursorStore, + }); + + const parsed = await adapter.pullInboundMessages(); + expect(getUpdates).toHaveBeenNthCalledWith(1, { offset: 42, limit: 100 }); + expect(parsed).toHaveLength(1); + expect(cursorStore.load).toHaveBeenCalledTimes(1); + expect(cursorStore.save).not.toHaveBeenCalled(); + + await adapter.ackInboundMessages?.(parsed); + expect(cursorStore.save).toHaveBeenCalledTimes(1); + expect(cursorStore.save).toHaveBeenCalledWith(51); + + await adapter.pullInboundMessages(); + expect(getUpdates).toHaveBeenNthCalledWith(2, { offset: 51, limit: 100 }); + }); + + it('includes Telegram API error descriptions when available', async () => { + const postSpy = vi.spyOn(axios, 'post').mockResolvedValue({ + status: 400, + data: { + ok: false, + description: 'Bad Request: chat not found', + }, + } as never); + + try { + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + }); + + await expect(adapter.sendMessage({ + conversationId: '-100999', + threadId: null, + text: 'hello', + })).rejects.toThrow('Bad Request: chat not found'); + } finally { + postSpy.mockRestore(); + } + }); + + it('bounds getUpdates HTTP timeout under the worker IO timeout to avoid overlapping polls', async () => { + const postSpy = vi.spyOn(axios, 'post').mockImplementation(async (url, data, config) => { + if (typeof url === 'string' && url.includes('/getUpdates')) { + return { + status: 200, + data: { ok: true, result: [] }, + __capturedConfig: config, + __capturedBody: data, + } as never; + } + if (typeof url === 'string' && url.includes('/getMe')) { + return { + status: 200, + data: { ok: true, result: { id: 9, username: 'happier_bot' } }, + __capturedConfig: config, + __capturedBody: data, + } as never; + } + throw new Error(`Unexpected Telegram API call: ${String(url)}`); + }); + + try { + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + }); + + await expect(adapter.pullInboundMessages()).resolves.toEqual([]); + + const getUpdatesCall = postSpy.mock.calls.find((call) => String(call[0]).includes('/getUpdates')); + expect(getUpdatesCall).toBeTruthy(); + const getUpdatesConfig = getUpdatesCall?.[2] as { timeout?: number } | undefined; + expect(typeof getUpdatesConfig?.timeout).toBe('number'); + expect((getUpdatesConfig?.timeout ?? 0)).toBeLessThanOrEqual(30_000); + } finally { + postSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts new file mode 100644 index 000000000..87d4dbf8f --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -0,0 +1,867 @@ +import axios from 'axios'; + +import { ChannelBridgePermanentDeliveryError } from '@/channels/core/channelBridgeWorker'; +import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channels/core/channelBridgeWorker'; +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { logger } from '@/ui/logger'; + +import type { + TelegramWebhookUpdateStore, + TelegramWebhookUpdateStoreSnapshot, +} from './telegramWebhookUpdateStore'; + +type TelegramSelfUser = Readonly<{ id: number; username: string | null }>; +type TelegramApiMethod = 'getMe' | 'getUpdates' | 'sendMessage'; + +type TelegramApiClient = Readonly<{ + getMe: () => Promise; + getUpdates: (params: Readonly<{ offset: number | null; limit: number }>) => Promise; + sendMessage: (params: Readonly<{ chatId: string; threadId: string | null; text: string }>) => Promise; +}>; + +type TelegramWebhookQueuedUpdate = Readonly<{ + id: number; + update: unknown; + updateId: number | null; +}>; + +type TelegramWebhookQueueState = Readonly<{ + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: TelegramWebhookQueuedUpdate[]; + queuedUpdateIds: ReadonlySet; +}>; + +const TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS = 25; +const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = (TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS + 4) * 1_000; +const TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH = 4_096; + +function telegramApiUrl(botToken: string, method: string): string { + return `https://api.telegram.org/bot${botToken}/${method}`; +} + +function extractTelegramApiDescription(data: unknown): string | null { + const record = asRecord(data); + const description = record && typeof record.description === 'string' ? record.description.trim() : ''; + return description.length > 0 ? description : null; +} + +function formatTelegramApiFailure(method: TelegramApiMethod, status: number, description: string | null): string { + return description && description.length > 0 + ? `Telegram ${method} failed (${status}): ${description}` + : `Telegram ${method} failed (${status})`; +} + +export class TelegramApiError extends Error { + readonly method: TelegramApiMethod; + readonly statusCode: number; + readonly description: string | null; + + constructor(params: Readonly<{ method: TelegramApiMethod; statusCode: number; data: unknown }>) { + const description = extractTelegramApiDescription(params.data); + super(formatTelegramApiFailure(params.method, params.statusCode, description)); + this.name = 'TelegramApiError'; + this.method = params.method; + this.statusCode = params.statusCode; + this.description = description; + } +} + +function createTelegramTransportError(method: TelegramApiMethod, error: unknown): Error { + const serialized = serializeAxiosErrorForLog(error); + const safeDetails = { + code: typeof serialized.code === 'string' ? serialized.code : undefined, + status: typeof serialized.status === 'number' ? serialized.status : undefined, + method: typeof serialized.method === 'string' ? serialized.method : undefined, + url: typeof serialized.url === 'string' ? serialized.url : undefined, + }; + return new Error(`Telegram ${method} transport error: ${JSON.stringify(safeDetails)}`); +} + +function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { + return { + getMe: async () => { + let response: any; + try { + response = await axios.post(telegramApiUrl(botToken, 'getMe'), {}, { + timeout: 10_000, + validateStatus: () => true, + }); + } catch (error) { + throw createTelegramTransportError('getMe', error); + } + if (response.status !== 200 || !response.data || response.data.ok !== true || !response.data.result) { + throw new TelegramApiError({ + method: 'getMe', + statusCode: response.status, + data: response.data, + }); + } + const user = response.data.result; + return { + id: Number(user.id), + username: typeof user.username === 'string' ? user.username : null, + }; + }, + getUpdates: async ({ offset, limit }) => { + let response: any; + try { + response = await axios.post(telegramApiUrl(botToken, 'getUpdates'), { + ...(typeof offset === 'number' ? { offset } : {}), + limit, + timeout: TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS, + allowed_updates: ['message', 'channel_post'], + }, { + timeout: TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }); + } catch (error) { + throw createTelegramTransportError('getUpdates', error); + } + if (response.status !== 200 || !response.data || response.data.ok !== true || !Array.isArray(response.data.result)) { + throw new TelegramApiError({ + method: 'getUpdates', + statusCode: response.status, + data: response.data, + }); + } + return response.data.result; + }, + sendMessage: async ({ chatId, threadId, text }) => { + const parsedThreadId = Number.parseInt(typeof threadId === 'string' ? threadId.trim() : '', 10); + const messageThreadId = Number.isSafeInteger(parsedThreadId) && parsedThreadId > 0 + ? parsedThreadId + : null; + let response: any; + try { + response = await axios.post(telegramApiUrl(botToken, 'sendMessage'), { + chat_id: chatId, + text, + ...(messageThreadId !== null ? { message_thread_id: messageThreadId } : {}), + }, { + timeout: 10_000, + validateStatus: () => true, + }); + } catch (error) { + throw createTelegramTransportError('sendMessage', error); + } + if (response.status !== 200 || !response.data || response.data.ok !== true) { + const apiError = new TelegramApiError({ + method: 'sendMessage', + statusCode: response.status, + data: response.data, + }); + + if (apiError.statusCode === 403) { + throw new ChannelBridgePermanentDeliveryError({ + code: 'forbidden', + message: apiError.message, + }); + } + if (apiError.statusCode === 400 && apiError.description !== null && /chat not found/i.test(apiError.description)) { + throw new ChannelBridgePermanentDeliveryError({ + code: 'conversation_not_found', + message: apiError.message, + }); + } + + throw apiError; + } + }, + }; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function parseInboundFromUpdate(params: Readonly<{ + update: unknown; + selfBotId: number | null; + allowedChatIds: ReadonlySet | null; + allowAllSharedChats: boolean; + requireTopics: boolean; +}>): ChannelBridgeInboundMessage | null { + const update = asRecord(params.update); + if (!update) return null; + const rawMessage = asRecord(update.message) ?? asRecord(update.channel_post); + if (!rawMessage) return null; + + const rawText = typeof rawMessage.text === 'string' ? rawMessage.text.trim() : ''; + const rawCaption = typeof rawMessage.caption === 'string' ? rawMessage.caption.trim() : ''; + const text = rawText || rawCaption; + if (!text) return null; + + const chat = asRecord(rawMessage.chat); + if (!chat) return null; + const rawChatId = chat.id; + const conversationId = + typeof rawChatId === 'number' && Number.isFinite(rawChatId) + ? String(Math.trunc(rawChatId)) + : typeof rawChatId === 'string' + ? rawChatId.trim() + : ''; + if (!conversationId) return null; + + const chatType = typeof chat.type === 'string' ? chat.type : ''; + const isPrivateChat = chatType === 'private'; + if (!isPrivateChat && !params.allowAllSharedChats) { + if (!params.allowedChatIds || !params.allowedChatIds.has(conversationId)) { + return null; + } + } + + const conversationKind = + isPrivateChat + ? 'dm' + : chatType === 'channel' + ? 'channel' + : chatType === 'group' || chatType === 'supergroup' + ? 'group' + : 'unknown'; + + const threadId = + typeof rawMessage.message_thread_id === 'number' && Number.isFinite(rawMessage.message_thread_id) + ? String(Math.trunc(rawMessage.message_thread_id)) + : null; + + if (params.requireTopics) { + if (!isPrivateChat && (chatType !== 'supergroup' || threadId === null)) { + return null; + } + } + + const sender = asRecord(rawMessage.from); + const senderChat = asRecord(rawMessage.sender_chat); + const senderIdFrom = + sender && typeof sender.id === 'number' && Number.isFinite(sender.id) ? Math.trunc(sender.id) : null; + const senderChatId = + senderChat && typeof senderChat.id === 'number' && Number.isFinite(senderChat.id) + ? Math.trunc(senderChat.id) + : senderChat && typeof senderChat.id === 'string' + ? senderChat.id.trim() + : null; + const senderId = + senderIdFrom !== null + ? String(senderIdFrom) + : senderChatId !== null + ? String(senderChatId) + : null; + const senderIsBot = sender?.is_bot === true; + if (senderIsBot && senderIdFrom !== null && params.selfBotId !== null && senderIdFrom === params.selfBotId) { + return null; + } + + const messageId = + typeof rawMessage.message_id === 'number' && Number.isFinite(rawMessage.message_id) + ? String(Math.trunc(rawMessage.message_id)) + : ''; + if (!messageId) return null; + + return { + providerId: 'telegram', + conversationId, + threadId, + senderId, + conversationKind, + text, + messageId, + }; +} + +function parseHighestUpdateOffset(updates: readonly unknown[]): number | null { + let max: number | null = null; + for (const item of updates) { + const current = parseUpdateId(item); + if (current === null) continue; + if (max === null || current > max) max = current; + } + return max; +} + +function parseUpdateId(update: unknown): number | null { + const record = asRecord(update); + if (!record) return null; + const rawUpdateId = record.update_id; + if (typeof rawUpdateId !== 'number' || !Number.isFinite(rawUpdateId)) return null; + return Math.trunc(rawUpdateId); +} + +function inboundMessageKey(message: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + messageId: string; +}>): string { + return JSON.stringify([message.providerId, message.conversationId, message.threadId, message.messageId]); +} + +function cloneWebhookQueueState(state: TelegramWebhookQueueState): TelegramWebhookQueueState { + return { + lastHandledWebhookUpdateId: state.lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId, + queuedWebhookUpdates: state.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + updateId: row.updateId, + })), + queuedUpdateIds: new Set(state.queuedUpdateIds), + }; +} + +function normalizeWebhookQueueSnapshot(snapshot: TelegramWebhookUpdateStoreSnapshot | null): TelegramWebhookQueueState { + if (!snapshot) { + return { + lastHandledWebhookUpdateId: null, + nextQueuedWebhookId: 1, + queuedWebhookUpdates: [], + queuedUpdateIds: new Set(), + }; + } + + const queuedWebhookUpdates: TelegramWebhookQueuedUpdate[] = []; + const queuedUpdateIds = new Set(); + let maxQueuedWebhookId = 0; + + for (const row of snapshot.queuedWebhookUpdates) { + const id = Number.isFinite(row.id) ? Math.max(1, Math.trunc(row.id)) : null; + if (id === null) { + continue; + } + const updateId = parseUpdateId(row.update); + queuedWebhookUpdates.push({ + id, + update: row.update, + updateId, + }); + if (updateId !== null) { + queuedUpdateIds.add(updateId); + } + if (id > maxQueuedWebhookId) { + maxQueuedWebhookId = id; + } + } + + queuedWebhookUpdates.sort((left, right) => left.id - right.id); + + const nextQueuedWebhookId = Math.max( + 1, + Math.trunc(snapshot.nextQueuedWebhookId), + maxQueuedWebhookId + 1, + ); + + return { + lastHandledWebhookUpdateId: + typeof snapshot.lastHandledWebhookUpdateId === 'number' && Number.isFinite(snapshot.lastHandledWebhookUpdateId) + ? Math.max(0, Math.trunc(snapshot.lastHandledWebhookUpdateId)) + : null, + nextQueuedWebhookId, + queuedWebhookUpdates, + queuedUpdateIds, + }; +} + +function webhookQueueSnapshotToStore(snapshot: TelegramWebhookQueueState): TelegramWebhookUpdateStoreSnapshot { + return { + lastHandledWebhookUpdateId: snapshot.lastHandledWebhookUpdateId, + nextQueuedWebhookId: snapshot.nextQueuedWebhookId, + queuedWebhookUpdates: snapshot.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + })), + }; +} + +export function createTelegramChannelAdapter(params: Readonly<{ + botToken: string; + api?: TelegramApiClient; + webhookMode?: boolean; + updateLimit?: number; + allowedChatIds?: ReadonlySet | null; + allowAllSharedChats?: boolean; + requireTopics?: boolean; + pollingCursorStore?: Readonly<{ + load: () => Promise; + save: (offset: number) => Promise; + }>; + webhookUpdateStore?: TelegramWebhookUpdateStore; +}>): ChannelBridgeAdapter & Readonly<{ enqueueWebhookUpdate: (update: unknown) => void | Promise }> { + const api = params.api ?? createDefaultTelegramApiClient(params.botToken); + const webhookMode = params.webhookMode === true; + const updateLimit = + typeof params.updateLimit === 'number' && Number.isFinite(params.updateLimit) + ? Math.max(1, Math.min(100, Math.trunc(params.updateLimit))) + : 100; + const allowedChatIds = params.allowedChatIds ?? null; + const allowAllSharedChats = params.allowAllSharedChats === true; + const requireTopics = params.requireTopics === true; + const MAX_WEBHOOK_QUEUE_SIZE = 2_000; + + type PendingWebhookAck = { + queueIds: Set; + maxUpdateId: number | null; + }; + + type PendingPollingBatchAck = { + messages: ChannelBridgeInboundMessage[]; + pendingMessageKeys: Set; + ackedMessageKeys: Set; + maxUpdateId: number | null; + }; + + let selfBotId: number | null = null; + /** + * Telegram polling cursor (`getUpdates` offset). + * + * If a `pollingCursorStore` is provided, the adapter loads and persists this cursor + * (best-effort) so polling can resume after daemon restarts without replaying the + * retention window. If not provided, the cursor is process-local and polling is + * at-least-once across restarts. + */ + let updateOffset: number | null = null; + const pollingCursorStore = params.pollingCursorStore ?? null; + let pollingCursorLoaded = false; + let lastPersistedPollingOffset: number | null = null; + let pollingCursorSaveQueue = Promise.resolve(); + let webhookQueueState: TelegramWebhookQueueState | null = null; + let webhookQueueLoaded = false; + let webhookQueueLoadPromise: Promise | null = null; + let webhookQueueMutationQueue = Promise.resolve(); + let droppedWebhookUpdates = 0; + const pendingWebhookAcksByMessageKey = new Map(); + let pendingPollingBatchAck: PendingPollingBatchAck | null = null; + const webhookUpdateStore = params.webhookUpdateStore ?? null; + + async function ensurePollingCursorLoaded(): Promise { + if (pollingCursorLoaded) return; + pollingCursorLoaded = true; + if (!pollingCursorStore) return; + + try { + const loaded = await pollingCursorStore.load(); + if (typeof loaded === 'number' && Number.isFinite(loaded)) { + const candidate = Math.max(0, Math.trunc(loaded)); + updateOffset = updateOffset === null ? candidate : Math.max(updateOffset, candidate); + lastPersistedPollingOffset = candidate; + } + } catch (error) { + logger.warn('[channelBridge] Failed to load Telegram polling cursor; continuing with empty cursor', error); + } + } + + async function persistPollingOffset(offset: number): Promise { + if (!pollingCursorStore) return; + if (lastPersistedPollingOffset !== null && offset <= lastPersistedPollingOffset) return; + lastPersistedPollingOffset = offset; + + pollingCursorSaveQueue = pollingCursorSaveQueue + .then(() => pollingCursorStore.save(offset)) + .catch((error) => { + logger.warn('[channelBridge] Failed to persist Telegram polling cursor; continuing without persistence', error); + }); + + await pollingCursorSaveQueue; + } + + function withWebhookQueueMutation(work: () => Promise): Promise { + const run = webhookQueueMutationQueue.then(work, work); + webhookQueueMutationQueue = run.then(() => undefined, () => undefined); + return run; + } + + function getWebhookQueueState(): TelegramWebhookQueueState { + if (!webhookQueueState) { + throw new Error('Telegram webhook queue is not loaded'); + } + return webhookQueueState; + } + + function setWebhookQueueState(nextState: TelegramWebhookQueueState): void { + webhookQueueState = cloneWebhookQueueState(nextState); + } + + async function ensureWebhookQueueLoaded(): Promise { + if (webhookQueueLoaded) return; + if (webhookQueueLoadPromise) { + await webhookQueueLoadPromise; + return; + } + + webhookQueueLoadPromise = (async () => { + try { + if (!webhookUpdateStore) { + setWebhookQueueState(normalizeWebhookQueueSnapshot(null)); + return; + } + + const loaded = await webhookUpdateStore.load(); + setWebhookQueueState(normalizeWebhookQueueSnapshot(loaded)); + } catch (error) { + logger.warn('[channelBridge] Failed to load Telegram webhook queue; continuing with empty queue', error); + setWebhookQueueState(normalizeWebhookQueueSnapshot(null)); + } finally { + webhookQueueLoaded = true; + webhookQueueLoadPromise = null; + } + })(); + + await webhookQueueLoadPromise; + } + + async function saveWebhookQueueState(nextState: TelegramWebhookQueueState): Promise { + if (!webhookUpdateStore) return; + await webhookUpdateStore.save(webhookQueueSnapshotToStore(nextState)); + } + + function queueWebhookUpdateIdExists(state: TelegramWebhookQueueState, updateId: number): boolean { + return state.queuedUpdateIds.has(updateId); + } + + function removeQueuedWebhookUpdatesById(state: TelegramWebhookQueueState, ids: ReadonlySet): TelegramWebhookQueueState { + if (ids.size === 0) return state; + + const nextQueuedWebhookUpdates: TelegramWebhookQueuedUpdate[] = []; + const nextQueuedUpdateIds = new Set(state.queuedUpdateIds); + let lastHandledWebhookUpdateId = state.lastHandledWebhookUpdateId; + + for (const row of state.queuedWebhookUpdates) { + if (!ids.has(row.id)) { + nextQueuedWebhookUpdates.push(row); + continue; + } + + if (row.updateId !== null) { + nextQueuedUpdateIds.delete(row.updateId); + lastHandledWebhookUpdateId = + lastHandledWebhookUpdateId === null + ? row.updateId + : Math.max(lastHandledWebhookUpdateId, row.updateId); + } + } + + return { + lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId, + queuedWebhookUpdates: nextQueuedWebhookUpdates, + queuedUpdateIds: nextQueuedUpdateIds, + }; + } + + function dropOldestWebhookUpdate(state: TelegramWebhookQueueState): Readonly<{ + state: TelegramWebhookQueueState; + droppedQueueIds: ReadonlySet; + }> { + if (state.queuedWebhookUpdates.length === 0) { + return { + state, + droppedQueueIds: new Set(), + }; + } + const [oldest, ...rest] = state.queuedWebhookUpdates; + const nextQueuedUpdateIds = new Set(state.queuedUpdateIds); + if (oldest?.updateId !== null) { + nextQueuedUpdateIds.delete(oldest.updateId); + } + return { + state: { + lastHandledWebhookUpdateId: state.lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId, + queuedWebhookUpdates: rest, + queuedUpdateIds: nextQueuedUpdateIds, + }, + droppedQueueIds: oldest ? new Set([oldest.id]) : new Set(), + }; + } + + function appendWebhookUpdate(state: TelegramWebhookQueueState, update: unknown): Readonly<{ + state: TelegramWebhookQueueState; + droppedQueueIds: ReadonlySet; + }> { + const updateId = parseUpdateId(update); + if (updateId !== null) { + if (state.lastHandledWebhookUpdateId !== null && updateId <= state.lastHandledWebhookUpdateId) { + return { state, droppedQueueIds: new Set() }; + } + if (queueWebhookUpdateIdExists(state, updateId)) { + return { state, droppedQueueIds: new Set() }; + } + } + + let nextState: TelegramWebhookQueueState = { + lastHandledWebhookUpdateId: state.lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId + 1, + queuedWebhookUpdates: [ + ...state.queuedWebhookUpdates, + { + id: state.nextQueuedWebhookId, + update, + updateId, + }, + ], + queuedUpdateIds: updateId === null ? new Set(state.queuedUpdateIds) : new Set(state.queuedUpdateIds).add(updateId), + }; + + let droppedQueueIds = new Set(); + if (nextState.queuedWebhookUpdates.length > MAX_WEBHOOK_QUEUE_SIZE) { + const dropped = dropOldestWebhookUpdate(nextState); + nextState = dropped.state; + droppedQueueIds = new Set(dropped.droppedQueueIds); + } + + return { + state: nextState, + droppedQueueIds, + }; + } + + async function persistWebhookQueueOrWarn(state: TelegramWebhookQueueState): Promise { + if (!webhookUpdateStore) return; + try { + await saveWebhookQueueState(state); + } catch (error) { + logger.warn('[channelBridge] Failed to persist Telegram webhook queue; continuing without persistence', error); + } + } + + function dropPendingWebhookAckIds(ids: ReadonlySet): void { + if (ids.size === 0) return; + + for (const [key, pending] of pendingWebhookAcksByMessageKey) { + for (const id of ids) { + pending.queueIds.delete(id); + } + if (pending.queueIds.size === 0) { + pendingWebhookAcksByMessageKey.delete(key); + } + } + } + + async function ensureSelfIdentity(): Promise { + if (selfBotId !== null) return; + const self = await api.getMe(); + selfBotId = Number.isFinite(self.id) ? Math.trunc(self.id) : null; + } + + async function parseUpdates(updates: readonly unknown[]): Promise { + await ensureSelfIdentity(); + const out: ChannelBridgeInboundMessage[] = []; + for (const update of updates) { + const parsed = parseInboundFromUpdate({ + update, + selfBotId, + allowedChatIds, + allowAllSharedChats, + requireTopics, + }); + if (parsed) out.push(parsed); + } + return out; + } + + return { + providerId: 'telegram', + enqueueWebhookUpdate: (update: unknown) => withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + const appended = appendWebhookUpdate(currentState, update); + if (appended.state === currentState) { + return; + } + + await saveWebhookQueueState(appended.state); + setWebhookQueueState(appended.state); + if (appended.droppedQueueIds.size > 0) { + dropPendingWebhookAckIds(appended.droppedQueueIds); + droppedWebhookUpdates += appended.droppedQueueIds.size; + } + }), + pullInboundMessages: async () => { + if (webhookMode) { + const snapshot = await withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + return currentState.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + updateId: row.updateId, + })); + }); + + if (droppedWebhookUpdates > 0) { + logger.warn( + `[channelBridge] Telegram webhook queue overflow: dropped ${droppedWebhookUpdates} oldest update(s)`, + ); + droppedWebhookUpdates = 0; + } + + await ensureSelfIdentity(); + + const parsed: ChannelBridgeInboundMessage[] = []; + const consumedWithoutAck = new Set(); + for (const row of snapshot) { + const message = parseInboundFromUpdate({ + update: row.update, + selfBotId, + allowedChatIds, + allowAllSharedChats, + requireTopics, + }); + if (!message) { + consumedWithoutAck.add(row.id); + continue; + } + + parsed.push(message); + const key = inboundMessageKey(message); + const pending = pendingWebhookAcksByMessageKey.get(key) ?? { queueIds: new Set(), maxUpdateId: null }; + pending.queueIds.add(row.id); + + if (row.updateId !== null) { + pending.maxUpdateId = pending.maxUpdateId === null ? row.updateId : Math.max(pending.maxUpdateId, row.updateId); + } + + pendingWebhookAcksByMessageKey.set(key, pending); + } + + if (consumedWithoutAck.size > 0) { + await withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + const nextState = removeQueuedWebhookUpdatesById(currentState, consumedWithoutAck); + if (nextState !== currentState) { + setWebhookQueueState(nextState); + await persistWebhookQueueOrWarn(nextState); + } + dropPendingWebhookAckIds(consumedWithoutAck); + }); + } + return parsed; + } + + if (pendingPollingBatchAck) { + return pendingPollingBatchAck.messages.map((message) => ({ ...message })); + } + + await ensurePollingCursorLoaded(); + const updates = await api.getUpdates({ + offset: updateOffset, + limit: updateLimit, + }); + + const parsed = await parseUpdates(updates); + const maxUpdateId = parseHighestUpdateOffset(updates); + if (parsed.length === 0) { + if (maxUpdateId !== null) { + const nextOffset = maxUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); + } + return []; + } + + pendingPollingBatchAck = { + messages: parsed, + pendingMessageKeys: new Set(parsed.map((message) => inboundMessageKey(message))), + ackedMessageKeys: new Set(), + maxUpdateId, + }; + + return parsed; + }, + ackInboundMessages: async (messages) => { + if (!webhookMode) { + const pendingBatch = pendingPollingBatchAck; + if (!pendingBatch || messages.length === 0) { + return; + } + + for (const message of messages) { + const key = inboundMessageKey(message); + if (pendingBatch.pendingMessageKeys.has(key)) { + pendingBatch.ackedMessageKeys.add(key); + } + } + + const allPendingMessagesAcked = + pendingBatch.pendingMessageKeys.size > 0 + && Array.from(pendingBatch.pendingMessageKeys).every((key) => pendingBatch.ackedMessageKeys.has(key)); + + if (!allPendingMessagesAcked) { + return; + } + + if (pendingBatch.maxUpdateId !== null) { + const nextOffset = pendingBatch.maxUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); + } + + pendingPollingBatchAck = null; + return; + } + + if (messages.length === 0) { + return; + } + + const consumedIds = new Set(); + + for (const message of messages) { + const key = inboundMessageKey(message); + const pending = pendingWebhookAcksByMessageKey.get(key); + if (!pending) { + continue; + } + + for (const queueId of pending.queueIds) { + consumedIds.add(queueId); + } + + pendingWebhookAcksByMessageKey.delete(key); + } + + if (consumedIds.size > 0) { + await withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + const nextState = removeQueuedWebhookUpdatesById(currentState, consumedIds); + if (nextState !== currentState) { + setWebhookQueueState(nextState); + await persistWebhookQueueOrWarn(nextState); + } + dropPendingWebhookAckIds(consumedIds); + + if (nextState.lastHandledWebhookUpdateId !== null) { + const nextOffset = nextState.lastHandledWebhookUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); + } + }); + } + }, + sendMessage: async (message) => { + const normalizedText = String(message.text).trim(); + const codePoints = Array.from(normalizedText); + const text = codePoints.length > TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH + ? codePoints.slice(0, TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH).join('') + : normalizedText; + + if (!text) { + return; + } + + if (text !== normalizedText) { + logger.warn( + `[channelBridge] Truncated Telegram outbound message for conversation ${message.conversationId} to ${TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH} characters`, + ); + } + + await api.sendMessage({ + chatId: message.conversationId, + threadId: message.threadId, + text, + }); + }, + }; +} diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts new file mode 100644 index 000000000..03ec476db --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +describe('createTelegramPollingCursorStore', () => { + const envKeys = ['HAPPIER_HOME_DIR'] as const; + let envScope = createEnvKeyScope(envKeys); + + afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + vi.resetModules(); + }); + + it('persists polling cursors under the active server directory scoped by account and bot token', async () => { + await withTempDir('happier-channel-bridge-telegram-cursors-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createTelegramPollingCursorStore } = await import('./telegramPollingCursorStore'); + + const storeA = createTelegramPollingCursorStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + await storeA.save(123); + await expect(storeA.load()).resolves.toBe(123); + + const storeB = createTelegramPollingCursorStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + await expect(storeB.load()).resolves.toBe(123); + + const otherAccountStore = createTelegramPollingCursorStore({ + accountId: 'acct-2', + botToken: 'token-1', + }); + await expect(otherAccountStore.load()).resolves.toBe(null); + + const otherTokenStore = createTelegramPollingCursorStore({ + accountId: 'acct-1', + botToken: 'token-2', + }); + await expect(otherTokenStore.load()).resolves.toBe(null); + }); + }); +}); diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts new file mode 100644 index 000000000..e17733dda --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts @@ -0,0 +1,120 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { configuration } from '@/configuration'; +import { assertFilesystemSafeAccountId } from '@/channels/state/assertFilesystemSafeAccountId'; +import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; +import { logger } from '@/ui/logger'; + +type StoredTelegramPollingCursorDocV1 = Readonly<{ + schemaVersion: 1; + updateOffset: number; +}>; + +const STORE_SCHEMA_VERSION = 1; +const BOT_TOKEN_HASH_LENGTH = 32; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + return truncated >= 0 ? truncated : null; +} + +function parseStoredDoc(value: unknown): StoredTelegramPollingCursorDocV1 | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== STORE_SCHEMA_VERSION) return null; + const updateOffset = toNonNegativeInt(record.updateOffset); + if (updateOffset === null) return null; + return { schemaVersion: 1, updateOffset }; +} + +function hashBotToken(botToken: string): string { + const normalized = botToken.trim(); + if (!normalized) { + throw new Error('Telegram bot token is required'); + } + return createHash('sha256').update(normalized).digest('hex').slice(0, BOT_TOKEN_HASH_LENGTH); +} + +async function bestEffortChmod0700(path: string): Promise { + if (process.platform === 'win32') return; + await chmod(path, 0o700).catch(() => {}); +} + +function resolveCursorFile(accountId: string, botToken: string): string { + const tokenHash = hashBotToken(botToken); + return join( + configuration.activeServerDir, + 'channel-bridges', + 'v1', + 'account', + accountId, + 'providers', + 'telegram', + 'polling-cursors', + `${tokenHash}.json`, + ); +} + +export type TelegramPollingCursorStore = Readonly<{ + load: () => Promise; + save: (offset: number) => Promise; +}>; + +export function createTelegramPollingCursorStore(params: Readonly<{ accountId: string; botToken: string }>): TelegramPollingCursorStore { + const accountId = assertFilesystemSafeAccountId(params.accountId); + const cursorFile = resolveCursorFile(accountId, params.botToken); + let queue = Promise.resolve(); + + const enqueue = (work: () => Promise): Promise => { + const run = queue.then(work, work); + queue = run.then(() => undefined, () => undefined); + return run; + }; + + return { + load: () => enqueue(async () => { + try { + const raw = await readFile(cursorFile, { encoding: 'utf-8' }).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') return null; + throw error; + }); + const parsed = raw ? parseStoredDoc(JSON.parse(raw)) : null; + return parsed?.updateOffset ?? null; + } catch (error) { + logger.warn('[channelBridge] Failed to read Telegram polling cursor; treating cursor as missing', error); + return null; + } + }), + save: (offset) => enqueue(async () => { + const candidate = toNonNegativeInt(offset); + if (candidate === null) { + throw new Error('Invalid Telegram polling cursor offset'); + } + + const accountDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId); + const telegramDir = join(accountDir, 'providers', 'telegram'); + const cursorsDir = join(telegramDir, 'polling-cursors'); + await mkdir(cursorsDir, { recursive: true, mode: 0o700 }); + await bestEffortChmod0700(configuration.activeServerDir); + + const doc: StoredTelegramPollingCursorDocV1 = { + schemaVersion: 1, + updateOffset: candidate, + }; + await writeJsonAtomic(cursorFile, doc); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); + await bestEffortChmod0700(accountDir); + await bestEffortChmod0700(cursorsDir); + }), + }; +} diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts new file mode 100644 index 000000000..b5d72f011 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts @@ -0,0 +1,190 @@ +import axios from 'axios'; +import { describe, expect, it, vi } from 'vitest'; + +import { logger } from '@/ui/logger'; +import { startTelegramWebhookRelay } from './telegramWebhookRelay'; + +describe('startTelegramWebhookRelay', () => { + it('rejects webhook secret tokens outside Telegram-safe charset', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretToken: 'bad$token', + onUpdate: () => { + throw new Error('should not be called'); + }, + })).rejects.toThrow('Webhook secret token must match [A-Za-z0-9_-]'); + }); + + it('accepts webhook updates on the fixed webhook path', async () => { + const received: unknown[] = []; + + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretToken: 'secret-123', + onUpdate: (update) => { + received.push(update); + }, + }); + + try { + const response = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { + update_id: 42, + message: { text: 'hello' }, + }, { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'secret-123', + }, + }); + + expect(response.status).toBe(200); + expect(received).toEqual([ + { + update_id: 42, + message: { text: 'hello' }, + }, + ]); + } finally { + await relay.stop(); + } + }); + + it('rejects requests when header secret token is missing or invalid', async () => { + const received: unknown[] = []; + + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretToken: 'header-token-abc', + onUpdate: (update) => { + received.push(update); + }, + }); + + try { + const missingHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { + update_id: 101, + message: { text: 'hello' }, + }, { + validateStatus: () => true, + }); + + const invalidHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { + update_id: 102, + message: { text: 'hello again' }, + }, { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'wrong-token', + }, + validateStatus: () => true, + }); + + expect(missingHeaderResponse.status).toBe(401); + expect(invalidHeaderResponse.status).toBe(401); + expect(received).toEqual([]); + } finally { + await relay.stop(); + } + }); + + it('does not acknowledge webhook updates when onUpdate fails', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + let relay: Awaited> | null = null; + + try { + relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretToken: 'secret-fail', + onUpdate: async () => { + throw new Error('failed to process update'); + }, + }); + + const response = await axios.post( + `http://127.0.0.1:${relay.port}/telegram/webhook`, + { + update_id: 202, + message: { text: 'hello' }, + }, + { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'secret-fail', + }, + validateStatus: () => true, + }, + ); + + expect(response.status).toBeGreaterThanOrEqual(500); + expect(response.status).toBeLessThan(600); + expect(warnSpy).toHaveBeenCalledWith( + '[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', + expect.objectContaining({ + path: '/telegram/webhook', + tokenLength: 'secret-fail'.length, + }), + ); + } finally { + warnSpy.mockRestore(); + await relay?.stop(); + } + }); + + it('rejects webhook secret tokens that exceed the maximum length', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretToken: 'x'.repeat(300), + onUpdate: () => undefined, + })).rejects.toThrow('Webhook secret token is too long'); + }); + + it('rejects requests with oversized header secret tokens without allocating large buffers', async () => { + const received: unknown[] = []; + + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretToken: 'header-token-abc', + onUpdate: (update) => { + received.push(update); + }, + }); + + try { + const oversizedHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { + update_id: 103, + message: { text: 'hello' }, + }, { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'x'.repeat(400), + }, + validateStatus: () => true, + }); + + expect(oversizedHeaderResponse.status).toBe(431); + expect(received).toEqual([]); + } finally { + await relay.stop(); + } + }); + + it('rejects non-loopback webhook hosts', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '0.0.0.0', + secretToken: 'secret-123', + onUpdate: () => undefined, + })).rejects.toThrow('Webhook host must be loopback-only'); + }); + + it('rejects non-finite webhook ports', async () => { + await expect(startTelegramWebhookRelay({ + port: Number.NaN, + host: '127.0.0.1', + secretToken: 'secret-123', + onUpdate: () => undefined, + })).rejects.toThrow('Webhook port must be a finite number'); + }); +}); diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts new file mode 100644 index 000000000..d9bb3db4f --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -0,0 +1,111 @@ +import { timingSafeEqual } from 'node:crypto'; + +import fastify from 'fastify'; + +import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { logger } from '@/ui/logger'; +import { + assertTelegramWebhookSecretToken, + TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH, +} from './telegramWebhookSecretToken'; + +function secureCompareToken(providedToken: string, expectedToken: string): boolean { + const providedBytes = Buffer.from(providedToken, 'utf8'); + const expectedBytes = Buffer.from(expectedToken, 'utf8'); + const length = Math.max(1, providedBytes.length, expectedBytes.length); + const providedPadded = Buffer.alloc(length); + const expectedPadded = Buffer.alloc(length); + providedBytes.copy(providedPadded); + expectedBytes.copy(expectedPadded); + + const matches = timingSafeEqual(providedPadded, expectedPadded); + return matches && providedBytes.length === expectedBytes.length; +} + +export type TelegramWebhookRelayHandle = Readonly<{ + port: number; + stop: () => Promise; +}>; + +export async function startTelegramWebhookRelay(params: Readonly<{ + port: number; + host?: string; + secretToken: string; + onUpdate: (update: unknown) => void | Promise; +}>): Promise { + const secretToken = assertTelegramWebhookSecretToken(params.secretToken, { + empty: 'Webhook secret token is required', + invalid: 'Webhook secret token must match [A-Za-z0-9_-]', + tooLong: 'Webhook secret token is too long', + }); + + const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; + if (!isLoopbackHostname(host)) { + throw new Error('Webhook host must be loopback-only'); + } + if (params.port !== undefined && params.port !== null && !Number.isFinite(params.port)) { + throw new Error('Webhook port must be a finite number'); + } + const requestedPort = params.port === undefined || params.port === null ? 0 : Math.trunc(params.port); + if (requestedPort < 0 || requestedPort > 65_535) { + throw new Error('Webhook port must be between 0 and 65535'); + } + const path = '/telegram/webhook'; + const redactedPath = path; + + const app = fastify({ logger: false, bodyLimit: 1_000_000 }); + app.route({ + method: 'POST', + url: path, + onRequest: async (request, reply) => { + const providedHeader = request.headers['x-telegram-bot-api-secret-token']; + const providedToken = + typeof providedHeader === 'string' + ? providedHeader + : Array.isArray(providedHeader) && providedHeader.length > 0 + ? providedHeader[0] ?? '' + : ''; + const trimmedProvidedToken = providedToken.trim(); + if (trimmedProvidedToken.length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) { + return reply.status(431).send({ ok: false, error: 'Request Header Fields Too Large' }); + } + if (!secureCompareToken(trimmedProvidedToken, secretToken)) { + return reply.status(401).send({ ok: false, error: 'Unauthorized' }); + } + return undefined; + }, + handler: async (request, reply) => { + const providedHeader = request.headers['x-telegram-bot-api-secret-token']; + const providedToken = + typeof providedHeader === 'string' + ? providedHeader + : Array.isArray(providedHeader) && providedHeader.length > 0 + ? providedHeader[0] ?? '' + : ''; + const trimmedProvidedToken = providedToken.trim(); + + try { + await params.onUpdate(request.body); + return reply.send({ ok: true }); + } catch (error) { + logger.warn('[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', { + path: redactedPath, + tokenLength: trimmedProvidedToken.length, + error: error instanceof Error ? error.message : String(error), + }); + return reply.status(500).send({ ok: false, error: 'Internal Server Error' }); + } + }, + }); + + await app.listen({ port: requestedPort, host }); + const address = app.server.address(); + const boundPort = typeof address === 'object' && address ? address.port : requestedPort; + + return { + port: boundPort, + stop: async () => { + await app.close(); + }, + }; +} diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts new file mode 100644 index 000000000..642da7247 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts @@ -0,0 +1,28 @@ +const TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; +export const TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH = 256; + +export function readTelegramWebhookSecretToken(value: unknown): string | null { + if (typeof value !== 'string') return null; + const token = value.trim(); + if (token.length === 0) return null; + if (token.length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) return null; + if (!TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN.test(token)) return null; + return token; +} + +export function assertTelegramWebhookSecretToken( + value: string, + errors: Readonly<{ empty: string; invalid: string; tooLong: string }>, +): string { + if (value.trim().length === 0) { + throw new Error(errors.empty); + } + if (value.trim().length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) { + throw new Error(errors.tooLong); + } + const token = readTelegramWebhookSecretToken(value); + if (!token) { + throw new Error(errors.invalid); + } + return token; +} diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts new file mode 100644 index 000000000..bcefeab00 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +describe('createTelegramWebhookUpdateStore', () => { + const envKeys = ['HAPPIER_HOME_DIR'] as const; + let envScope = createEnvKeyScope(envKeys); + + afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + vi.resetModules(); + }); + + it('persists queued webhook updates scoped by account and bot token', async () => { + await withTempDir('happier-channel-bridge-telegram-webhook-updates-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createTelegramWebhookUpdateStore } = await import('./telegramWebhookUpdateStore'); + + const storeA = createTelegramWebhookUpdateStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + + await storeA.save({ + lastHandledWebhookUpdateId: 41, + nextQueuedWebhookId: 2, + queuedWebhookUpdates: [ + { + id: 1, + update: { + update_id: 42, + message: { text: 'hello' }, + }, + }, + ], + }); + + const storeB = createTelegramWebhookUpdateStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + + await expect(storeB.load()).resolves.toEqual({ + lastHandledWebhookUpdateId: 41, + nextQueuedWebhookId: 2, + queuedWebhookUpdates: [ + { + id: 1, + update: { + update_id: 42, + message: { text: 'hello' }, + }, + }, + ], + }); + + const otherAccountStore = createTelegramWebhookUpdateStore({ + accountId: 'acct-2', + botToken: 'token-1', + }); + await expect(otherAccountStore.load()).resolves.toBe(null); + + const otherTokenStore = createTelegramWebhookUpdateStore({ + accountId: 'acct-1', + botToken: 'token-2', + }); + await expect(otherTokenStore.load()).resolves.toBe(null); + }); + }); +}); diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts new file mode 100644 index 000000000..e730ff634 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts @@ -0,0 +1,187 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { configuration } from '@/configuration'; +import { assertFilesystemSafeAccountId } from '@/channels/state/assertFilesystemSafeAccountId'; +import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; +import { logger } from '@/ui/logger'; + +type RecordLike = Record; + +export type TelegramWebhookUpdateStoreSnapshot = Readonly<{ + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: readonly Readonly<{ + id: number; + update: unknown; + }>[]; +}>; + +export type TelegramWebhookUpdateStore = Readonly<{ + load: () => Promise; + save: (snapshot: TelegramWebhookUpdateStoreSnapshot) => Promise; +}>; + +type StoredTelegramWebhookUpdateDocV1 = Readonly<{ + schemaVersion: 1; + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: Array>; +}>; + +const STORE_SCHEMA_VERSION = 1; +const BOT_TOKEN_HASH_LENGTH = 32; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + return truncated >= 0 ? truncated : null; +} + +function toPositiveInt(value: unknown): number | null { + const candidate = toNonNegativeInt(value); + return candidate !== null && candidate > 0 ? candidate : null; +} + +function parseStoredDoc(value: unknown): StoredTelegramWebhookUpdateDocV1 | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== STORE_SCHEMA_VERSION) return null; + + const queuedWebhookUpdatesRaw = Array.isArray(record.queuedWebhookUpdates) ? record.queuedWebhookUpdates : []; + const queuedWebhookUpdates: StoredTelegramWebhookUpdateDocV1['queuedWebhookUpdates'] = []; + let maxQueuedWebhookId = 0; + + for (const entry of queuedWebhookUpdatesRaw) { + const row = asRecord(entry); + if (!row) continue; + const id = toPositiveInt(row.id); + if (id === null) continue; + queuedWebhookUpdates.push({ + id, + update: row.update, + }); + if (id > maxQueuedWebhookId) { + maxQueuedWebhookId = id; + } + } + + queuedWebhookUpdates.sort((left, right) => left.id - right.id); + + const nextQueuedWebhookIdCandidate = toPositiveInt(record.nextQueuedWebhookId); + const nextQueuedWebhookId = Math.max( + maxQueuedWebhookId + 1, + nextQueuedWebhookIdCandidate === null ? 1 : nextQueuedWebhookIdCandidate, + ); + + return { + schemaVersion: STORE_SCHEMA_VERSION, + lastHandledWebhookUpdateId: toNonNegativeInt(record.lastHandledWebhookUpdateId), + nextQueuedWebhookId, + queuedWebhookUpdates, + }; +} + +function hashBotToken(botToken: string): string { + const normalized = botToken.trim(); + if (!normalized) { + throw new Error('Telegram bot token is required'); + } + return createHash('sha256').update(normalized).digest('hex').slice(0, BOT_TOKEN_HASH_LENGTH); +} + +async function bestEffortChmod0700(path: string): Promise { + if (process.platform === 'win32') return; + await chmod(path, 0o700).catch(() => {}); +} + +function resolveStoreFilePath(accountId: string, botToken: string): string { + const tokenHash = hashBotToken(botToken); + return join( + configuration.activeServerDir, + 'channel-bridges', + 'v1', + 'account', + accountId, + 'providers', + 'telegram', + 'webhook-updates', + `${tokenHash}.json`, + ); +} + +function snapshotToDoc(snapshot: TelegramWebhookUpdateStoreSnapshot): StoredTelegramWebhookUpdateDocV1 { + return { + schemaVersion: STORE_SCHEMA_VERSION, + lastHandledWebhookUpdateId: snapshot.lastHandledWebhookUpdateId === null + ? null + : Math.max(0, Math.trunc(snapshot.lastHandledWebhookUpdateId)), + nextQueuedWebhookId: Math.max(1, Math.trunc(snapshot.nextQueuedWebhookId)), + queuedWebhookUpdates: snapshot.queuedWebhookUpdates.map((row) => ({ + id: Math.max(1, Math.trunc(row.id)), + update: row.update, + })), + }; +} + +export function createTelegramWebhookUpdateStore(params: Readonly<{ accountId: string; botToken: string }>): TelegramWebhookUpdateStore { + const accountId = assertFilesystemSafeAccountId(params.accountId); + const updateFile = resolveStoreFilePath(accountId, params.botToken); + let queue = Promise.resolve(); + + const enqueue = (work: () => Promise): Promise => { + const run = queue.then(work, work); + queue = run.then(() => undefined, () => undefined); + return run; + }; + + return { + load: () => enqueue(async () => { + try { + const raw = await readFile(updateFile, { encoding: 'utf-8' }).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') return null; + throw error; + }); + const parsed = raw ? parseStoredDoc(JSON.parse(raw)) : null; + return parsed + ? { + lastHandledWebhookUpdateId: parsed.lastHandledWebhookUpdateId, + nextQueuedWebhookId: parsed.nextQueuedWebhookId, + queuedWebhookUpdates: parsed.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + })), + } + : null; + } catch (error) { + logger.warn('[channelBridge] Failed to read Telegram webhook update queue; treating queue as missing', error); + return null; + } + }), + save: (snapshot) => enqueue(async () => { + const doc = snapshotToDoc(snapshot); + const accountDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId); + const telegramDir = join(accountDir, 'providers', 'telegram'); + const updatesDir = join(telegramDir, 'webhook-updates'); + + await mkdir(updatesDir, { recursive: true, mode: 0o700 }); + await bestEffortChmod0700(configuration.activeServerDir); + + await writeJsonAtomic(updateFile, doc); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); + await bestEffortChmod0700(accountDir); + await bestEffortChmod0700(updatesDir); + }), + }; +} diff --git a/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts new file mode 100644 index 000000000..4b0521567 --- /dev/null +++ b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts @@ -0,0 +1,90 @@ +import type { + ChannelBindingStore, + ChannelBridgeAdapter, + ChannelBridgeDeps, + ChannelBridgeWorkerHandle, +} from '@/channels/core/channelBridgeWorker'; +import { startChannelBridgeWorker } from '@/channels/core/channelBridgeWorker'; +import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { listChannelBridgeProviderIds, resolveChannelBridgeProviderDefinition } from '@/channels/providers/_registry/channelBridgeProviderRegistry'; +import type { ChannelBridgeProviderRuntimeContext } from '@/channels/providers/_registry/types'; +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { logger } from '@/ui/logger'; + +export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; + +async function startProviderRuntimes(params: Readonly<{ + runtimeConfig: ChannelBridgeRuntimeConfig; + context: ChannelBridgeProviderRuntimeContext; +}>): Promise Promise }>> { + const adapters: ChannelBridgeAdapter[] = []; + const stopFns: Array<() => Promise> = []; + + for (const providerId of listChannelBridgeProviderIds()) { + const provider = resolveChannelBridgeProviderDefinition(providerId); + const providerConfig = provider.readConfig(params.runtimeConfig); + let runtime: Awaited> | null; + try { + runtime = await provider.createRuntime({ config: providerConfig, context: params.context }); + } catch (error) { + logger.warn( + `[channelBridge] Failed to start provider runtime (provider=${providerId}); skipping provider`, + serializeAxiosErrorForLog(error), + ); + continue; + } + if (!runtime) continue; + adapters.push(...runtime.adapters); + stopFns.push(runtime.stop); + } + + return { + adapters, + stop: async () => { + for (const stop of stopFns) { + try { + await stop(); + } catch { + // Best-effort shutdown: keep stopping remaining provider runtimes. + } + } + }, + }; +} + +export async function startChannelBridgeRuntime(params: Readonly<{ + store: ChannelBindingStore; + deps: ChannelBridgeDeps; + runtimeConfig: ChannelBridgeRuntimeConfig; + context: ChannelBridgeProviderRuntimeContext; +}>): Promise { + const providers = await startProviderRuntimes({ runtimeConfig: params.runtimeConfig, context: params.context }); + if (providers.adapters.length === 0) return null; + + let worker: ChannelBridgeWorkerHandle; + try { + worker = startChannelBridgeWorker({ + store: params.store, + adapters: providers.adapters, + deps: params.deps, + tickMs: params.runtimeConfig.tickMs, + }); + } catch (error) { + await providers.stop().catch(() => undefined); + throw error; + } + + let stopped = false; + return { + trigger: worker.trigger, + stop: async () => { + if (stopped) return; + stopped = true; + try { + await worker.stop(); + } finally { + await providers.stop(); + } + }, + }; +} diff --git a/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts new file mode 100644 index 000000000..2d1d7e9d1 --- /dev/null +++ b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; + +const credentials = { + token: 'token-1', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array([1, 2, 3]), + }, +}; + +describe('startChannelBridgeFromEnv startup failures', () => { + it('stops relay if worker creation throws', async () => { + vi.resetModules(); + + const relayStop = vi.fn(async () => undefined); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn(() => { + throw new Error('worker start failed'); + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + })), + })); + + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + await expect(startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-1', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('worker start failed'); + + expect(relayStop).toHaveBeenCalledTimes(1); + }); + + it('does not fail shutdown when relay stop throws after worker stop succeeds', async () => { + vi.resetModules(); + + const relayStop = vi.fn(async () => { + throw new Error('relay stop failed'); + }); + const workerStop = vi.fn(async () => undefined); + const warnSpy = vi.fn(); + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: warnSpy, + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn(() => ({ + trigger: vi.fn(), + stop: workerStop, + })), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + })), + })); + + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-1', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + await expect(handle!.stop()).resolves.toBeUndefined(); + expect(workerStop).toHaveBeenCalledTimes(1); + expect(relayStop).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + '[channelBridge] Error stopping Telegram webhook relay during shutdown', + expect.any(Error), + ); + }); +}); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts new file mode 100644 index 000000000..d42847819 --- /dev/null +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -0,0 +1,1100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { startChannelBridgeFromEnv } from './startChannelBridgeWorker'; +import type { ChannelBridgeDeps } from '@/channels/core/channelBridgeWorker'; + +type SendCommittedFn = typeof import('@/session/transport/socket/sessionSocketSendMessage').sendSessionMessageViaSocketCommitted; + +const credentials = { + token: 'token-1', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array([1, 2, 3]), + }, +}; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.resetModules(); + + for (const modulePath of [ + '@/session/transport/http/sessionsHttp', + '@/api/session/fetchEncryptedTranscriptWindow', + '@/session/transport/socket/sessionSocketSendMessage', + '@/session/replay/decryptTranscriptRows', + '@/ui/logger', + '@/channels/core/channelBridgeWorker', + '@/channels/providers/telegram/telegramAdapter', + '@/channels/providers/telegram/telegramWebhookRelay', + '@/channels/state/localBindingStore', + ]) { + vi.doUnmock(modulePath); + } +}); + +describe('startChannelBridgeFromEnv', () => { + it('returns null when telegram token is not configured and no custom adapters are provided', async () => { + const handle = await startChannelBridgeFromEnv({ + credentials, + env: {}, + }); + + expect(handle).toBeNull(); + }); + + it('starts with injected adapters/deps even without telegram env configuration', async () => { + const stopSpy = vi.fn(); + const adapter = { + providerId: 'fake', + pullInboundMessages: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + stop: stopSpy, + }; + + const deps = { + listSessions: vi.fn(async () => []), + resolveSessionIdOrPrefix: vi.fn(async () => ({ ok: false as const, code: 'session_not_found' as const })), + sendUserMessageToSession: vi.fn(async () => undefined), + resolveLatestSessionSeq: vi.fn(async () => 0), + fetchAgentMessagesAfterSeq: vi.fn(async () => []), + onWarning: vi.fn(), + }; + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { HAPPIER_CHANNEL_BRIDGE_TICK_MS: '500' } as NodeJS.ProcessEnv, + adapters: [adapter], + deps, + }); + + expect(handle).not.toBeNull(); + handle?.trigger(); + await handle?.stop(); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('fetches only the first sessions page in default bridge deps', async () => { + vi.resetModules(); + + const fetchSessionsPage = vi + .fn() + .mockResolvedValueOnce({ + sessions: [{ id: 'sess-1', metadata: null }], + nextCursor: 'cursor-1', + hasNext: true, + }) + .mockResolvedValueOnce({ + sessions: [{ id: 'sess-2', metadata: null }], + nextCursor: null, + hasNext: false, + }); + + let capturedDeps: ChannelBridgeDeps | null = null; + + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionsPage, + }; + }); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: ChannelBridgeDeps }) => { + capturedDeps = params.deps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + const sessions = await capturedDeps!.listSessions(); + expect(fetchSessionsPage).toHaveBeenCalledTimes(1); + expect(fetchSessionsPage).toHaveBeenNthCalledWith(1, { + token: credentials.token, + activeOnly: false, + limit: 20, + cursor: undefined, + }); + expect(sessions.map((row) => row.sessionId)).toEqual(['sess-1']); + + await handle?.stop(); + }); + + it('reuses cached session context between send and transcript fetch calls', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-ctx-1', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const fetchAfterSeq = vi.fn(async () => []); + const sendCommitted = vi.fn(async (_params) => undefined); + + let capturedDeps: ChannelBridgeDeps | null = null; + + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/api/session/fetchEncryptedTranscriptWindow', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchEncryptedTranscriptPageAfterSeq: fetchAfterSeq, + }; + }); + + vi.doMock('@/session/transport/socket/sessionSocketSendMessage', () => ({ + sendSessionMessageViaSocketCommitted: sendCommitted, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-ctx-1', + text: 'hello from channel', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + messageId: 'msg-ctx-1', + }); + await capturedDeps!.fetchAgentMessagesAfterSeq({ + sessionId: 'sess-ctx-1', + afterSeq: 0, + }); + + expect(fetchSessionById).toHaveBeenCalledTimes(1); + expect(sendCommitted).toHaveBeenCalledTimes(1); + expect(fetchAfterSeq).toHaveBeenCalledTimes(1); + + await handle?.stop(); + }); + + it('bypasses decryptTranscriptRows for plain sessions when fetching agent output', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-plain-1', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const fetchAfterSeq = vi.fn(async () => ([ + { + seq: 11, + createdAt: 1, + content: { + t: 'plain', + v: { + role: 'agent', + content: { + type: 'text', + text: 'plain assistant output', + }, + }, + }, + }, + { + seq: 12, + createdAt: 2, + content: { + t: 'plain', + v: { + role: 'user', + content: { + type: 'text', + text: 'user message', + }, + }, + }, + }, + ])); + const decryptRows = vi.fn(() => ([ + { + seq: 99, + createdAtMs: 1, + role: 'agent', + content: { type: 'text', text: 'should not be used' }, + }, + ])); + + let capturedDeps: { + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; + } | null = null; + + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/api/session/fetchEncryptedTranscriptWindow', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchEncryptedTranscriptPageAfterSeq: fetchAfterSeq, + }; + }); + + vi.doMock('@/session/replay/decryptTranscriptRows', () => ({ + decryptTranscriptRows: decryptRows, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + const rows = await capturedDeps!.fetchAgentMessagesAfterSeq({ + sessionId: 'sess-plain-1', + afterSeq: 0, + }); + + expect(rows).toEqual({ + messages: [ + { + seq: 11, + text: 'plain assistant output', + }, + ], + highestSeenSeq: 12, + }); + expect(decryptRows).not.toHaveBeenCalled(); + + await handle?.stop(); + }); + + it('uses deterministic localId for retried channel messages and rejects missing message ids', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-local-id-1', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const sendCommitted = vi.fn(async (_params) => undefined); + + let capturedDeps: { + sendUserMessageToSession: (params: { + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + messageId?: string; + }) => Promise; + } | null = null; + + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/session/transport/socket/sessionSocketSendMessage', () => ({ + sendSessionMessageViaSocketCommitted: sendCommitted, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'first attempt', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-1', + }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'retry same message', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-1', + }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'different message', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-2', + }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-2', + text: 'same channel message into different session', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-1', + }); + + await expect(capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'missing message id', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + })).rejects.toThrow('inbound messageId is required'); + + await expect(capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'blank message id', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: ' ', + })).rejects.toThrow('inbound messageId is required'); + + expect(sendCommitted).toHaveBeenCalledTimes(4); + const firstLocalId = sendCommitted.mock.calls[0]?.[0]?.localId; + const secondLocalId = sendCommitted.mock.calls[1]?.[0]?.localId; + const thirdLocalId = sendCommitted.mock.calls[2]?.[0]?.localId; + const fourthLocalId = sendCommitted.mock.calls[3]?.[0]?.localId; + + expect(firstLocalId).toBe(secondLocalId); + expect(thirdLocalId).not.toBe(firstLocalId); + expect(fourthLocalId).not.toBe(firstLocalId); + + await handle?.stop(); + }); + + it('warns when agent transcript rows cannot be mapped to bridge text output', async () => { + vi.resetModules(); + + const warnSpy = vi.fn(); + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-plain-unextractable', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const fetchAfterSeq = vi.fn(async () => ([ + { + seq: 11, + createdAt: 1, + content: { + t: 'plain', + v: { + role: 'agent', + content: { + type: 'output', + data: { + message: 123, + }, + }, + }, + }, + }, + ])); + + let capturedDeps: { + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; + } | null = null; + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: warnSpy, + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/api/session/fetchEncryptedTranscriptWindow', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchEncryptedTranscriptPageAfterSeq: fetchAfterSeq, + }; + }); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + const rows = await capturedDeps!.fetchAgentMessagesAfterSeq({ + sessionId: 'sess-plain-unextractable', + afterSeq: 0, + }); + + expect(rows).toEqual({ + messages: [], + highestSeenSeq: 11, + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[channelBridge] Skipped agent transcript row with unsupported content'), + ); + + await handle?.stop(); + }); + + it('refreshes session runtime LRU order on cache hits', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async ({ sessionId }: { sessionId: string }) => ({ + id: sessionId, + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const sendCommitted = vi.fn(async (_params) => undefined); + + let capturedDeps: ChannelBridgeDeps | null = null; + + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/session/transport/socket/sessionSocketSendMessage', () => ({ + sendSessionMessageViaSocketCommitted: sendCommitted, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: ChannelBridgeDeps }) => { + capturedDeps = params.deps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + for (let index = 0; index < 200; index += 1) { + const sessionId = `sess-lru-${index}`; + await capturedDeps!.sendUserMessageToSession({ + sessionId, + text: 'prime cache', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + messageId: `msg-${sessionId}`, + }); + } + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-lru-0', + text: 'refresh oldest', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + messageId: 'msg-sess-lru-0-refresh', + }); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-lru-200', + text: 'trigger eviction', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + messageId: 'msg-sess-lru-200-evict', + }); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-lru-0', + text: 'should stay cached', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + messageId: 'msg-sess-lru-0-stay', + }); + + expect(fetchSessionById).toHaveBeenCalledTimes(201); + + await handle?.stop(); + }); + + it('warns when webhook mode is enabled without webhook secret', async () => { + vi.resetModules(); + + const warnSpy = vi.fn(); + const startRelay = vi.fn(); + const createTelegramChannelAdapter = vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })); + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: warnSpy, + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })), + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter, + })); + + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: startRelay, + }; + }); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + } as NodeJS.ProcessEnv, + }); + + expect(handle).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + '[channelBridge] Failed to start provider runtime (provider=telegram); skipping provider', + expect.objectContaining({ + message: expect.stringMatching(/webhook.*secret/i), + }), + ); + expect(startRelay).not.toHaveBeenCalled(); + expect(createTelegramChannelAdapter).not.toHaveBeenCalled(); + + await handle?.stop(); + }); + + it('stops webhook relay when binding store initialization fails before worker startup', async () => { + vi.resetModules(); + + const relayStop = vi.fn(async () => undefined); + const startWorker = vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })); + const storeInitError = new Error('binding store init failed'); + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: startWorker, + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); + + vi.doMock('@/channels/state/localBindingStore', () => ({ + createLocalChannelBindingStore: vi.fn(() => { + throw storeInitError; + }), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + await expect(startChannelBridgeFromEnv({ + credentials, + serverId: 'server-a', + accountId: 'acct-a', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-token', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('binding store init failed'); + + expect(relayStop).toHaveBeenCalledTimes(0); + expect(startWorker).not.toHaveBeenCalled(); + }); + + it('starts with in-memory bindings when only serverId is provided', async () => { + vi.resetModules(); + + const startWorker = vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })); + const createLocalChannelBindingStore = vi.fn(); + const createInMemoryChannelBindingStore = vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore, + startChannelBridgeWorker: startWorker, + })); + + vi.doMock('@/channels/state/localBindingStore', () => ({ + createLocalChannelBindingStore, + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const worker = await startChannelBridgeFromEnv({ + credentials, + serverId: 'server-only', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(worker).not.toBeNull(); + expect(createInMemoryChannelBindingStore).toHaveBeenCalledTimes(1); + expect(createLocalChannelBindingStore).not.toHaveBeenCalled(); + expect(startWorker).toHaveBeenCalledTimes(1); + }); + + it('uses local bindings when accountId is provided (server scope comes from activeServerDir)', async () => { + vi.resetModules(); + + const startWorker = vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })); + const createInMemoryChannelBindingStore = vi.fn(); + const createLocalChannelBindingStore = vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore, + startChannelBridgeWorker: startWorker, + })); + + vi.doMock('@/channels/state/localBindingStore', () => ({ + createLocalChannelBindingStore, + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const worker = await startChannelBridgeFromEnv({ + credentials, + accountId: 'acct_123', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(worker).not.toBeNull(); + expect(createLocalChannelBindingStore).toHaveBeenCalledTimes(1); + expect(createInMemoryChannelBindingStore).not.toHaveBeenCalled(); + expect(startWorker).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts new file mode 100644 index 000000000..23aeee277 --- /dev/null +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -0,0 +1,446 @@ +/** + * Channel bridge runtime wiring for the CLI daemon. + * + * Responsibilities: + * - resolve effective bridge runtime config from env/settings/account scope + * - construct provider adapters (Telegram today) and optional webhook relay + * - choose binding store implementation (in-memory or local file-backed state) + * - build default bridge deps that map channel events <-> session transcript I/O + * - start and return the core channel bridge worker lifecycle handle + * + * Public exports: + * - `ChannelBridgeRuntimeHandle` + * - `startChannelBridgeFromEnv` + * + * Runtime constraints: + * - designed for daemon/CLI runtime usage + * - webhook relay is loopback-bound and optional; polling remains supported + */ +import { createHash } from 'node:crypto'; + +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { fetchEncryptedTranscriptPageAfterSeq, fetchEncryptedTranscriptPageLatest } from '@/api/session/fetchEncryptedTranscriptWindow'; +import type { Credentials } from '@/persistence'; +import { decryptTranscriptRows } from '@/session/replay/decryptTranscriptRows'; +import { resolveSessionIdOrPrefix } from '@/session/query/resolveSessionId'; +import { + encryptSessionPayload, + resolveSessionEncryptionContextFromCredentials, + resolveSessionStoredContentEncryptionMode, + tryDecryptSessionMetadata, + type SessionEncryptionContext, +} from '@/session/transport/encryption/sessionEncryptionContext'; +import { sendSessionMessageViaSocketCommitted } from '@/session/transport/socket/sessionSocketSendMessage'; +import { fetchSessionById, fetchSessionsPage, type RawSessionListRow } from '@/session/transport/http/sessionsHttp'; +import { logger } from '@/ui/logger'; + +import { + createInMemoryChannelBindingStore, + startChannelBridgeWorker, + type ChannelBindingStore, + type ChannelBridgeAdapter, + type ChannelBridgeAgentMessageRow, + type ChannelBridgeDeps, + type ChannelBridgeWorkerHandle, +} from '@/channels/core/channelBridgeWorker'; +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { startChannelBridgeRuntime } from '@/channels/runtime/startChannelBridgeRuntime'; +import { createLocalChannelBindingStore } from '@/channels/state/localBindingStore'; + +export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; + +type DefaultChannelBridgeDepsHandle = Readonly<{ + deps: ChannelBridgeDeps; + dispose: () => void; +}>; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + return truncated >= 0 ? truncated : null; +} + +function createStableBridgeLocalId(params: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + sessionId: string; + text: string; + messageId?: string; +}>): string { + const normalizedMessageId = typeof params.messageId === 'string' ? params.messageId.trim() : ''; + if (normalizedMessageId.length === 0) { + throw new Error('Channel bridge inbound messageId is required for stable idempotency'); + } + + const digest = createHash('sha256') + .update(params.providerId) + .update('\u0000') + .update(params.conversationId) + .update('\u0000') + .update(params.threadId ?? '') + .update('\u0000') + .update(params.sessionId) + .update('\u0000') + .update(normalizedMessageId) + .digest('hex'); + return `bridge-${digest}`; +} + +function extractAssistantText(content: unknown): string | null { + const contentRecord = asRecord(content); + if (!contentRecord) return null; + + const type = contentRecord.type; + if (type === 'text') { + const text = contentRecord.text; + return typeof text === 'string' && text.trim().length > 0 ? text.trim() : null; + } + + if (type === 'acp') { + const data = asRecord(contentRecord.data); + if (!data) return null; + + const dataType = data.type; + if (dataType === 'message' || dataType === 'reasoning') { + const message = data.message; + return typeof message === 'string' && message.trim().length > 0 ? message.trim() : null; + } + return null; + } + + if (type === 'output') { + const data = asRecord(contentRecord.data); + if (!data) return null; + + const message = data.message; + return typeof message === 'string' && message.trim().length > 0 ? message.trim() : null; + } + + return null; +} + +type PlainTranscriptRowLike = Readonly<{ + seq: number; + createdAt: number; + content: unknown; +}>; + +function parsePlainTranscriptRows(rows: readonly PlainTranscriptRowLike[]): ReadonlyArray> { + const out: Array> = []; + + for (const row of rows) { + const content = asRecord(row.content); + if (!content || content.t !== 'plain') continue; + const value = asRecord(content.v); + if (!value) continue; + + const role = value.role; + if (role !== 'user' && role !== 'agent') continue; + + out.push({ + seq: row.seq, + createdAtMs: row.createdAt, + role, + content: value.content, + }); + } + + return out; +} + +function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChannelBridgeDepsHandle { + type CachedSessionRuntime = Readonly<{ + ctx: SessionEncryptionContext; + mode: ReturnType; + }>; + + const sessionRuntimeCache = new Map(); + const SESSION_CTX_CACHE_MAX_ENTRIES = 200; + + // Bounded LRU-style cache: cache hits are reinserted to the tail as most-recently-used. + // No TTL is applied by design to avoid per-tick server fetches; stale entries are naturally + // refreshed on miss/eviction and this cache stores only encryption runtime metadata. + const rememberSessionRuntime = (sessionId: string, runtime: CachedSessionRuntime): CachedSessionRuntime => { + if (sessionRuntimeCache.has(sessionId)) { + sessionRuntimeCache.delete(sessionId); + } + sessionRuntimeCache.set(sessionId, runtime); + while (sessionRuntimeCache.size > SESSION_CTX_CACHE_MAX_ENTRIES) { + const oldestKey = sessionRuntimeCache.keys().next().value; + if (!oldestKey) break; + sessionRuntimeCache.delete(oldestKey); + } + return runtime; + }; + + async function resolveSessionRuntime(sessionId: string): Promise { + const cached = sessionRuntimeCache.get(sessionId); + if (cached) return rememberSessionRuntime(sessionId, cached); + + const rawSession = await fetchSessionById({ token: credentials.token, sessionId }); + if (!rawSession) return null; + const runtime: CachedSessionRuntime = { + ctx: resolveSessionEncryptionContextFromCredentials(credentials, rawSession), + mode: resolveSessionStoredContentEncryptionMode(rawSession), + }; + return rememberSessionRuntime(sessionId, runtime); + } + + const onWarning: NonNullable = (message, error) => { + if (typeof error === 'undefined') { + logger.warn(`[channelBridge] ${message}`); + } else { + logger.warn(`[channelBridge] ${message}`, serializeAxiosErrorForLog(error)); + } + }; + + return { + deps: { + listSessions: async () => { + const displayLimit = 20; + const pageSize = displayLimit; + const maxPages = 1; + const sessions: RawSessionListRow[] = []; + let cursor: string | undefined; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const page = await fetchSessionsPage({ + token: credentials.token, + activeOnly: false, + limit: pageSize, + cursor, + }); + sessions.push(...page.sessions); + if (sessions.length >= displayLimit) { + cursor = undefined; + break; + } + if (!page.hasNext || !page.nextCursor) { + cursor = undefined; + break; + } + cursor = page.nextCursor; + } + + return sessions.slice(0, displayLimit).map((row) => { + const meta = tryDecryptSessionMetadata({ credentials, rawSession: row }); + const tagRaw = meta?.tag; + const tag = typeof tagRaw === 'string' ? tagRaw.trim() : ''; + return { + sessionId: row.id, + label: tag.length > 0 ? tag : null, + }; + }); + }, + resolveSessionIdOrPrefix: async (idOrPrefix: string) => { + return await resolveSessionIdOrPrefix({ + credentials, + idOrPrefix, + }); + }, + sendUserMessageToSession: async (params) => { + const runtime = await resolveSessionRuntime(params.sessionId); + if (!runtime) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const ctx = runtime.ctx; + const mode = runtime.mode; + const record = { + role: 'user', + content: { type: 'text', text: params.text }, + meta: { + sentFrom: params.sentFrom, + source: params.sentFrom, + channel: { + providerId: params.providerId, + conversationId: params.conversationId, + threadId: params.threadId, + }, + }, + }; + + const content = + mode === 'plain' + ? ({ t: 'plain', v: record } as const) + : ({ t: 'encrypted', c: encryptSessionPayload({ ctx, payload: record }) } as const); + + await sendSessionMessageViaSocketCommitted({ + token: credentials.token, + sessionId: params.sessionId, + content, + localId: createStableBridgeLocalId({ + providerId: params.providerId, + conversationId: params.conversationId, + threadId: params.threadId, + sessionId: params.sessionId, + text: params.text, + messageId: params.messageId, + }), + sentFrom: params.sentFrom, + }); + }, + resolveLatestSessionSeq: async (sessionId: string) => { + const rows = await fetchEncryptedTranscriptPageLatest({ + token: credentials.token, + sessionId, + limit: 1, + }); + if (rows.length === 0) return 0; + return Math.max(0, Math.trunc(rows[0]!.seq)); + }, + fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => { + const runtime = await resolveSessionRuntime(sessionId); + if (!runtime) { + return { + messages: [], + highestSeenSeq: null, + }; + } + + const encrypted = await fetchEncryptedTranscriptPageAfterSeq({ + token: credentials.token, + sessionId, + afterSeq, + limit: 50, + }); + + const highestSeenSeq = encrypted.reduce((currentMax, row) => { + const parsedSeq = toNonNegativeInt(row.seq); + if (parsedSeq === null) return currentMax; + if (currentMax === null) return parsedSeq; + return parsedSeq > currentMax ? parsedSeq : currentMax; + }, null); + + const transcriptRows = + runtime.mode === 'plain' + ? parsePlainTranscriptRows(encrypted) + : decryptTranscriptRows({ + ctx: runtime.ctx, + rows: encrypted, + }); + + const messages: ChannelBridgeAgentMessageRow[] = []; + for (const row of transcriptRows) { + if (row.role !== 'agent') { + continue; + } + + const text = extractAssistantText(row.content); + if (!text) { + onWarning( + `Skipped agent transcript row with unsupported content for session=${sessionId} seq=${row.seq}`, + ); + continue; + } + + messages.push({ + seq: row.seq, + text, + }); + } + + return { + messages, + highestSeenSeq, + }; + }, + onWarning, + }, + dispose: () => { + sessionRuntimeCache.clear(); + }, + }; +} + +export async function startChannelBridgeFromEnv(params: Readonly<{ + credentials: Credentials; + env?: NodeJS.ProcessEnv; + settings?: unknown; + serverId?: string | null; + accountId?: string | null; + deps?: ChannelBridgeDeps; + store?: ChannelBindingStore; + adapters?: readonly ChannelBridgeAdapter[]; +}>): Promise { + const env = params.env ?? process.env; + const runtimeConfig = resolveChannelBridgeRuntimeConfig({ + env, + settings: params.settings, + serverId: params.serverId, + accountId: params.accountId, + }); + + let defaultDepsHandle: DefaultChannelBridgeDepsHandle | null = null; + try { + const deps = (() => { + if (params.deps) return params.deps; + defaultDepsHandle = createDefaultChannelBridgeDeps(params.credentials); + return defaultDepsHandle.deps; + })(); + + let store = params.store ?? null; + if (!store) { + const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : ''; + if (accountId) { + store = createLocalChannelBindingStore({ accountId }); + } + } + if (!store) { + store = createInMemoryChannelBindingStore(); + } + + const injectedAdapters = params.adapters ? [...params.adapters] : []; + const worker = injectedAdapters.length > 0 + ? startChannelBridgeWorker({ + store, + adapters: injectedAdapters, + deps, + tickMs: runtimeConfig.tickMs, + }) + : await startChannelBridgeRuntime({ + store, + deps, + runtimeConfig, + context: { + serverId: typeof params.serverId === 'string' ? params.serverId.trim() || null : null, + accountId: typeof params.accountId === 'string' ? params.accountId.trim() || null : null, + }, + }); + + if (!worker) { + defaultDepsHandle?.dispose(); + return null; + } + + return { + trigger: worker.trigger, + stop: async () => { + try { + await worker.stop(); + } finally { + defaultDepsHandle?.dispose(); + } + }, + }; + } catch (error) { + defaultDepsHandle?.dispose(); + throw error; + } +} diff --git a/apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts b/apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts new file mode 100644 index 000000000..c43b11647 --- /dev/null +++ b/apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts @@ -0,0 +1,19 @@ +const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; + +export function assertFilesystemSafeAccountId(raw: string): string { + const value = raw.trim(); + if (!value) { + throw new Error('Invalid accountId: empty'); + } + if (value === '.' || value === '..') { + throw new Error(`Invalid accountId: ${value}`); + } + if (value.includes('/') || value.includes('\\')) { + throw new Error(`Invalid accountId: ${value}`); + } + if (!ACCOUNT_ID_SAFE_RE.test(value)) { + throw new Error(`Invalid accountId: ${value}`); + } + return value; +} + diff --git a/apps/cli/src/channels/state/localBindingStore.test.ts b/apps/cli/src/channels/state/localBindingStore.test.ts new file mode 100644 index 000000000..0fcfd495d --- /dev/null +++ b/apps/cli/src/channels/state/localBindingStore.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +describe('createLocalChannelBindingStore', () => { + const envKeys = ['HAPPIER_HOME_DIR'] as const; + let envScope = createEnvKeyScope(envKeys); + + afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + vi.resetModules(); + }); + + it('persists bindings under the active server directory scoped by account', async () => { + await withTempDir('happier-channel-bridge-bindings-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createLocalChannelBindingStore } = await import('./localBindingStore'); + + const storeA = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await storeA.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const storeB = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await expect(storeB.getBinding({ providerId: 'telegram', conversationId: '-1001', threadId: null })).resolves.toEqual( + expect.objectContaining({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + }), + ); + }); + }); + + it('keeps lastForwardedSeq monotonic', async () => { + await withTempDir('happier-channel-bridge-bindings-monotonic-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createLocalChannelBindingStore } = await import('./localBindingStore'); + + const store = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + await expect( + store.updateLastForwardedSeq( + { providerId: 'telegram', conversationId: '-1001', threadId: null }, + { expectedSessionId: 'sess-1', seq: 10 }, + ), + ).resolves.toBe(true); + + await expect( + store.updateLastForwardedSeq( + { providerId: 'telegram', conversationId: '-1001', threadId: null }, + { expectedSessionId: 'sess-1', seq: 5 }, + ), + ).resolves.toBe(false); + + await expect(store.getBinding({ providerId: 'telegram', conversationId: '-1001', threadId: null })).resolves.toEqual( + expect.objectContaining({ + lastForwardedSeq: 10, + }), + ); + }); + }); + + it('creates account-scoped binding directories with 0700 permissions on unix', async () => { + if (process.platform === 'win32') return; + + await withTempDir('happier-channel-bridge-bindings-perms-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { configuration } = await import('@/configuration'); + const { createLocalChannelBindingStore } = await import('./localBindingStore'); + + const store = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const accountDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', 'acct-1'); + const perms = (await stat(accountDir)).mode & 0o777; + expect(perms).toBe(0o700); + }); + }); +}); diff --git a/apps/cli/src/channels/state/localBindingStore.ts b/apps/cli/src/channels/state/localBindingStore.ts new file mode 100644 index 000000000..85c72aa1c --- /dev/null +++ b/apps/cli/src/channels/state/localBindingStore.ts @@ -0,0 +1,298 @@ +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; +import { configuration } from '@/configuration'; +import { assertFilesystemSafeAccountId } from '@/channels/state/assertFilesystemSafeAccountId'; +import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; +import { logger } from '@/ui/logger'; + +type RecordLike = Record; + +type StoredBindingsDocV1 = Readonly<{ + schemaVersion: 1; + bindings: Array>; +}>; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + if (truncated < 0) return null; + return truncated; +} + +function bindingKey(ref: ChannelBridgeConversationRef): string { + return JSON.stringify([ref.providerId, ref.conversationId, ref.threadId]); +} + +function cloneBinding(binding: ChannelSessionBinding): ChannelSessionBinding { + return { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + ownerSenderId: binding.ownerSenderId, + inboundMode: binding.inboundMode, + allowMissingSenderId: binding.allowMissingSenderId, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + }; +} + +function cloneBindings(bindings: readonly ChannelSessionBinding[]): ChannelSessionBinding[] { + return bindings.map((binding) => cloneBinding(binding)); +} + +function parseStoredBindingsDoc(value: unknown): StoredBindingsDocV1 | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== 1) return null; + const bindingsRaw = record.bindings; + if (!Array.isArray(bindingsRaw)) return null; + + const bindings: StoredBindingsDocV1['bindings'] = []; + for (const entry of bindingsRaw) { + const row = asRecord(entry); + if (!row) continue; + if (typeof row.providerId !== 'string' || row.providerId.trim().length === 0) continue; + if (typeof row.conversationId !== 'string' || row.conversationId.trim().length === 0) continue; + if (typeof row.sessionId !== 'string' || row.sessionId.trim().length === 0) continue; + const threadId = + row.threadId === null + ? null + : typeof row.threadId === 'string' && row.threadId.trim().length > 0 + ? row.threadId.trim() + : null; + const lastForwardedSeq = toNonNegativeInt(row.lastForwardedSeq) ?? 0; + const ownerSenderId = + typeof row.ownerSenderId === 'string' && row.ownerSenderId.trim().length > 0 + ? row.ownerSenderId.trim() + : null; + const inboundMode = row.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const allowMissingSenderId = row.allowMissingSenderId === true; + const createdAtMs = toNonNegativeInt(row.createdAtMs) ?? Date.now(); + const updatedAtMs = toNonNegativeInt(row.updatedAtMs) ?? createdAtMs; + bindings.push({ + providerId: row.providerId.trim(), + conversationId: row.conversationId.trim(), + threadId, + sessionId: row.sessionId.trim(), + lastForwardedSeq, + ownerSenderId, + inboundMode, + allowMissingSenderId, + createdAtMs, + updatedAtMs, + }); + } + + return { + schemaVersion: 1, + bindings, + }; +} + +async function bestEffortChmod0700(path: string): Promise { + if (process.platform === 'win32') return; + await chmod(path, 0o700).catch(() => {}); +} + +function resolveBindingsFilePath(accountId: string): string { + return join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId, 'bindings.json'); +} + +type BindingCache = Readonly<{ + bindings: ChannelSessionBinding[]; + fetchedAtMs: number; +}>; + +export function createLocalChannelBindingStore(params: Readonly<{ + accountId: string; + cacheTtlMs?: number; +}>): ChannelBindingStore { + const accountId = assertFilesystemSafeAccountId(params.accountId); + const cacheTtlMs = typeof params.cacheTtlMs === 'number' ? Math.max(0, Math.trunc(params.cacheTtlMs)) : 1_000; + const bindingsFile = resolveBindingsFilePath(accountId); + let cache: BindingCache | null = null; + let queue = Promise.resolve(); + + const enqueue = (work: () => Promise): Promise => { + const run = queue.then(work, work); + queue = run.then(() => undefined, () => undefined); + return run; + }; + + async function load(forceRefresh: boolean): Promise { + if (!forceRefresh && cache && Date.now() - cache.fetchedAtMs <= cacheTtlMs) { + return cache; + } + + try { + const raw = await readFile(bindingsFile, { encoding: 'utf-8' }).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') return null; + throw error; + }); + const parsed = raw ? parseStoredBindingsDoc(JSON.parse(raw)) : null; + const next: BindingCache = { + bindings: parsed ? parsed.bindings.map((binding) => ({ + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + ownerSenderId: binding.ownerSenderId, + inboundMode: binding.inboundMode, + allowMissingSenderId: binding.allowMissingSenderId, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + })) : [], + fetchedAtMs: Date.now(), + }; + cache = next; + return next; + } catch (error) { + logger.warn('[channelBridge] Failed to read local channel bridge bindings; using empty bindings', error); + const next: BindingCache = { bindings: [], fetchedAtMs: Date.now() }; + cache = next; + return next; + } + } + + async function persist(bindings: readonly ChannelSessionBinding[]): Promise { + const channelBridgesDir = join(configuration.activeServerDir, 'channel-bridges'); + const v1Dir = join(channelBridgesDir, 'v1'); + const accountsDir = join(v1Dir, 'account'); + const accountDir = join(accountsDir, accountId); + + await mkdir(accountDir, { recursive: true, mode: 0o700 }); + await bestEffortChmod0700(configuration.activeServerDir); + + const doc: StoredBindingsDocV1 = { + schemaVersion: 1, + bindings: bindings.map((binding) => ({ + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + ownerSenderId: binding.ownerSenderId, + inboundMode: binding.inboundMode, + allowMissingSenderId: binding.allowMissingSenderId, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + })), + }; + await writeJsonAtomic(bindingsFile, doc); + await bestEffortChmod0700(channelBridgesDir); + await bestEffortChmod0700(v1Dir); + await bestEffortChmod0700(accountsDir); + await bestEffortChmod0700(accountDir); + } + + function setCache(bindings: readonly ChannelSessionBinding[]): void { + cache = { + bindings: cloneBindings(bindings), + fetchedAtMs: Date.now(), + }; + } + + return { + listBindings: async () => { + const current = await load(false); + return cloneBindings(current.bindings); + }, + getBinding: async (ref) => { + const current = await load(false); + const key = bindingKey(ref); + const found = current.bindings.find((binding) => bindingKey(binding) === key); + return found ? cloneBinding(found) : null; + }, + upsertBinding: async (binding) => enqueue(async () => { + const now = Date.now(); + const current = await load(true); + const key = bindingKey(binding); + const existing = current.bindings.find((row) => bindingKey(row) === key); + const next: ChannelSessionBinding = { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: Math.max(0, Math.trunc(binding.lastForwardedSeq)), + ownerSenderId: typeof binding.ownerSenderId === 'string' && binding.ownerSenderId.trim().length > 0 ? binding.ownerSenderId.trim() : null, + inboundMode: binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly', + allowMissingSenderId: binding.allowMissingSenderId === true, + createdAtMs: existing?.createdAtMs ?? now, + updatedAtMs: now, + }; + const without = current.bindings.filter((row) => bindingKey(row) !== key); + const nextBindings = [...without, next]; + await persist(nextBindings); + setCache(nextBindings); + return cloneBinding(next); + }), + updateLastForwardedSeq: async (ref, params) => enqueue(async () => { + const current = await load(true); + const key = bindingKey(ref); + const nextBindings = current.bindings.map((row) => { + if (bindingKey(row) !== key) return row; + if (row.sessionId !== params.expectedSessionId) return row; + const nextSeq = Math.max(0, Math.trunc(params.seq)); + if (nextSeq <= row.lastForwardedSeq) return row; + return { + ...row, + lastForwardedSeq: nextSeq, + updatedAtMs: Date.now(), + }; + }); + + const before = current.bindings.find((row) => bindingKey(row) === key); + const after = nextBindings.find((row) => bindingKey(row) === key); + const changed = + !!before && + !!after && + before.sessionId === params.expectedSessionId && + after.sessionId === params.expectedSessionId && + after.lastForwardedSeq > before.lastForwardedSeq; + + if (!changed) { + setCache(nextBindings); + return false; + } + + await persist(nextBindings); + setCache(nextBindings); + return true; + }), + removeBinding: async (ref) => enqueue(async () => { + const current = await load(true); + const key = bindingKey(ref); + const nextBindings = current.bindings.filter((row) => bindingKey(row) !== key); + const removed = nextBindings.length !== current.bindings.length; + if (!removed) { + setCache(nextBindings); + return false; + } + await persist(nextBindings); + setCache(nextBindings); + return true; + }), + }; +} diff --git a/apps/cli/src/cli/commandRegistry.ts b/apps/cli/src/cli/commandRegistry.ts index 7039d3f4a..90977eeff 100644 --- a/apps/cli/src/cli/commandRegistry.ts +++ b/apps/cli/src/cli/commandRegistry.ts @@ -4,6 +4,7 @@ import { AGENTS, type AgentCatalogEntry } from '@/backends/catalog'; import { handleAttachCliCommand } from './commands/attach'; import { handleAuthCliCommand } from './commands/auth'; +import { handleBridgeCliCommand } from './commands/bridge'; import { handleBugReportCliCommand } from './commands/bugReport'; import { handleConnectCliCommand } from './commands/connect'; import { handleDaemonCliCommand } from './commands/daemon'; @@ -47,6 +48,7 @@ export const commandRegistry: Readonly> = { attach: handleAttachCliCommand, 'acp-catalog': handleConfiguredAcpCatalogCliCommand, auth: handleAuthCliCommand, + bridge: handleBridgeCliCommand, 'bug-report': handleBugReportCliCommand, connect: handleConnectCliCommand, daemon: handleDaemonCliCommand, diff --git a/apps/cli/src/cli/commandSurfaceManifest.test.ts b/apps/cli/src/cli/commandSurfaceManifest.test.ts index 377027660..b8d7a0eba 100644 --- a/apps/cli/src/cli/commandSurfaceManifest.test.ts +++ b/apps/cli/src/cli/commandSurfaceManifest.test.ts @@ -12,6 +12,7 @@ describe('CLI command-surface manifest', () => { 'opencode', 'gemini', 'connect', + 'bridge', 'notify', 'install', 'daemon', diff --git a/apps/cli/src/cli/commandSurfaceManifest.ts b/apps/cli/src/cli/commandSurfaceManifest.ts index 1bfa13107..ba12988cc 100644 --- a/apps/cli/src/cli/commandSurfaceManifest.ts +++ b/apps/cli/src/cli/commandSurfaceManifest.ts @@ -43,6 +43,12 @@ const COMMAND_SURFACE_MANIFEST: readonly CliCommandSurfaceEntry[] = [ rootHelpDescription: 'Connect AI vendor API keys', allowTmux: false, }, + { + command: 'bridge', + rootHelpLabel: 'happier bridge', + rootHelpDescription: 'Manage channel bridges', + allowTmux: false, + }, { command: 'notify', rootHelpLabel: 'happier notify', diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts new file mode 100644 index 000000000..08f18ad74 --- /dev/null +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -0,0 +1,214 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { reloadConfiguration } from '@/configuration'; + +const readCredentialsMock = vi.fn(); +const readSettingsMock = vi.fn(); +const updateSettingsMock = vi.fn(); +const decodeJwtPayloadMock = vi.fn(); +const checkDaemonMock = vi.fn(); + +vi.mock('@/persistence', () => ({ + readCredentials: readCredentialsMock, + readSettings: readSettingsMock, + updateSettings: updateSettingsMock, +})); + +vi.mock('@/cloud/decodeJwtPayload', () => ({ + decodeJwtPayload: decodeJwtPayloadMock, +})); + +vi.mock('@/daemon/controlClient', () => ({ + checkIfDaemonRunningAndCleanupStaleState: checkDaemonMock, +})); + +describe('happier bridge command (local-only v1)', () => { + let homeDir: string; + const prevHomeDir = process.env.HAPPIER_HOME_DIR; + const prevServerUrl = process.env.HAPPIER_SERVER_URL; + const prevWebappUrl = process.env.HAPPIER_WEBAPP_URL; + let logSpy: ReturnType | null = null; + let lastUpdatedSettings: unknown = null; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + logSpy?.mockRestore(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + readCredentialsMock.mockResolvedValue({ token: 'token.jwt' }); + decodeJwtPayloadMock.mockReturnValue({ sub: 'acct_123' }); + readSettingsMock.mockResolvedValue({}); + checkDaemonMock.mockResolvedValue(false); + + updateSettingsMock.mockImplementation(async (updater: (current: unknown) => Promise | unknown) => { + lastUpdatedSettings = await updater({}); + }); + + homeDir = await mkdtemp(join(tmpdir(), 'happier-bridge-cmd-')); + process.env.HAPPIER_HOME_DIR = homeDir; + process.env.HAPPIER_SERVER_URL = 'http://127.0.0.1:3005'; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3006'; + reloadConfiguration(); + }); + + afterEach(async () => { + logSpy?.mockRestore(); + logSpy = null; + process.exitCode = undefined; + if (prevHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = prevHomeDir; + if (prevServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = prevServerUrl; + if (prevWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = prevWebappUrl; + reloadConfiguration(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('writes telegram set updates to local settings', async () => { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--allow-all', '--require-topics', 'true'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).toHaveBeenCalledTimes(1); + expect(lastUpdatedSettings).toMatchObject({ + experiments: true, + featureToggles: { + channelBridges: true, + }, + }); + expect(process.exitCode).toBeUndefined(); + }); + + it('clears telegram config in local settings', async () => { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'clear'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + }); + + it('rejects webhook secrets that do not match Telegram-safe token charset', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-secret', 'bad$secret'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --webhook-secret value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('rejects overlong webhook secret values before persisting settings', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-secret', 'x'.repeat(257)], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Webhook secret token is too long'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('rejects explicitly passed empty --bot-token values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', ' '], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --bot-token value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('rejects explicitly passed empty --webhook-secret values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-secret', ' '], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --webhook-secret value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('rejects explicitly passed empty --allowed-chat-ids values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--allowed-chat-ids', ' '], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --allowed-chat-ids value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts new file mode 100644 index 000000000..a166bf2d7 --- /dev/null +++ b/apps/cli/src/cli/commands/bridge.ts @@ -0,0 +1,383 @@ +import chalk from 'chalk'; + +import type { CommandContext } from '@/cli/commandRegistry'; +import { configuration } from '@/configuration'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; +import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient'; +import { + readScopedTelegramBridgeConfig, + removeScopedTelegramBridgeConfig, + upsertScopedTelegramBridgeConfig, +} from '@/channels/channelBridgeAccountConfig'; +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { createLocalChannelBindingStore } from '@/channels/state/localBindingStore'; +import { ensureExperimentalSettingsFeatureToggleEnabled } from '@/features/settingsFeatureToggles'; +import { readCredentials, readSettings, updateSettings } from '@/persistence'; +import { argvValue } from '@/cli/commands/server/commandUtilities'; +import { join } from 'node:path'; +import { assertTelegramWebhookSecretToken } from '@/channels/providers/telegram/telegramWebhookSecretToken'; + +function parseBooleanInput(raw: string, flagName: string): boolean { + const value = raw.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(value)) return true; + if (['0', 'false', 'no', 'off'].includes(value)) return false; + throw new Error(`Invalid ${flagName} value: ${raw}`); +} + +function parseIntegerInput(raw: string, flagName: string, min: number, max: number): number { + const trimmed = raw.trim(); + if (!/^[-]?\d+$/.test(trimmed)) { + throw new Error(`Invalid ${flagName} value: ${raw}`); + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + throw new Error(`Invalid ${flagName} value: ${raw}`); + } + return Math.trunc(parsed); +} + +function parseCsvList(raw: string): string[] { + return raw + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function maskSecret(value: string): string { + if (!value.trim()) return ''; + return `<${value.length} chars>`; +} + +async function resolveActiveAuthContext(): Promise> { + const credentials = await readCredentials(); + if (!credentials) { + throw new Error('Not authenticated. Run: happier auth login'); + } + const payload = decodeJwtPayload(credentials.token); + const accountId = payload && typeof payload.sub === 'string' ? payload.sub.trim() : ''; + if (!accountId) { + throw new Error('Unable to resolve account id from credentials token'); + } + return { + accountId, + token: credentials.token, + }; +} + +function showBridgeHelp(): void { + console.log(` +${chalk.bold('happier bridge')} - Channel bridge configuration (account-scoped) + +${chalk.bold('Usage:')} + happier bridge list + happier bridge telegram set [--bot-token ] [--allowed-chat-ids ] [--allow-all-shared-chats |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port (default: 8787)] + happier bridge telegram clear + +${chalk.bold('Notes:')} + - Scope is the active server + authenticated account. + - Bridge config is local-only (settings/env) in v1. + - Conversation bindings are created from the channel itself via slash commands: + /sessions, /attach , /detach, /help + - Restart daemon to apply: happier daemon stop && happier daemon start +`); +} + +async function cmdList(): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + if (!serverId) { + throw new Error('Unable to resolve active server id'); + } + const auth = await resolveActiveAuthContext(); + const accountId = auth.accountId; + const settings = await readSettings(); + + const scopedTelegram = readScopedTelegramBridgeConfig({ + settings, + serverId, + accountId, + }); + + const effective = resolveChannelBridgeRuntimeConfig({ + env: process.env, + settings, + serverId, + accountId, + }); + + const daemonRunning = await checkIfDaemonRunningAndCleanupStaleState(); + + console.log(chalk.bold('Bridge scope')); + console.log(` Server: ${serverId}`); + console.log(` Account: ${accountId}`); + console.log(` Daemon: ${daemonRunning ? 'running' : 'stopped'}`); + + console.log(chalk.bold('\nTelegram (scoped settings.json)')); + if (!scopedTelegram) { + console.log(' configured: no'); + } else { + const scopedToken = typeof scopedTelegram.botToken === 'string' ? scopedTelegram.botToken : ''; + const scopedAllowed = Array.isArray(scopedTelegram.allowedChatIds) ? scopedTelegram.allowedChatIds : []; + const scopedAllowAllSharedChats = scopedTelegram.allowAllSharedChats === true; + const scopedRequireTopics = scopedTelegram.requireTopics === true; + console.log(' configured: yes'); + console.log(` botToken: ${maskSecret(scopedToken)}`); + if (scopedAllowAllSharedChats) { + console.log(' allowedChatIds: (allow all shared chats - DANGEROUS)'); + } else { + console.log(` allowedChatIds: ${scopedAllowed.length > 0 ? scopedAllowed.join(', ') : '(dm-only)'}`); + } + console.log(` requireTopics: ${scopedRequireTopics ? 'true' : 'false'}`); + } + + console.log(chalk.bold('\nTelegram (effective runtime: env > settings.json)')); + const telegramEffective = effective.providers.telegram; + console.log(` botToken: ${maskSecret(telegramEffective.botToken)}`); + if (telegramEffective.allowAllSharedChats) { + console.log(' allowedChatIds: (allow all shared chats - DANGEROUS)'); + } else { + console.log( + ` allowedChatIds: ${telegramEffective.allowedChatIds.length > 0 ? telegramEffective.allowedChatIds.join(', ') : '(dm-only)'}`, + ); + } + console.log(` requireTopics: ${telegramEffective.requireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${telegramEffective.webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${telegramEffective.webhookHost}`); + console.log(` webhook.port: ${telegramEffective.webhookPort}`); + + try { + const store = createLocalChannelBindingStore({ accountId }); + const bindings = await store.listBindings(); + const bindingsFile = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId, 'bindings.json'); + console.log(chalk.bold('\nBindings (local state)')); + console.log(` file: ${bindingsFile}`); + if (bindings.length === 0) { + console.log(' (none) - attach from a DM or a shared chat topic using /attach '); + } else { + console.log(` count: ${bindings.length}`); + for (const binding of bindings.slice(0, 20)) { + const thread = binding.threadId ? `/${binding.threadId}` : ''; + const owner = binding.ownerSenderId ? `owner=${binding.ownerSenderId}` : 'owner='; + const inbound = binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const allowMissing = binding.allowMissingSenderId ? ', allowMissingSenderId=true' : ''; + console.log( + ` - ${binding.providerId}:${binding.conversationId}${thread} → ${binding.sessionId} (${inbound}${allowMissing}; ${owner})`, + ); + } + if (bindings.length > 20) { + console.log(` … and ${bindings.length - 20} more`); + } + } + } catch (error) { + console.log(chalk.yellow('\nBindings (local state)')); + console.log(chalk.yellow(` Failed to read bindings: ${error instanceof Error ? error.message : String(error)}`)); + } +} + +async function cmdTelegramSet(args: string[]): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + if (!serverId) { + throw new Error('Unable to resolve active server id'); + } + const auth = await resolveActiveAuthContext(); + const accountId = auth.accountId; + + const rawBotToken = argvValue(args, '--bot-token'); + const hasBotTokenFlag = args.some((arg) => arg === '--bot-token' || arg.startsWith('--bot-token=')); + const botToken = rawBotToken.trim(); + const allowedChatIdsRaw = argvValue(args, '--allowed-chat-ids').trim(); + const hasAllowedChatIdsFlag = args.some((arg) => arg === '--allowed-chat-ids' || arg.startsWith('--allowed-chat-ids=')); + const allowAllSharedChatsRaw = argvValue(args, '--allow-all-shared-chats').trim(); + const hasAllowAllSharedChatsFlag = args.some((arg) => arg === '--allow-all-shared-chats' || arg.startsWith('--allow-all-shared-chats=')); + const allowAll = args.includes('--allow-all'); + const requireTopicsRaw = argvValue(args, '--require-topics').trim(); + const hasRequireTopicsFlag = args.some((arg) => arg === '--require-topics' || arg.startsWith('--require-topics=')); + const tickMsRaw = argvValue(args, '--tick-ms').trim(); + const hasTickMsFlag = args.some((arg) => arg === '--tick-ms' || arg.startsWith('--tick-ms=')); + const webhookEnabledRaw = argvValue(args, '--webhook-enabled').trim(); + const hasWebhookEnabledFlag = args.some((arg) => arg === '--webhook-enabled' || arg.startsWith('--webhook-enabled=')); + const webhookSecret = argvValue(args, '--webhook-secret').trim(); + const hasWebhookSecretFlag = args.some((arg) => arg === '--webhook-secret' || arg.startsWith('--webhook-secret=')); + const webhookHost = argvValue(args, '--webhook-host').trim(); + const hasWebhookHostFlag = args.some((arg) => arg === '--webhook-host' || arg.startsWith('--webhook-host=')); + const webhookPortRaw = argvValue(args, '--webhook-port').trim(); + const hasWebhookPortFlag = args.some((arg) => arg === '--webhook-port' || arg.startsWith('--webhook-port=')); + + if (hasAllowedChatIdsFlag && !allowedChatIdsRaw) { + throw new Error('Invalid --allowed-chat-ids value: cannot be empty'); + } + if (hasAllowAllSharedChatsFlag && !allowAllSharedChatsRaw) { + throw new Error('Invalid --allow-all-shared-chats value: cannot be empty'); + } + if (hasRequireTopicsFlag && !requireTopicsRaw) { + throw new Error('Invalid --require-topics value: cannot be empty'); + } + if (hasTickMsFlag && !tickMsRaw) { + throw new Error('Invalid --tick-ms value: cannot be empty'); + } + if (hasWebhookEnabledFlag && !webhookEnabledRaw) { + throw new Error('Invalid --webhook-enabled value: cannot be empty'); + } + if (hasWebhookSecretFlag && !webhookSecret) { + throw new Error('Invalid --webhook-secret value: cannot be empty'); + } + if (hasWebhookHostFlag && !webhookHost) { + throw new Error('Invalid --webhook-host value: cannot be empty'); + } + if (hasWebhookPortFlag && !webhookPortRaw) { + throw new Error('Invalid --webhook-port value: cannot be empty'); + } + + if (allowAll && allowedChatIdsRaw) { + throw new Error('Cannot combine --allow-all with --allowed-chat-ids'); + } + if (allowAllSharedChatsRaw && allowedChatIdsRaw && parseBooleanInput(allowAllSharedChatsRaw, '--allow-all-shared-chats')) { + throw new Error('Cannot combine --allow-all-shared-chats=true with --allowed-chat-ids'); + } + + const update: { + tickMs?: number; + botToken?: string; + allowedChatIds?: string[]; + allowAllSharedChats?: boolean; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookSecret?: string; + webhookHost?: string; + webhookPort?: number; + } = {}; + + if (hasBotTokenFlag) { + if (!botToken) { + throw new Error('Invalid --bot-token value: cannot be empty'); + } + update.botToken = botToken; + } + if (allowAll) { + update.allowAllSharedChats = true; + } else if (allowedChatIdsRaw) { + const parsedAllowedChatIds = parseCsvList(allowedChatIdsRaw); + if (parsedAllowedChatIds.length === 0) { + throw new Error('Invalid --allowed-chat-ids value: provide at least one chat id'); + } + update.allowedChatIds = parsedAllowedChatIds; + } + if (allowAllSharedChatsRaw) { + update.allowAllSharedChats = parseBooleanInput(allowAllSharedChatsRaw, '--allow-all-shared-chats'); + } + if (requireTopicsRaw) { + update.requireTopics = parseBooleanInput(requireTopicsRaw, '--require-topics'); + } + if (tickMsRaw) { + update.tickMs = parseIntegerInput(tickMsRaw, '--tick-ms', 250, 60_000); + } + if (webhookEnabledRaw) { + update.webhookEnabled = parseBooleanInput(webhookEnabledRaw, '--webhook-enabled'); + } + if (webhookSecret) { + update.webhookSecret = assertTelegramWebhookSecretToken(webhookSecret, { + empty: 'Invalid --webhook-secret value: cannot be empty', + tooLong: 'Webhook secret token is too long', + invalid: 'Invalid --webhook-secret value: must match [A-Za-z0-9_-] (Telegram webhook token restriction)', + }); + } + if (webhookHost) { + if (!isLoopbackHostname(webhookHost)) { + throw new Error('Invalid --webhook-host value: must be a loopback address (127.0.0.1, ::1, or localhost)'); + } + update.webhookHost = webhookHost; + } + if (webhookPortRaw) { + update.webhookPort = parseIntegerInput(webhookPortRaw, '--webhook-port', 1, 65_535); + } + + if (Object.keys(update).length === 0) { + throw new Error( + 'No updates provided. Use flags like --bot-token, --allowed-chat-ids, --allow-all, --allow-all-shared-chats, --require-topics, --tick-ms, --webhook-enabled, --webhook-secret, --webhook-host, --webhook-port', + ); + } + + await updateSettings(async (current) => + ensureExperimentalSettingsFeatureToggleEnabled({ + settings: upsertScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + update, + }), + featureId: 'channelBridges', + }), + ); + + console.log(chalk.green('✓ Saved Telegram bridge config for active account scope')); + console.log(` Server: ${serverId}`); + console.log(` Account: ${accountId}`); + console.log(' Persisted: scoped settings.json'); + console.log(' Enabled: experimental feature toggle channelBridges'); + console.log(' Restart daemon to apply changes:'); + console.log(chalk.cyan(' happier daemon stop && happier daemon start')); +} + +async function cmdTelegramClear(): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + if (!serverId) { + throw new Error('Unable to resolve active server id'); + } + const auth = await resolveActiveAuthContext(); + const accountId = auth.accountId; + await updateSettings(async (current) => + removeScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + }), + ); + + console.log(chalk.green('✓ Cleared Telegram bridge config for active account scope')); + console.log(` Server: ${serverId}`); + console.log(` Account: ${accountId}`); + console.log(' Cleared: scoped settings.json'); + console.log(' Restart daemon to apply changes:'); + console.log(chalk.cyan(' happier daemon stop && happier daemon start')); +} + +async function cmdTelegram(args: string[]): Promise { + const sub = String(args[0] ?? '').trim(); + if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { + showBridgeHelp(); + return; + } + if (sub === 'set') { + await cmdTelegramSet(args.slice(1)); + return; + } + if (sub === 'clear') { + await cmdTelegramClear(); + return; + } + throw new Error(`Unknown bridge telegram subcommand: ${sub}`); +} + +export async function handleBridgeCliCommand(context: CommandContext): Promise { + const args = context.args.slice(1); + const sub = String(args[0] ?? '').trim(); + + try { + if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { + showBridgeHelp(); + return; + } + if (sub === 'list') { + await cmdList(); + return; + } + if (sub === 'telegram') { + await cmdTelegram(args.slice(1)); + return; + } + throw new Error(`Unknown bridge subcommand: ${sub}`); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts b/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts index bd16eccfb..b960945ef 100644 --- a/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts +++ b/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts @@ -55,4 +55,37 @@ describe('dispatchCli --tmux disallowed controller commands', () => { expect(exitSpy).toHaveBeenCalledWith(1); expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); }); + + it('rejects --tmux-first invocations for controller commands', async () => { + await expect( + dispatchCli({ + args: ['--tmux', 'bridge', 'list'], + rawArgv: ['happier', '--tmux', 'bridge', 'list'], + terminalRuntime: null, + }), + ).rejects.toThrow('process.exit(1)'); + expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); + }); + + it('rejects --tmux for bug-report controller command', async () => { + await expect( + dispatchCli({ + args: ['--tmux', 'bug-report'], + rawArgv: ['happier', '--tmux', 'bug-report'], + terminalRuntime: null, + }), + ).rejects.toThrow('process.exit(1)'); + expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); + }); + + it('rejects --tmux for self-update controller command', async () => { + await expect( + dispatchCli({ + args: ['self-update', '--tmux'], + rawArgv: ['happier', 'self-update', '--tmux'], + terminalRuntime: null, + }), + ).rejects.toThrow('process.exit(1)'); + expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/cli/dispatch.ts b/apps/cli/src/cli/dispatch.ts index 1aa88ec5f..4015d0e90 100644 --- a/apps/cli/src/cli/dispatch.ts +++ b/apps/cli/src/cli/dispatch.ts @@ -62,12 +62,15 @@ export async function dispatchCli(params: Readonly<{ // Headless tmux launcher (CLI flow) if (args.includes('--tmux')) { + const argsWithoutTmux = args.filter((arg) => arg !== '--tmux'); + const tmuxSubcommand = argsWithoutTmux.find((arg) => !arg.startsWith('-')) ?? ''; + // If user is asking for help/version, don't start a session. if (args.includes('-h') || args.includes('--help') || args.includes('-v') || args.includes('--version')) { const idx = args.indexOf('--tmux'); if (idx !== -1) args.splice(idx, 1); } else { - if (subcommand && !isTmuxAllowedCommand(subcommand)) { + if (tmuxSubcommand && !isTmuxAllowedCommand(tmuxSubcommand)) { console.error(chalk.red('Error:'), '--tmux can only be used when starting a session.'); process.exit(1); return; diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts new file mode 100644 index 000000000..bd271cdfd --- /dev/null +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FeaturesResponseSchema } from '@happier-dev/protocol'; + +import { resolveChannelBridgesDaemonEnabled } from './resolveChannelBridgesDaemonEnabled'; + +describe('resolveChannelBridgesDaemonEnabled', () => { + it('returns false when the server reports channel bridges disabled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: false, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })) as unknown as typeof fetch, + ); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, + serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + }); + + it('returns false when user has not enabled the experimental toggle', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })) as unknown as typeof fetch, + ); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, + serverUrl: 'https://api.example.test', + settings: {}, + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + }); + + it('returns true when server reports telegram bridge enabled and user has opted in', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })) as unknown as typeof fetch, + ); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, + serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, + timeoutMs: 100, + }); + + expect(enabled).toBe(true); + }); + + it('does not enable channel bridges when build policy denies the feature', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })); + vi.stubGlobal('fetch', fetchMock as any); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { + HAPPIER_BUILD_FEATURES_DENY: 'channelBridges.telegram', + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1', + }, + serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not probe the server when local policy disables channel bridges', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })); + vi.stubGlobal('fetch', fetchMock as any); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '0', + }, + serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts new file mode 100644 index 000000000..ca0fa6d11 --- /dev/null +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts @@ -0,0 +1,59 @@ +import type { FeatureId } from '@happier-dev/protocol'; + +import { + resolveCliFeatureDecision, + resolveCliFeatureDecisionForServer, +} from '@/features/featureDecisionService'; +import { resolveExperimentalSettingsFeatureToggleEnabled } from '@/features/settingsFeatureToggles'; +import { listChannelBridgeProviderIds } from '@/channels/providers/_registry/channelBridgeProviderRegistry'; + +export async function resolveChannelBridgesDaemonEnabled(params: { + env: NodeJS.ProcessEnv; + serverUrl: string; + settings?: unknown; + timeoutMs?: number; +}): Promise { + const localToggleEnabled = resolveExperimentalSettingsFeatureToggleEnabled({ + settings: params.settings, + featureId: 'channelBridges', + defaultEnabled: false, + }); + + if (!localToggleEnabled) { + return false; + } + + const providerFeatureIds = listChannelBridgeProviderIds() + .map((providerId) => `channelBridges.${providerId}` as const); + + // Avoid probing the server when global policy (build/local) denies all providers. + const anyProviderPotentiallyEnabled = providerFeatureIds.some((featureId) => { + const decision = resolveCliFeatureDecision({ + featureId: featureId as FeatureId, + env: params.env, + serverSnapshot: undefined, + }); + return decision.state === 'enabled' || decision.blockedBy === 'server' || decision.state === 'unknown'; + }); + if (!anyProviderPotentiallyEnabled) { + return false; + } + + const resolved = await resolveCliFeatureDecisionForServer({ + featureId: 'channelBridges', + env: params.env, + serverUrl: params.serverUrl, + timeoutMs: params.timeoutMs, + }); + + const providerEnabled = providerFeatureIds.some((featureId) => { + const decision = resolveCliFeatureDecision({ + featureId: featureId as FeatureId, + env: params.env, + serverSnapshot: resolved.serverSnapshot, + }); + return decision.state === 'enabled'; + }); + + return providerEnabled; +} diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 3a5876f4e..e45597fea 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; type ShutdownSource = 'happier-app' | 'happier-cli' | 'os-signal' | 'exception'; type BuildHappyCliSubprocessLaunchSpec = typeof import('@/utils/spawnHappyCLI').buildHappyCliSubprocessLaunchSpec; +type BridgeWorkerHandle = Readonly<{ stop: () => Promise; trigger: () => void }>; const harness = vi.hoisted(() => { let resolveShutdown: ((value: { source: ShutdownSource; errorMessage?: string }) => void) | null = null; @@ -13,6 +14,7 @@ const harness = vi.hoisted(() => { const automationWorkerRefreshAssignments = vi.fn(async () => {}); const automationWorkerPause = vi.fn(); const automationWorkerResume = vi.fn(); + const startChannelBridgeFromEnv = vi.fn<() => Promise>(async () => null); const startAutomationWorker = vi.fn(() => { if (autoShutdownAfterAutomationStart && requestShutdownRef) { setTimeout(() => requestShutdownRef?.('happier-cli'), 0); @@ -35,6 +37,8 @@ const harness = vi.hoisted(() => { resume: connectedServiceQuotasResume, })); + const resolveChannelBridgesDaemonEnabled = vi.fn(async () => true); + const apiMachine = { setRPCHandlers: vi.fn(), onUpdate: vi.fn(), @@ -86,6 +90,8 @@ const harness = vi.hoisted(() => { connectedServiceQuotasResume, connectedServiceQuotasStop, createDaemonShutdownController, + startChannelBridgeFromEnv, + resolveChannelBridgesDaemonEnabled, emitMachineConnectionState: (state: any) => machineConnectionStateListener?.(state), setAutoShutdownAfterAutomationStart: (value: boolean) => { autoShutdownAfterAutomationStart = value; @@ -94,6 +100,14 @@ const harness = vi.hoisted(() => { }; }); +vi.mock('@/channels/startChannelBridgeWorker', () => ({ + startChannelBridgeFromEnv: harness.startChannelBridgeFromEnv, +})); + +vi.mock('./channels/resolveChannelBridgesDaemonEnabled', () => ({ + resolveChannelBridgesDaemonEnabled: harness.resolveChannelBridgesDaemonEnabled, +})); + vi.mock('@/api/api', () => ({ ApiClient: { create: vi.fn(async () => ({ @@ -166,6 +180,7 @@ vi.mock('@/persistence', () => ({ writeDaemonState: vi.fn(), acquireDaemonLock: vi.fn(async () => harness.lockHandle), releaseDaemonLock: vi.fn(async () => {}), + readSettings: vi.fn(async () => ({})), readCredentials: vi.fn(async () => null), })); @@ -320,6 +335,7 @@ vi.mock('./shutdownPolicy', () => ({ describe('startDaemon automation wiring (integration)', () => { afterEach(() => { + vi.clearAllMocks(); vi.restoreAllMocks(); harness.setAutoShutdownAfterAutomationStart(true); }); @@ -477,6 +493,56 @@ describe('startDaemon automation wiring (integration)', () => { } }); + it('does not start channel bridge worker when channel bridges are disabled', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + harness.resolveChannelBridgesDaemonEnabled.mockResolvedValueOnce(false); + + try { + const { startDaemon } = await import('./startDaemon'); + await startDaemon(); + + expect(harness.startChannelBridgeFromEnv).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + } + }); + + it('stops a bridge worker that resolves after shutdown has already started', async () => { + vi.useRealTimers(); + + const delayedWorkerStop = vi.fn(async () => {}); + let resolveBridgeStartup!: () => void; + const bridgeStartup = new Promise((resolve) => { + resolveBridgeStartup = resolve; + }); + harness.startChannelBridgeFromEnv.mockImplementationOnce(async () => { + await bridgeStartup; + return { + stop: delayedWorkerStop, + trigger: vi.fn(), + }; + }); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + const { startDaemon } = await import('./startDaemon'); + const run = startDaemon(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + harness.requestShutdown('happier-cli'); + resolveBridgeStartup(); + await run; + + expect(harness.startChannelBridgeFromEnv).toHaveBeenCalledTimes(1); + expect(delayedWorkerStop).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + } + }); + it('does not leak bearer tokens when machine registration fails', async () => { vi.useRealTimers(); @@ -499,6 +565,7 @@ describe('startDaemon automation wiring (integration)', () => { }); const { logger } = await import('@/ui/logger'); + const { startChannelBridgeFromEnv } = await import('@/channels/startChannelBridgeWorker'); const { startDaemon } = await import('./startDaemon'); const run = startDaemon(); @@ -510,6 +577,7 @@ describe('startDaemon automation wiring (integration)', () => { const debugMock = (logger as any).debug as any; const serialized = JSON.stringify([...warnMock.mock.calls, ...debugMock.mock.calls]); expect(serialized).not.toContain(leakedBearer); + expect(startChannelBridgeFromEnv).toHaveBeenCalledTimes(1); } finally { exitSpy.mockRestore(); } diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index b0591ed40..e24bec2fd 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -16,19 +16,20 @@ import { } from '@/rpc/handlers/registerSessionHandlers'; import { logger } from '@/ui/logger'; import { authAndSetupMachineIfNeeded } from '@/ui/auth'; -import { configuration } from '@/configuration'; import { startCaffeinate, stopCaffeinate } from '@/integrations/caffeinate'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; import { buildHappyCliSubprocessLaunchSpec, spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { getVendorResumeSupport, requireCatalogEntry, resolveAgentCliSubcommand, resolveCatalogAgentId } from '@/backends/catalog'; import { CATALOG_AGENT_IDS } from '@/backends/types'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; import { writeDaemonState, DaemonLocallyPersistedState, acquireDaemonLock, releaseDaemonLock, readCredentials, + readSettings, } from '@/persistence'; import { createSessionAttachFile } from './sessionAttachFile'; import { getDaemonShutdownExitCode, getDaemonShutdownWatchdogTimeoutMs } from './shutdownPolicy'; @@ -87,6 +88,8 @@ import { computeDaemonSpawnRequestKey, createSpawnRequestCoalescer } from './spa import { startAutomationWorker, type AutomationWorkerHandle } from './automation/automationWorker'; import { startMemoryWorker, type MemoryWorkerHandle } from './memory/memoryWorker'; import { createDaemonConnectivityCoordinator } from './connection/createDaemonConnectivityCoordinator'; +import { startChannelBridgeFromEnv, type ChannelBridgeRuntimeHandle } from '@/channels/startChannelBridgeWorker'; +import { configuration } from '@/configuration'; import { resolveConnectedServiceAuthForSpawn } from './connectedServices/resolveConnectedServiceAuthForSpawn'; import { shouldResolveConnectedServiceAuthForSpawn } from './connectedServices/shouldResolveConnectedServiceAuthForSpawn'; import { ConnectedServiceRefreshCoordinator } from './connectedServices/refresh/ConnectedServiceRefreshCoordinator'; @@ -96,6 +99,7 @@ import { ConnectedServiceQuotasCoordinator } from './connectedServices/quotas/Co import { createConnectedServiceQuotaFetchers } from './connectedServices/quotas/createConnectedServiceQuotaFetchers'; import { resolveConnectedServiceQuotasDaemonOptions } from './connectedServices/quotas/resolveConnectedServiceQuotasDaemonOptions'; import { resolveConnectedServicesQuotasDaemonEnabled } from './connectedServices/quotas/resolveConnectedServicesQuotasDaemonEnabled'; +import { resolveChannelBridgesDaemonEnabled } from './channels/resolveChannelBridgesDaemonEnabled'; import { startConnectedServiceQuotasLoop, type ConnectedServiceQuotasLoopHandle } from './connectedServices/quotas/startConnectedServiceQuotasLoop'; import { HAPPIER_DAEMON_INITIAL_PROMPT_ENV_KEY, @@ -282,6 +286,7 @@ export async function startDaemon(): Promise { let apiMachineForSessions: ApiMachineClient | null = null; let automationWorker: AutomationWorkerHandle | null = null; let memoryWorker: MemoryWorkerHandle | null = null; + let channelBridgeWorker: ChannelBridgeRuntimeHandle | null = null; let apiMachine: ApiMachineClient | null = null; let machineConnectionStateCleanup: (() => void) | null = null; let shutdownInitiated = false; @@ -1521,6 +1526,75 @@ export async function startDaemon(): Promise { // Do machine bootstrap in the background so shutdown requests are not blocked by /v1/machines latency. void (async () => { + const startChannelBridgeWorkerBestEffort = async (): Promise => { + const bridgeSettings = await readSettings().catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to read settings for channel bridge startup; channel bridges require user experimental opt-in and will remain disabled', + error instanceof Error ? error.message : String(error), + ); + return null; + }); + + const channelBridgesEnabled = await resolveChannelBridgesDaemonEnabled({ + env: process.env, + serverUrl: configuration.serverUrl, + settings: bridgeSettings, + timeoutMs: 1500, + }).catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to resolve channel bridge feature gating; skipping channel bridge startup', + serializeAxiosErrorForLog(error), + ); + return false; + }); + if (!channelBridgesEnabled || shutdownInitiated) { + return; + } + + const tokenPayload = decodeJwtPayload(credentials.token); + const channelBridgeAccountId = + tokenPayload && typeof tokenPayload.sub === 'string' + ? tokenPayload.sub.trim() + : null; + const channelBridgeServerId = (configuration.activeServerId ?? '').trim() || null; + const channelBridgeRuntimeSettings: unknown = bridgeSettings; + + if (shutdownInitiated) { + return; + } + + const worker = await startChannelBridgeFromEnv({ + credentials, + ...(channelBridgeRuntimeSettings ? { settings: channelBridgeRuntimeSettings } : {}), + ...(channelBridgeServerId ? { serverId: channelBridgeServerId } : {}), + ...(channelBridgeAccountId ? { accountId: channelBridgeAccountId } : {}), + }).catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to start channel bridge worker (best-effort)', + serializeAxiosErrorForLog(error), + ); + return null; + }); + + if (!worker) { + return; + } + + if (shutdownInitiated) { + await worker.stop().catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to stop channel bridge worker started during shutdown', + serializeAxiosErrorForLog(error), + ); + }); + return; + } + + channelBridgeWorker = worker; + }; + + void startChannelBridgeWorkerBestEffort(); + let attempts = 0; while (!shutdownInitiated) { try { @@ -1817,6 +1891,39 @@ export async function startDaemon(): Promise { if (memoryWorker) { memoryWorker.stop(); } + if (channelBridgeWorker) { + const channelBridgeStopTimeoutMs = resolvePositiveIntEnv( + process.env.HAPPIER_DAEMON_CHANNEL_BRIDGE_STOP_TIMEOUT_MS, + 5_000, + { min: 250, max: 60_000 }, + ); + let channelBridgeStopTimeoutHandle: NodeJS.Timeout | null = null; + try { + const stopResult = await Promise.race<'stopped' | 'timeout'>([ + channelBridgeWorker.stop().then(() => 'stopped' as const), + new Promise<'timeout'>((resolve) => { + channelBridgeStopTimeoutHandle = setTimeout(() => resolve('timeout'), channelBridgeStopTimeoutMs); + channelBridgeStopTimeoutHandle.unref?.(); + }), + ]); + + if (stopResult === 'timeout') { + logger.warn( + `[DAEMON RUN] Channel bridge worker stop timed out after ${channelBridgeStopTimeoutMs}ms; continuing shutdown`, + ); + } + } catch (error) { + logger.warn( + '[DAEMON RUN] Failed to stop channel bridge worker during shutdown (best-effort)', + serializeAxiosErrorForLog(error), + ); + } finally { + if (channelBridgeStopTimeoutHandle) { + clearTimeout(channelBridgeStopTimeoutHandle); + channelBridgeStopTimeoutHandle = null; + } + } + } // Best-effort cleanup for provider-managed background processes (e.g. shared OpenCode server). // Important: do not tear down shared provider background processes while session runners are still diff --git a/apps/cli/src/features/featureLocalPolicy.ts b/apps/cli/src/features/featureLocalPolicy.ts index 2718913cc..a9bd0b3e3 100644 --- a/apps/cli/src/features/featureLocalPolicy.ts +++ b/apps/cli/src/features/featureLocalPolicy.ts @@ -9,6 +9,8 @@ const LOCAL_POLICY_BY_FEATURE: Readonly parseBooleanEnv(env.HAPPIER_FEATURE_VOICE__ENABLED, true), connectedServices: (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CONNECTED_SERVICES__ENABLED, true), 'connectedServices.quotas': (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CONNECTED_SERVICES_QUOTAS__ENABLED, true), + channelBridges: (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED, true), + 'channelBridges.telegram': (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED, true), }; export function resolveCliLocalFeaturePolicyEnabled(featureId: FeatureId, env: NodeJS.ProcessEnv): boolean { diff --git a/apps/cli/src/features/settingsFeatureToggles.ts b/apps/cli/src/features/settingsFeatureToggles.ts new file mode 100644 index 000000000..00fa4dcb7 --- /dev/null +++ b/apps/cli/src/features/settingsFeatureToggles.ts @@ -0,0 +1,48 @@ +type RecordLike = Record; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function shallowCloneRecord(value: unknown): RecordLike { + const record = asRecord(value); + if (!record) return {}; + return { ...record }; +} + +export function resolveExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ + settings: unknown; + featureId: string; + defaultEnabled: boolean; +}>): boolean { + const root = asRecord(params.settings); + if (!root) return false; + + if (root.experiments !== true) return false; + + const featureToggles = asRecord(root.featureToggles); + const explicit = featureToggles ? featureToggles[params.featureId] : undefined; + if (typeof explicit === 'boolean') return explicit; + + return params.defaultEnabled === true; +} + +export function ensureExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ + settings: TSettings; + featureId: string; +}>): TSettings { + const root = shallowCloneRecord(params.settings); + + if (root.experiments !== true) { + root.experiments = true; + } + + const existingFeatureToggles = asRecord(root.featureToggles); + root.featureToggles = { + ...(existingFeatureToggles ?? {}), + [params.featureId]: true, + }; + + return root as unknown as TSettings; +} diff --git a/apps/cli/src/persistence.ts b/apps/cli/src/persistence.ts index 13f8842c8..c5dc9b99b 100644 --- a/apps/cli/src/persistence.ts +++ b/apps/cli/src/persistence.ts @@ -38,6 +38,13 @@ function bestEffortChmodSync(path: string, mode: number): void { // Incremented when Settings structure changes. export const SUPPORTED_SCHEMA_VERSION = 6; +export interface ChannelBridgeSettings { + tickMs?: number + providers?: Record + byServerId?: Record + [key: string]: unknown +} + export interface Settings { // Schema version for backwards compatibility schemaVersion: number @@ -108,6 +115,11 @@ export interface Settings { * Parsed/normalized by `settings/memorySettings.ts`. */ memory?: unknown + + /** + * Channel bridge configuration (account/server scoped + optional global defaults). + */ + channelBridge?: ChannelBridgeSettings } const defaultSettings: Settings = { diff --git a/apps/cli/src/server/serverUrlClassification.test.ts b/apps/cli/src/server/serverUrlClassification.test.ts index 192dc56bb..cccfd6507 100644 --- a/apps/cli/src/server/serverUrlClassification.test.ts +++ b/apps/cli/src/server/serverUrlClassification.test.ts @@ -4,6 +4,7 @@ import { isInsecureRemoteHttpServerUrl, isLocalishHostname, isLocalishServerUrl, + isLoopbackHostname, isLoopbackHttpServerUrl, } from '@/server/serverUrlClassification'; @@ -35,6 +36,17 @@ describe('serverUrlClassification', () => { expect(isLoopbackHttpServerUrl('https://localhost:3005')).toBe(false); }); + it('detects loopback hostnames', () => { + expect(isLoopbackHostname('localhost')).toBe(true); + expect(isLoopbackHostname('127.0.0.1')).toBe(true); + expect(isLoopbackHostname('127.0.0.5')).toBe(true); + expect(isLoopbackHostname('::1')).toBe(true); + expect(isLoopbackHostname('192.168.1.20')).toBe(false); + expect(isLoopbackHostname('0.0.0.0')).toBe(false); + expect(isLoopbackHostname('example.com')).toBe(false); + expect(isLoopbackHostname('')).toBe(false); + }); + it('classifies server URLs by host, not by http scheme alone', () => { expect(isLocalishServerUrl('http://127.0.0.1:3005')).toBe(true); expect(isLocalishServerUrl('https://192.168.1.20:3005')).toBe(true); diff --git a/apps/cli/src/ui/doctor.test.ts b/apps/cli/src/ui/doctor.test.ts index 9a4ba471b..c79a0090e 100644 --- a/apps/cli/src/ui/doctor.test.ts +++ b/apps/cli/src/ui/doctor.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { maskValue, redactDaemonStateForDisplay, shouldShowGlobalProcessInventory } from './doctor'; +import { + collectMissingRequiredWebhookFields, + resolveTelegramWebhookValidationInputs, + isMissingRequiredTelegramWebhookSecret, + parseStrictWebhookPort, + applyDoctorExitCode, + maskValue, + redactSettingsForDisplay, + redactDaemonStateForDisplay, + shouldShowGlobalProcessInventory, +} from './doctor'; describe('doctor redaction', () => { it('does not treat ${VAR:-default} templates as safe', () => { @@ -55,6 +65,105 @@ describe('doctor redaction', () => { controlToken: '', }); }); + + it('redacts channel bridge secret fields from settings output', () => { + const input = { + channelBridge: { + byServerId: { + 'local-3005': { + byAccountId: { + acct1: { + providers: { + telegram: { + botToken: 'bot-token-123', + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + secret: 'legacy-webhook-secret', + }, + secrets: { + botToken: 'bot-token-123', + webhookSecret: 'webhook-secret-123', + extraSecret: 'extra-secret', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const redacted = redactSettingsForDisplay(input as never) as any; + const telegram = redacted.channelBridge.byServerId['local-3005'].byAccountId.acct1.providers.telegram; + expect(telegram.botToken).toBe(''); + expect(telegram.webhook.secret).toBe(''); + expect(telegram.secrets.botToken).toBe(''); + expect(telegram.secrets.webhookSecret).toBe(''); + expect(telegram.secrets.extraSecret).toBe(''); + expect(telegram.webhook.host).toBe('127.0.0.1'); + expect(telegram.webhook.port).toBe(8787); + }); + + it('redacts global and server-scoped channel bridge provider secrets', () => { + const input = { + channelBridge: { + providers: { + telegram: { + botToken: 'global-bot-token', + webhook: { + secret: 'global-webhook-secret', + }, + secrets: { + botToken: 'global-bot-token', + }, + }, + }, + byServerId: { + 'local-3005': { + providers: { + telegram: { + botToken: 'server-bot-token', + webhook: { + secret: 'server-webhook-secret', + }, + secrets: { + webhookSecret: 'server-webhook-secret', + }, + }, + }, + }, + }, + }, + }; + + const redacted = redactSettingsForDisplay(input as never) as any; + expect(redacted.channelBridge.providers.telegram.botToken).toBe(''); + expect(redacted.channelBridge.providers.telegram.webhook.secret).toBe(''); + expect(redacted.channelBridge.providers.telegram.secrets.botToken).toBe(''); + expect(redacted.channelBridge.byServerId['local-3005'].providers.telegram.botToken).toBe(''); + expect(redacted.channelBridge.byServerId['local-3005'].providers.telegram.webhook.secret).toBe(''); + expect(redacted.channelBridge.byServerId['local-3005'].providers.telegram.secrets.webhookSecret).toBe(''); + }); +}); + +describe('parseStrictWebhookPort', () => { + it('rejects negative and out-of-range values', () => { + expect(parseStrictWebhookPort('-1')).toBeNull(); + expect(parseStrictWebhookPort(' -8787 ')).toBeNull(); + expect(parseStrictWebhookPort('0')).toBeNull(); + expect(parseStrictWebhookPort(0)).toBeNull(); + expect(parseStrictWebhookPort('65536')).toBeNull(); + expect(parseStrictWebhookPort(70_000)).toBeNull(); + }); + + it('accepts valid positive integer values', () => { + expect(parseStrictWebhookPort('8787')).toBe(8787); + expect(parseStrictWebhookPort(8787)).toBe(8787); + expect(parseStrictWebhookPort('65535')).toBe(65_535); + }); }); describe('doctor process inventory visibility', () => { @@ -66,3 +175,114 @@ describe('doctor process inventory visibility', () => { expect(shouldShowGlobalProcessInventory('all')).toBe(true); }); }); + +describe('telegram webhook secret requirements', () => { + it('requires webhook secret only when webhook mode is enabled', () => { + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: true, webhookSecret: '' })).toBe(true); + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: true, webhookSecret: ' ' })).toBe(true); + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: true, webhookSecret: 'secret-123' })).toBe(false); + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: false, webhookSecret: '' })).toBe(false); + }); +}); + +describe('generic webhook field requirements', () => { + it('returns no issues when webhook mode is disabled', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: false, + webhookSecret: '', + webhookHost: '', + webhookPort: null, + }), + ).toEqual([]); + }); + + it('flags missing required webhook fields when enabled', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: true, + webhookSecret: ' ', + webhookHost: '', + webhookPort: null, + }), + ).toEqual([ + 'webhook.secret: (required when webhook.enabled=true)', + 'webhook.host: (required when webhook.enabled=true)', + 'webhook.port: (required when webhook.enabled=true)', + ]); + }); + + it('flags non-loopback webhook hosts when enabled', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: true, + webhookSecret: 'secret-1', + webhookHost: '0.0.0.0', + webhookPort: 8787, + }), + ).toEqual([ + "webhook.host: '0.0.0.0' is not loopback-only (required when webhook.enabled=true)", + ]); + }); + + it('accepts valid webhook secret, host, and port', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: true, + webhookSecret: 'secret-1', + webhookHost: '127.0.0.1', + webhookPort: 8787, + }), + ).toEqual([]); + }); +}); + +describe('telegram webhook validation inputs', () => { + it('uses resolved runtime defaults for host and port validation', () => { + expect( + resolveTelegramWebhookValidationInputs({ + runtimeWebhookHost: '127.0.0.1', + runtimeWebhookPort: 8787, + }), + ).toEqual({ + webhookHost: '127.0.0.1', + webhookPort: 8787, + }); + }); + + it('flags invalid runtime values so critical checks can fail loudly', () => { + expect( + resolveTelegramWebhookValidationInputs({ + runtimeWebhookHost: ' ', + runtimeWebhookPort: Number.NaN, + }), + ).toEqual({ + webhookHost: '', + webhookPort: null, + }); + }); +}); + +describe('doctor exit code behavior', () => { + it('sets process exit code to 1 when critical failures are present', () => { + const previousExitCode = process.exitCode; + try { + process.exitCode = 0; + applyDoctorExitCode(true); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + }); + + it('leaves process exit code unchanged when no critical failures are present', () => { + const previousExitCode = process.exitCode; + try { + process.exitCode = 0; + applyDoctorExitCode(false); + expect(process.exitCode).toBe(0); + } finally { + process.exitCode = previousExitCode; + } + }); +}); diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index 0c2e527a6..028e88494 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -11,6 +11,8 @@ import { readSettings, readCredentials } from '@/persistence' import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient' import { findRunawayHappyProcesses, findAllHappyProcesses } from '@/daemon/doctor' import { readDaemonState, type DaemonLocallyPersistedState } from '@/persistence' +import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { runChannelBridgeDoctorSection } from '@/ui/doctor/channelBridgesDoctor'; import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -43,9 +45,118 @@ export function maskValue(value: string | undefined): string | undefined { return `<${value.length} chars>`; } +export function isMissingRequiredTelegramWebhookSecret(params: Readonly<{ + webhookEnabled: boolean; + webhookSecret: string; +}>): boolean { + return params.webhookEnabled && params.webhookSecret.trim().length === 0; +} + +export function collectMissingRequiredWebhookFields(params: Readonly<{ + webhookEnabled: boolean; + webhookSecret: string; + webhookHost: string; + webhookPort: number | null; +}>): string[] { + if (!params.webhookEnabled) return []; + const issues: string[] = []; + if (params.webhookSecret.trim().length === 0) { + issues.push('webhook.secret: (required when webhook.enabled=true)'); + } + const normalizedWebhookHost = params.webhookHost.trim(); + if (normalizedWebhookHost.length === 0) { + issues.push('webhook.host: (required when webhook.enabled=true)'); + } else if (!isLoopbackHostname(normalizedWebhookHost)) { + issues.push( + `webhook.host: '${normalizedWebhookHost}' is not loopback-only (required when webhook.enabled=true)`, + ); + } + if ( + !Number.isFinite(params.webhookPort) + || params.webhookPort === null + || !Number.isInteger(params.webhookPort) + || params.webhookPort <= 0 + || params.webhookPort > 65_535 + ) { + issues.push('webhook.port: (required when webhook.enabled=true)'); + } + return issues; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +export function parseStrictWebhookPort(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + if (!Number.isInteger(value) || value <= 0 || value > 65_535) return null; + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) return null; + return parsed; + } + return null; +} + +function redactProviderSecrets(providerRecord: Record): void { + const secrets = asRecord(providerRecord.secrets); + if (secrets) { + for (const [key, value] of Object.entries(secrets)) { + if (typeof value === 'string' && value.trim().length > 0) { + secrets[key] = ''; + } + } + } + + if (typeof providerRecord.botToken === 'string' && providerRecord.botToken.trim().length > 0) { + providerRecord.botToken = ''; + } + + const webhook = asRecord(providerRecord.webhook); + if (webhook && typeof webhook.secret === 'string' && webhook.secret.trim().length > 0) { + webhook.secret = ''; + } +} + +function redactProvidersMap(providers: Record | null): void { + if (!providers) return; + for (const providerScope of Object.values(providers)) { + const providerRecord = asRecord(providerScope); + if (!providerRecord) continue; + redactProviderSecrets(providerRecord); + } +} + +export function resolveTelegramWebhookValidationInputs(params: Readonly<{ + runtimeWebhookHost: string; + runtimeWebhookPort: number; +}>): Readonly<{ + webhookHost: string; + webhookPort: number | null; +}> { + const webhookHost = String(params.runtimeWebhookHost ?? '').trim(); + const webhookPort = + Number.isFinite(params.runtimeWebhookPort) + && Number.isInteger(params.runtimeWebhookPort) + && params.runtimeWebhookPort > 0 + && params.runtimeWebhookPort <= 65_535 + ? params.runtimeWebhookPort + : null; + + return { + webhookHost, + webhookPort, + }; +} + type SettingsForDisplay = Awaited>; -function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisplay { +export function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisplay { const redacted = JSON.parse(JSON.stringify(settings ?? {})) as SettingsForDisplay; const redactedRecord = redacted as unknown as Record; @@ -54,6 +165,33 @@ function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisp delete redactedRecord.localEnvironmentVariables; } + const channelBridge = asRecord(redactedRecord.channelBridge); + if (!channelBridge) { + return redacted; + } + + redactProvidersMap(asRecord(channelBridge.providers)); + + const byServerId = asRecord(channelBridge.byServerId); + if (!byServerId) { + return redacted; + } + + for (const serverScope of Object.values(byServerId)) { + const serverRecord = asRecord(serverScope); + if (!serverRecord) continue; + + redactProvidersMap(asRecord(serverRecord.providers)); + + const byAccountId = asRecord(serverRecord.byAccountId); + if (!byAccountId) continue; + + for (const accountScope of Object.values(byAccountId)) { + const accountRecord = asRecord(accountScope); + redactProvidersMap(asRecord(accountRecord?.providers)); + } + } + return redacted; } @@ -123,11 +261,19 @@ export function shouldShowGlobalProcessInventory(filter: 'all' | 'daemon'): bool return filter === 'all'; } +export function applyDoctorExitCode(hasCriticalFailures: boolean): void { + if (hasCriticalFailures) { + process.exitCode = 1; + } +} + export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise { // Default to 'all' if no filter specified if (!filter) { filter = 'all'; } + + let hasCriticalFailures = false; console.log(chalk.bold.cyan('\n🩺 Happier CLI Doctor\n')); @@ -157,9 +303,15 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(`Wrapper Script: ${chalk.blue(formatDoctorSpawnPathLabel(runtimeDiagnostics.wrapperPath))}`); console.log(`CLI Entrypoint: ${chalk.blue(formatDoctorSpawnPathLabel(runtimeDiagnostics.cliEntrypointPath))}`); if (runtimeDiagnostics.wrapperExists !== null) { + if (!runtimeDiagnostics.wrapperExists) { + hasCriticalFailures = true; + } console.log(`Wrapper Exists: ${runtimeDiagnostics.wrapperExists ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); } if (runtimeDiagnostics.cliEntrypointExists !== null) { + if (!runtimeDiagnostics.cliEntrypointExists) { + hasCriticalFailures = true; + } console.log(`CLI Exists: ${runtimeDiagnostics.cliEntrypointExists ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); } console.log(''); @@ -209,19 +361,22 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } // Settings + let settingsSnapshot: Awaited> | null = null; try { - const settings = await readSettings(); + settingsSnapshot = await readSettings(); console.log(chalk.bold('\n📄 Settings (settings.json):')); - console.log(chalk.gray(JSON.stringify(redactSettingsForDisplay(settings), null, 2))); + console.log(chalk.gray(JSON.stringify(redactSettingsForDisplay(settingsSnapshot), null, 2))); } catch (error) { console.log(chalk.bold('\n📄 Settings:')); console.log(chalk.red('❌ Failed to read settings')); + hasCriticalFailures = true; } // Authentication status + let credentials: Awaited> | null = null; console.log(chalk.bold('\n🔐 Authentication')); try { - const credentials = await readCredentials(); + credentials = await readCredentials(); if (credentials) { console.log(chalk.green('✓ Authenticated (credentials found)')); if (snapshot?.accountId) { @@ -232,6 +387,23 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } } catch (error) { console.log(chalk.red('❌ Error reading credentials')); + hasCriticalFailures = true; + } + + try { + const settings = settingsSnapshot ?? await readSettings(); + const channelBridgeResult = await runChannelBridgeDoctorSection({ + settings, + credentialsToken: credentials?.token ?? null, + }); + if (channelBridgeResult.hasCriticalFailures) { + hasCriticalFailures = true; + } + console.log(chalk.gray('Apply changes with daemon restart: happier daemon stop && happier daemon start')); + } catch (error) { + hasCriticalFailures = true; + const message = error instanceof Error ? error.message : String(error); + console.log(chalk.red(`❌ Failed to evaluate channel bridge diagnostics: ${message}`)); } } @@ -252,7 +424,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } else if (state && !isRunning) { console.log(chalk.yellow('⚠️ Daemon state exists but process not running (stale)')); } else { - console.log(chalk.red('❌ Daemon is not running')); + console.log(chalk.yellow('⚠️ Daemon is not running')); } // Show daemon state file @@ -301,7 +473,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise }); }); } else { - console.log(chalk.red('❌ No happier processes found')); + console.log(chalk.yellow('⚠️ No happier processes found (process inventory may be unavailable in this runtime)')); } if (allProcesses.length > 1) { // More than just current process @@ -311,6 +483,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } } catch (error) { console.log(chalk.red('❌ Error checking daemon status')); + hasCriticalFailures = true; } // Log files - only show for 'all' filter @@ -362,5 +535,11 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(`Documentation: ${chalk.blue('https://app.happier.dev')}`); } - console.log(chalk.green('\n✅ Doctor diagnosis complete!\n')); + if (hasCriticalFailures) { + console.log(chalk.red('\n❌ Doctor diagnosis complete!\n')); + } else { + console.log(chalk.green('\n✅ Doctor diagnosis complete!\n')); + } + + applyDoctorExitCode(hasCriticalFailures); } diff --git a/apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts b/apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts new file mode 100644 index 000000000..c098cabfc --- /dev/null +++ b/apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { runChannelBridgeDoctorSection } from './channelBridgesDoctor'; + +describe('runChannelBridgeDoctorSection', () => { + it('treats webhook enabled with missing secret as a critical failure', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const result = await runChannelBridgeDoctorSection({ + credentialsToken: null, + settings: { + channelBridge: { + providers: { + telegram: { + secrets: { + botToken: 'bot-token', + webhookSecret: '', + }, + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + }, + }, + }, + }, + }, + }); + + expect(result.hasCriticalFailures).toBe(true); + expect(logSpy.mock.calls.map((call) => String(call[0]))).toContain( + '❌ webhook.enabled=true but webhook.secret is missing (bridge will not start)', + ); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/ui/doctor/channelBridgesDoctor.ts b/apps/cli/src/ui/doctor/channelBridgesDoctor.ts new file mode 100644 index 000000000..5c0c4ed9f --- /dev/null +++ b/apps/cli/src/ui/doctor/channelBridgesDoctor.ts @@ -0,0 +1,138 @@ +import chalk from 'chalk'; + +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import type { ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; +import { createLocalChannelBindingStore } from '@/channels/state/localBindingStore'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; +import { configuration } from '@/configuration'; + +export type ChannelBridgeDoctorSectionResult = Readonly<{ + hasCriticalFailures: boolean; +}>; + +function formatBindingRef(binding: ChannelSessionBinding): string { + const thread = binding.threadId ? `/${binding.threadId}` : ''; + return `${binding.providerId}:${binding.conversationId}${thread}`; +} + +function formatSenderIdStatus(binding: ChannelSessionBinding): string { + if (binding.ownerSenderId) return `owner=${binding.ownerSenderId}`; + if (binding.allowMissingSenderId) return 'owner= (allowMissingSenderId=true)'; + return 'owner='; +} + +function formatInboundMode(binding: ChannelSessionBinding): string { + const mode = binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const missing = binding.allowMissingSenderId ? ', allowMissingSenderId=true' : ''; + return `${mode}${missing}`; +} + +function isTelegramConfigured(runtime: ReturnType['providers']['telegram']): boolean { + return ( + runtime.botToken.trim().length > 0 + || runtime.webhookEnabled + || runtime.webhookSecret.trim().length > 0 + || runtime.allowedChatIds.length > 0 + || runtime.allowAllSharedChats + || runtime.requireTopics + ); +} + +export async function runChannelBridgeDoctorSection(params: Readonly<{ + settings: unknown; + credentialsToken: string | null; +}>): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + const payload = params.credentialsToken ? decodeJwtPayload(params.credentialsToken) : null; + const accountId = payload && typeof payload.sub === 'string' ? payload.sub.trim() : ''; + + const runtime = resolveChannelBridgeRuntimeConfig({ + env: process.env, + settings: params.settings, + serverId, + accountId: accountId || null, + }); + + console.log(chalk.bold('\n🔌 Channel Bridges')); + console.log(`Server scope: ${serverId || '(unknown)'}`); + console.log(`Account scope: ${accountId || '(unknown)'}`); + console.log('Runtime source: env > local settings'); + + let hasCriticalFailures = false; + + const telegram = runtime.providers.telegram; + if (!isTelegramConfigured(telegram)) { + console.log(chalk.gray('Telegram bridge not configured for active scope')); + return { hasCriticalFailures }; + } + + const tokenMissing = telegram.botToken.trim().length === 0; + if (tokenMissing) { + console.log(chalk.red('❌ Telegram bridge configuration present but bot token is missing (bridge will not start)')); + hasCriticalFailures = true; + } else { + console.log(chalk.green('✓ Telegram bridge configured (bot token present)')); + } + + if (telegram.allowAllSharedChats) { + console.log(chalk.yellow('⚠️ allowAllSharedChats=true (any shared chat can be attached; high risk)')); + } + + const allowedChatIdsLabel = (() => { + if (telegram.allowAllSharedChats) { + return '(allow all shared chats - UNSAFE)'; + } + if (telegram.allowedChatIds.length > 0) { + return telegram.allowedChatIds.join(', '); + } + return '(dm-only)'; + })(); + + console.log(` allowedChatIds: ${allowedChatIdsLabel}`); + console.log(` requireTopics: ${telegram.requireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${telegram.webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${telegram.webhookHost}`); + console.log(` webhook.port: ${telegram.webhookPort}`); + + if (telegram.webhookEnabled && telegram.webhookSecret.trim().length === 0) { + console.log(chalk.red('❌ webhook.enabled=true but webhook.secret is missing (bridge will not start)')); + hasCriticalFailures = true; + } + + if (!accountId) { + console.log(chalk.gray('Bindings: unavailable (no authenticated account id)')); + return { hasCriticalFailures }; + } + + try { + const store = createLocalChannelBindingStore({ accountId }); + const bindings = await store.listBindings(); + console.log(chalk.bold('\nBindings (local state)')); + console.log(` count: ${bindings.length}`); + + const riskyAnyone = bindings.filter((binding) => binding.inboundMode === 'anyone').length; + const riskyMissingSender = bindings.filter((binding) => binding.allowMissingSenderId).length; + if (riskyAnyone > 0) { + console.log(chalk.yellow(`⚠️ ${riskyAnyone} binding(s) allow inbound from anyone`)); + } + if (riskyMissingSender > 0) { + console.log(chalk.yellow(`⚠️ ${riskyMissingSender} binding(s) allow missing sender identity (unsafe)`)); + } + + const displayLimit = 20; + for (const binding of bindings.slice(0, displayLimit)) { + console.log( + ` - ${formatBindingRef(binding)} → ${binding.sessionId} (${formatInboundMode(binding)}; ${formatSenderIdStatus(binding)})`, + ); + } + if (bindings.length > displayLimit) { + console.log(` … and ${bindings.length - displayLimit} more`); + } + console.log(chalk.gray('Manage bindings in-channel: /sessions, /attach, /detach, /help')); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(chalk.yellow(`⚠️ Failed to read local bindings: ${message}`)); + } + + return { hasCriticalFailures }; +} diff --git a/apps/server/sources/app/features/catalog/featureEnvSchema.ts b/apps/server/sources/app/features/catalog/featureEnvSchema.ts index 280404e6c..f7a4ab078 100644 --- a/apps/server/sources/app/features/catalog/featureEnvSchema.ts +++ b/apps/server/sources/app/features/catalog/featureEnvSchema.ts @@ -15,6 +15,9 @@ export const FEATURE_ENV_KEYS = Object.freeze({ connectedServicesEnabled: 'HAPPIER_FEATURE_CONNECTED_SERVICES__ENABLED', connectedServicesQuotasEnabled: 'HAPPIER_FEATURE_CONNECTED_SERVICES_QUOTAS__ENABLED', + channelBridgesEnabled: 'HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED', + channelBridgesTelegramEnabled: 'HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED', + updatesOtaEnabled: 'HAPPIER_FEATURE_UPDATES_OTA__ENABLED', attachmentsUploadsEnabled: 'HAPPIER_FEATURE_ATTACHMENTS_UPLOADS__ENABLED', diff --git a/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts b/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts index a340dfb65..b6bae6a71 100644 --- a/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts +++ b/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { readAuthFeatureEnv, + readChannelBridgesFeatureEnv, readConnectedServicesFeatureEnv, readMachineTransferFeatureEnv, readSessionHandoffFeatureEnv, @@ -16,6 +17,20 @@ describe('readConnectedServicesFeatureEnv', () => { }); }); +describe('readChannelBridgesFeatureEnv', () => { + it('defaults enabled to true when env is unset', () => { + const env: NodeJS.ProcessEnv = {}; + const res = readChannelBridgesFeatureEnv(env); + expect(res.enabled).toBe(true); + }); + + it('defaults telegramEnabled to true when env is unset', () => { + const env: NodeJS.ProcessEnv = {}; + const res = readChannelBridgesFeatureEnv(env); + expect(res.telegramEnabled).toBe(true); + }); +}); + describe('readAuthFeatureEnv', () => { it('falls back to legacy AUTH_UI_* env vars for auto-redirect', () => { const env: NodeJS.ProcessEnv = { diff --git a/apps/server/sources/app/features/catalog/readFeatureEnv.ts b/apps/server/sources/app/features/catalog/readFeatureEnv.ts index 07117f8f1..189e04540 100644 --- a/apps/server/sources/app/features/catalog/readFeatureEnv.ts +++ b/apps/server/sources/app/features/catalog/readFeatureEnv.ts @@ -26,6 +26,11 @@ export type ConnectedServicesFeatureEnv = Readonly<{ quotasEnabled: boolean; }>; +export type ChannelBridgesFeatureEnv = Readonly<{ + enabled: boolean; + telegramEnabled: boolean; +}>; + export type UpdatesFeatureEnv = Readonly<{ otaEnabled: boolean; }>; @@ -213,6 +218,13 @@ export function readConnectedServicesFeatureEnv(env: NodeJS.ProcessEnv): Connect }; } +export function readChannelBridgesFeatureEnv(env: NodeJS.ProcessEnv): ChannelBridgesFeatureEnv { + return { + enabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.channelBridgesEnabled], true), + telegramEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.channelBridgesTelegramEnabled], true), + }; +} + export function readUpdatesFeatureEnv(env: NodeJS.ProcessEnv): UpdatesFeatureEnv { return { otaEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.updatesOtaEnabled], true), diff --git a/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts b/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts index f819c1f79..5fabc2497 100644 --- a/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts +++ b/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveMachineTransferFeature } from "../machineTransferFeature"; +import { resolveChannelBridgesFeature } from "../channelBridgesFeature"; import { resolveSessionHandoffFeature } from "../sessionHandoffFeature"; import { resolveTerminalFeature } from "../terminalFeature"; import { resolveServerFeaturePayload } from "./resolveServerFeaturePayload"; @@ -125,6 +126,36 @@ describe("resolveServerFeaturePayload", () => { expect(payload.capabilities.machines.transfer.serverRouted.maxBytes).toBe(2 * 1024 * 1024 * 1024); }); + it("enables channel bridges by default so the experimental UI toggle can appear", () => { + const payload = resolveServerFeaturePayload({} as NodeJS.ProcessEnv, [resolveChannelBridgesFeature]); + expect(payload.features.channelBridges.enabled).toBe(true); + expect(payload.features.channelBridges.telegram.enabled).toBe(true); + }); + + it("disables channel bridges (and all providers) when the env toggle is off", () => { + const payload = resolveServerFeaturePayload( + { + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: "0", + } as NodeJS.ProcessEnv, + [resolveChannelBridgesFeature], + ); + + expect(payload.features.channelBridges.enabled).toBe(false); + expect(payload.features.channelBridges.telegram.enabled).toBe(false); + }); + + it("disables only telegram provider when the env toggle is off", () => { + const payload = resolveServerFeaturePayload( + { + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: "0", + } as NodeJS.ProcessEnv, + [resolveChannelBridgesFeature], + ); + + expect(payload.features.channelBridges.enabled).toBe(true); + expect(payload.features.channelBridges.telegram.enabled).toBe(false); + }); + it("disables only generic server-routed transfer when the env toggle is off", () => { const payload = resolveServerFeaturePayload({ HAPPIER_FEATURE_MACHINES_TRANSFER_SERVER_ROUTED__ENABLED: "0", diff --git a/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts b/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts index 5ea0fb109..32177031a 100644 --- a/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts +++ b/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts @@ -8,6 +8,7 @@ import { resolveFriendsFeature } from '../friendsFeature'; import { resolveOAuthFeature } from '../oauthFeature'; import { resolveAuthFeature } from '../authFeature'; import { resolveConnectedServicesFeature } from '../connectedServicesFeature'; +import { resolveChannelBridgesFeature } from '../channelBridgesFeature'; import { resolveUpdatesFeature } from '../updatesFeature'; import { resolveAttachmentsUploadsFeature } from '../attachmentsUploadsFeature'; import { resolveMachineTransferFeature } from '../machineTransferFeature'; @@ -28,6 +29,7 @@ export const serverFeatureRegistry: readonly ServerFeatureResolver[] = Object.fr (_env) => resolveSharingFeature(), (env) => resolveVoiceFeature(env), (env) => resolveConnectedServicesFeature(env), + (env) => resolveChannelBridgesFeature(env), (env) => resolveUpdatesFeature(env), (env) => resolveAttachmentsUploadsFeature(env), (env) => resolveMachineTransferFeature(env), diff --git a/apps/server/sources/app/features/channelBridgesFeature.ts b/apps/server/sources/app/features/channelBridgesFeature.ts new file mode 100644 index 000000000..809674c86 --- /dev/null +++ b/apps/server/sources/app/features/channelBridgesFeature.ts @@ -0,0 +1,20 @@ +import type { FeaturesPayloadDelta } from "./types"; + +import { readChannelBridgesFeatureEnv } from "./catalog/readFeatureEnv"; + +export function resolveChannelBridgesFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta { + const featureEnv = readChannelBridgesFeatureEnv(env); + + const enabled = featureEnv.enabled; + const telegramEnabled = enabled && featureEnv.telegramEnabled; + + return { + features: { + channelBridges: { + enabled, + telegram: { enabled: telegramEnabled }, + }, + }, + }; +} + diff --git a/apps/ui/metro.config.js b/apps/ui/metro.config.js index 2cac02519..58b316d82 100644 --- a/apps/ui/metro.config.js +++ b/apps/ui/metro.config.js @@ -47,16 +47,25 @@ config.transformer.getTransformOptions = async () => ({ const testRouteBlockList = /[\\/]sources[\\/]app[\\/].*\.(test|spec)\.[jt]sx?$/; const projectArtifactsBlockList = /[\\/]\.project[\\/]/; const nextBuildArtifactsBlockList = /[\\/]\.next[\\/]/; +const projectRootLivesUnderProjectWorktrees = /[\\/]\.project[\\/]worktrees[\\/]/.test( + path.resolve(String(config.projectRoot ?? __dirname)), +); // Avoid scanning duplicate workspace-local `node_modules/**` trees (typically symlink-heavy) when Metro falls back // to the native `find` crawler (no Watchman). We still keep the monorepo root `node_modules` and `apps/ui/node_modules`. const workspaceNodeModulesBlockList = /[\\/]apps[\\/](?!ui[\\/])[^\\/]+[\\/]node_modules[\\/]|[\\/]packages[\\/][^\\/]+[\\/]node_modules[\\/]/; const existingBlockList = config.resolver.blockList; +const projectLocalBlockListEntries = [ + testRouteBlockList, + ...(projectRootLivesUnderProjectWorktrees ? [] : [projectArtifactsBlockList]), + nextBuildArtifactsBlockList, + workspaceNodeModulesBlockList, +]; config.resolver.blockList = Array.isArray(existingBlockList) - ? [...existingBlockList, testRouteBlockList, projectArtifactsBlockList, nextBuildArtifactsBlockList, workspaceNodeModulesBlockList] + ? [...existingBlockList, ...projectLocalBlockListEntries] : existingBlockList - ? [existingBlockList, testRouteBlockList, projectArtifactsBlockList, nextBuildArtifactsBlockList, workspaceNodeModulesBlockList] - : [testRouteBlockList, projectArtifactsBlockList, nextBuildArtifactsBlockList, workspaceNodeModulesBlockList]; + ? [existingBlockList, ...projectLocalBlockListEntries] + : projectLocalBlockListEntries; const existingWatchFolders = Array.isArray(config.watchFolders) ? config.watchFolders : []; config.watchFolders = existingWatchFolders.filter( diff --git a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx index 5cd845bad..64ad3bc35 100644 --- a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx +++ b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx @@ -11,6 +11,7 @@ import { (globalThis as any).__DEV__ = false; const useServerFeaturesMainSelectionSnapshotMock = vi.fn(); +const useServerFeaturesRuntimeSnapshotMock = vi.fn(); const useEffectiveServerSelectionMock = vi.fn(); vi.mock('@/sync/domains/features/featureDecisionRuntime', async (importOriginal) => { @@ -18,6 +19,7 @@ vi.mock('@/sync/domains/features/featureDecisionRuntime', async (importOriginal) return { ...actual, useServerFeaturesMainSelectionSnapshot: (...args: any[]) => useServerFeaturesMainSelectionSnapshotMock(...args), + useServerFeaturesRuntimeSnapshot: (...args: any[]) => useServerFeaturesRuntimeSnapshotMock(...args), }; }); @@ -82,6 +84,7 @@ describe('FeaturesSettingsScreen gating', () => { useEffectiveServerSelectionMock.mockReturnValue({ serverIds: [] }); useServerFeaturesMainSelectionSnapshotMock.mockReturnValue({ status: 'ready', serverIds: [], snapshotsByServerId: {} }); + useServerFeaturesRuntimeSnapshotMock.mockReturnValue({ status: 'loading' }); useSettingMutableMock.mockImplementation((key: string) => { if (key === 'experiments') return createNoopMutable(true); @@ -182,6 +185,73 @@ describe('FeaturesSettingsScreen gating', () => { expect(voiceAgentItem).toBeTruthy(); }); + it('shows channel bridges toggle when server supports it', async () => { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; + + useEffectiveServerSelectionMock.mockReturnValue({ serverIds: ['server-1'] }); + useServerFeaturesMainSelectionSnapshotMock.mockReturnValue({ + status: 'ready', + serverIds: ['server-1'], + snapshotsByServerId: { + 'server-1': { + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + voice: { enabled: true, happierVoice: { enabled: false } }, + }, + }), + }, + }, + }); + useServerFeaturesRuntimeSnapshotMock.mockReturnValue({ + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + }), + }); + + const { default: FeaturesSettingsScreen } = await import('@/app/(app)/settings/features'); + + const screen = await renderSettingsView(React.createElement(FeaturesSettingsScreen)); + const bridgeItem = screen.findRowByTitle('settingsFeatures.expChannelBridges'); + expect(bridgeItem).toBeTruthy(); + }); + + it('hides the channel bridges toggle when channel bridges are hard-disabled by the runtime server snapshot', async () => { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; + + useEffectiveServerSelectionMock.mockReturnValue({ serverIds: ['server-1'] }); + useServerFeaturesMainSelectionSnapshotMock.mockReturnValue({ + status: 'ready', + serverIds: ['server-1'], + snapshotsByServerId: { + 'server-1': { + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + }), + }, + }, + }); + useServerFeaturesRuntimeSnapshotMock.mockReturnValue({ + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: false, telegram: { enabled: true } }, + }, + }), + }); + + const { default: FeaturesSettingsScreen } = await import('@/app/(app)/settings/features'); + + const screen = await renderSettingsView(React.createElement(FeaturesSettingsScreen)); + expect(screen.findRowByTitle('settingsFeatures.expChannelBridges')).toBeNull(); + }); + it('turning off connectedServices also disables connectedServices.quotas', async () => { vi.resetModules(); const setFeatureToggles = vi.fn(); diff --git a/apps/ui/sources/app/(app)/settings/channel-bridges.tsx b/apps/ui/sources/app/(app)/settings/channel-bridges.tsx new file mode 100644 index 000000000..28a729545 --- /dev/null +++ b/apps/ui/sources/app/(app)/settings/channel-bridges.tsx @@ -0,0 +1,4 @@ +import { ChannelBridgesSettingsView } from '@/components/settings/channelBridges/ChannelBridgesSettingsView'; + +export default ChannelBridgesSettingsView; + diff --git a/apps/ui/sources/app/(app)/settings/features.tsx b/apps/ui/sources/app/(app)/settings/features.tsx index 93f9ef95e..338018c20 100644 --- a/apps/ui/sources/app/(app)/settings/features.tsx +++ b/apps/ui/sources/app/(app)/settings/features.tsx @@ -22,10 +22,14 @@ import { buildUiFeatureToggleDefaults, listUiFeatureToggleDefinitions, resolveUiFeatureToggleEnabled, + type UiFeatureToggleDefinition, } from '@/sync/domains/features/featureRegistry'; import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBuildPolicy'; import { useEffectiveServerSelection } from '@/hooks/server/useEffectiveServerSelection'; -import { useServerFeaturesMainSelectionSnapshot } from '@/sync/domains/features/featureDecisionRuntime'; +import { + useServerFeaturesMainSelectionSnapshot, + useServerFeaturesRuntimeSnapshot, +} from '@/sync/domains/features/featureDecisionRuntime'; export default React.memo(function FeaturesSettingsScreen() { const { theme } = useUnistyles(); @@ -83,12 +87,20 @@ export default React.memo(function FeaturesSettingsScreen() { const shouldProbeServerForToggleVisibility = React.useMemo(() => { for (const def of toggleDefinitions) { if (getFeatureBuildPolicyDecision(def.featureId) === 'deny') continue; - if (featureRequiresServerSnapshot(def.featureId)) return true; + if (def.serverVisibilityScope === 'main_selection' && featureRequiresServerSnapshot(def.featureId)) return true; + } + return false; + }, [toggleDefinitions]); + const shouldProbeRuntimeServerForToggleVisibility = React.useMemo(() => { + for (const def of toggleDefinitions) { + if (getFeatureBuildPolicyDecision(def.featureId) === 'deny') continue; + if (def.serverVisibilityScope === 'runtime' && featureRequiresServerSnapshot(def.featureId)) return true; } return false; }, [toggleDefinitions]); const serverSnapshot = useServerFeaturesMainSelectionSnapshot(selection.serverIds, { enabled: shouldProbeServerForToggleVisibility }); + const runtimeServerSnapshot = useServerFeaturesRuntimeSnapshot({ enabled: shouldProbeRuntimeServerForToggleVisibility }); const serverProbeFeatureIdsByFeatureId = React.useMemo(() => { const memo = new Map(); @@ -127,9 +139,39 @@ export default React.memo(function FeaturesSettingsScreen() { return memo; }, [toggleDefinitions]); - const isToggleHardDisabledByServer = React.useCallback( + const isKnownFeatureId = React.useCallback((featureId: unknown): featureId is FeatureId => { + return typeof featureId === 'string' && (FEATURE_IDS as readonly string[]).includes(featureId); + }, []); + + const isRuntimeFeatureHardDisabledByServer = React.useCallback( (featureId: FeatureId): boolean => { + if (!isKnownFeatureId(featureId)) return false; if (!featureRequiresServerSnapshot(featureId)) return false; + if (runtimeServerSnapshot.status === 'loading') return false; + if (runtimeServerSnapshot.status === 'error') return false; + if (runtimeServerSnapshot.status === 'unsupported') return true; + + const serverFeatureIdsToProbe = serverProbeFeatureIdsByFeatureId.get(featureId) ?? []; + if (serverFeatureIdsToProbe.length === 0) return false; + + for (const serverFeatureId of serverFeatureIdsToProbe) { + const enabled = readServerEnabledBit(runtimeServerSnapshot.features, serverFeatureId) === true; + if (!enabled) return true; + } + + return false; + }, + [isKnownFeatureId, runtimeServerSnapshot, serverProbeFeatureIdsByFeatureId], + ); + + const isFeatureHardDisabledByServer = React.useCallback( + (definition: UiFeatureToggleDefinition): boolean => { + const featureId = definition.featureId; + if (!isKnownFeatureId(featureId)) return false; + if (!featureRequiresServerSnapshot(featureId)) return false; + if (definition.serverVisibilityScope === 'runtime') { + return isRuntimeFeatureHardDisabledByServer(featureId); + } if (serverSnapshot.status !== 'ready') return false; if (serverSnapshot.serverIds.length === 0) return false; @@ -158,16 +200,16 @@ export default React.memo(function FeaturesSettingsScreen() { return false; }, - [serverProbeFeatureIdsByFeatureId, serverSnapshot], + [isKnownFeatureId, isRuntimeFeatureHardDisabledByServer, serverProbeFeatureIdsByFeatureId, serverSnapshot], ); const visibleToggleDefinitions = React.useMemo(() => { return toggleDefinitions.filter((d) => { if (getFeatureBuildPolicyDecision(d.featureId) === 'deny') return false; - if (isToggleHardDisabledByServer(d.featureId)) return false; + if (isFeatureHardDisabledByServer(d)) return false; return true; }); - }, [isToggleHardDisabledByServer, toggleDefinitions]); + }, [isFeatureHardDisabledByServer, toggleDefinitions]); const standardToggleDefinitions = visibleToggleDefinitions.filter((d) => !d.isExperimental); const experimentalToggleDefinitions = visibleToggleDefinitions.filter((d) => d.isExperimental); @@ -212,10 +254,12 @@ export default React.memo(function FeaturesSettingsScreen() { const embeddedTerminalDockSettingVisible = React.useMemo(() => { if (!experiments) return false; - if (isToggleHardDisabledByServer('terminal.embeddedPty')) return false; + const embeddedTerminalDockToggle = + toggleDefinitions.find((definition) => definition.featureId === 'terminal.embeddedPty') ?? null; + if (embeddedTerminalDockToggle && isFeatureHardDisabledByServer(embeddedTerminalDockToggle)) return false; if (isLocallyBlockedByDependencies('terminal.embeddedPty')) return false; return resolveUiFeatureToggleEnabled(toggleSettings, 'terminal.embeddedPty'); - }, [experiments, isToggleHardDisabledByServer, isLocallyBlockedByDependencies, toggleSettings]); + }, [experiments, isFeatureHardDisabledByServer, isLocallyBlockedByDependencies, toggleDefinitions, toggleSettings]); const embeddedTerminalDockLocationLabel = React.useMemo(() => { switch (embeddedTerminalDockLocation) { diff --git a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx index d7261d7a8..c82f72aba 100644 --- a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx @@ -22,6 +22,21 @@ const shared = vi.hoisted(() => ({ canRequestReviewSpy: vi.fn(async () => true), })); +const useFeatureDecisionMock = vi.hoisted(() => vi.fn()); + +useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId !== 'channelBridges' && featureId !== 'channelBridges.telegram') return null; + return { + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; +}); + const settingsViewWebDimensions = { width: 1600, height: 900, scale: 2, fontScale: 1 }; function createPassthroughNode(name: string) { @@ -223,7 +238,7 @@ vi.mock('@/hooks/server/useFeatureEnabled', () => ({ })); vi.mock('@/hooks/server/useFeatureDecision', () => ({ - useFeatureDecision: () => null, + useFeatureDecision: (featureId: string, scope?: unknown) => useFeatureDecisionMock(featureId, scope), })); vi.mock('@/sync/domains/server/serverProfiles', () => ({ @@ -252,6 +267,19 @@ afterEach(() => { shared.requestReviewSpy.mockClear(); shared.canRequestReviewSpy.mockReset(); shared.canRequestReviewSpy.mockResolvedValue(true); + useFeatureDecisionMock.mockReset(); + useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId !== 'channelBridges' && featureId !== 'channelBridges.telegram') return null; + return { + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + }); }); describe('SettingsView', () => { @@ -296,6 +324,102 @@ describe('SettingsView', () => { expect(shared.routerPushSpy).toHaveBeenCalledWith('/(app)/settings/features'); }); + it('blurs the active element before routing to Channel Bridges on web', async () => { + const previousBuildAllows = process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW; + const previousBuildDenies = process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + const previousEmbeddedPolicyEnv = process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV; + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; + delete process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + delete process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV; + + try { + vi.resetModules(); + const { SettingsView } = await import('./SettingsView'); + const screen = await renderSettingsView(React.createElement(SettingsView)); + + expect(screen.findRowByTitle('settings.channelBridges')).toBeTruthy(); + + await act(async () => { + screen.pressRowByTitle('settings.channelBridges'); + }); + + expect(shared.deferOnWebSpy).toHaveBeenCalled(); + expect(shared.navigateWithBlurOnWebSpy).toHaveBeenCalled(); + expect(shared.routerPushSpy).toHaveBeenCalledWith('/(app)/settings/channel-bridges'); + } finally { + if (typeof previousBuildAllows === 'string') { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = previousBuildAllows; + } else { + delete process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW; + } + if (typeof previousBuildDenies === 'string') { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY = previousBuildDenies; + } else { + delete process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + } + if (typeof previousEmbeddedPolicyEnv === 'string') { + process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV = previousEmbeddedPolicyEnv; + } else { + delete process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV; + } + } + }); + + it('hides Channel Bridges in Settings when the runtime decision is disabled', async () => { + useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId !== 'channelBridges') return null; + return { + featureId: 'channelBridges', + state: 'disabled', + blockedBy: 'server', + blockerCode: 'feature_disabled', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + }); + + const { SettingsView } = await import('./SettingsView'); + const screen = await renderSettingsView(React.createElement(SettingsView)); + + expect(screen.findRowByTitle('settings.channelBridges')).toBeNull(); + }); + + it('hides Channel Bridges in Settings when Telegram is not enabled at runtime', async () => { + useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId === 'channelBridges') { + return { + featureId: 'channelBridges', + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + } + + if (featureId === 'channelBridges.telegram') { + return { + featureId: 'channelBridges.telegram', + state: 'disabled', + blockedBy: 'server', + blockerCode: 'feature_disabled', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + } + + return null; + }); + + const { SettingsView } = await import('./SettingsView'); + const screen = await renderSettingsView(React.createElement(SettingsView)); + + expect(screen.findRowByTitle('settings.channelBridges')).toBeNull(); + }); + it('routes to the in-app bug report composer by default when Report issue is pressed', async () => { const { SettingsView } = await import('./SettingsView'); const screen = await renderSettingsView(React.createElement(SettingsView)); diff --git a/apps/ui/sources/components/settings/SettingsView.tsx b/apps/ui/sources/components/settings/SettingsView.tsx index 0b842cf44..b008fd681 100644 --- a/apps/ui/sources/components/settings/SettingsView.tsx +++ b/apps/ui/sources/components/settings/SettingsView.tsx @@ -39,6 +39,7 @@ import { isRunningOnMac } from '@/utils/platform/platform'; import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics'; import { navigateWithBlurOnWeb } from '@/utils/platform/navigateWithBlurOnWeb'; import { deferOnWeb } from '@/utils/platform/deferOnWeb'; +import { useChannelBridgesRuntimeVisibility } from '@/components/settings/channelBridges/channelBridgesVisibility'; export const SettingsView = React.memo(function SettingsView() { const { theme } = useUnistyles(); @@ -66,6 +67,7 @@ export const SettingsView = React.memo(function SettingsView() { const automationsSupport = useAutomationsSupport(); const showAutomations = automationsSupport?.discoverable !== false; const automationsNeedLocalEnablement = automationsSupport?.blockedBy === 'local_policy'; + const { showSettingsEntry: showChannelBridges } = useChannelBridgesRuntimeVisibility(); const profile = useProfile(); const displayName = getDisplayName(profile); const avatarUrl = getAvatarUrl(profile); @@ -402,6 +404,14 @@ export const SettingsView = React.memo(function SettingsView() { onPress={() => router.push('/(app)/settings/connected-services')} /> ) : null} + {showChannelBridges ? ( + } + onPress={() => pushRoute('/(app)/settings/channel-bridges')} + /> + ) : null} {mcpServersEnabled && ( vi.fn()); + +vi.mock('@/hooks/server/useFeatureDecision', () => ({ + useFeatureDecision: (featureId: FeatureId, scope?: unknown) => useFeatureDecisionMock(featureId, scope), +})); + +installSettingsViewCommonModuleMocks({ + text: async () => { + const { createTextModuleMock } = await import('@/dev/testkit/mocks/text'); + return createTextModuleMock({ + translate: (key) => key, + }); + }, +}); + +function createDecision(params: Readonly<{ + featureId: FeatureId; + state: FeatureState; + blockedBy: FeatureAxis | null; + blockerCode: FeatureBlockerCode; +}>): FeatureDecision { + return { + featureId: params.featureId, + state: params.state, + blockedBy: params.blockedBy, + blockerCode: params.blockerCode, + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; +} + +afterEach(() => { + standardCleanup(); + useFeatureDecisionMock.mockReset(); +}); + +describe('ChannelBridgesSettingsView', () => { + it('requests runtime-scoped feature decisions', async () => { + useFeatureDecisionMock.mockReturnValue(null); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(useFeatureDecisionMock).toHaveBeenCalledWith('channelBridges', { scopeKind: 'runtime' }); + expect(useFeatureDecisionMock).toHaveBeenCalledWith('channelBridges.telegram', { scopeKind: 'runtime' }); + }); + + it('shows a loading state before decisions resolve', async () => { + useFeatureDecisionMock.mockReturnValue(null); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-loading')).toBeTruthy(); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + expect(screen.findByTestId('settings-channel-bridges-enable-in-features')).toBeNull(); + }); + + it('shows unsupported state when server does not support channel bridges', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'unsupported', + blockedBy: 'server', + blockerCode: 'not_implemented', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'unsupported', + blockedBy: 'server', + blockerCode: 'not_implemented', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-unsupported')).toBeTruthy(); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + expect(screen.findByTestId('settings-channel-bridges-enable-in-features')).toBeNull(); + }); + + it('shows enablement call-to-action when blocked by local policy', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'disabled', + blockedBy: 'local_policy', + blockerCode: 'flag_disabled', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'disabled', + blockedBy: 'dependency', + blockerCode: 'dependency_disabled', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-enable-in-features')).toBeTruthy(); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + }); + + it('does not render provider configuration when telegram is disabled', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'disabled', + blockedBy: 'server', + blockerCode: 'feature_disabled', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + }); + + it('renders provider configuration when channel bridges and Telegram are enabled', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeTruthy(); + }); +}); diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx new file mode 100644 index 000000000..078319f04 --- /dev/null +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { useUnistyles } from 'react-native-unistyles'; + +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { layout } from '@/components/ui/layout/layout'; +import { CodeView } from '@/components/ui/media/CodeView'; +import { Text } from '@/components/ui/text/Text'; +import type { FeatureId } from '@happier-dev/protocol'; +import { FeatureDiagnosticsPanel } from '@/components/settings/features/FeatureDiagnosticsPanel'; +import { t } from '@/text'; +import { useChannelBridgesRuntimeVisibility } from './channelBridgesVisibility'; + +export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSettingsView() { + const { theme } = useUnistyles(); + const router = useRouter(); + const { + loading, + needsLocalEnablement, + supported, + telegramEnabled, + } = useChannelBridgesRuntimeVisibility(); + + const configureTelegramCommand = React.useMemo(() => { + return [ + 'happier bridge telegram set', + '--bot-token ', + '--allowed-chat-ids ', + '--require-topics false', + ].join(' '); + }, []); + + const diagnosticsFeatureIds = React.useMemo(() => ([ + 'channelBridges', + 'channelBridges.telegram', + ] as const satisfies readonly FeatureId[]), []); + + return ( + + + + {loading ? ( + + + {t('common.loading')} + + + ) : !supported ? ( + + + {t('settingsChannelBridges.unsupported')} + + + ) : needsLocalEnablement ? ( + } + onPress={() => router.push('/(app)/settings/features')} + /> + ) : ( + + + {t('settingsChannelBridges.description')} + + + )} + + + {supported && !needsLocalEnablement && telegramEnabled && !loading ? ( + + + + + + ) : null} + + {!loading && supported ? ( + + ) : null} + + + ); +}); diff --git a/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts b/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts new file mode 100644 index 000000000..4d3390876 --- /dev/null +++ b/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts @@ -0,0 +1,39 @@ +import type { FeatureDecision } from '@happier-dev/protocol'; + +import { readServerEnabledBit } from '@happier-dev/protocol'; + +import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; +import type { ServerFeaturesSnapshot } from '@/sync/api/capabilities/serverFeaturesClient'; + +export function useChannelBridgesRuntimeVisibility() { + const channelBridgesDecision = useFeatureDecision('channelBridges', { scopeKind: 'runtime' }); + const telegramDecision = useFeatureDecision('channelBridges.telegram', { scopeKind: 'runtime' }); + + const loading = channelBridgesDecision === null; + const supported = channelBridgesDecision?.state !== 'unsupported'; + const needsLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; + const telegramEnabled = telegramDecision?.state === 'enabled'; + const showSettingsEntry = channelBridgesDecision?.state === 'enabled' && telegramEnabled; + + return { + channelBridgesDecision, + telegramDecision, + loading, + supported, + needsLocalEnablement, + telegramEnabled, + showSettingsEntry, + } as const; +} + +export function isChannelBridgesFamilyHardDisabledByServer(snapshot: ServerFeaturesSnapshot): boolean { + if (snapshot.status === 'error') return false; + if (snapshot.status === 'unsupported') return true; + + return readServerEnabledBit(snapshot.features, 'channelBridges') !== true + || readServerEnabledBit(snapshot.features, 'channelBridges.telegram') !== true; +} + +export function isChannelBridgesRuntimeEnabled(decision: FeatureDecision | null): boolean { + return decision?.state === 'enabled'; +} diff --git a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx index f4b207595..3f1bac1ea 100644 --- a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx +++ b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx @@ -7,10 +7,10 @@ import { renderScreen } from '@/dev/testkit'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -const useFeatureDecisionMock = vi.fn<(featureId: FeatureId) => FeatureDecision | null>(); +const useFeatureDecisionMock = vi.fn<(featureId: FeatureId, scope?: unknown) => FeatureDecision | null>(); vi.mock('@/hooks/server/useFeatureDecision', () => ({ - useFeatureDecision: (featureId: FeatureId) => useFeatureDecisionMock(featureId), + useFeatureDecision: (featureId: FeatureId, scope?: unknown) => useFeatureDecisionMock(featureId, scope), })); vi.mock('@/components/ui/lists/ItemGroup', () => ({ @@ -59,4 +59,16 @@ describe('FeatureDiagnosticsPanel', () => { expect(items).toHaveLength(featureIds.length); expect(items.map((item) => item.props.title)).toEqual(featureIds); }); + + it('forwards scope to useFeatureDecision', async () => { + const { FeatureDiagnosticsPanel } = await import('./FeatureDiagnosticsPanel'); + + const featureIds: FeatureId[] = ['voice', 'automations']; + useFeatureDecisionMock.mockReturnValue(null); + + await renderScreen(React.createElement(FeatureDiagnosticsPanel, { featureIds, scope: { scopeKind: 'runtime' } })); + + expect(useFeatureDecisionMock).toHaveBeenCalledWith('voice', { scopeKind: 'runtime' }); + expect(useFeatureDecisionMock).toHaveBeenCalledWith('automations', { scopeKind: 'runtime' }); + }); }); diff --git a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx index 9117ce929..3da089fc3 100644 --- a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx +++ b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx @@ -3,6 +3,7 @@ import type { FeatureId } from '@happier-dev/protocol'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import type { FeatureDecisionScopeParams } from '@/hooks/server/useFeatureDecision'; import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; import { t } from '@/text'; @@ -17,8 +18,8 @@ function formatDecisionSubtitle(decision: ReturnType) }); } -const FeatureDiagnosticsRow = React.memo(function FeatureDiagnosticsRow(props: { featureId: FeatureId }) { - const decision = useFeatureDecision(props.featureId); +const FeatureDiagnosticsRow = React.memo(function FeatureDiagnosticsRow(props: { featureId: FeatureId; scope?: FeatureDecisionScopeParams }) { + const decision = useFeatureDecision(props.featureId, props.scope); return ( {props.featureIds.map((featureId) => ( - + ))} ); diff --git a/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx b/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx index ef4040e77..79d896f53 100644 --- a/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx +++ b/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx @@ -20,9 +20,12 @@ export const EmbeddedTerminalToolbarIconButton = React.memo((props: EmbeddedTerm accessibilityLabel={props.accessibilityLabel} hitSlop={8} onPress={props.onPress} - style={({ pressed, hovered }) => ({ - opacity: pressed ? 0.68 : hovered ? 0.82 : 1, - })} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return { + opacity: state.pressed ? 0.68 : hovered ? 0.82 : 1, + }; + }} > diff --git a/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx b/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx index 1bba5de0d..50443e5e7 100644 --- a/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx +++ b/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx @@ -73,7 +73,6 @@ export const ToolTimelineRowHeader = React.memo(function ToolTimelineRowHeader(p @@ -83,7 +82,6 @@ export const ToolTimelineRowHeader = React.memo(function ToolTimelineRowHeader(p style={[ styles.iconLayer, styles.iconLayerOverlay, - Platform.OS === 'web' ? styles.iconLayerTransition : null, isHovered ? null : styles.iconLayerHidden, ]} > @@ -190,11 +188,6 @@ const styles = StyleSheet.create((theme, _runtime) => ({ alignItems: 'center', justifyContent: 'center', }, - iconLayerTransition: { - transitionProperty: 'opacity', - transitionDuration: '140ms', - transitionTimingFunction: 'ease', - }, iconLayerHidden: { opacity: 0, }, diff --git a/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx b/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx index e8ace4c28..282c322f6 100644 --- a/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx +++ b/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx @@ -178,11 +178,14 @@ export const DiffFilesListView = React.forwardRef setFocusedFileKey(file.key)} onBlur={() => setFocusedFileKey((prev) => (prev === file.key ? null : prev))} - style={({ hovered, pressed }) => ([ - styles.fileRowInteractive, - hovered ? styles.fileRowHovered : null, - pressed ? styles.fileRowPressed : null, - ])} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return [ + styles.fileRowInteractive, + hovered ? styles.fileRowHovered : null, + state.pressed ? styles.fileRowPressed : null, + ]; + }} accessibilityRole="button" > @@ -241,11 +244,14 @@ export const DiffFilesListView = React.forwardRef ([ - styles.openFileButton, - hovered ? styles.openFileButtonHovered : null, - pressed ? styles.openFileButtonPressed : null, - ])} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return [ + styles.openFileButton, + hovered ? styles.openFileButtonHovered : null, + state.pressed ? styles.openFileButtonPressed : null, + ]; + }} > diff --git a/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx b/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx index e15d39f04..62572555d 100644 --- a/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx +++ b/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx @@ -39,12 +39,15 @@ export const DiffPresentationStyleToggleButton = React.memo ([ - styles.root, - hovered ? styles.rootHovered : null, - pressed ? styles.rootPressed : null, - disabled ? styles.rootDisabled : null, - ])} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return [ + styles.root, + hovered ? styles.rootHovered : null, + state.pressed ? styles.rootPressed : null, + disabled ? styles.rootDisabled : null, + ]; + }} > { const config = requireFreshMetroConfig(); expect(config?.resolver?.useWatchman).toBe(false); }); + + it('does not block the current UI workspace when the checkout itself lives under .project/worktrees', () => { + const config = requireFreshMetroConfig(); + const blockList = Array.isArray(config?.resolver?.blockList) + ? config.resolver.blockList + : [config?.resolver?.blockList].filter(Boolean); + const workspaceEntryPath = path.resolve(String(config?.projectRoot ?? ''), 'index.ts'); + + expect(blockList.some((pattern: RegExp) => pattern.test(workspaceEntryPath))).toBe(false); + }); }); diff --git a/apps/ui/sources/dev/testkit/fixtures/featureFixtures.ts b/apps/ui/sources/dev/testkit/fixtures/featureFixtures.ts index 8ff83e93f..3a36404d1 100644 --- a/apps/ui/sources/dev/testkit/fixtures/featureFixtures.ts +++ b/apps/ui/sources/dev/testkit/fixtures/featureFixtures.ts @@ -4,6 +4,7 @@ type RootLayoutFeaturesOverrides = Omit, 'features' features?: Omit< Partial, | 'attachments' + | 'channelBridges' | 'automations' | 'connectedServices' | 'updates' @@ -19,6 +20,7 @@ type RootLayoutFeaturesOverrides = Omit, 'features' > & Readonly<{ attachments?: Partial; + channelBridges?: Partial; automations?: Partial; connectedServices?: Partial; updates?: Partial; @@ -54,6 +56,10 @@ const BASE_ROOT_LAYOUT_FEATURES: RootLayoutFeatures = { attachments: { uploads: { enabled: true }, }, + channelBridges: { + enabled: true, + telegram: { enabled: true }, + }, automations: { enabled: true, }, @@ -190,6 +196,7 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO const nextMachines: Partial = nextFeatures.machines ?? {}; const nextTerminal: Partial = nextFeatures.terminal ?? {}; const nextAttachments: Partial = nextFeatures.attachments ?? {}; + const nextChannelBridges: Partial = nextFeatures.channelBridges ?? {}; const nextEncryption: Partial = nextFeatures.encryption ?? {}; const nextE2ee: Partial = nextFeatures.e2ee ?? {}; const nextConnectedServices: Partial = @@ -235,6 +242,14 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO ...BASE_ROOT_LAYOUT_FEATURES.features.attachments, ...nextAttachments, }, + channelBridges: { + ...BASE_ROOT_LAYOUT_FEATURES.features.channelBridges, + ...nextChannelBridges, + telegram: { + ...BASE_ROOT_LAYOUT_FEATURES.features.channelBridges.telegram, + ...(nextChannelBridges.telegram ?? {}), + }, + }, sharing: { ...BASE_ROOT_LAYOUT_FEATURES.features.sharing, ...nextSharing, diff --git a/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts b/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts index 712781313..01a671faf 100644 --- a/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts +++ b/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts @@ -61,6 +61,10 @@ export function buildServerFeaturesResponse(overrides: FixtureOverrides = {}): F attachments: { uploads: { enabled: true }, }, + channelBridges: { + enabled: true, + telegram: { enabled: true }, + }, automations: { enabled: overrides.automationsEnabled ?? true, }, diff --git a/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts b/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts index 4cf610f88..afcc8bd79 100644 --- a/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts +++ b/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts @@ -13,6 +13,7 @@ const LOCAL_POLICY_BY_FEATURE: Readonly resolveUiFeatureToggleEnabled(settings, 'connectedServices.quotas'), + channelBridges: (settings) => resolveUiFeatureToggleEnabled(settings, 'channelBridges'), 'updates.ota': () => parseBooleanEnv(process.env.EXPO_PUBLIC_HAPPIER_FEATURE_UPDATES_OTA__ENABLED, true), 'attachments.uploads': (settings) => resolveUiFeatureToggleEnabled(settings, 'attachments.uploads'), 'social.friends': (settings) => resolveUiFeatureToggleEnabled(settings, 'social.friends'), diff --git a/apps/ui/sources/sync/domains/features/featureRegistry.ts b/apps/ui/sources/sync/domains/features/featureRegistry.ts index c0e8df37f..49f59446e 100644 --- a/apps/ui/sources/sync/domains/features/featureRegistry.ts +++ b/apps/ui/sources/sync/domains/features/featureRegistry.ts @@ -1,4 +1,4 @@ -export type { UiFeatureDefinition } from './registry/uiFeatureRegistry'; +export type { UiFeatureDefinition, UiFeatureToggleServerVisibilityScope } from './registry/uiFeatureRegistry'; export { UI_FEATURE_REGISTRY, getUiFeatureDefinition } from './registry/uiFeatureRegistry'; export type { UiFeatureToggleDefinition } from './registry/uiFeatureToggles'; @@ -6,5 +6,5 @@ export { listUiFeatureToggleDefinitions, resolveUiFeatureToggleEnabled, buildUiFeatureToggleDefaults, + resolveUiFeatureToggleServerVisibilityScope, } from './registry/uiFeatureToggles'; - diff --git a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts index 9dc437c55..7bcc3cbac 100644 --- a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts +++ b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts @@ -1,11 +1,14 @@ import type { FeatureId } from '@happier-dev/protocol'; import type { TranslationKey } from '@/text'; +export type UiFeatureToggleServerVisibilityScope = 'main_selection' | 'runtime'; + export type UiFeatureDefinition = Readonly<{ settingsToggle?: Readonly<{ showInSettings: boolean; isExperimental: boolean; defaultEnabled: boolean; + serverVisibilityScope?: UiFeatureToggleServerVisibilityScope; titleKey: TranslationKey; subtitleKey: TranslationKey; icon: Readonly<{ @@ -89,6 +92,20 @@ export const UI_FEATURE_REGISTRY = { icon: { ioniconName: 'analytics-outline', color: '#34C759' }, }, }, + channelBridges: { + settingsToggle: { + showInSettings: true, + isExperimental: true, + defaultEnabled: false, + serverVisibilityScope: 'runtime', + titleKey: 'settingsFeatures.expChannelBridges', + subtitleKey: 'settingsFeatures.expChannelBridgesSubtitle', + icon: { ioniconName: 'swap-horizontal-outline', color: '#FF9500' }, + }, + }, + 'channelBridges.telegram': { + settingsToggle: undefined, + }, 'updates.ota': { settingsToggle: undefined, }, diff --git a/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts b/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts index 016c3c0ac..72583cc28 100644 --- a/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts +++ b/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts @@ -1,7 +1,12 @@ import type { FeatureId } from '@happier-dev/protocol'; import type { TranslationKey } from '@/text'; -import { getUiFeatureDefinition, UI_FEATURE_REGISTRY } from './uiFeatureRegistry'; +import { + getUiFeatureDefinition, + UI_FEATURE_REGISTRY, + type UiFeatureDefinition, + type UiFeatureToggleServerVisibilityScope, +} from './uiFeatureRegistry'; type FeatureToggleSettings = Readonly<{ experiments?: boolean | null | undefined; @@ -12,6 +17,7 @@ export type UiFeatureToggleDefinition = Readonly<{ featureId: FeatureId; isExperimental: boolean; defaultEnabled: boolean; + serverVisibilityScope: UiFeatureToggleServerVisibilityScope; titleKey: TranslationKey; subtitleKey: TranslationKey; icon: Readonly<{ @@ -24,12 +30,13 @@ export function listUiFeatureToggleDefinitions(): ReadonlyArray = { }, }, + settingsChannelBridges: { + unsupported: '此環境不支援頻道橋接。', + enableInFeatures: '啟用頻道橋接', + enableInFeaturesSubtitle: '頻道橋接為實驗功能,預設關閉。', + description: '頻道橋接可將外部聊天(Telegram)附加到工作階段,並將訊息轉發給代理。', + telegramTitle: 'Telegram', + telegramFooter: '請透過 CLI 設定 Telegram,然後在 Telegram 中使用 /sessions、/attach、/detach、/help 管理綁定。', + }, + settingsFeatures: { // Features settings screen experiments: '實驗功能', @@ -2246,6 +2255,8 @@ const zhHantOverrides: DeepPartial = { expConnectedServicesSubtitle: '啟用已連結服務設定與工作階段綁定', expConnectedServicesQuotas: '已連結服務配額', expConnectedServicesQuotasSubtitle: '顯示已連結服務的配額徽章與用量儀表', + expChannelBridges: '頻道橋接', + expChannelBridgesSubtitle: '將 Telegram 等聊天頻道連接到 Happier 工作階段(實驗性)', expMemorySearch: '記憶搜尋', expMemorySearchSubtitle: '啟用本機記憶搜尋頁面與設定', expSessionsDirect: '直接工作階段', diff --git a/docs/README.md b/docs/README.md index b08c940ea..aed1c226d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,9 @@ This folder documents how Happier works internally, with a focus on protocol, ba - opencode-feature-matrix.md: Low-level OpenCode implementation matrix and unified-architecture migration notes. - pi-feature-matrix.md: Low-level PI implementation matrix and unified-architecture migration notes. - acp-provider-feature-matrix.md: Low-level ACP-provider matrix and catalog migration notes. +- channel-bridge.md: Provider-agnostic bridge architecture, ingress mode matrix, and relay deployment model. +- channel-bridge-uat.md: End-to-end UAT checklist for bridge behavior across account/machine scopes. +- telegram-channel-bridge.md: Telegram adapter setup, commands, and channel-specific bridge usage. - issue-triage.md: How the GitHub issue triage workflows are wired to maintainer tooling. ## Conventions diff --git a/docs/assets/doctor-critical-summary.png b/docs/assets/doctor-critical-summary.png new file mode 100644 index 000000000..b7161e170 Binary files /dev/null and b/docs/assets/doctor-critical-summary.png differ diff --git a/docs/assets/doctor-missing-host-port-summary.png b/docs/assets/doctor-missing-host-port-summary.png new file mode 100644 index 000000000..23052c6a0 Binary files /dev/null and b/docs/assets/doctor-missing-host-port-summary.png differ diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md new file mode 100644 index 000000000..16e7140e1 --- /dev/null +++ b/docs/channel-bridge-uat.md @@ -0,0 +1,105 @@ +# Channel Bridge UAT (Single Account, Multi-Machine) + +This checklist validates that one Happier account can use multiple machines + Telegram bridge without cross-account bleed. + +## 1) Start from clean local state (test env) + +- Use a fresh test home per run: + +```bash +export HAPPIER_HOME_DIR="$(mktemp -d /tmp/happier-uat-XXXXXX)" +``` + +- Stop daemon: `happier daemon stop` +- Remove stale local settings if present: `rm -f "$HAPPIER_HOME_DIR/settings.json"` +- Confirm active server: `happier server list` +- Start daemon: `happier daemon start` + +## 2) Register primary machine + account + +- Open Web UI for active server. +- Create account (or login with existing account secret key). +- Verify account id in UI/CLI matches expected scope. + +## 3) Configure Telegram bridge for this account + +- Configure bridge from CLI: + +```bash +happier bridge telegram set \ + --bot-token \ + --allow-all \ + --require-topics true +``` + +- Verify persisted state: + +```bash +happier bridge list +``` + +Expected: +- `Telegram (scoped settings.json)` shows `configured: yes` +- `Telegram (effective runtime: env > settings.json)` shows configured token/topic policy + +## 4) Restart daemon and verify bridge worker startup + +```bash +happier daemon stop && happier daemon start +happier doctor +``` + +Expected: +- Channel bridge section shows configured state. +- No crash loop in daemon logs. +- Final diagnosis line matches overall health: + - `✅ Doctor diagnosis complete!` when no critical failures + - `❌ Doctor diagnosis complete!` when any critical failure exists (for example, Telegram bridge configured but bot token missing) + +## 5) Session bind + bi-direction test from Telegram + +- In Happier, create a new session and copy session id. +- In Telegram chat/topic with bot: + - `/sessions` + - `/attach ` + - send: `bridge-e2e-ok` +- In Happier session, confirm message appears. +- Send reply in Happier session, confirm it appears back in same Telegram thread/topic. + +## 6) Add second machine to same account + +- Open a second browser/device. +- Use **Login with mobile app** → **Restore with Secret Key Instead**. +- Authenticate into same account. + +Expected: +- Same account id. +- Existing sessions are visible/resumable (subject to agent backend credentials). +- Bridge config + bindings are local-only in v1: configure the bridge on the second machine too (or copy scoped `settings.json`), and attach the Telegram conversation again via `/attach`. + +## 7) Isolation check (optional second account) + +- Create another account on same server. +- Configure a different Telegram bot/chat allowlist. + +Expected: +- No cross-account session visibility. +- No cross-account channel binding behavior. + +## 8) Failure-mode checks + +- Set `allowedChatIds` to a different chat and verify current chat is blocked. +- Revert to `--allow-all` and verify chat works again. +- In a shared chat/topic, validate inbound authorization: + - User A runs `/attach ` (default owner-only). + - User B sends a normal message and verify the bot replies with an authorization error and does **not** forward into the session. + - User A runs `/attach --anyone`, then User B sends a message again and verify it **is** forwarded. +- If using webhook mode, verify secret mismatch returns a non-200 response: + - `404` when the URL path is wrong (route mismatch) + - `401` when `X-Telegram-Bot-Api-Secret-Token` is missing/invalid + +## Notes + +- `allowedChatIds: []` means **DM-only** (shared chats are blocked by default unless `allowAllSharedChats=true`). +- Runtime precedence is: `HAPPIER_* env` > `settings.json` > defaults. +- Secrets (bot/API tokens) stay local-only (`settings.json`/env). diff --git a/docs/channel-bridge.md b/docs/channel-bridge.md new file mode 100644 index 000000000..fe302bc3a --- /dev/null +++ b/docs/channel-bridge.md @@ -0,0 +1,57 @@ +# Channel Bridge Core + +The channel bridge core is a provider-agnostic runtime that maps external channel conversations to Happier sessions. + +Core implementation in this PR: + +- Worker/runtime loop: `apps/cli/src/channels/core/channelBridgeWorker.ts` + +## Core responsibilities + +- receive inbound messages from adapter implementations +- parse shared control commands (`/sessions`, `/attach`, `/session`, `/detach`, `/help`, `/start` as alias of `/help`) +- maintain conversation-to-session bindings by `(providerId, conversationId, threadId|null)` +- forward bound user text into the target session +- fetch assistant output after a cursor and forward back to channel conversations +- track per-binding cursor (`lastForwardedSeq`) to avoid replaying older assistant rows + +## Binding model + +Bindings are keyed by provider + conversation + optional thread/topic. + +- `providerId`: adapter namespace (for example, channel family) +- `conversationId`: channel-specific room/chat identifier +- `threadId`: optional sub-thread/topic identifier +- `sessionId`: Happier session bound to that conversation key +- `lastForwardedSeq`: last assistant transcript row sent through bridge + +## Tick loop behavior + +Each tick performs: + +1. pull inbound messages from each adapter +2. handle control commands (`/sessions`, `/attach`, `/session`, `/detach`, `/help`, `/start`) +3. route non-command user text to bound sessions +4. list current bindings +5. fetch assistant rows after each binding cursor +6. forward assistant messages back through the adapter +7. advance binding cursor after successful forwarding + +The worker uses single-flight scheduling in `startChannelBridgeWorker` so only one tick executes at a time. + +## Adapter contract + +Adapters plug into the core using a small interface: + +- `providerId` +- `pullInboundMessages()` +- `sendMessage({ conversationId, threadId, text })` +- optional `stop()` lifecycle hook + +This keeps command semantics, binding behavior, and session forwarding logic centralized in the core. + +## Scope notes + +This document covers core bridge runtime behavior only. + +Provider-specific transport details and adapter-specific setup are documented in companion docs (for example, `docs/telegram-channel-bridge.md`). diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md new file mode 100644 index 000000000..3a465ed5b --- /dev/null +++ b/docs/telegram-channel-bridge.md @@ -0,0 +1,268 @@ +# Telegram Session Bridge + +This repository now includes a **built-in channel bridge core** plus a **Telegram adapter**. + +- Core worker: `apps/cli/src/channels/core/channelBridgeWorker.ts` +- Telegram adapter: `apps/cli/src/channels/providers/telegram/telegramAdapter.ts` +- Runtime wiring: `apps/cli/src/channels/startChannelBridgeWorker.ts` + +The design is intentionally modular so additional adapters (Discord, Slack, WhatsApp, etc.) can plug into the same core contract. + +For core bridge architecture, ingress mode matrix, and relay deployment model, see [docs/channel-bridge.md](./channel-bridge.md). + +## Current Behavior + +- Bi-directional flow between Telegram and Happier sessions. +- Conversation-to-session mapping via Telegram commands. +- Mapping key is `(provider, chat_id, topic_thread_id|null)`: + - **DM** = `thread_id = null` + - **Topic** = `thread_id = ` +- Outbound agent replies are forwarded back into the mapped DM/topic. +- Inbound forwarding policy is configured **per binding** at attach-time: + - Default is **owner-only** (only the user who ran `/attach` can forward messages into the session from that conversation). + - Use `/attach ... --anyone` to allow any member of a shared chat/topic to forward messages (only recommended for fully-trusted chats). + - If sender identity is missing, `/attach` and forwarding are **denied by default** (safe-by-default). You can override with `--allow-missing-sender-id` (unsafe). + +## BotFather + Telegram Setup + +1. Create bot with `@BotFather` (`/newbot`) and copy bot token. +2. In BotFather, disable privacy mode (`/setprivacy` → **Disable**) so group/topic messages are visible. +3. Add bot to your group/supergroup. +4. Promote bot to **admin** in the group so it can read/send in topics. +5. (Recommended) Use a **supergroup with topics** and bind one topic per Happier session. + +## Configuration (`settings.json`) + Environment Overrides + +You can configure the bridge in `~/.happier/settings.json` (or your `HAPPIER_HOME_DIR/settings.json`), and still use env vars for overrides. + +Recommended model: configure bridges per `serverId` + `accountId`, so each account can own its own adapters and credentials. + +Example: + +```json +{ + "channelBridge": { + "byServerId": { + "127.0.0.1-3005": { + "byAccountId": { + "cmmb9sp...": { + "tickMs": 2500, + "providers": { + "telegram": { + "allowedChatIds": ["-1001234567890", "-10055555555"], + "requireTopics": true, + "webhook": { + "enabled": false, + "host": "127.0.0.1", + "port": 8787 + }, + "secrets": { + "botToken": "", + "webhookSecret": "" + } + } + } + } + } + } + } + } +} +``` + +Backward compatibility is preserved: root-level `channelBridge.tickMs` and `channelBridge.providers` still work as global defaults. + +Precedence is: + +1. `HAPPIER_*` environment variables +2. `settings.json` bridge config (local, scoped by `serverId` + `accountId`, includes secrets) +3. `settings.json` bridge config (local, scoped by `serverId`) +4. `settings.json` bridge config (local, global defaults) +5. built-in defaults + +`allowedChatIds` behavior: + +- `[]` (empty list) = **DM-only** (shared chats are blocked by default). +- non-empty list = allow only those shared chat IDs. +- to allow *all* shared chats (unsafe), set `allowAllSharedChats=true` (CLI: `--allow-all` or `--allow-all-shared-chats true`). + +## Secret Handling Policy (for all adapters) + +- In v1, bridge configuration is **local-only** (settings/env). +- Store secrets only in: + - local scoped `settings.json` (`providers..secrets.*`), or + - process env vars (`HAPPIER_*`). +- Do not assume any server-side shared bridge config exists (even for non-secret fields). + +For new adapters (Discord/Slack/WhatsApp/etc), use the same model: + +```json +{ + "providers": { + "adapterName": { + "...nonSecretFields": true, + "secrets": { + "token": "", + "apiKey": "" + } + } + } +} +``` + +## Feature gating (experimental) + +Channel bridges are gated in three places: + +- Server feature gates: `channelBridges` and `channelBridges.telegram` must be enabled in `/v1/features` (server env/build policy can hard-disable them). +- Local experimental opt-in: users must enable the `Channel bridges` experimental toggle in Settings → Features → Experimental options. +- Daemon runtime: the daemon starts the worker only when both the server gates are enabled and the local toggle is enabled. + +Note: `happier bridge telegram set ...` automatically enables the local experimental toggle (`channelBridges`). + +Server env keys (v1): +- `HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED=0|1` +- `HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED=0|1` + +## Environment Variables (daemon process env) + +Set these on the daemon process to override `settings.json` (or to run env-only). Environment variables are read directly from `process.env` (there is no automatic `.env.local` loading for the CLI/daemon). + +```bash +HAPPIER_TELEGRAM_BOT_TOKEN= + +# Optional hardening: +HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS=-1001234567890,-10055555555 +HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS=0 +HAPPIER_TELEGRAM_REQUIRE_TOPICS=1 + +# Bridge tick cadence (ms) +HAPPIER_CHANNEL_BRIDGE_TICK_MS=2500 +``` + +## CLI Management (account-scoped) + +Use the bridge CLI to manage Telegram config for the active `serverId + accountId` scope: + +- `telegram set` + - writes full config (including secrets) to local scoped `settings.json` +- `telegram clear` clears local scoped `settings.json` +- `bridge list` prints scoped local config and effective runtime resolution + +```bash +happier bridge list + +happier bridge telegram set \ + --bot-token \ + --allow-all \ + --require-topics true \ + --tick-ms 2500 + +# webhook relay configuration (polling is default) +happier bridge telegram set \ + --webhook-enabled true \ + --webhook-secret \ + --webhook-host 127.0.0.1 \ + --webhook-port 8787 + +# optional: restrict to specific chats +happier bridge telegram set --allowed-chat-ids -1001234567890,-10022222222 + +happier bridge telegram clear +``` + +Then apply changes by restarting daemon: + +```bash +happier daemon stop && happier daemon start +``` + +## Doctor Status Aggregation + +`happier doctor` aggregates critical failures and sets the final diagnosis line accordingly: + +- final line is `✅ Doctor diagnosis complete!` only when no critical failures are found +- final line is `❌ Doctor diagnosis complete!` if any critical failure is detected + +Telegram bridge example: + +- if Telegram bridge is configured but bot token is missing, doctor prints a red bridge error and the final line is `❌` +- if `webhook.enabled=true` and `secrets.webhookSecret` (or `HAPPIER_TELEGRAM_WEBHOOK_SECRET`) is empty, doctor prints a critical bridge error and the final line is `❌` + +## Webhook Setup (daemon relay mode) + +By default the adapter polls `getUpdates`. + +If you prefer webhooks, enable the built-in relay in the daemon: + +```bash +HAPPIER_TELEGRAM_WEBHOOK_ENABLED=1 +HAPPIER_TELEGRAM_WEBHOOK_SECRET= +HAPPIER_TELEGRAM_WEBHOOK_HOST=127.0.0.1 +HAPPIER_TELEGRAM_WEBHOOK_PORT=8787 +``` + +`HAPPIER_TELEGRAM_WEBHOOK_SECRET` is used for Telegram `secret_token` header validation (`X-Telegram-Bot-Api-Secret-Token`). +The token must match Telegram’s webhook `secret_token` constraints: `[A-Za-z0-9_-]` and a max length of 256 characters. + +The webhook endpoint path is fixed (`POST /telegram/webhook`) and does not include any secrets. + +In daemon-relay mode, Telegram must call a public URL that forwards to the daemon relay (loopback-only). +If you do not have an inbound public endpoint/tunnel to the daemon, use polling mode (`getUpdates`). + +Expose/proxy this daemon endpoint publicly: + +```text +POST /telegram/webhook +``` + +Example: + +```text +https://your-public-host/telegram/webhook + -> http://127.0.0.1:8787/telegram/webhook +``` + +Set Telegram webhook: + +```bash +# Recommended: use a POST body so the secret does not end up in URLs (shell history, proxy logs, etc.) +curl -X POST "https://api.telegram.org/bot/setWebhook" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://your-public-host/telegram/webhook","secret_token":""}' +``` + +Use a high-entropy random value and avoid sharing/logging webhook secrets. +Ensure your public reverse proxy/tunnel forwards the `X-Telegram-Bot-Api-Secret-Token` header unchanged to the daemon relay. + +If you switch back to polling mode, clear webhook first: + +```bash +curl "https://api.telegram.org/bot/deleteWebhook" +``` + +## Telegram Commands (inside DM/topic) + +- `/help` — command list +- `/sessions` — list active Happier sessions +- `/attach [--anyone] [--allow-missing-sender-id]` — bind current DM/topic to a session +- `/session` — show current binding +- `/detach` — remove binding + +After `/attach`, normal messages in that DM/topic are forwarded into the mapped session, and agent replies flow back into that same DM/topic. + +Authorization notes: +- By default, inbound forwarding is **owner-only** (the user who attached the conversation). +- In shared chats, other users who try to send messages will receive a denial reply (and the message will not be forwarded). +- `--anyone` opts a binding into allowing messages from any sender in that conversation. +- `--allow-missing-sender-id` disables sender-based safety checks for that binding (unsafe; only use if the platform/conversation does not provide stable sender identities). + +## Extending Beyond Telegram + +To add another provider, implement the same adapter shape used by Telegram: + +- inbound pull method +- outbound send method +- provider id + conversation/thread identifiers + +No session pipeline logic needs to be duplicated; the core bridge worker handles command routing, binding state, and session forwarding. diff --git a/packages/protocol/src/features/catalog.test.ts b/packages/protocol/src/features/catalog.test.ts index 6b6921542..d2b0f085a 100644 --- a/packages/protocol/src/features/catalog.test.ts +++ b/packages/protocol/src/features/catalog.test.ts @@ -27,6 +27,11 @@ describe('feature catalog', () => { expect(isFeatureId('connectedServices.quotas')).toBe(true); }); + it('includes channel bridge feature ids', () => { + expect(isFeatureId('channelBridges')).toBe(true); + expect(isFeatureId('channelBridges.telegram')).toBe(true); + }); + it('includes OTA updates feature id', () => { expect(isFeatureId('updates.ota')).toBe(true); }); diff --git a/packages/protocol/src/features/catalog.ts b/packages/protocol/src/features/catalog.ts index 5dc4764d7..30ed262cb 100644 --- a/packages/protocol/src/features/catalog.ts +++ b/packages/protocol/src/features/catalog.ts @@ -51,6 +51,18 @@ const FEATURE_CATALOG_DEFINITION = { dependencies: ['connectedServices'], representation: 'server', }, + channelBridges: { + description: 'Channel bridge integrations (Telegram/Discord/etc).', + defaultFailMode: 'fail_closed', + dependencies: [], + representation: 'server', + }, + 'channelBridges.telegram': { + description: 'Telegram channel bridge provider.', + defaultFailMode: 'fail_closed', + dependencies: ['channelBridges'], + representation: 'server', + }, 'updates.ota': { description: 'Expo over-the-air update checks and apply flows.', defaultFailMode: 'fail_closed', diff --git a/packages/protocol/src/features/payload/featureGatesSchema.test.ts b/packages/protocol/src/features/payload/featureGatesSchema.test.ts new file mode 100644 index 000000000..b3452c83f --- /dev/null +++ b/packages/protocol/src/features/payload/featureGatesSchema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { readServerEnabledBit } from '../serverEnabledBit.js'; +import { FeaturesResponseSchema } from './featuresResponseSchema.js'; + +describe('FeatureGatesSchema', () => { + it('preserves channel bridge gates', () => { + const parsed = FeaturesResponseSchema.parse({ + features: { + channelBridges: { + enabled: true, + telegram: { enabled: true }, + }, + }, + capabilities: {}, + }); + + expect(readServerEnabledBit(parsed, 'channelBridges')).toBe(true); + expect(readServerEnabledBit(parsed, 'channelBridges.telegram')).toBe(true); + }); +}); + diff --git a/packages/protocol/src/features/payload/featureGatesSchema.ts b/packages/protocol/src/features/payload/featureGatesSchema.ts index b12fc6a8a..5f67c1563 100644 --- a/packages/protocol/src/features/payload/featureGatesSchema.ts +++ b/packages/protocol/src/features/payload/featureGatesSchema.ts @@ -44,6 +44,13 @@ export const FeatureGatesSchema = z.object({ }) .optional() .default({ enabled: false, quotas: DEFAULT_GATE_DISABLED }), + channelBridges: z + .object({ + enabled: z.boolean(), + telegram: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + }) + .optional() + .default({ enabled: false, telegram: DEFAULT_GATE_DISABLED }), updates: z .object({ ota: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), diff --git a/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts b/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts new file mode 100644 index 000000000..31bb82480 --- /dev/null +++ b/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: channel bridges foundation', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('advertises channel bridge gates as disabled when the server env gate is disabled', async () => { + const testDir = run.testDir('feature-negotiation-channel-bridges-disabled'); + server = await startServerLight({ + testDir, + dbProvider: 'sqlite', + extraEnv: { + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '0', + }, + }); + + const features = await fetchJson(`${server.baseUrl}/v1/features`); + expect(features.status).toBe(200); + expect(features.data?.features?.channelBridges?.enabled).toBe(false); + expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(false); + }, 180_000); + + it('advertises telegram gate as disabled when the server env gate is disabled', async () => { + const testDir = run.testDir('feature-negotiation-channel-bridges-telegram-disabled'); + server = await startServerLight({ + testDir, + dbProvider: 'sqlite', + extraEnv: { + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '0', + }, + }); + + const features = await fetchJson(`${server.baseUrl}/v1/features`); + expect(features.status).toBe(200); + expect(features.data?.features?.channelBridges?.enabled).toBe(true); + expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(false); + }, 180_000); + + it('advertises channel bridge gates as enabled by default', async () => { + const testDir = run.testDir('feature-negotiation-channel-bridges-enabled'); + server = await startServerLight({ testDir, dbProvider: 'sqlite' }); + + const features = await fetchJson(`${server.baseUrl}/v1/features`); + expect(features.status).toBe(200); + expect(features.data?.features?.channelBridges?.enabled).toBe(true); + expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(true); + }, 180_000); +}); diff --git a/packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts b/packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts new file mode 100644 index 000000000..3379905c8 --- /dev/null +++ b/packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; + +import { + createChannelBridgeInboundDeduper, + createInMemoryChannelBindingStore, + executeChannelBridgeTick, + type ChannelBridgeAdapter, + type ChannelBridgeAgentMessageRow, + type ChannelBridgeDeps, + type ChannelBridgeInboundMessage, + type ChannelBridgeInboundMode, +} from '../../../../apps/cli/src/channels/core/channelBridgeWorker'; + +type OutboundRow = Readonly<{ conversationId: string; threadId: string | null; text: string }>; + +function createInMemoryAdapter(providerId: string) { + const inboundQueue: ChannelBridgeInboundMessage[] = []; + const outbound: OutboundRow[] = []; + + const adapter: ChannelBridgeAdapter = { + providerId, + pullInboundMessages: async () => { + const batch = inboundQueue.splice(0, inboundQueue.length); + return batch; + }, + sendMessage: async (params) => { + outbound.push({ conversationId: params.conversationId, threadId: params.threadId, text: params.text }); + }, + }; + + return { + adapter, + outbound, + pushInbound: (...messages: ChannelBridgeInboundMessage[]) => { + inboundQueue.push(...messages); + }, + } as const; +} + +function createInMemorySessionDeps(params: Readonly<{ + sessionId: string; + label?: string | null; + inboundMode: ChannelBridgeInboundMode; + ownerSenderId: string; + conversationId: string; + threadId: string | null; + providerId: string; +}>) { + const agentRows: ChannelBridgeAgentMessageRow[] = []; + let nextSeq = 0; + const forwardedUserMessages: Array> = []; + + const deps: ChannelBridgeDeps = { + listSessions: async () => [{ sessionId: params.sessionId, label: params.label ?? null }], + resolveSessionIdOrPrefix: async (idOrPrefix) => { + if (idOrPrefix === params.sessionId || params.sessionId.startsWith(idOrPrefix)) { + return { ok: true, sessionId: params.sessionId }; + } + return { ok: false, code: 'session_not_found' }; + }, + sendUserMessageToSession: async (send) => { + forwardedUserMessages.push({ + sessionId: send.sessionId, + text: send.text, + providerId: send.providerId, + sentFrom: send.sentFrom, + messageId: send.messageId, + }); + + nextSeq += 1; + agentRows.push({ seq: nextSeq, text: `echo:${send.text}` }); + }, + resolveLatestSessionSeq: async () => nextSeq, + fetchAgentMessagesAfterSeq: async ({ afterSeq }) => agentRows.filter((row) => row.seq > afterSeq), + }; + + return { + deps, + agentRows, + forwardedUserMessages, + pushAgentMessage: (text: string) => { + nextSeq += 1; + agentRows.push({ seq: nextSeq, text }); + }, + } as const; +} + +describe('core e2e: channel bridge in-memory contract', () => { + it('DM: /attach then forwards inbound message and agent output back to the channel', async () => { + const store = createInMemoryChannelBindingStore(); + const adapter = createInMemoryAdapter('test'); + const session = createInMemorySessionDeps({ + sessionId: 'sess-1', + inboundMode: 'ownerOnly', + ownerSenderId: 'user-1', + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + }); + + adapter.pushInbound( + { + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: '/attach sess-1', + messageId: 'm-attach', + }, + { + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: 'hello', + messageId: 'm-hello', + }, + ); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(session.forwardedUserMessages).toEqual([ + expect.objectContaining({ sessionId: 'sess-1', text: 'hello', providerId: 'test', messageId: 'm-hello' }), + ]); + + const outbound = adapter.outbound.map((row) => row.text).join('\n'); + expect(outbound).toContain('Attached'); + expect(outbound).toContain('echo:hello'); + }); + + it('Group: default owner-only denies non-owner forwarding', async () => { + const store = createInMemoryChannelBindingStore(); + const adapter = createInMemoryAdapter('test'); + const session = createInMemorySessionDeps({ + sessionId: 'sess-1', + inboundMode: 'ownerOnly', + ownerSenderId: 'user-1', + providerId: 'test', + conversationId: 'group-1', + threadId: null, + }); + + adapter.pushInbound( + { + providerId: 'test', + conversationId: 'group-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'group', + text: '/attach sess-1', + messageId: 'm-attach', + }, + { + providerId: 'test', + conversationId: 'group-1', + threadId: null, + senderId: 'user-2', + conversationKind: 'group', + text: 'pls run rm -rf /', + messageId: 'm-hijack', + }, + ); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(session.forwardedUserMessages).toEqual([]); + expect(adapter.outbound.map((row) => row.text).join('\n')).toContain('not authorized'); + }); + + it('Restart-safe: advancing binding cursor prevents duplicate outbound forwards across ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const adapter = createInMemoryAdapter('test'); + const session = createInMemorySessionDeps({ + sessionId: 'sess-1', + inboundMode: 'ownerOnly', + ownerSenderId: 'user-1', + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + }); + + adapter.pushInbound({ + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: '/attach sess-1', + messageId: 'm-attach', + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + session.pushAgentMessage('agent:one'); + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const firstOutboundCount = adapter.outbound.filter((row) => row.text.includes('agent:one')).length; + expect(firstOutboundCount).toBe(1); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const secondOutboundCount = adapter.outbound.filter((row) => row.text.includes('agent:one')).length; + expect(secondOutboundCount).toBe(1); + }); +}); diff --git a/packages/tests/vitest.core.config.ts b/packages/tests/vitest.core.config.ts index de7b0c5af..0c4744034 100644 --- a/packages/tests/vitest.core.config.ts +++ b/packages/tests/vitest.core.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from 'vitest/config'; +import { resolve as resolvePath } from 'node:path'; import { resolveVitestFeatureTestExcludeGlobs } from '../../scripts/testing/featureTestGating'; +const repoRoot = resolvePath(__dirname, '../..'); + export default defineConfig({ + resolve: { + alias: { + '@': resolvePath(repoRoot, 'apps/cli/src'), + }, + }, test: { environment: 'node', include: [ diff --git a/packages/tests/vitest.core.fast.config.ts b/packages/tests/vitest.core.fast.config.ts index e5196b2de..7e76fec97 100644 --- a/packages/tests/vitest.core.fast.config.ts +++ b/packages/tests/vitest.core.fast.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from 'vitest/config'; +import { resolve as resolvePath } from 'node:path'; import { resolveVitestFeatureTestExcludeGlobs } from '../../scripts/testing/featureTestGating'; +const repoRoot = resolvePath(__dirname, '../..'); + export default defineConfig({ + resolve: { + alias: { + '@': resolvePath(repoRoot, 'apps/cli/src'), + }, + }, test: { environment: 'node', include: [ diff --git a/packages/tests/vitest.core.slow.config.ts b/packages/tests/vitest.core.slow.config.ts index 9bd2d489f..d2216022b 100644 --- a/packages/tests/vitest.core.slow.config.ts +++ b/packages/tests/vitest.core.slow.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from 'vitest/config'; +import { resolve as resolvePath } from 'node:path'; import { resolveVitestFeatureTestExcludeGlobs } from '../../scripts/testing/featureTestGating'; +const repoRoot = resolvePath(__dirname, '../..'); + export default defineConfig({ + resolve: { + alias: { + '@': resolvePath(repoRoot, 'apps/cli/src'), + }, + }, test: { environment: 'node', include: ['suites/core-e2e/**/*.slow.e2e.test.ts'],