feat(channels): add google chat adapter and webhook route
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user