import { createSlackAdapter } from "@chat-adapter/slack"; import { createTelegramAdapter } from "@chat-adapter/telegram"; import { createWhatsAppAdapter } from "@chat-adapter/whatsapp"; import type { MessagingInboundMessage, MessagingOutboundStatus } from "@rakazo/adapter-kit"; import type { Adapter } from "chat"; import { createLarkAdapter, Domain } from "chat-adapter-lark"; import { createSendblueAdapter } from "chat-adapter-sendblue"; import type { MessagingPlatform } from "./chat-sdk-surface.js"; import { isVitestRuntime } from "./test-runtime.js"; /** * Parsed platform credentials, filled from process.env at the composition * roots. A platform mounts when its full credential set is present. */ export interface MessagingEnvironmentValues { sendblueApiKeyId?: string | undefined; sendblueApiSecret?: string | undefined; sendblueSigningSecret?: string | undefined; sendbluePhoneNumber?: string | undefined; slackBotToken?: string | undefined; slackSigningSecret?: string | undefined; whatsappAccessToken?: string | undefined; whatsappPhoneNumberId?: string | undefined; whatsappAppSecret?: string | undefined; whatsappVerifyToken?: string | undefined; telegramBotToken?: string | undefined; telegramWebhookSecret?: string | undefined; larkAppId?: string | undefined; larkAppSecret?: string | undefined; larkVerificationToken?: string | undefined; larkEncryptKey?: string | undefined; larkDomain?: string | undefined; } export function messagingEnvFromProcess( env: Record, ): MessagingEnvironmentValues { // Same trim/empty-to-undefined normalization the API's env loader applies, // so a credential with stray whitespace behaves identically in both roles. const clean = (value: string | undefined) => value?.trim() || undefined; return { sendblueApiKeyId: clean(env.SENDBLUE_API_KEY_ID), sendblueApiSecret: clean(env.SENDBLUE_API_SECRET), sendblueSigningSecret: clean(env.SENDBLUE_SIGNING_SECRET), sendbluePhoneNumber: clean(env.SENDBLUE_PHONE_NUMBER), slackBotToken: clean(env.SLACK_BOT_TOKEN), slackSigningSecret: clean(env.SLACK_SIGNING_SECRET), whatsappAccessToken: clean(env.WHATSAPP_ACCESS_TOKEN), whatsappPhoneNumberId: clean(env.WHATSAPP_PHONE_NUMBER_ID), whatsappAppSecret: clean(env.WHATSAPP_APP_SECRET), whatsappVerifyToken: clean(env.WHATSAPP_VERIFY_TOKEN), telegramBotToken: clean(env.TELEGRAM_BOT_TOKEN), telegramWebhookSecret: clean(env.TELEGRAM_WEBHOOK_SECRET_TOKEN), larkAppId: clean(env.LARK_APP_ID), larkAppSecret: clean(env.LARK_APP_SECRET), larkVerificationToken: clean(env.LARK_VERIFICATION_TOKEN), larkEncryptKey: clean(env.LARK_ENCRYPT_KEY), larkDomain: clean(env.LARK_DOMAIN), }; } /** * Build the platform list for every fully configured provider. Group * conversations stay sendblue-only until channel semantics are mapped for * the other platforms, so their capabilities say so instead of half-working. * * `pollInboundMessages` must be true only in the one process that also * registers the inbound sink (messaging.onInbound — apps/api/src/app.ts). * Telegram's "auto" mode starts a long-poll the moment anything calls * chat.initialize() when no webhook is registered — and that includes a * process that only ever meant to *send*: outbound delivery * (sendToThread) lazily initializes too. A second process polling with no * inbound sink attached doesn't just do nothing — it actively steals * Telegram's single getUpdates slot away from the process that IS * listening, so both sides spend every cycle losing a 409 Conflict to the * other and messages stop arriving at all. Any caller that only sends * (e.g. apps/worker/src/index.ts, for messaging.deliver jobs) must leave * this false so Telegram mode resolves to "webhook" (passive — resolves * bot identity for outbound calls, never polls, and no webhook route is * mounted there for it to receive on anyway). */ export function messagingPlatformsFromEnv( env: MessagingEnvironmentValues, options: { pollInboundMessages?: boolean } = {}, ): MessagingPlatform[] { const platforms: MessagingPlatform[] = []; if ( env.sendblueApiKeyId && env.sendblueApiSecret && env.sendblueSigningSecret && env.sendbluePhoneNumber ) { const lineNumber = env.sendbluePhoneNumber; const adapter = createSendblueAdapter({ apiKey: env.sendblueApiKeyId, apiSecret: env.sendblueApiSecret, defaultFromNumber: lineNumber, webhookSecret: env.sendblueSigningSecret, allowedServices: ["iMessage", "SMS", "RCS"], }); // chat@4.39 derives thread.isDM solely from the optional Adapter.isDM // hook, and chat-adapter-sendblue@0.2.0 omits it — without this every // 1:1 message would route as a group. Derive it from the thread id. Object.assign(adapter, { isDM: (threadId: string) => !adapter.decodeThreadId(threadId).groupId, } satisfies Pick); platforms.push({ provider: "sendblue", capabilities: { direct: true, groups: true, typing: true }, adapter, directThreadId: (address) => adapter.encodeThreadId({ fromNumber: lineNumber, contactNumber: address }), peekStatus: (payload) => parseSendblueStatus(payload), participants: (raw) => sendblueParticipants(raw, lineNumber), channelName: (raw) => sendblueGroupName(raw), transport: (raw) => sendblueTransport(raw), }); } if (env.slackBotToken && env.slackSigningSecret) { platforms.push({ provider: "slack", capabilities: { direct: true, groups: true, typing: false }, adapter: createSlackAdapter({ botToken: env.slackBotToken, signingSecret: env.slackSigningSecret, }), enrichTeamRoom: enrichSlackTeamRoom, }); } if ( env.whatsappAccessToken && env.whatsappPhoneNumberId && env.whatsappAppSecret && env.whatsappVerifyToken ) { platforms.push({ provider: "whatsapp", capabilities: { direct: true, groups: false, typing: false }, adapter: createWhatsAppAdapter({ accessToken: env.whatsappAccessToken, phoneNumberId: env.whatsappPhoneNumberId, appSecret: env.whatsappAppSecret, verifyToken: env.whatsappVerifyToken, }), }); } // Both required: without the secret token the adapter accepts unsigned // webhook posts, so a forged update could reach inbound processing. if (env.telegramBotToken && env.telegramWebhookSecret) { platforms.push({ provider: "telegram", capabilities: { direct: true, groups: false, typing: false }, // Auto mode: uses the webhook route when Telegram has one registered // (checked via getWebhookInfo), and otherwise falls back to // long-polling getUpdates. Self-hosted/local deployments typically // have no public HTTPS endpoint for Telegram to push to, so the API // process calls initialize() at startup (apps/api/src/app.ts) to // start that polling loop immediately rather than waiting for the // first inbound webhook or outbound send. It must be the API // process specifically: that's where the inbound sink is registered, // and Telegram allows only one live getUpdates connection per bot — // a second poller elsewhere would just steal that slot and drop // every message into the void. adapter: createTelegramAdapter({ botToken: env.telegramBotToken, secretToken: env.telegramWebhookSecret, mode: options.pollInboundMessages ? "auto" : "webhook", }), }); } // App ID, secret, and verification token are all required: without the // token the adapter accepts unsigned webhook posts. Encrypt key and // domain are optional (event encryption / open.feishu.cn vs open.larksuite.com). if (env.larkAppId && env.larkAppSecret && env.larkVerificationToken) { platforms.push({ provider: "lark", capabilities: { direct: true, groups: false, typing: false }, // Webhook-only: ws/long-connection incoming would consume events so // the HTTP webhook at /api/v1/messaging/webhook/lark never sees them. adapter: createLarkAdapter({ appId: env.larkAppId, appSecret: env.larkAppSecret, verificationToken: env.larkVerificationToken, incoming: { events: "webhook", callbacks: "webhook" }, // Explicit defaults prevent the adapter from rereading untrimmed process.env values. encryptKey: env.larkEncryptKey ?? "", domain: env.larkDomain?.toLowerCase() === "lark" ? Domain.Lark : Domain.Feishu, }), }); } return platforms; } /** Never live under the test runner; tests build surfaces explicitly. */ export function isMessagingEnabled(platforms: MessagingPlatform[]): boolean { return platforms.length > 0 && !isVitestRuntime(); } /** * Linked users run on their own credentials, so linking-only deployments * need no deployment key. Open signup provisions users with no credential * of their own, so that mode requires the deployment model key — without * it their runs cannot execute. */ export function isMessagingSurfaceEnabled( platforms: MessagingPlatform[], options: { deploymentModelKey: string | undefined; openSignup: boolean }, ): boolean { if (!isMessagingEnabled(platforms)) return false; return options.openSignup ? Boolean(options.deploymentModelKey) : true; } /** Sendblue reports outbound delivery as webhooks the Chat SDK ignores. */ export function parseSendblueStatus(payload: unknown): MessagingOutboundStatus | null { if (typeof payload !== "object" || payload === null) return null; const body = payload as Record; if (body.is_outbound !== true) return null; if (typeof body.message_handle !== "string" || !body.message_handle) return null; return { type: "status", provider: "sendblue", handle: body.message_handle, status: typeof body.status === "string" ? body.status : "", }; } /** Pull Slack team-room fields the Chat SDK does not expose on Message. */ export function enrichSlackTeamRoom( raw: unknown, base: MessagingInboundMessage, ): Partial { const root = asRecord(raw); if (!root) return {}; const event = asRecord(root.event) ?? root; const teamId = stringField(root, "team_id") ?? stringField(event, "team") ?? stringField(event, "team_id"); const eventType = stringField(event, "type"); const botProfile = asRecord(event.bot_profile) ?? {}; const botId = stringField(event, "bot_id") ?? stringField(botProfile, "id"); const threadTs = stringField(event, "thread_ts"); const channel = stringField(event, "channel"); const enrichment: Partial = {}; if (teamId) enrichment.workspaceId = teamId; if (channel) enrichment.conversationKey = channel; if (botId) enrichment.senderIsBot = true; else if (typeof event.bot_id === "string" || event.subtype === "bot_message") { enrichment.senderIsBot = true; } if (threadTs) enrichment.replyThreadId = threadTs; else enrichment.replyThreadId = null; if (!base.isDirect) { const text = stringField(event, "text") ?? base.content; const botUserId = slackAuthorizedBotUserId(root); // app_mention is Slack's bot-directed event. A bare <@U…> mention of // someone else must stay ambient so listen policy still applies. enrichment.kind = eventType === "app_mention" || mentionsSlackBot(text, botUserId) ? "mention" : "ambient"; } return enrichment; } /** Bot user id from Slack's event authorizations (the app that received the event). */ function slackAuthorizedBotUserId(root: Record): string | undefined { const authorizations = root.authorizations; if (!Array.isArray(authorizations)) return undefined; for (const entry of authorizations) { const record = asRecord(entry); if (record?.is_bot !== true) continue; const userId = stringField(record, "user_id"); if (userId) return userId; } return undefined; } function mentionsSlackBot(text: string, botUserId: string | undefined): boolean { if (!botUserId) return false; return text.includes(`<@${botUserId}>`); } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null ? (value as Record) : null; } function stringField(record: Record, key: string): string | undefined { const value = record[key]; return typeof value === "string" && value ? value : undefined; } function sendblueParticipants(raw: unknown, lineNumber: string): string[] { if (typeof raw !== "object" || raw === null) return []; const participants = (raw as { participants?: unknown }).participants; if (!Array.isArray(participants)) return []; return participants.filter( (entry): entry is string => typeof entry === "string" && entry !== lineNumber, ); } function sendblueGroupName(raw: unknown): string | null { if (typeof raw !== "object" || raw === null) return null; const name = (raw as { group_display_name?: unknown }).group_display_name; return typeof name === "string" && name ? name : null; } function sendblueTransport(raw: unknown): string | null { if (typeof raw !== "object" || raw === null) return null; const service = (raw as { service?: unknown }).service; return service === "iMessage" || service === "SMS" || service === "RCS" ? service : null; }