privateer-agent 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +35 -5
- package/extensions/privateer-brand.ts +213 -34
- package/extensions/privateer-context.ts +59 -0
- package/extensions/privateer-gate.ts +351 -6
- package/extensions/privateer-posture.ts +18 -2
- package/extensions/privateer-privacy.ts +50 -1
- package/package.json +4 -1
- package/src/auth/privateer.ts +151 -19
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +414 -28
- package/src/cli/daemonCli.ts +67 -0
- package/src/config/version.ts +16 -0
- package/src/context.ts +171 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +522 -34
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/providers/account.ts +20 -12
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +524 -0
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Slack channel adapter — templated off the Telegram one against the same
|
|
2
|
+
// ChannelAdapter interface. Uses Socket Mode (an outbound WebSocket), so like
|
|
3
|
+
// Telegram long-poll it needs NO public inbound endpoint: it works from a laptop
|
|
4
|
+
// behind NAT. Zero new dependencies — `ws` is already a dep (see relayClient.ts)
|
|
5
|
+
// and the rest is the Slack Web API over fetch.
|
|
6
|
+
//
|
|
7
|
+
// Slack app setup (one-time):
|
|
8
|
+
// - Enable Socket Mode → generate an app-level token `xapp-…` (scope
|
|
9
|
+
// connections:write). That's `appToken`.
|
|
10
|
+
// - Bot token `xoxb-…` with scope `chat:write`. That's `botToken`.
|
|
11
|
+
// - Event Subscriptions → subscribe the bot to `message.im` (DMs). Then DM the bot.
|
|
12
|
+
//
|
|
13
|
+
// Everything above the adapter (allowlist, serialization, redaction, chunking) is
|
|
14
|
+
// MessagingBridge, reused verbatim.
|
|
15
|
+
|
|
16
|
+
import WebSocket from "ws";
|
|
17
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
const API = "https://slack.com/api";
|
|
20
|
+
const RECONNECT_MS = 3000;
|
|
21
|
+
const HTTP_TIMEOUT_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
// The Events-API payload we read (Socket Mode wraps this in an envelope).
|
|
24
|
+
interface SlackEventPayload {
|
|
25
|
+
event?: {
|
|
26
|
+
type?: string;
|
|
27
|
+
subtype?: string;
|
|
28
|
+
bot_id?: string;
|
|
29
|
+
user?: string;
|
|
30
|
+
text?: string;
|
|
31
|
+
channel?: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Pure mapping: an Events-API payload → normalized InboundMessage, or null for
|
|
36
|
+
// anything we shouldn't treat as a user prompt. Critically filters out our own
|
|
37
|
+
// bot's messages (`bot_id`) and non-plain events (`subtype`: edits, joins, etc.)
|
|
38
|
+
// so the agent never talks to itself. Extracted so it's unit-testable.
|
|
39
|
+
export function messageFromSlackEvent(payload: SlackEventPayload): InboundMessage | null {
|
|
40
|
+
const ev = payload.event;
|
|
41
|
+
if (!ev || ev.type !== "message") return null;
|
|
42
|
+
if (ev.bot_id || ev.subtype) return null; // bot echoes / edits / system messages
|
|
43
|
+
if (typeof ev.text !== "string" || !ev.channel || !ev.user) return null;
|
|
44
|
+
return { chatId: ev.channel, userId: ev.user, text: ev.text };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SlackOptions {
|
|
48
|
+
appToken: string; // xapp-… (Socket Mode connection)
|
|
49
|
+
botToken: string; // xoxb-… (chat:write)
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
onLog?: (msg: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SlackAdapter implements ChannelAdapter {
|
|
55
|
+
readonly name = "slack";
|
|
56
|
+
private readonly appToken: string;
|
|
57
|
+
private readonly botToken: string;
|
|
58
|
+
private readonly fetchImpl: typeof fetch;
|
|
59
|
+
private readonly onLog?: (msg: string) => void;
|
|
60
|
+
private ws?: WebSocket;
|
|
61
|
+
private running = false;
|
|
62
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
63
|
+
private onMessage?: (m: InboundMessage) => void;
|
|
64
|
+
|
|
65
|
+
constructor(opts: SlackOptions) {
|
|
66
|
+
this.appToken = opts.appToken;
|
|
67
|
+
this.botToken = opts.botToken;
|
|
68
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
69
|
+
this.onLog = opts.onLog;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
73
|
+
if (this.running) return;
|
|
74
|
+
this.running = true;
|
|
75
|
+
this.onMessage = onMessage;
|
|
76
|
+
await this.connect();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
stop(): void {
|
|
80
|
+
this.running = false;
|
|
81
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
82
|
+
try {
|
|
83
|
+
this.ws?.close();
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
this.ws = undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async sendText(chatId: string, text: string): Promise<void> {
|
|
91
|
+
if (!text) return;
|
|
92
|
+
const res = await this.web("chat.postMessage", { channel: chatId, text });
|
|
93
|
+
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
|
94
|
+
if (!data.ok) this.log(`chat.postMessage failed: ${data.error ?? "unknown"}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Slack has no bot "typing" over the Web API, so sendTyping is intentionally
|
|
98
|
+
// absent — the bridge guards `sendTyping?.()`.
|
|
99
|
+
|
|
100
|
+
private log(msg: string): void {
|
|
101
|
+
this.onLog?.(`slack: ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Mint a fresh Socket Mode WSS URL and connect. Slack rotates the URL, sending a
|
|
105
|
+
// `disconnect` frame before it does; we just reconnect (re-minting) each time.
|
|
106
|
+
private async connect(): Promise<void> {
|
|
107
|
+
if (!this.running) return;
|
|
108
|
+
let url: string;
|
|
109
|
+
try {
|
|
110
|
+
const res = await this.web("apps.connections.open", {}, this.appToken);
|
|
111
|
+
const data = (await res.json()) as { ok?: boolean; url?: string; error?: string };
|
|
112
|
+
if (!data.ok || !data.url) throw new Error(data.error ?? "no url");
|
|
113
|
+
url = data.url;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
this.log(`apps.connections.open failed: ${e instanceof Error ? e.message : String(e)} — retrying`);
|
|
116
|
+
return this.scheduleReconnect();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ws = new WebSocket(url);
|
|
120
|
+
this.ws = ws;
|
|
121
|
+
ws.on("message", (raw) => this.handleFrame(ws, raw));
|
|
122
|
+
ws.on("close", () => {
|
|
123
|
+
if (this.ws === ws) this.ws = undefined;
|
|
124
|
+
if (this.running) this.scheduleReconnect();
|
|
125
|
+
});
|
|
126
|
+
ws.on("error", (err: Error) => this.log(`ws error: ${err?.message ?? err}`));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private scheduleReconnect(): void {
|
|
130
|
+
if (!this.running || this.reconnectTimer) return;
|
|
131
|
+
this.reconnectTimer = setTimeout(() => {
|
|
132
|
+
this.reconnectTimer = undefined;
|
|
133
|
+
void this.connect();
|
|
134
|
+
}, RECONNECT_MS);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private handleFrame(ws: WebSocket, raw: WebSocket.RawData): void {
|
|
138
|
+
let frame: { type?: string; envelope_id?: string; payload?: SlackEventPayload };
|
|
139
|
+
try {
|
|
140
|
+
frame = JSON.parse(raw.toString());
|
|
141
|
+
} catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Ack EVERY envelope immediately (Slack retries if not acked within 3s), before
|
|
145
|
+
// doing any work — onMessage only queues an async turn.
|
|
146
|
+
if (frame.envelope_id) {
|
|
147
|
+
try {
|
|
148
|
+
ws.send(JSON.stringify({ envelope_id: frame.envelope_id }));
|
|
149
|
+
} catch {
|
|
150
|
+
/* socket dying */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (frame.type === "disconnect") {
|
|
154
|
+
// URL refresh / server drain — drop this socket; `close` reconnects.
|
|
155
|
+
try {
|
|
156
|
+
ws.close();
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (frame.type === "events_api" && frame.payload) {
|
|
163
|
+
const m = messageFromSlackEvent(frame.payload);
|
|
164
|
+
if (m) this.onMessage?.(m);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async web(method: string, body: Record<string, unknown>, token = this.botToken): Promise<Response> {
|
|
169
|
+
return this.fetchImpl(`${API}/${method}`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "content-type": "application/json; charset=utf-8", authorization: `Bearer ${token}` },
|
|
172
|
+
body: JSON.stringify(body),
|
|
173
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Channels daemon heartbeat — the one bit of shared state between the channels
|
|
2
|
+
// daemon (channels/run.ts, the WRITER) and the always-on management relay
|
|
3
|
+
// (daemon/index.ts → channelsControl, the READER).
|
|
4
|
+
//
|
|
5
|
+
// The two run as SEPARATE processes, so the manager can't ask the channels
|
|
6
|
+
// daemon directly whether it's live. Instead the channels daemon writes a small
|
|
7
|
+
// heartbeat file with the platforms it's currently serving; the reader treats a
|
|
8
|
+
// FRESH heartbeat as "running" and a stale/absent one as "not running". This is
|
|
9
|
+
// best-effort presence, never a dependency: a missing file just means the app
|
|
10
|
+
// shows the platform as configured-but-offline.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { globalDir } from "../config/paths.ts";
|
|
15
|
+
|
|
16
|
+
// Heartbeat cadence + freshness window. The channels daemon rewrites the file
|
|
17
|
+
// every HEARTBEAT_MS; a heartbeat older than STALE_MS is treated as dead (the
|
|
18
|
+
// process exited without clearing it, or wedged).
|
|
19
|
+
export const HEARTBEAT_MS = 30_000;
|
|
20
|
+
const STALE_MS = 90_000; // 3 missed beats
|
|
21
|
+
|
|
22
|
+
interface ChannelsStatus {
|
|
23
|
+
pid: number;
|
|
24
|
+
at: string; // ISO timestamp of the last heartbeat
|
|
25
|
+
platforms: string[]; // platforms with a live bridge this beat
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statusPath(): string {
|
|
29
|
+
return join(globalDir(), "channels-status.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// WRITER: record which platforms have a live bridge right now. Called on start
|
|
33
|
+
// and on each heartbeat tick. Best-effort — a failed write never breaks a turn.
|
|
34
|
+
export function writeChannelsStatus(platforms: string[]): void {
|
|
35
|
+
try {
|
|
36
|
+
const status: ChannelsStatus = { pid: process.pid, at: new Date().toISOString(), platforms };
|
|
37
|
+
writeFileSync(statusPath(), JSON.stringify(status));
|
|
38
|
+
} catch {
|
|
39
|
+
/* best effort */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// READER: the set of platforms the channels daemon is currently serving, or an
|
|
44
|
+
// empty set when the daemon is down / the heartbeat is stale. Never throws.
|
|
45
|
+
export function readRunningPlatforms(): Set<string> {
|
|
46
|
+
try {
|
|
47
|
+
const status = JSON.parse(readFileSync(statusPath(), "utf8")) as ChannelsStatus;
|
|
48
|
+
const age = Date.now() - Date.parse(status.at);
|
|
49
|
+
if (!Number.isFinite(age) || age > STALE_MS) return new Set();
|
|
50
|
+
return new Set(Array.isArray(status.platforms) ? status.platforms.map(String) : []);
|
|
51
|
+
} catch {
|
|
52
|
+
return new Set();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Telegram channel adapter — the least-friction platform to prototype: a bot token
|
|
2
|
+
// from @BotFather, plain HTTPS, long-poll `getUpdates` (no public endpoint, no
|
|
3
|
+
// gateway socket, no app review). Zero new dependencies — the Bot API is just JSON
|
|
4
|
+
// over fetch.
|
|
5
|
+
//
|
|
6
|
+
// This file is the ONLY platform-specific code. A Slack/Discord/WhatsApp adapter
|
|
7
|
+
// implements the same ChannelAdapter interface and reuses MessagingBridge verbatim.
|
|
8
|
+
|
|
9
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const API = "https://api.telegram.org";
|
|
12
|
+
// Long-poll seconds we ask Telegram to hold the connection; the HTTP timeout below
|
|
13
|
+
// must exceed this or we'd cancel every idle poll.
|
|
14
|
+
const POLL_SECONDS = 50;
|
|
15
|
+
const POLL_HTTP_TIMEOUT_MS = (POLL_SECONDS + 10) * 1000;
|
|
16
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
17
|
+
const BACKOFF_MS = 3000;
|
|
18
|
+
|
|
19
|
+
// Shape of the bits of a Telegram Update we read.
|
|
20
|
+
interface TgUpdate {
|
|
21
|
+
update_id: number;
|
|
22
|
+
message?: {
|
|
23
|
+
text?: string;
|
|
24
|
+
chat?: { id?: number | string };
|
|
25
|
+
from?: { id?: number | string; username?: string; first_name?: string };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure mapping Update → normalized InboundMessage (null for non-text / malformed
|
|
30
|
+
// updates). Extracted so it's unit-testable without the polling loop.
|
|
31
|
+
export function messageFromUpdate(upd: TgUpdate): InboundMessage | null {
|
|
32
|
+
const msg = upd.message;
|
|
33
|
+
if (!msg || typeof msg.text !== "string") return null;
|
|
34
|
+
const chatId = msg.chat?.id;
|
|
35
|
+
if (chatId === undefined || chatId === null) return null;
|
|
36
|
+
const from = msg.from;
|
|
37
|
+
return {
|
|
38
|
+
chatId: String(chatId),
|
|
39
|
+
userId: String(from?.id ?? chatId),
|
|
40
|
+
userName: from?.username || from?.first_name,
|
|
41
|
+
text: msg.text,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TelegramOptions {
|
|
46
|
+
botToken: string;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
onLog?: (msg: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class TelegramAdapter implements ChannelAdapter {
|
|
52
|
+
readonly name = "telegram";
|
|
53
|
+
private readonly token: string;
|
|
54
|
+
private readonly fetchImpl: typeof fetch;
|
|
55
|
+
private readonly onLog?: (msg: string) => void;
|
|
56
|
+
private offset = 0; // next update_id to fetch from
|
|
57
|
+
private running = false;
|
|
58
|
+
private poll?: AbortController;
|
|
59
|
+
|
|
60
|
+
constructor(opts: TelegramOptions) {
|
|
61
|
+
this.token = opts.botToken;
|
|
62
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
63
|
+
this.onLog = opts.onLog;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
67
|
+
if (this.running) return;
|
|
68
|
+
this.running = true;
|
|
69
|
+
void this.loop(onMessage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop(): void {
|
|
73
|
+
this.running = false;
|
|
74
|
+
this.poll?.abort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async sendText(chatId: string, text: string): Promise<void> {
|
|
78
|
+
if (!text) return;
|
|
79
|
+
await this.call("sendMessage", { chat_id: chatId, text }, SEND_TIMEOUT_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
sendTyping(chatId: string): void {
|
|
83
|
+
// Best-effort; failures are cosmetic.
|
|
84
|
+
void this.call("sendChatAction", { chat_id: chatId, action: "typing" }, 10_000).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private log(msg: string): void {
|
|
88
|
+
this.onLog?.(`telegram: ${msg}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async loop(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
92
|
+
while (this.running) {
|
|
93
|
+
this.poll = new AbortController();
|
|
94
|
+
try {
|
|
95
|
+
const res = await this.call(
|
|
96
|
+
"getUpdates",
|
|
97
|
+
{ timeout: POLL_SECONDS, offset: this.offset, allowed_updates: ["message"] },
|
|
98
|
+
POLL_HTTP_TIMEOUT_MS,
|
|
99
|
+
this.poll.signal,
|
|
100
|
+
);
|
|
101
|
+
const data = (await res.json()) as { ok?: boolean; result?: TgUpdate[]; description?: string };
|
|
102
|
+
if (!data.ok) {
|
|
103
|
+
this.log(`getUpdates not ok: ${data.description ?? "unknown"}`);
|
|
104
|
+
await this.sleep(BACKOFF_MS);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const upd of data.result ?? []) {
|
|
108
|
+
this.offset = Math.max(this.offset, upd.update_id + 1);
|
|
109
|
+
const m = messageFromUpdate(upd);
|
|
110
|
+
if (m) onMessage(m);
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!this.running) break; // stop() aborted the poll — expected
|
|
114
|
+
this.log(`poll error: ${e instanceof Error ? e.message : String(e)} — retrying`);
|
|
115
|
+
await this.sleep(BACKOFF_MS);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async call(
|
|
121
|
+
method: string,
|
|
122
|
+
body: Record<string, unknown>,
|
|
123
|
+
timeoutMs: number,
|
|
124
|
+
extraSignal?: AbortSignal,
|
|
125
|
+
): Promise<Response> {
|
|
126
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
127
|
+
const signal = extraSignal ? AbortSignal.any([extraSignal, timeout]) : timeout;
|
|
128
|
+
return this.fetchImpl(`${API}/bot${this.token}/${method}`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "content-type": "application/json" },
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private sleep(ms: number): Promise<void> {
|
|
137
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Messaging-channel plumbing — the inbound/conversational counterpart to the relay.
|
|
2
|
+
//
|
|
3
|
+
// The relay (src/remote/*) lets the Privateer app drive this terminal. A messaging
|
|
4
|
+
// channel (Telegram/Slack/Discord/WhatsApp) is the SAME idea with a different
|
|
5
|
+
// transport: a user's message becomes a prompt, the agent's reply goes back to the
|
|
6
|
+
// channel. `ChannelAdapter` is the one platform-specific seam; everything above it
|
|
7
|
+
// (allowlist, per-chat serialization, redaction, chunking) lives in MessagingBridge
|
|
8
|
+
// and is shared across every platform.
|
|
9
|
+
|
|
10
|
+
// A normalized inbound message from any platform. `chatId` scopes the conversation
|
|
11
|
+
// (so each thread keeps its own agent session); `userId` is who sent it (allowlist
|
|
12
|
+
// key). Both are strings so platform-native numeric ids don't leak type differences
|
|
13
|
+
// upward.
|
|
14
|
+
export interface InboundMessage {
|
|
15
|
+
chatId: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
userName?: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The per-platform transport. Implementations own the connection (long-poll,
|
|
22
|
+
// gateway socket, or inbound webhook) and the wire format; they surface normalized
|
|
23
|
+
// messages and accept plain text back. Keep them DUMB: no allowlist, no redaction,
|
|
24
|
+
// no chunking — the bridge does all of that so it's written once and tested once.
|
|
25
|
+
export interface ChannelAdapter {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
// Begin receiving. Call `onMessage` for every inbound user message.
|
|
28
|
+
start(onMessage: (m: InboundMessage) => void): Promise<void>;
|
|
29
|
+
// Send a reply to a conversation. The bridge guarantees `text` is already
|
|
30
|
+
// redacted and within the platform's per-message length cap.
|
|
31
|
+
sendText(chatId: string, text: string): Promise<void>;
|
|
32
|
+
// Optional "the agent is working" affordance (typing indicator). Best-effort.
|
|
33
|
+
sendTyping?(chatId: string): void;
|
|
34
|
+
// Stop receiving and release the connection.
|
|
35
|
+
stop(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// WhatsApp channel adapter — the official Meta Cloud API. Unlike the socket/
|
|
2
|
+
// long-poll adapters, Cloud API delivers inbound messages by POSTing to a webhook,
|
|
3
|
+
// so this adapter is the INBOUND-WEBHOOK shape: start() runs a small HTTP listener
|
|
4
|
+
// (Node's built-in `http`, zero new deps) and sends via the Graph API over fetch.
|
|
5
|
+
//
|
|
6
|
+
// TRADEOFF: a webhook needs a PUBLIC HTTPS URL for Meta to reach — so unlike the
|
|
7
|
+
// others this can't run purely behind NAT. Expose the port with a tunnel
|
|
8
|
+
// (cloudflared / ngrok) or host it, and register that URL + the verify token in the
|
|
9
|
+
// Meta app's webhook config. This is inherent to the Cloud API, not the adapter.
|
|
10
|
+
//
|
|
11
|
+
// Meta app setup (one-time):
|
|
12
|
+
// - WhatsApp product → note the phone number id → `phoneNumberId`, and a
|
|
13
|
+
// (system-user) access token → `accessToken`.
|
|
14
|
+
// - Webhooks → callback URL = https://<your-tunnel>/webhook, verify token =
|
|
15
|
+
// whatever you set as `verifyToken`; subscribe to the `messages` field.
|
|
16
|
+
// - Optional but recommended: set `appSecret` to verify Meta's X-Hub-Signature-256.
|
|
17
|
+
//
|
|
18
|
+
// Everything above the adapter (allowlist, serialization, redaction, chunking) is
|
|
19
|
+
// MessagingBridge, reused verbatim.
|
|
20
|
+
|
|
21
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
22
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
const GRAPH = "https://graph.facebook.com/v21.0";
|
|
26
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
interface WaWebhookBody {
|
|
29
|
+
entry?: Array<{
|
|
30
|
+
changes?: Array<{
|
|
31
|
+
value?: {
|
|
32
|
+
// Inbound user messages. (Delivery/read receipts arrive under `statuses`,
|
|
33
|
+
// which we deliberately ignore.)
|
|
34
|
+
messages?: Array<{ from?: string; type?: string; text?: { body?: string } }>;
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pure mapping: a webhook POST body → the text messages it carries. A single POST
|
|
41
|
+
// can batch several. Non-text messages and status callbacks yield nothing.
|
|
42
|
+
// Extracted so it's unit-testable without an HTTP server.
|
|
43
|
+
export function messagesFromWebhook(body: WaWebhookBody): InboundMessage[] {
|
|
44
|
+
const out: InboundMessage[] = [];
|
|
45
|
+
for (const entry of body.entry ?? []) {
|
|
46
|
+
for (const change of entry.changes ?? []) {
|
|
47
|
+
for (const msg of change.value?.messages ?? []) {
|
|
48
|
+
if (msg.type !== "text") continue;
|
|
49
|
+
const text = msg.text?.body;
|
|
50
|
+
if (!msg.from || typeof text !== "string") continue;
|
|
51
|
+
// The sender's phone number is both the conversation and the user id.
|
|
52
|
+
out.push({ chatId: msg.from, userId: msg.from, text });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WhatsAppOptions {
|
|
60
|
+
phoneNumberId: string;
|
|
61
|
+
accessToken: string;
|
|
62
|
+
verifyToken: string;
|
|
63
|
+
appSecret?: string; // enables X-Hub-Signature-256 verification when set
|
|
64
|
+
port?: number;
|
|
65
|
+
path?: string;
|
|
66
|
+
fetchImpl?: typeof fetch;
|
|
67
|
+
onLog?: (msg: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class WhatsAppAdapter implements ChannelAdapter {
|
|
71
|
+
readonly name = "whatsapp";
|
|
72
|
+
private readonly opts: WhatsAppOptions;
|
|
73
|
+
private readonly fetchImpl: typeof fetch;
|
|
74
|
+
private readonly onLog?: (msg: string) => void;
|
|
75
|
+
private readonly port: number;
|
|
76
|
+
private readonly path: string;
|
|
77
|
+
private server?: Server;
|
|
78
|
+
private onMessage?: (m: InboundMessage) => void;
|
|
79
|
+
|
|
80
|
+
constructor(opts: WhatsAppOptions) {
|
|
81
|
+
this.opts = opts;
|
|
82
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
83
|
+
this.onLog = opts.onLog;
|
|
84
|
+
this.port = opts.port ?? 8787;
|
|
85
|
+
this.path = opts.path ?? "/webhook";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
89
|
+
this.onMessage = onMessage;
|
|
90
|
+
this.server = createServer((req, res) => this.handle(req, res));
|
|
91
|
+
await new Promise<void>((resolve) => this.server!.listen(this.port, () => resolve()));
|
|
92
|
+
this.log(`webhook listening on :${this.port}${this.path} — expose it publicly for Meta to reach.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stop(): void {
|
|
96
|
+
this.server?.close();
|
|
97
|
+
this.server = undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async sendText(chatId: string, text: string): Promise<void> {
|
|
101
|
+
if (!text) return;
|
|
102
|
+
const res = await this.fetchImpl(`${GRAPH}/${this.opts.phoneNumberId}/messages`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { authorization: `Bearer ${this.opts.accessToken}`, "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ messaging_product: "whatsapp", to: chatId, type: "text", text: { body: text } }),
|
|
106
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) this.log(`send failed: HTTP ${res.status}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private log(msg: string): void {
|
|
112
|
+
this.onLog?.(`whatsapp: ${msg}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private handle(req: IncomingMessage, res: ServerResponse): void {
|
|
116
|
+
const url = new URL(req.url ?? "/", `http://localhost:${this.port}`);
|
|
117
|
+
if (url.pathname !== this.path) {
|
|
118
|
+
res.writeHead(404);
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// GET: Meta's one-time verification challenge.
|
|
124
|
+
if (req.method === "GET") {
|
|
125
|
+
const mode = url.searchParams.get("hub.mode");
|
|
126
|
+
const token = url.searchParams.get("hub.verify_token");
|
|
127
|
+
const challenge = url.searchParams.get("hub.challenge") ?? "";
|
|
128
|
+
if (mode === "subscribe" && token === this.opts.verifyToken) {
|
|
129
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
130
|
+
res.end(challenge);
|
|
131
|
+
} else {
|
|
132
|
+
res.writeHead(403);
|
|
133
|
+
res.end();
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// POST: an inbound message batch.
|
|
139
|
+
if (req.method === "POST") {
|
|
140
|
+
let raw = "";
|
|
141
|
+
req.on("data", (c) => (raw += c));
|
|
142
|
+
req.on("end", () => {
|
|
143
|
+
if (!this.verifySignature(req, raw)) {
|
|
144
|
+
this.log("rejected webhook: bad or missing X-Hub-Signature-256");
|
|
145
|
+
res.writeHead(401);
|
|
146
|
+
res.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Ack fast — Meta retries the delivery on any non-200.
|
|
150
|
+
res.writeHead(200);
|
|
151
|
+
res.end();
|
|
152
|
+
let body: WaWebhookBody;
|
|
153
|
+
try {
|
|
154
|
+
body = JSON.parse(raw);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const m of messagesFromWebhook(body)) this.onMessage?.(m);
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
res.writeHead(405);
|
|
164
|
+
res.end();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Verify Meta's HMAC signature when an app secret is configured. Absent → skipped
|
|
168
|
+
// (documented tradeoff); the allowlist still gates who may drive the agent.
|
|
169
|
+
private verifySignature(req: IncomingMessage, raw: string): boolean {
|
|
170
|
+
if (!this.opts.appSecret) return true;
|
|
171
|
+
const header = req.headers["x-hub-signature-256"];
|
|
172
|
+
if (typeof header !== "string") return false;
|
|
173
|
+
const expected = "sha256=" + createHmac("sha256", this.opts.appSecret).update(raw).digest("hex");
|
|
174
|
+
const a = Buffer.from(header);
|
|
175
|
+
const b = Buffer.from(expected);
|
|
176
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
177
|
+
}
|
|
178
|
+
}
|