feat(channels): add google chat adapter and webhook route
This commit is contained in:
@@ -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-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-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
|
- **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
|
- **Model Switching**: Switch between cloud/local models on demand
|
||||||
- **Session Persistence**: SQLite-backed conversation history
|
- **Session Persistence**: SQLite-backed conversation history
|
||||||
@@ -160,6 +160,18 @@ teams:
|
|||||||
# Bot Framework messaging endpoint should point to:
|
# Bot Framework messaging endpoint should point to:
|
||||||
# POST https://<your-flynn-host>/teams/events
|
# POST https://<your-flynn-host>/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://<your-flynn-host>/google-chat/events
|
||||||
|
|
||||||
models:
|
models:
|
||||||
default:
|
default:
|
||||||
provider: anthropic
|
provider: anthropic
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ telegram:
|
|||||||
# allowed_conversation_ids: [] # Empty = allow all conversations
|
# allowed_conversation_ids: [] # Empty = allow all conversations
|
||||||
# require_mention: true
|
# 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:
|
server:
|
||||||
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
# Tailscale Serve config (optional). Enable `serve: true` to expose the
|
||||||
# gateway to your tailnet via `tailscale serve`.
|
# gateway to your tailnet via `tailscale serve`.
|
||||||
|
|||||||
@@ -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 /webhooks/:name` (HMAC-validated when webhook secret is configured)
|
||||||
- `POST /gmail/push` (Google Pub/Sub push)
|
- `POST /gmail/push` (Google Pub/Sub push)
|
||||||
- `POST /teams/events` (Microsoft Bot Framework activity callback)
|
- `POST /teams/events` (Microsoft Bot Framework activity callback)
|
||||||
|
- `POST /google-chat/events` (Google Chat event callback, optional webhook token check)
|
||||||
|
|
||||||
## Message Format
|
## Message Format
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string>).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: '<users/1> 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<string, unknown> };
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
this._status = 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
this._status = 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
const endpoint = `https://chat.googleapis.com/v1/${spaceName}/messages`;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { 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<string> {
|
||||||
|
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<string, unknown> {
|
||||||
|
if (this.config.serviceAccountJson) {
|
||||||
|
return JSON.parse(this.config.serviceAccountJson) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (this.config.serviceAccountKeyFile) {
|
||||||
|
const raw = readFileSync(this.config.serviceAccountKeyFile, 'utf8');
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const envJson = process.env.GOOGLE_CHAT_SERVICE_ACCOUNT_JSON;
|
||||||
|
if (envJson && envJson.trim().length > 0) {
|
||||||
|
return JSON.parse(envJson) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
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:<base64url-json> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './adapter.js';
|
||||||
@@ -18,4 +18,5 @@ export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js
|
|||||||
export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js';
|
export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js';
|
||||||
export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js';
|
export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js';
|
||||||
export { TeamsAdapter, type TeamsAdapterConfig } from './teams/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';
|
export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js';
|
||||||
|
|||||||
@@ -159,6 +159,18 @@ models:
|
|||||||
const teams = redacted.teams as Record<string, unknown>;
|
const teams = redacted.teams as Record<string, unknown>;
|
||||||
expect(teams.app_password).toBe('***');
|
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<string, unknown>;
|
||||||
|
expect(googleChat.service_account_json).toBe('***');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDataDir', () => {
|
describe('getDataDir', () => {
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ export function loadConfigSafe(configPath?: string): { config?: Config; error?:
|
|||||||
|
|
||||||
/** Deep-clone config and replace sensitive fields with '***'. */
|
/** Deep-clone config and replace sensitive fields with '***'. */
|
||||||
export function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
|
export function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
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 {
|
function redact(obj: unknown): unknown {
|
||||||
if (obj === null || obj === undefined) {return obj;}
|
if (obj === null || obj === undefined) {return obj;}
|
||||||
|
|||||||
@@ -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', () => {
|
describe('configSchema — whatsapp', () => {
|
||||||
const minimalConfig = {
|
const minimalConfig = {
|
||||||
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
|
||||||
|
|||||||
@@ -418,6 +418,14 @@ const teamsSchema = z.object({
|
|||||||
require_mention: z.boolean().default(true),
|
require_mention: z.boolean().default(true),
|
||||||
}).optional();
|
}).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({
|
const browserSchema = z.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
executable_path: z.string().optional(),
|
executable_path: z.string().optional(),
|
||||||
@@ -586,6 +594,7 @@ export const configSchema = z.object({
|
|||||||
matrix: matrixSchema,
|
matrix: matrixSchema,
|
||||||
signal: signalSchema,
|
signal: signalSchema,
|
||||||
teams: teamsSchema,
|
teams: teamsSchema,
|
||||||
|
google_chat: googleChatSchema,
|
||||||
server: serverSchema.default({}),
|
server: serverSchema.default({}),
|
||||||
models: modelsSchema,
|
models: modelsSchema,
|
||||||
backends: backendsSchema.default({}),
|
backends: backendsSchema.default({}),
|
||||||
@@ -632,6 +641,7 @@ export type WhatsAppConfig = z.infer<typeof whatsappSchema>;
|
|||||||
export type MatrixConfig = z.infer<typeof matrixSchema>;
|
export type MatrixConfig = z.infer<typeof matrixSchema>;
|
||||||
export type SignalConfig = z.infer<typeof signalSchema>;
|
export type SignalConfig = z.infer<typeof signalSchema>;
|
||||||
export type TeamsConfig = z.infer<typeof teamsSchema>;
|
export type TeamsConfig = z.infer<typeof teamsSchema>;
|
||||||
|
export type GoogleChatConfig = z.infer<typeof googleChatSchema>;
|
||||||
export type RetryPolicyConfig = z.infer<typeof retrySchema>;
|
export type RetryPolicyConfig = z.infer<typeof retrySchema>;
|
||||||
export type ContextLevel = z.infer<typeof contextLevelSchema>;
|
export type ContextLevel = z.infer<typeof contextLevelSchema>;
|
||||||
export type PromptConfig = z.infer<typeof promptSchema>;
|
export type PromptConfig = z.infer<typeof promptSchema>;
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type { HookEngine } from '../hooks/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 { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
|
||||||
import type { GatewayServer } from '../gateway/index.js';
|
import type { GatewayServer } from '../gateway/index.js';
|
||||||
|
|
||||||
@@ -113,6 +113,19 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
|
|||||||
gateway.setTeamsHandler(teamsAdapter);
|
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)
|
// Register WebChat adapter (wraps the gateway)
|
||||||
const webChatAdapter = new WebChatAdapter({ gateway });
|
const webChatAdapter = new WebChatAdapter({ gateway });
|
||||||
channelRegistry.register(webChatAdapter);
|
channelRegistry.register(webChatAdapter);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function makeBaseConfig(): Config {
|
|||||||
matrix: undefined,
|
matrix: undefined,
|
||||||
signal: undefined,
|
signal: undefined,
|
||||||
teams: undefined,
|
teams: undefined,
|
||||||
|
google_chat: undefined,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ describe('discoverServices', () => {
|
|||||||
expect.objectContaining({ name: 'matrix', status: 'not_configured' }),
|
expect.objectContaining({ name: 'matrix', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'signal', status: 'not_configured' }),
|
expect.objectContaining({ name: 'signal', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'teams', 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: 'cron', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
||||||
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
expect.objectContaining({ name: 'web_search', status: 'configured' }),
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function discoverServices(
|
|||||||
{ key: 'matrix', name: 'matrix', description: 'Matrix bot' },
|
{ key: 'matrix', name: 'matrix', description: 'Matrix bot' },
|
||||||
{ key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' },
|
{ key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' },
|
||||||
{ key: 'teams', name: 'teams', description: 'Microsoft Teams bot' },
|
{ 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) {
|
for (const { key, name, description } of channelConfigs) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type { RoutingPolicy } from '../routing/index.js';
|
|||||||
import type { ChannelRegistry } from '../channels/index.js';
|
import type { ChannelRegistry } from '../channels/index.js';
|
||||||
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
|
import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js';
|
||||||
import type { TeamsAdapter } from '../channels/teams/adapter.js';
|
import type { TeamsAdapter } from '../channels/teams/adapter.js';
|
||||||
|
import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js';
|
||||||
|
|
||||||
export interface GatewayServerConfig {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -96,6 +97,8 @@ export interface GatewayServerConfig {
|
|||||||
};
|
};
|
||||||
/** Optional Teams adapter for inbound Bot Framework activity webhooks. */
|
/** Optional Teams adapter for inbound Bot Framework activity webhooks. */
|
||||||
teamsHandler?: Pick<TeamsAdapter, 'handleRequest'>;
|
teamsHandler?: Pick<TeamsAdapter, 'handleRequest'>;
|
||||||
|
/** Optional Google Chat adapter for inbound Chat event webhooks. */
|
||||||
|
googleChatHandler?: Pick<GoogleChatAdapter, 'handleRequest'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
export class GatewayServer {
|
||||||
@@ -485,6 +488,12 @@ export class GatewayServer {
|
|||||||
return;
|
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
|
// Apply auth to HTTP requests when configured
|
||||||
const authConfig = this.config.auth ?? {};
|
const authConfig = this.config.auth ?? {};
|
||||||
if (this.config.authHttp !== false && authConfig.token) {
|
if (this.config.authHttp !== false && authConfig.token) {
|
||||||
@@ -577,6 +586,11 @@ export class GatewayServer {
|
|||||||
this.config.teamsHandler = handler;
|
this.config.teamsHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set the Google Chat handler for inbound event HTTP routes (late binding). */
|
||||||
|
setGoogleChatHandler(handler: Pick<GoogleChatAdapter, 'handleRequest'>): void {
|
||||||
|
this.config.googleChatHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
private async startDiscovery(host: string, port: number): Promise<void> {
|
private async startDiscovery(host: string, port: number): Promise<void> {
|
||||||
const discovery = this.config.discovery;
|
const discovery = this.config.discovery;
|
||||||
if (!discovery?.enabled) {
|
if (!discovery?.enabled) {
|
||||||
|
|||||||
Reference in New Issue
Block a user