From 693dcd84214961625e044e6bbd8eeb377bc27d52 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 02:07:55 -0800 Subject: [PATCH] feat(channels): add google chat adapter and webhook route --- README.md | 14 +- config/default.yaml | 9 + docs/api/PROTOCOL.md | 1 + src/channels/googleChat/adapter.test.ts | 97 +++++++++ src/channels/googleChat/adapter.ts | 263 ++++++++++++++++++++++++ src/channels/googleChat/index.ts | 1 + src/channels/index.ts | 1 + src/cli/shared.test.ts | 12 ++ src/cli/shared.ts | 2 +- src/config/schema.test.ts | 24 +++ src/config/schema.ts | 10 + src/daemon/channels.ts | 15 +- src/gateway/handlers/services.test.ts | 2 + src/gateway/handlers/services.ts | 1 + src/gateway/server.ts | 14 ++ 15 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 src/channels/googleChat/adapter.test.ts create mode 100644 src/channels/googleChat/adapter.ts create mode 100644 src/channels/googleChat/index.ts diff --git a/README.md b/README.md index eb3b7a6..bb1a8d1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard - **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing -- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, and Microsoft Teams with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Microsoft Teams, and Google Chat with unified adapter interface - **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor - **Model Switching**: Switch between cloud/local models on demand - **Session Persistence**: SQLite-backed conversation history @@ -160,6 +160,18 @@ teams: # Bot Framework messaging endpoint should point to: # POST https:///teams/events +# Optional: Google Chat +google_chat: + service_account_key_file: "~/.config/flynn/google-chat-service-account.json" + # or: + # service_account_json: "${GOOGLE_CHAT_SERVICE_ACCOUNT_JSON}" + webhook_token: "${GOOGLE_CHAT_WEBHOOK_TOKEN}" + allowed_space_names: [] + require_mention: true + +# Google Chat messaging endpoint should point to: +# POST https:///google-chat/events + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index e86fe64..b413c98 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -27,6 +27,15 @@ telegram: # allowed_conversation_ids: [] # Empty = allow all conversations # require_mention: true +# Optional: Google Chat +# google_chat: +# service_account_key_file: ~/.config/flynn/google-chat-service-account.json +# # or inline via env var expansion: +# # service_account_json: ${GOOGLE_CHAT_SERVICE_ACCOUNT_JSON} +# webhook_token: ${GOOGLE_CHAT_WEBHOOK_TOKEN} +# allowed_space_names: [] # Empty = allow all spaces +# require_mention: true + server: # Tailscale Serve config (optional). Enable `serve: true` to expose the # gateway to your tailnet via `tailscale serve`. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 5700c7d..18d438d 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -150,6 +150,7 @@ Exceptions (handled by their own trust/auth model and therefore bypass gateway t - `POST /webhooks/:name` (HMAC-validated when webhook secret is configured) - `POST /gmail/push` (Google Pub/Sub push) - `POST /teams/events` (Microsoft Bot Framework activity callback) +- `POST /google-chat/events` (Google Chat event callback, optional webhook token check) ## Message Format diff --git a/src/channels/googleChat/adapter.test.ts b/src/channels/googleChat/adapter.test.ts new file mode 100644 index 0000000..410c5d2 --- /dev/null +++ b/src/channels/googleChat/adapter.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; + +const getAccessTokenMock = vi.fn(async () => ({ token: 'gchat-token' })); +const getClientMock = vi.fn(async () => ({ getAccessToken: getAccessTokenMock })); +const googleAuthCtorMock = vi.fn(() => ({ getClient: getClientMock })); + +vi.mock('googleapis', () => ({ + google: { + auth: { + GoogleAuth: googleAuthCtorMock, + }, + }, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('GoogleChatAdapter', () => { + let GoogleChatAdapter: typeof import('./adapter.js').GoogleChatAdapter; + + beforeAll(async () => { + ({ GoogleChatAdapter } = await import('./adapter.js')); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + it('has name google_chat and starts disconnected', () => { + const adapter = new GoogleChatAdapter({ serviceAccountJson: '{}' }); + expect(adapter.name).toBe('google_chat'); + expect(adapter.status).toBe('disconnected'); + }); + + it('send posts to Google Chat API with bearer token', async () => { + const adapter = new GoogleChatAdapter({ serviceAccountJson: '{"type":"service_account"}' }); + await adapter.connect(); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => '', + } as Response); + + const peer = `gchat:${Buffer.from(JSON.stringify({ spaceName: 'spaces/AAA', threadName: 'spaces/AAA/threads/BBB' }), 'utf8').toString('base64url')}`; + await adapter.send(peer, { text: 'hello chat' }); + + expect(googleAuthCtorMock).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe('https://chat.googleapis.com/v1/spaces/AAA/messages'); + const init = mockFetch.mock.calls[0]?.[1] as RequestInit; + expect((init.headers as Record).Authorization).toBe('Bearer gchat-token'); + }); + + it('handleEvent forwards MESSAGE event with reply metadata', async () => { + const adapter = new GoogleChatAdapter({ serviceAccountJson: '{}', requireMention: true }); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleEvent({ + type: 'MESSAGE', + message: { + name: 'spaces/AAA/messages/123', + argumentText: 'status check', + text: ' status check', + sender: { name: 'users/1', displayName: 'Alice' }, + space: { name: 'spaces/AAA', type: 'ROOM' }, + thread: { name: 'spaces/AAA/threads/BBB' }, + }, + }); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0]?.[0] as { channel: string; text: string; metadata?: Record }; + expect(msg.channel).toBe('google_chat'); + expect(msg.text).toBe('status check'); + expect(typeof msg.metadata?.replyPeerId).toBe('string'); + }); + + it('drops non-DM room message when mention is required and argumentText is missing', async () => { + const adapter = new GoogleChatAdapter({ serviceAccountJson: '{}', requireMention: true }); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleEvent({ + type: 'MESSAGE', + message: { + name: 'spaces/AAA/messages/123', + text: 'no mention', + sender: { name: 'users/1', displayName: 'Alice' }, + space: { name: 'spaces/AAA', type: 'ROOM' }, + }, + }); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/googleChat/adapter.ts b/src/channels/googleChat/adapter.ts new file mode 100644 index 0000000..3fa8712 --- /dev/null +++ b/src/channels/googleChat/adapter.ts @@ -0,0 +1,263 @@ +import { readFileSync } from 'fs'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { google } from 'googleapis'; + +import type { + InboundMessage, + OutboundMessage, + ChannelAdapter, + ChannelStatus, +} from '../types.js'; +import { shouldIgnoreForMissingMention, splitMessage } from '../utils.js'; +import { readRequestBody } from '../../utils/httpBody.js'; + +export interface GoogleChatAdapterConfig { + serviceAccountKeyFile?: string; + serviceAccountJson?: string; + webhookToken?: string; + allowedSpaceNames?: string[]; + requireMention?: boolean; +} + +interface GoogleChatEvent { + type?: string; + token?: string; + message?: { + name?: string; + text?: string; + argumentText?: string; + sender?: { name?: string; displayName?: string; type?: string }; + space?: { name?: string; type?: string }; + thread?: { name?: string }; + }; +} + +const MAX_MESSAGE_LENGTH = 3500; +const CHAT_BOT_SCOPE = 'https://www.googleapis.com/auth/chat.bot'; + +export class GoogleChatAdapter implements ChannelAdapter { + readonly name = 'google_chat'; + + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + private accessTokenCache: { token: string; expiresAt: number } | null = null; + + constructor(private readonly config: GoogleChatAdapterConfig) {} + + get status(): ChannelStatus { + return this._status; + } + + onMessage(handler: (msg: InboundMessage) => void): void { + this.messageHandler = handler; + } + + async connect(): Promise { + this._status = 'connected'; + } + + async disconnect(): Promise { + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + if (this._status !== 'connected') { + throw new Error('Google Chat adapter not connected'); + } + + const ref = this.decodePeerRef(peerId); + const text = message.text.trim(); + if (!text) { + return; + } + + const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text]; + for (const chunk of chunks) { + await this.sendMessage(ref.spaceName, ref.threadName, chunk); + } + } + + async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + let body = ''; + try { + body = await readRequestBody(req, { maxBytes: 1_048_576 }); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request body' })); + return; + } + + let event: GoogleChatEvent; + try { + event = JSON.parse(body) as GoogleChatEvent; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + if (this.config.webhookToken && event.token !== this.config.webhookToken) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid webhook token' })); + return; + } + + await this.handleEvent(event); + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ accepted: true })); + } + + async handleEvent(event: GoogleChatEvent): Promise { + if (!this.messageHandler) { + return; + } + if (event.type !== 'MESSAGE') { + return; + } + + const message = event.message; + const spaceName = message?.space?.name?.trim(); + if (!spaceName) { + return; + } + if (this.config.allowedSpaceNames && this.config.allowedSpaceNames.length > 0) { + if (!this.config.allowedSpaceNames.includes(spaceName)) { + return; + } + } + + const isDm = (message?.space?.type ?? '').toUpperCase() === 'DM'; + const argumentText = (message?.argumentText ?? '').trim(); + const rawText = argumentText || (message?.text ?? '').trim(); + if (!rawText) { + return; + } + + if (shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot: isDm || argumentText.length > 0, + })) { + return; + } + + const senderId = message?.sender?.name?.trim(); + if (!senderId) { + return; + } + const threadName = message?.thread?.name?.trim() || undefined; + const peerRef = this.encodePeerRef({ spaceName, threadName }); + + this.messageHandler({ + id: message?.name ?? `google-chat-${Date.now()}`, + channel: 'google_chat', + senderId: `${spaceName}:${senderId}`, + senderName: message?.sender?.displayName, + text: rawText, + timestamp: Date.now(), + metadata: { + spaceName, + threadName, + replyPeerId: peerRef, + }, + }); + } + + private async sendMessage(spaceName: string, threadName: string | undefined, text: string): Promise { + const token = await this.getAccessToken(); + const endpoint = `https://chat.googleapis.com/v1/${spaceName}/messages`; + + const body: Record = { text }; + if (threadName) { + body.thread = { name: threadName }; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const responseBody = await response.text().catch(() => ''); + throw new Error(`Google Chat send failed (${response.status}): ${responseBody}`); + } + } + + private async getAccessToken(): Promise { + const now = Date.now(); + if (this.accessTokenCache && this.accessTokenCache.expiresAt > now + 30_000) { + return this.accessTokenCache.token; + } + + const credentials = this.resolveServiceAccountCredentials(); + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: [CHAT_BOT_SCOPE], + }); + const client = await auth.getClient(); + const tokenResponse = await client.getAccessToken(); + const token = typeof tokenResponse === 'string' ? tokenResponse : tokenResponse.token; + if (!token) { + throw new Error('Google Chat auth failed: missing access token'); + } + + this.accessTokenCache = { + token, + expiresAt: now + 50 * 60 * 1000, + }; + return token; + } + + private resolveServiceAccountCredentials(): Record { + if (this.config.serviceAccountJson) { + return JSON.parse(this.config.serviceAccountJson) as Record; + } + if (this.config.serviceAccountKeyFile) { + const raw = readFileSync(this.config.serviceAccountKeyFile, 'utf8'); + return JSON.parse(raw) as Record; + } + const envJson = process.env.GOOGLE_CHAT_SERVICE_ACCOUNT_JSON; + if (envJson && envJson.trim().length > 0) { + return JSON.parse(envJson) as Record; + } + throw new Error( + 'Google Chat credentials missing: set google_chat.service_account_json, google_chat.service_account_key_file, or GOOGLE_CHAT_SERVICE_ACCOUNT_JSON', + ); + } + + private encodePeerRef(ref: { spaceName: string; threadName?: string }): string { + const payload = Buffer.from(JSON.stringify(ref), 'utf8').toString('base64url'); + return `gchat:${payload}`; + } + + private decodePeerRef(peerId: string): { spaceName: string; threadName?: string } { + if (!peerId.startsWith('gchat:')) { + throw new Error('Google Chat peerId must use gchat: format'); + } + const encoded = peerId.slice('gchat:'.length); + let decoded = ''; + try { + decoded = Buffer.from(encoded, 'base64url').toString('utf8'); + } catch { + throw new Error('Invalid Google Chat peer reference encoding'); + } + let parsed: { spaceName?: string; threadName?: string } = {}; + try { + parsed = JSON.parse(decoded) as { spaceName?: string; threadName?: string }; + } catch { + throw new Error('Invalid Google Chat peer reference payload'); + } + const spaceName = parsed.spaceName; + if (!spaceName) { + throw new Error('Google Chat peer reference missing spaceName'); + } + return { + spaceName, + threadName: parsed.threadName, + }; + } +} diff --git a/src/channels/googleChat/index.ts b/src/channels/googleChat/index.ts new file mode 100644 index 0000000..a550c7b --- /dev/null +++ b/src/channels/googleChat/index.ts @@ -0,0 +1 @@ +export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './adapter.js'; diff --git a/src/channels/index.ts b/src/channels/index.ts index b35f726..10c6a40 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -18,4 +18,5 @@ export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js'; export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js'; export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js'; +export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js'; export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/cli/shared.test.ts b/src/cli/shared.test.ts index 9d25c1f..3fe5267 100644 --- a/src/cli/shared.test.ts +++ b/src/cli/shared.test.ts @@ -159,6 +159,18 @@ models: const teams = redacted.teams as Record; expect(teams.app_password).toBe('***'); }); + + it('redacts service_account_json (google chat)', () => { + const config = { + google_chat: { + service_account_json: '{"private_key":"secret"}', + }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + }; + const redacted = redactSecrets(config); + const googleChat = redacted.google_chat as Record; + expect(googleChat.service_account_json).toBe('***'); + }); }); describe('getDataDir', () => { diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 5bf4a0e..95af5b3 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -74,7 +74,7 @@ export function loadConfigSafe(configPath?: string): { config?: Config; error?: /** Deep-clone config and replace sensitive fields with '***'. */ export function redactSecrets(config: Record): Record { - const sensitiveKeys = ['bot_token', 'api_key', 'auth_token', 'access_token', 'app_password']; + const sensitiveKeys = ['bot_token', 'api_key', 'auth_token', 'access_token', 'app_password', 'service_account_json', 'private_key']; function redact(obj: unknown): unknown { if (obj === null || obj === undefined) {return obj;} diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 49d2a6d..7e30dc8 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -375,6 +375,30 @@ describe('configSchema — teams', () => { }); }); +describe('configSchema — google_chat', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts google_chat config and defaults filters', () => { + const result = configSchema.parse({ + ...minimalConfig, + google_chat: { + service_account_key_file: '/tmp/gchat-service-account.json', + }, + }); + + expect(result.google_chat).toBeDefined(); + if (!result.google_chat) { + throw new Error('Expected google_chat config'); + } + expect(result.google_chat.service_account_key_file).toBe('/tmp/gchat-service-account.json'); + expect(result.google_chat.allowed_space_names).toEqual([]); + expect(result.google_chat.require_mention).toBe(true); + }); +}); + describe('configSchema — whatsapp', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index fa73ba8..66a9338 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -418,6 +418,14 @@ const teamsSchema = z.object({ require_mention: z.boolean().default(true), }).optional(); +const googleChatSchema = z.object({ + service_account_key_file: z.string().optional(), + service_account_json: z.string().optional(), + webhook_token: z.string().optional(), + allowed_space_names: z.array(z.string()).default([]), + require_mention: z.boolean().default(true), +}).optional(); + const browserSchema = z.object({ enabled: z.boolean().default(false), executable_path: z.string().optional(), @@ -586,6 +594,7 @@ export const configSchema = z.object({ matrix: matrixSchema, signal: signalSchema, teams: teamsSchema, + google_chat: googleChatSchema, server: serverSchema.default({}), models: modelsSchema, backends: backendsSchema.default({}), @@ -632,6 +641,7 @@ export type WhatsAppConfig = z.infer; export type MatrixConfig = z.infer; export type SignalConfig = z.infer; export type TeamsConfig = z.infer; +export type GoogleChatConfig = z.infer; export type RetryPolicyConfig = z.infer; export type ContextLevel = z.infer; export type PromptConfig = z.infer; diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index c7db42a..3f9be78 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -1,6 +1,6 @@ import type { Config } from '../config/index.js'; import type { HookEngine } from '../hooks/index.js'; -import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, TeamsAdapter, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, TeamsAdapter, GoogleChatAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -113,6 +113,19 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { gateway.setTeamsHandler(teamsAdapter); } + // Register Google Chat adapter (if configured) + if (config.google_chat) { + const googleChatAdapter = new GoogleChatAdapter({ + serviceAccountKeyFile: config.google_chat.service_account_key_file, + serviceAccountJson: config.google_chat.service_account_json, + webhookToken: config.google_chat.webhook_token, + allowedSpaceNames: config.google_chat.allowed_space_names.length > 0 ? config.google_chat.allowed_space_names : undefined, + requireMention: config.google_chat.require_mention, + }); + channelRegistry.register(googleChatAdapter); + gateway.setGoogleChatHandler(googleChatAdapter); + } + // Register WebChat adapter (wraps the gateway) const webChatAdapter = new WebChatAdapter({ gateway }); channelRegistry.register(webChatAdapter); diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index 98d7369..2a17c78 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -32,6 +32,7 @@ function makeBaseConfig(): Config { matrix: undefined, signal: undefined, teams: undefined, + google_chat: undefined, } as unknown as Config; } @@ -47,6 +48,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'matrix', status: 'not_configured' }), expect.objectContaining({ name: 'signal', status: 'not_configured' }), expect.objectContaining({ name: 'teams', status: 'not_configured' }), + expect.objectContaining({ name: 'google_chat', status: 'not_configured' }), expect.objectContaining({ name: 'cron', status: 'not_configured' }), expect.objectContaining({ name: 'mcp', status: 'not_configured' }), expect.objectContaining({ name: 'web_search', status: 'configured' }), diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index 9dc7cc8..2ee25c0 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -55,6 +55,7 @@ export function discoverServices( { key: 'matrix', name: 'matrix', description: 'Matrix bot' }, { key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' }, { key: 'teams', name: 'teams', description: 'Microsoft Teams bot' }, + { key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' }, ]; for (const { key, name, description } of channelConfigs) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 30fae42..4d220f7 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -44,6 +44,7 @@ import type { RoutingPolicy } from '../routing/index.js'; import type { ChannelRegistry } from '../channels/index.js'; import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js'; import type { TeamsAdapter } from '../channels/teams/adapter.js'; +import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js'; export interface GatewayServerConfig { port: number; @@ -96,6 +97,8 @@ export interface GatewayServerConfig { }; /** Optional Teams adapter for inbound Bot Framework activity webhooks. */ teamsHandler?: Pick; + /** Optional Google Chat adapter for inbound Chat event webhooks. */ + googleChatHandler?: Pick; } export class GatewayServer { @@ -485,6 +488,12 @@ export class GatewayServer { return; } + // Google Chat events route — bypass gateway auth (Google Chat posts directly) + if (this.config.googleChatHandler && req.method === 'POST' && req.url?.startsWith('/google-chat/events')) { + await this.config.googleChatHandler.handleRequest(req, res); + return; + } + // Apply auth to HTTP requests when configured const authConfig = this.config.auth ?? {}; if (this.config.authHttp !== false && authConfig.token) { @@ -577,6 +586,11 @@ export class GatewayServer { this.config.teamsHandler = handler; } + /** Set the Google Chat handler for inbound event HTTP routes (late binding). */ + setGoogleChatHandler(handler: Pick): void { + this.config.googleChatHandler = handler; + } + private async startDiscovery(host: string, port: number): Promise { const discovery = this.config.discovery; if (!discovery?.enabled) {