diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 69879d045..b738c9b3b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -461,6 +461,7 @@ jobs: - acp-bridge - openclaw - brand + - gateway steps: - name: Checkout code diff --git a/package-lock.json b/package-lock.json index 2773df01a..1e3964d2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-relay", - "version": "4.0.4", + "version": "4.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-relay", - "version": "4.0.4", + "version": "4.0.5", "bundleDependencies": [ "@agent-relay/cloud", "@agent-relay/config", @@ -24,14 +24,14 @@ "web" ], "dependencies": { - "@agent-relay/cloud": "4.0.4", - "@agent-relay/config": "4.0.4", - "@agent-relay/hooks": "4.0.4", - "@agent-relay/sdk": "4.0.4", - "@agent-relay/telemetry": "4.0.4", - "@agent-relay/trajectory": "4.0.4", - "@agent-relay/user-directory": "4.0.4", - "@agent-relay/utils": "4.0.4", + "@agent-relay/cloud": "4.0.5", + "@agent-relay/config": "4.0.5", + "@agent-relay/hooks": "4.0.5", + "@agent-relay/sdk": "4.0.5", + "@agent-relay/telemetry": "4.0.5", + "@agent-relay/trajectory": "4.0.5", + "@agent-relay/user-directory": "4.0.5", + "@agent-relay/utils": "4.0.5", "@aws-sdk/client-s3": "3.1020.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relayauth/core": "^0.1.2", @@ -119,6 +119,10 @@ "resolved": "packages/config", "link": true }, + "node_modules/@agent-relay/gateway": { + "resolved": "packages/gateway", + "link": true + }, "node_modules/@agent-relay/hooks": { "resolved": "packages/hooks", "link": true @@ -15244,10 +15248,10 @@ }, "packages/acp-bridge": { "name": "@agent-relay/acp-bridge", - "version": "4.0.4", + "version": "4.0.5", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "4.0.4", + "@agent-relay/sdk": "4.0.5", "@agentclientprotocol/sdk": "^0.12.0" }, "bin": { @@ -15264,13 +15268,13 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "4.0.4" + "version": "4.0.5" }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/config": "4.0.4", + "@agent-relay/config": "4.0.5", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -15283,7 +15287,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -15294,13 +15298,25 @@ "vitest": "^3.2.4" } }, + "packages/gateway": { + "name": "@agent-relay/gateway", + "version": "4.0.5", + "dependencies": { + "@agent-relay/sdk": "4.0.5" + }, + "devDependencies": { + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, "packages/hooks": { "name": "@agent-relay/hooks", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/config": "4.0.4", - "@agent-relay/sdk": "4.0.4", - "@agent-relay/trajectory": "4.0.4" + "@agent-relay/config": "4.0.5", + "@agent-relay/sdk": "4.0.5", + "@agent-relay/trajectory": "4.0.5" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15310,9 +15326,9 @@ }, "packages/memory": { "name": "@agent-relay/memory", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/hooks": "4.0.4" + "@agent-relay/hooks": "4.0.5" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15322,11 +15338,11 @@ }, "packages/openclaw": { "name": "@agent-relay/openclaw", - "version": "4.0.4", + "version": "4.0.5", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "4.0.4", + "@agent-relay/sdk": "4.0.5", "@relaycast/sdk": "^1.0.0", "ws": "^8.0.0" }, @@ -16150,9 +16166,9 @@ }, "packages/policy": { "name": "@agent-relay/policy", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/config": "4.0.4" + "@agent-relay/config": "4.0.5" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16162,9 +16178,9 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/config": "4.0.4", + "@agent-relay/config": "4.0.5", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": "^0.1.2", "@sinclair/typebox": "^0.34.48", @@ -16252,7 +16268,7 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { "posthog-node": "^4.0.1" }, @@ -16264,9 +16280,9 @@ }, "packages/trajectory": { "name": "@agent-relay/trajectory", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/config": "4.0.4" + "@agent-relay/config": "4.0.5" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16276,9 +16292,9 @@ }, "packages/user-directory": { "name": "@agent-relay/user-directory", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/utils": "4.0.4" + "@agent-relay/utils": "4.0.5" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16288,9 +16304,9 @@ }, "packages/utils": { "name": "@agent-relay/utils", - "version": "4.0.4", + "version": "4.0.5", "dependencies": { - "@agent-relay/config": "4.0.4", + "@agent-relay/config": "4.0.5", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/packages/gateway/package.json b/packages/gateway/package.json new file mode 100644 index 000000000..a61ff129b --- /dev/null +++ b/packages/gateway/package.json @@ -0,0 +1,41 @@ +{ + "name": "@agent-relay/gateway", + "version": "4.0.5", + "description": "Shared gateway contracts for Agent Relay surface adapters and webhook routing", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@agent-relay/sdk": "4.0.5" + }, + "devDependencies": { + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relay.git", + "directory": "packages/gateway" + } +} diff --git a/packages/gateway/src/adapters/index.ts b/packages/gateway/src/adapters/index.ts new file mode 100644 index 000000000..c5468eb80 --- /dev/null +++ b/packages/gateway/src/adapters/index.ts @@ -0,0 +1,3 @@ +export { SlackAdapter, type SlackAdapterOptions } from './slack.js'; +export { WhatsAppAdapter, type WhatsAppAdapterOptions } from './whatsapp.js'; +export { TelegramAdapter, type TelegramAdapterOptions } from './telegram.js'; diff --git a/packages/gateway/src/adapters/slack.ts b/packages/gateway/src/adapters/slack.ts new file mode 100644 index 000000000..2aa2016c4 --- /dev/null +++ b/packages/gateway/src/adapters/slack.ts @@ -0,0 +1,297 @@ +/** + * Slack Surface Adapter + * + * Implements SurfaceAdapter for Slack Events API and Web API. + * - verify(): Slack v0 HMAC-SHA256 signature verification + * - receive(): Parses app_mention, message, reaction events into NormalizedMessage[] + * - deliver(): Posts via Slack Web API (chat.postMessage, reactions.add) + */ + +import crypto from 'crypto'; +import type { + SurfaceAdapter, + SignatureConfig, + HeaderMap, + NormalizedMessage, + OutboundMessage, + DeliveryResult, + GatewayMetadata, +} from '../types.js'; + +export interface SlackAdapterOptions { + botToken: string; + signingSecret: string; +} + +/** + * Extract agent mentions from text (@agent-name patterns, not Slack user mentions) + */ +function extractAgentMentions(text: string | null | undefined): string[] { + if (!text) return []; + const mentionPattern = /(?])/g; + const mentions: string[] = []; + let match; + while ((match = mentionPattern.exec(text)) !== null) { + mentions.push(match[1].toLowerCase()); + } + return [...new Set(mentions)]; +} + +/** + * Clean Slack message text (remove formatting) + */ +function cleanSlackText(text: string | null | undefined): string { + if (!text) return ''; + return text + .replace(/<@[A-Z0-9]+\|([^>]+)>/g, '@$1') + .replace(/<@[A-Z0-9]+>/g, '@user') + .replace(/<([^|>]+)\|([^>]+)>/g, '$2') + .replace(/<([^>]+)>/g, '$1'); +} + +/** + * Call the Slack Web API + */ +async function slackAPI( + token: string, + method: string, + body: Record +): Promise<{ ok: boolean; error?: string; ts?: string; channel?: string }> { + const response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + return response.json() as Promise<{ ok: boolean; error?: string; ts?: string; channel?: string }>; +} + +export class SlackAdapter implements SurfaceAdapter { + readonly type = 'slack' as const; + readonly signature: SignatureConfig = { + header: 'x-slack-signature', + algorithm: 'slack-v0', + secretEnvVar: 'SLACK_SIGNING_SECRET', + }; + + private botToken: string; + private signingSecret: string; + + constructor(options: SlackAdapterOptions) { + this.botToken = options.botToken; + this.signingSecret = options.signingSecret; + } + + verify(payload: string, headers: HeaderMap): boolean { + const signature = headers['x-slack-signature'] as string | undefined; + const timestamp = headers['x-slack-request-timestamp'] as string | undefined; + + if (!signature || !timestamp) return false; + + // Reject requests older than 5 minutes + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false; + + const sigBasestring = `v0:${timestamp}:${payload}`; + const expected = + 'v0=' + crypto.createHmac('sha256', this.signingSecret).update(sigBasestring).digest('hex'); + + try { + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } + } + + receive(payload: unknown, _headers: HeaderMap): NormalizedMessage[] { + const data = payload as Record; + const messages: NormalizedMessage[] = []; + + // URL verification is handled outside (return challenge) + if (data.type === 'url_verification') return []; + if (data.type !== 'event_callback') return []; + + const event = data.event as Record | undefined; + if (!event) return []; + + const eventType = event.type as string; + const teamId = (data.team_id as string) || 'unknown'; + const eventId = (data.event_id as string) || `slack-${Date.now()}`; + const eventTime = data.event_time as number | undefined; + + const base: Omit = { + id: eventId, + source: 'slack', + timestamp: eventTime ? new Date(eventTime * 1000) : new Date(), + actor: { + id: String(event.user || 'unknown'), + name: String(event.user || 'unknown'), + }, + context: { + name: teamId, + channel: event.channel as string | undefined, + }, + labels: [], + metadata: { + teamId, + channelId: event.channel, + channelType: event.channel_type, + ts: event.ts, + threadTs: event.thread_ts, + }, + rawPayload: payload, + }; + + switch (eventType) { + case 'app_mention': { + const text = event.text as string; + const agentMentions = extractAgentMentions(text); + messages.push({ + ...base, + type: 'mention', + item: { + type: 'message', + id: String(event.ts), + body: cleanSlackText(text), + }, + mentions: agentMentions.length > 0 ? agentMentions : ['lead'], + }); + break; + } + + case 'message': { + const text = event.text as string; + const subtype = event.subtype as string | undefined; + if (subtype && subtype !== 'thread_broadcast') break; + + const agentMentions = extractAgentMentions(text); + if (agentMentions.length > 0) { + messages.push({ + ...base, + type: 'mention', + item: { + type: 'message', + id: String(event.ts), + body: cleanSlackText(text), + }, + mentions: agentMentions, + }); + } + break; + } + + case 'reaction_added': { + const reaction = event.reaction as string; + const item = event.item as Record; + messages.push({ + ...base, + type: 'reaction_added', + item: { + type: 'message', + id: String(item?.ts || 'unknown'), + }, + labels: [reaction], + mentions: [], + }); + break; + } + + default: + messages.push({ + ...base, + type: `slack.${eventType}`, + mentions: [], + }); + } + + return messages; + } + + async deliver( + event: NormalizedMessage, + message: OutboundMessage, + config?: GatewayMetadata + ): Promise { + const token = (config?.botToken as string) || this.botToken; + + const channelId = + (message.metadata?.channel as string) || + (event.metadata?.channelId as string) || + String(message.target); + + if (!channelId) { + return { success: false, error: 'Channel ID required' }; + } + + try { + switch (message.type) { + case 'message': { + const threadTs = + (message.metadata?.threadTs as string) || + (event.metadata?.threadTs as string) || + (event.metadata?.ts as string); + + const result = await slackAPI(token, 'chat.postMessage', { + channel: channelId, + text: message.body, + thread_ts: threadTs, + unfurl_links: false, + unfurl_media: false, + }); + + if (!result.ok) { + return { success: false, error: result.error || 'Failed to post message' }; + } + + return { + success: true, + id: result.ts, + url: `https://slack.com/archives/${channelId}/p${result.ts?.replace('.', '')}`, + }; + } + + case 'comment': { + const threadTs = String(message.target); + const result = await slackAPI(token, 'chat.postMessage', { + channel: channelId, + text: message.body, + thread_ts: threadTs, + reply_broadcast: message.metadata?.broadcast === true, + }); + + if (!result.ok) { + return { success: false, error: result.error || 'Failed to post reply' }; + } + return { success: true, id: result.ts }; + } + + case 'reaction': { + const ts = String(message.target); + const emoji = (message.metadata?.emoji as string) || message.body.replace(/:/g, ''); + + const result = await slackAPI(token, 'reactions.add', { + channel: channelId, + timestamp: ts, + name: emoji, + }); + + if (!result.ok && result.error !== 'already_reacted') { + return { success: false, error: result.error || 'Failed to add reaction' }; + } + return { success: true }; + } + + default: + return { success: false, error: `Unsupported delivery type: ${message.type}` }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } +} diff --git a/packages/gateway/src/adapters/telegram.ts b/packages/gateway/src/adapters/telegram.ts new file mode 100644 index 000000000..2198c0dc4 --- /dev/null +++ b/packages/gateway/src/adapters/telegram.ts @@ -0,0 +1,273 @@ +/** + * Telegram Surface Adapter + * + * Implements SurfaceAdapter for the Telegram Bot API. + * - verify(): X-Telegram-Bot-Api-Secret-Token header comparison + * - receive(): Parses Telegram Bot API Update objects + * - deliver(): Sends via Telegram Bot API /sendMessage + */ + +import type { + SurfaceAdapter, + SignatureConfig, + HeaderMap, + NormalizedMessage, + OutboundMessage, + DeliveryResult, + GatewayMetadata, +} from '../types.js'; + +export interface TelegramAdapterOptions { + botToken: string; + webhookSecretToken: string; +} + +export class TelegramAdapter implements SurfaceAdapter { + readonly type = 'telegram' as const; + readonly signature: SignatureConfig = { + header: 'x-telegram-bot-api-secret-token', + algorithm: 'token', + secretEnvVar: 'TELEGRAM_WEBHOOK_SECRET', + }; + + private botToken: string; + private webhookSecretToken: string; + + constructor(options: TelegramAdapterOptions) { + this.botToken = options.botToken; + this.webhookSecretToken = options.webhookSecretToken; + } + + verify(_payload: string, headers: HeaderMap): boolean { + const token = headers['x-telegram-bot-api-secret-token'] as string | undefined; + if (!token) return false; + return token === this.webhookSecretToken; + } + + receive(payload: unknown, _headers: HeaderMap): NormalizedMessage[] { + const update = payload as Record; + const messages: NormalizedMessage[] = []; + + const updateId = update.update_id as number; + + // Handle regular messages + const msg = + (update.message as Record) || (update.edited_message as Record); + + if (msg) { + const chat = msg.chat as Record; + const from = msg.from as Record | undefined; + const text = msg.text as string | undefined; + + const isEdited = !!update.edited_message; + const isGroup = chat.type === 'group' || chat.type === 'supergroup'; + + // Check for bot commands + const entities = msg.entities as Array> | undefined; + const hasBotCommand = entities?.some((e) => e.type === 'bot_command') ?? false; + + let eventType = isEdited ? 'message_edited' : 'message'; + if (hasBotCommand) eventType = 'command'; + + // Extract mentions from entities + const mentions = this.extractMentions(text, entities); + + messages.push({ + id: `tg-${updateId}`, + source: 'telegram', + type: eventType, + timestamp: msg.date ? new Date((msg.date as number) * 1000) : new Date(), + actor: { + id: String(from?.id || 'unknown'), + name: (from?.first_name as string) || (from?.username as string) || 'unknown', + handle: from?.username as string | undefined, + }, + context: { + name: (chat.title as string) || String(chat.id), + channel: String(chat.id), + conversationId: String(chat.id), + threadId: msg.message_thread_id ? String(msg.message_thread_id) : undefined, + }, + item: { + type: 'message', + id: msg.message_id as number, + body: text || `[${msg.photo ? 'photo' : msg.document ? 'document' : 'media'}]`, + }, + mentions, + labels: isGroup ? ['group'] : ['private'], + metadata: { + chatId: chat.id, + chatType: chat.type, + messageId: msg.message_id, + isEdited, + replyToMessageId: (msg.reply_to_message as Record)?.message_id, + }, + rawPayload: payload, + }); + } + + // Handle callback queries (inline button presses) + const callbackQuery = update.callback_query as Record | undefined; + if (callbackQuery) { + const from = callbackQuery.from as Record; + const cbMessage = callbackQuery.message as Record | undefined; + const chat = cbMessage?.chat as Record | undefined; + + messages.push({ + id: `tg-cb-${callbackQuery.id}`, + source: 'telegram', + type: 'callback_query', + timestamp: new Date(), + actor: { + id: String(from?.id || 'unknown'), + name: (from?.first_name as string) || 'unknown', + handle: from?.username as string | undefined, + }, + context: { + name: (chat?.title as string) || String(chat?.id || 'unknown'), + channel: chat ? String(chat.id) : undefined, + }, + item: { + type: 'message', + id: (cbMessage?.message_id as number) || 0, + body: (callbackQuery.data as string) || '', + }, + mentions: [], + labels: ['callback'], + metadata: { + callbackQueryId: callbackQuery.id, + callbackData: callbackQuery.data, + chatId: chat?.id, + }, + rawPayload: payload, + }); + } + + return messages; + } + + async deliver( + event: NormalizedMessage, + message: OutboundMessage, + config?: GatewayMetadata + ): Promise { + const token = (config?.botToken as string) || this.botToken; + const chatId = + (message.metadata?.chatId as string | number) || + (event.metadata?.chatId as string | number) || + message.target; + + try { + switch (message.type) { + case 'message': + case 'comment': { + const body: Record = { + chat_id: chatId, + text: message.body, + parse_mode: 'Markdown', + }; + + // Reply to specific message + if (message.replyToMessageId) { + body.reply_to_message_id = Number(message.replyToMessageId); + } else if (message.type === 'comment' && message.target) { + body.reply_to_message_id = Number(message.target); + } + + const result = await this.telegramAPI(token, 'sendMessage', body); + + if (!result.ok) { + return { + success: false, + error: result.description || 'Failed to send message', + }; + } + + return { + success: true, + id: String(result.result?.message_id), + }; + } + + case 'reaction': { + const emoji = (message.metadata?.emoji as string) || message.body; + const messageId = Number(message.target); + + const result = await this.telegramAPI(token, 'setMessageReaction', { + chat_id: chatId, + message_id: messageId, + reaction: [{ type: 'emoji', emoji }], + }); + + if (!result.ok) { + return { + success: false, + error: result.description || 'Failed to set reaction', + }; + } + return { success: true }; + } + + default: + return { + success: false, + error: `Unsupported delivery type: ${message.type}`, + }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + private async telegramAPI( + token: string, + method: string, + body: Record + ): Promise<{ + ok: boolean; + description?: string; + result?: Record; + }> { + const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return response.json() as Promise<{ + ok: boolean; + description?: string; + result?: Record; + }>; + } + + private extractMentions( + text: string | null | undefined, + entities: Array> | undefined + ): string[] { + if (!text || !entities) return []; + + const mentions: string[] = []; + for (const entity of entities) { + if (entity.type === 'mention') { + const offset = entity.offset as number; + const length = entity.length as number; + const mention = text.slice(offset + 1, offset + length); // Skip @ + mentions.push(mention.toLowerCase()); + } + } + + // Also extract @agent-name patterns + const mentionPattern = /(?])/g; + let match; + while ((match = mentionPattern.exec(text)) !== null) { + const m = match[1].toLowerCase(); + if (!mentions.includes(m)) mentions.push(m); + } + + return [...new Set(mentions)]; + } +} diff --git a/packages/gateway/src/adapters/whatsapp.ts b/packages/gateway/src/adapters/whatsapp.ts new file mode 100644 index 000000000..cc6ec3233 --- /dev/null +++ b/packages/gateway/src/adapters/whatsapp.ts @@ -0,0 +1,218 @@ +/** + * WhatsApp Surface Adapter + * + * Implements SurfaceAdapter for WhatsApp Cloud API. + * - verify(): X-Hub-Signature-256 HMAC verification (Meta webhook standard) + * - receive(): Parses WhatsApp Cloud API webhook notifications + * - deliver(): Sends messages via Meta Cloud API /messages endpoint + */ + +import crypto from 'crypto'; +import type { + SurfaceAdapter, + SignatureConfig, + HeaderMap, + NormalizedMessage, + OutboundMessage, + DeliveryResult, + GatewayMetadata, +} from '../types.js'; + +export interface WhatsAppAdapterOptions { + phoneNumberId: string; + accessToken: string; + appSecret: string; + apiVersion?: string; +} + +export class WhatsAppAdapter implements SurfaceAdapter { + readonly type = 'whatsapp' as const; + readonly signature: SignatureConfig = { + header: 'x-hub-signature-256', + algorithm: 'sha256', + secretEnvVar: 'WHATSAPP_APP_SECRET', + signaturePrefix: 'sha256=', + }; + + private phoneNumberId: string; + private accessToken: string; + private appSecret: string; + private apiVersion: string; + + constructor(options: WhatsAppAdapterOptions) { + this.phoneNumberId = options.phoneNumberId; + this.accessToken = options.accessToken; + this.appSecret = options.appSecret; + this.apiVersion = options.apiVersion ?? 'v21.0'; + } + + verify(payload: string, headers: HeaderMap): boolean { + const signature = headers['x-hub-signature-256'] as string | undefined; + if (!signature) return false; + + const sig = signature.startsWith('sha256=') ? signature.slice(7) : signature; + const expected = crypto.createHmac('sha256', this.appSecret).update(payload).digest('hex'); + + try { + return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); + } catch { + return false; + } + } + + receive(payload: unknown, _headers: HeaderMap): NormalizedMessage[] { + const data = payload as Record; + const messages: NormalizedMessage[] = []; + + // WhatsApp Cloud API uses "entry" array + const entries = data.entry as Array> | undefined; + if (!entries) return []; + + for (const entry of entries) { + const changes = entry.changes as Array> | undefined; + if (!changes) continue; + + for (const change of changes) { + const value = change.value as Record | undefined; + if (!value) continue; + + const metadata = value.metadata as Record | undefined; + const phoneNumberId = (metadata?.phone_number_id as string) || 'unknown'; + + // Handle incoming messages + const incomingMessages = value.messages as Array> | undefined; + if (incomingMessages) { + for (const msg of incomingMessages) { + const contact = (value.contacts as Array>)?.find( + (c: Record) => c.wa_id === msg.from + ); + const profile = contact?.profile as Record | undefined; + + const text = + msg.type === 'text' ? ((msg.text as Record)?.body as string) : undefined; + + messages.push({ + id: (msg.id as string) || `wa-${Date.now()}`, + source: 'whatsapp', + type: 'message', + timestamp: msg.timestamp ? new Date(Number(msg.timestamp) * 1000) : new Date(), + actor: { + id: (msg.from as string) || 'unknown', + name: (profile?.name as string) || (msg.from as string) || 'unknown', + }, + context: { + name: phoneNumberId, + conversationId: (msg.context as Record)?.id as string | undefined, + }, + item: { + type: 'message', + id: (msg.id as string) || 'unknown', + body: text || `[${msg.type as string}]`, + }, + mentions: this.extractMentions(text), + labels: [], + metadata: { + phoneNumberId, + messageType: msg.type, + context: msg.context, + }, + rawPayload: payload, + }); + } + } + + // Handle status updates + const statuses = value.statuses as Array> | undefined; + if (statuses) { + for (const status of statuses) { + messages.push({ + id: (status.id as string) || `wa-status-${Date.now()}`, + source: 'whatsapp', + type: `status_${status.status as string}`, + timestamp: status.timestamp ? new Date(Number(status.timestamp) * 1000) : new Date(), + actor: { + id: (status.recipient_id as string) || 'unknown', + name: (status.recipient_id as string) || 'unknown', + }, + context: { name: phoneNumberId }, + mentions: [], + labels: [], + metadata: { + phoneNumberId, + status: status.status, + conversationId: (status.conversation as Record)?.id, + }, + rawPayload: payload, + }); + } + } + } + } + + return messages; + } + + async deliver( + _event: NormalizedMessage, + message: OutboundMessage, + config?: GatewayMetadata + ): Promise { + const token = (config?.accessToken as string) || this.accessToken; + const phoneId = (config?.phoneNumberId as string) || this.phoneNumberId; + const to = String(message.target); + + try { + const body: Record = { + messaging_product: 'whatsapp', + to, + type: 'text', + text: { body: message.body }, + }; + + // Reply to a specific message if provided + if (message.replyToMessageId) { + body.context = { message_id: message.replyToMessageId }; + } + + const response = await fetch(`https://graph.facebook.com/${this.apiVersion}/${phoneId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + return { success: false, error: `WhatsApp API error: ${error}` }; + } + + const result = (await response.json()) as { + messages?: Array<{ id: string }>; + }; + const messageId = result.messages?.[0]?.id; + + return { + success: true, + id: messageId, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + private extractMentions(text: string | null | undefined): string[] { + if (!text) return []; + const mentionPattern = /(?])/g; + const mentions: string[] = []; + let match; + while ((match = mentionPattern.exec(text)) !== null) { + mentions.push(match[1].toLowerCase()); + } + return [...new Set(mentions)]; + } +} diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts new file mode 100644 index 000000000..734a0bf18 --- /dev/null +++ b/packages/gateway/src/index.ts @@ -0,0 +1,4 @@ +export * from './types.js'; +export * from './router.js'; +export * from './rules-engine.js'; +export * from './adapters/index.js'; diff --git a/packages/gateway/src/router.ts b/packages/gateway/src/router.ts new file mode 100644 index 000000000..62f081d32 --- /dev/null +++ b/packages/gateway/src/router.ts @@ -0,0 +1,159 @@ +/** + * Gateway Router + * + * Routes incoming webhooks through the configurable pipeline: + * 1. Find adapter by source type + * 2. Verify signature + * 3. Parse payload into NormalizedMessage[] + * 4. Match messages against rules + * 5. Return ProcessResult + */ + +import type { + SurfaceType, + SurfaceAdapter, + HeaderMap, + NormalizedMessage, + OutboundMessage, + DeliveryResult, + GatewayMetadata, + WebhookRule, + ProcessResult, + ProcessResultEntry, + GatewayOptions, +} from './types.js'; +import { findMatchingRules } from './rules-engine.js'; + +function normalizeHeaders(headers: HeaderMap): HeaderMap { + const normalized: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + normalized[key.toLowerCase()] = value; + } + + return normalized; +} + +export class Gateway { + private readonly adapters = new Map(); + private rules: WebhookRule[]; + + constructor(options?: GatewayOptions) { + this.rules = options?.rules ?? []; + + if (options?.adapters) { + for (const adapter of options.adapters) { + this.registerAdapter(adapter); + } + } + } + + /** + * Register a surface adapter + */ + registerAdapter(adapter: SurfaceAdapter): void { + this.adapters.set(adapter.type, adapter); + } + + /** + * Get a registered adapter by type + */ + getAdapter(type: SurfaceType): SurfaceAdapter | undefined { + return this.adapters.get(type); + } + + /** + * Process an incoming webhook + */ + processWebhook(source: SurfaceType, payload: string, headers: HeaderMap): ProcessResult { + const normalizedHeaders = normalizeHeaders(headers); + const adapter = this.adapters.get(source); + + if (!adapter) { + return { + source, + verified: false, + entries: [], + error: `No adapter registered for source: ${source}`, + }; + } + + // Verify signature + const verified = adapter.verify(payload, normalizedHeaders); + if (!verified) { + return { + source, + verified: false, + entries: [], + error: 'Signature verification failed', + }; + } + + // Parse payload + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return { + source, + verified: true, + entries: [], + error: 'Invalid JSON payload', + }; + } + + // Receive normalized messages + let messages: NormalizedMessage[]; + try { + messages = adapter.receive(parsed, normalizedHeaders); + if (!Array.isArray(messages)) { + throw new Error('Adapter receive() must return an array of normalized messages'); + } + } catch (error) { + return { + source, + verified: true, + entries: [], + error: `Parse error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + + // Match rules for each message + const entries: ProcessResultEntry[] = messages.map((message) => { + const matchedRules = findMatchingRules(this.rules, message); + const actions = matchedRules.map((rule) => rule.action); + + return { + message, + matchedRules, + actions, + }; + }); + + return { + source, + verified: true, + entries, + }; + } + + /** + * Deliver an outbound message via the appropriate adapter + */ + async deliver( + event: NormalizedMessage, + message: OutboundMessage, + config?: GatewayMetadata + ): Promise { + const adapter = this.adapters.get(event.source); + + if (!adapter) { + return { + success: false, + error: `No adapter registered for source: ${event.source}`, + }; + } + + return adapter.deliver(event, message, config); + } +} diff --git a/packages/gateway/src/rules-engine.ts b/packages/gateway/src/rules-engine.ts new file mode 100644 index 000000000..32280a72d --- /dev/null +++ b/packages/gateway/src/rules-engine.ts @@ -0,0 +1,159 @@ +/** + * Gateway Rules Engine + * + * Matches normalized messages against configured rules and determines actions. + * Supports JSONPath-like conditions with comparison operators. + */ + +import type { NormalizedMessage, WebhookRule, GatewayAction } from './types.js'; + +/** + * Get a value from an object by dot-separated path + */ +function getValueByPath(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) return undefined; + if (typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + + return current; +} + +/** + * Simple JSONPath-like evaluator for conditions. + * Supports: $.field, $.field.subfield + * Operators: ==, !=, >, <, >=, <=, in, contains + */ +export function evaluateCondition(condition: string, message: NormalizedMessage): boolean { + if (!condition || condition.trim() === '') return true; + + try { + // Bounded whitespace (\s{0,16}) prevents polynomial-regex backtracking + // when the operator alternation fails to match on whitespace-heavy input. + // 16 chars of whitespace around an operator is already pathological — no + // legitimate rule needs more. See CodeQL js/polynomial-redos. + const conditionPattern = /^\$\.([a-zA-Z0-9_.]+)\s{0,16}(==|!=|>=|<=|>|<|in|contains)\s{0,16}(.+)$/; + const match = condition.match(conditionPattern); + + if (!match) { + console.warn(`[rules-engine] Invalid condition format: ${condition}`); + return false; + } + + const [, path, operator, rawValue] = match; + const value = rawValue.trim(); + + const messageValue = getValueByPath(message, path); + + let compareValue: unknown; + if (value.startsWith('[') && value.endsWith(']')) { + compareValue = JSON.parse(value); + } else if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + compareValue = value.slice(1, -1); + } else if (value === 'true') { + compareValue = true; + } else if (value === 'false') { + compareValue = false; + } else if (value === 'null') { + compareValue = null; + } else if (!isNaN(Number(value))) { + compareValue = Number(value); + } else { + compareValue = value; + } + + switch (operator) { + case '==': + if (compareValue === null) { + return messageValue === null || messageValue === undefined; + } + return messageValue === compareValue; + case '!=': + if (compareValue === null) { + return messageValue !== null && messageValue !== undefined; + } + return messageValue !== compareValue; + case 'in': + return Array.isArray(compareValue) && compareValue.includes(messageValue); + case 'contains': + if (Array.isArray(messageValue)) { + return messageValue.includes(compareValue); + } + if (typeof messageValue === 'string' && typeof compareValue === 'string') { + return messageValue.includes(compareValue); + } + return false; + case '>': + return ( + typeof messageValue === 'number' && typeof compareValue === 'number' && messageValue > compareValue + ); + case '<': + return ( + typeof messageValue === 'number' && typeof compareValue === 'number' && messageValue < compareValue + ); + case '>=': + return ( + typeof messageValue === 'number' && typeof compareValue === 'number' && messageValue >= compareValue + ); + case '<=': + return ( + typeof messageValue === 'number' && typeof compareValue === 'number' && messageValue <= compareValue + ); + default: + return false; + } + } catch (error) { + console.error(`[rules-engine] Error evaluating condition: ${condition}`, error); + return false; + } +} + +/** + * Check if a rule matches a normalized message + */ +export function matchesRule(rule: WebhookRule, message: NormalizedMessage): boolean { + if (!rule.enabled) return false; + + if (rule.source !== '*' && rule.source !== message.source) { + return false; + } + + if (rule.eventType !== '*' && rule.eventType !== message.type) { + if (rule.eventType.endsWith('*')) { + const prefix = rule.eventType.slice(0, -1); + if (!message.type.startsWith(prefix)) { + return false; + } + } else { + return false; + } + } + + if (rule.condition && !evaluateCondition(rule.condition, message)) { + return false; + } + + return true; +} + +/** + * Find all matching rules for a message, sorted by priority (lower = higher) + */ +export function findMatchingRules(rules: WebhookRule[], message: NormalizedMessage): WebhookRule[] { + return rules.filter((rule) => matchesRule(rule, message)).sort((a, b) => a.priority - b.priority); +} + +/** + * Extract actions from matched rules + */ +export function extractActions(rules: WebhookRule[], message: NormalizedMessage): GatewayAction[] { + const matched = findMatchingRules(rules, message); + return matched.map((rule) => rule.action); +} diff --git a/packages/gateway/src/types.ts b/packages/gateway/src/types.ts new file mode 100644 index 000000000..86578fba0 --- /dev/null +++ b/packages/gateway/src/types.ts @@ -0,0 +1,166 @@ +import type { SendMessageInput, SpawnProviderInput } from '@agent-relay/sdk'; + +export type SurfaceType = 'whatsapp' | 'slack' | 'telegram'; + +export type MessagePriority = 'critical' | 'high' | 'medium' | 'low'; + +export type MessageItemType = 'issue' | 'pull_request' | 'ticket' | 'message' | 'comment' | 'check'; + +export type DeliveryType = 'comment' | 'message' | 'reaction' | 'status'; + +export type SignatureAlgorithm = 'sha256' | 'sha1' | 'token' | 'slack-v0' | 'none'; + +export type HeaderValue = string | string[] | undefined; + +export type HeaderMap = Readonly>; + +export type GatewayMetadata = Readonly>; + +export interface MessageActor { + id: string; + name: string; + email?: string; + handle?: string; +} + +export interface MessageContext { + name: string; + url?: string; + channel?: string; + workspaceId?: string; + conversationId?: string; + threadId?: string; +} + +export interface MessageItem { + type: MessageItemType; + id: string | number; + number?: number; + title?: string; + body?: string; + url?: string; + state?: string; +} + +export interface NormalizedMessage { + id: string; + source: SurfaceType; + type: string; + timestamp: Date; + actor: MessageActor; + context: MessageContext; + item?: MessageItem; + mentions: string[]; + labels: string[]; + priority?: MessagePriority; + metadata: GatewayMetadata; + rawPayload: unknown; +} + +export interface OutboundMessage { + type: DeliveryType; + target: string | number; + body: string; + metadata?: GatewayMetadata; + replyToMessageId?: string; + subject?: string; +} + +export interface DeliveryResult { + success: boolean; + id?: string; + url?: string; + error?: string; + metadata?: GatewayMetadata; +} + +export interface SignatureConfig { + header: string; + algorithm: SignatureAlgorithm; + secretEnvVar: string; + signaturePrefix?: string; +} + +export interface SurfaceAdapter { + readonly type: SurfaceType; + readonly signature: SignatureConfig; + verify(payload: string, headers: HeaderMap): boolean; + receive(payload: unknown, headers: HeaderMap): NormalizedMessage[]; + deliver( + event: NormalizedMessage, + message: OutboundMessage, + config?: GatewayMetadata + ): Promise; +} + +interface GatewayActionBase { + config?: GatewayMetadata; +} + +export interface SpawnAgentAction extends GatewayActionBase { + type: 'spawn_agent'; + agent: SpawnProviderInput; + prompt?: string; +} + +export interface MessageAgentAction extends GatewayActionBase { + type: 'message_agent'; + message: SendMessageInput; +} + +export interface PostCommentAction extends GatewayActionBase { + type: 'post_comment'; + target: string | number; + body: string; +} + +export interface CreateIssueAction extends GatewayActionBase { + type: 'create_issue'; + title: string; + body?: string; + labels?: string[]; +} + +export interface CustomAction extends GatewayActionBase { + type: 'custom'; + name: string; + payload?: GatewayMetadata; +} + +export type GatewayAction = + | SpawnAgentAction + | MessageAgentAction + | PostCommentAction + | CreateIssueAction + | CustomAction; + +export type RuleSource = SurfaceType | '*'; + +export interface WebhookRule { + id: string; + name: string; + enabled: boolean; + source: RuleSource; + eventType: string; + condition?: string; + action: GatewayAction; + priority: number; +} + +export interface ProcessResultEntry { + message: NormalizedMessage; + matchedRules: WebhookRule[]; + actions: GatewayAction[]; +} + +export interface ProcessResult { + source: SurfaceType; + verified: boolean; + entries: ProcessResultEntry[]; + error?: string; +} + +export interface GatewayOptions { + adapters?: SurfaceAdapter[]; + rules?: WebhookRule[]; +} diff --git a/packages/gateway/tsconfig.json b/packages/gateway/tsconfig.json new file mode 100644 index 000000000..222999fef --- /dev/null +++ b/packages/gateway/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/memory/src/adapters/supermemory.ts b/packages/memory/src/adapters/supermemory.ts index 8883be0a5..37879fc0b 100644 --- a/packages/memory/src/adapters/supermemory.ts +++ b/packages/memory/src/adapters/supermemory.ts @@ -118,7 +118,7 @@ export class SupermemoryAdapter implements MemoryAdapter { }; // Remove undefined values - Object.keys(metadata).forEach(key => { + Object.keys(metadata).forEach((key) => { if (metadata[key] === undefined) { delete metadata[key]; } @@ -143,7 +143,7 @@ export class SupermemoryAdapter implements MemoryAdapter { return { success: false, error: `Failed to add memory: ${error}` }; } - const result = await response.json() as { id?: string; documentId?: string }; + const result = (await response.json()) as { id?: string; documentId?: string }; return { success: true, id: result.id ?? result.documentId }; } catch (error) { return { @@ -155,16 +155,16 @@ export class SupermemoryAdapter implements MemoryAdapter { async search(query: MemorySearchQuery): Promise { try { - const filters: Record = {}; + const filterConditions: Array<{ key: string; value: unknown }> = []; if (query.agentId) { - filters.agentId = query.agentId; + filterConditions.push({ key: 'agentId', value: query.agentId }); } if (query.projectId) { - filters.projectId = query.projectId; + filterConditions.push({ key: 'projectId', value: query.projectId }); } if (query.tags && query.tags.length > 0) { - filters.tags = query.tags; + filterConditions.push({ key: 'tags', value: query.tags }); } const body: Record = { @@ -173,8 +173,8 @@ export class SupermemoryAdapter implements MemoryAdapter { minScore: query.minScore ?? 0.5, }; - if (Object.keys(filters).length > 0) { - body.filters = filters; + if (filterConditions.length > 0) { + body.filters = { AND: filterConditions }; } if (this.container) { @@ -192,10 +192,10 @@ export class SupermemoryAdapter implements MemoryAdapter { return []; } - const result = await response.json() as { results?: SupermemorySearchResult[] }; + const result = (await response.json()) as { results?: SupermemorySearchResult[] }; const results = result.results ?? []; - return results.map(doc => this.documentToMemoryEntry(doc)); + return results.map((doc) => this.documentToMemoryEntry(doc)); } catch (error) { console.error('[supermemory] Search error:', error); return []; @@ -216,7 +216,7 @@ export class SupermemoryAdapter implements MemoryAdapter { return null; } - const doc = await response.json() as SupermemoryDocument; + const doc = (await response.json()) as SupermemoryDocument; return this.documentToMemoryEntry(doc); } catch (error) { console.error('[supermemory] Get error:', error); @@ -244,11 +244,7 @@ export class SupermemoryAdapter implements MemoryAdapter { } } - async update( - id: string, - content: string, - options?: Partial - ): Promise { + async update(id: string, content: string, options?: Partial): Promise { try { const body: Record = { content }; @@ -280,11 +276,7 @@ export class SupermemoryAdapter implements MemoryAdapter { } } - async list(options?: { - limit?: number; - agentId?: string; - projectId?: string; - }): Promise { + async list(options?: { limit?: number; agentId?: string; projectId?: string }): Promise { try { const body: Record = { limit: options?.limit ?? 50, @@ -292,12 +284,12 @@ export class SupermemoryAdapter implements MemoryAdapter { sortOrder: 'desc', }; - const filters: Record = {}; - if (options?.agentId) filters.agentId = options.agentId; - if (options?.projectId) filters.projectId = options.projectId; + const filterConditions: Array<{ key: string; value: unknown }> = []; + if (options?.agentId) filterConditions.push({ key: 'agentId', value: options.agentId }); + if (options?.projectId) filterConditions.push({ key: 'projectId', value: options.projectId }); - if (Object.keys(filters).length > 0) { - body.filters = filters; + if (filterConditions.length > 0) { + body.filters = { AND: filterConditions }; } if (this.container) { @@ -314,19 +306,15 @@ export class SupermemoryAdapter implements MemoryAdapter { return []; } - const result = await response.json() as SupermemoryListResponse; - return (result.documents ?? []).map(doc => this.documentToMemoryEntry(doc)); + const result = (await response.json()) as SupermemoryListResponse; + return (result.documents ?? []).map((doc) => this.documentToMemoryEntry(doc)); } catch (error) { console.error('[supermemory] List error:', error); return []; } } - async clear(options?: { - agentId?: string; - projectId?: string; - before?: number; - }): Promise { + async clear(options?: { agentId?: string; projectId?: string; before?: number }): Promise { try { // Supermemory supports bulk delete by container tags // For more specific filtering, we need to list and delete individually @@ -350,9 +338,7 @@ export class SupermemoryAdapter implements MemoryAdapter { projectId: options?.projectId, }); - const toDelete = options?.before - ? memories.filter(m => m.createdAt < options.before!) - : memories; + const toDelete = options?.before ? memories.filter((m) => m.createdAt < options.before!) : memories; for (const memory of toDelete) { await this.delete(memory.id); @@ -411,7 +397,7 @@ export class SupermemoryAdapter implements MemoryAdapter { ...options, headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, ...options.headers, }, signal: controller.signal, @@ -426,9 +412,7 @@ export class SupermemoryAdapter implements MemoryAdapter { /** * Convert a Supermemory document to a MemoryEntry */ - private documentToMemoryEntry( - doc: SupermemoryDocument | SupermemorySearchResult - ): MemoryEntry { + private documentToMemoryEntry(doc: SupermemoryDocument | SupermemorySearchResult): MemoryEntry { const metadata = doc.metadata ?? {}; return {