privateer-agent 0.3.6 → 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/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- 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 +389 -30
- package/src/cli/daemonCli.ts +67 -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 +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- 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 +512 -1
- 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,383 @@
|
|
|
1
|
+
// Headless entry for the messaging channels — `npm run channels` (or
|
|
2
|
+
// `node --env-file=.env --import tsx src/channels/run.ts`). Boots the Pi stack the
|
|
3
|
+
// same way the routines daemon does, then bridges any configured chat platform
|
|
4
|
+
// (Telegram, Slack) to per-conversation agent sessions.
|
|
5
|
+
//
|
|
6
|
+
// AUTHORIZATION MODEL (per channel):
|
|
7
|
+
// - admins — governed by the channel `posture`; the ONLY users whose yes/no
|
|
8
|
+
// resolves an approval prompt.
|
|
9
|
+
// - members — may chat, but every turn runs read-only (writes/bash denied) and
|
|
10
|
+
// they cannot answer approvals.
|
|
11
|
+
// (Legacy `allowFrom` is treated as `admins` for back-compat.)
|
|
12
|
+
//
|
|
13
|
+
// POSTURE (config + restart only — deliberately no in-chat toggle; a restart is the
|
|
14
|
+
// fail-safe reset). Applies to ADMIN turns; members are always read-only:
|
|
15
|
+
// - readonly — deny every write/edit/bash/fetch
|
|
16
|
+
// - approve — each risky action prompts an admin in-chat for yes/no (default)
|
|
17
|
+
// - auto — non-dangerous actions run unattended; dangerous shell + destructive
|
|
18
|
+
// actions still prompt
|
|
19
|
+
// `tools` is the hard tool CEILING an admin can reach (default: read-only).
|
|
20
|
+
//
|
|
21
|
+
// MAINTENANCE: sessions are in-memory, per conversation, and evicted after 30 min
|
|
22
|
+
// idle or at a 500-session cap. A restart resets all live state to config (roles,
|
|
23
|
+
// posture). Every prompt/approval is appended to ~/.privateer/channels-audit.log.
|
|
24
|
+
// Tokens live in config.json in plaintext — protect that file's permissions.
|
|
25
|
+
//
|
|
26
|
+
// Config lives in ~/.privateer/config.json (the same file the daemon reads):
|
|
27
|
+
// {
|
|
28
|
+
// "defaultModel": "openrouter/openai/gpt-4o-mini",
|
|
29
|
+
// "channels": {
|
|
30
|
+
// "model": "openrouter/openai/gpt-4o-mini", // optional shared override
|
|
31
|
+
// "tools": ["read","grep","find","ls"], // optional shared ceiling
|
|
32
|
+
// "posture": "approve", // optional shared default
|
|
33
|
+
// "cwd": "/path/to/project", // optional (else process.cwd())
|
|
34
|
+
// "telegram": { "botToken": "…", "admins": ["<tg-id>"], "members": ["<tg-id>"],
|
|
35
|
+
// "posture": "approve", "tools": ["read","grep","find","ls","edit","write","bash"] },
|
|
36
|
+
// "slack": { "appToken": "xapp-…", "botToken": "xoxb-…", "admins": ["<slack-id>"] },
|
|
37
|
+
// "discord": { "botToken": "…", "admins": ["<discord-id>"], "intents": 37376 },
|
|
38
|
+
// "whatsapp": { "phoneNumberId": "…", "accessToken": "…", "verifyToken": "…",
|
|
39
|
+
// "appSecret": "…?", "port": 8787, "admins": ["<phone-number>"] }
|
|
40
|
+
// }
|
|
41
|
+
// }
|
|
42
|
+
// Each platform block is optional — only the configured ones start. Approvals are
|
|
43
|
+
// text-reply based (universal across all five platforms); mapping them to native
|
|
44
|
+
// buttons (Slack blocks / Discord components / Telegram inline keyboards) is a
|
|
45
|
+
// per-adapter enhancement.
|
|
46
|
+
|
|
47
|
+
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
48
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
49
|
+
|
|
50
|
+
// Read-only default toolset — same rationale as the routines daemon's SAFE_TOOLS:
|
|
51
|
+
// a turn nobody is watching can't mutate the filesystem or shell out. Now that the
|
|
52
|
+
// gate routes approvals into the chat (see below), a user can safely widen this per
|
|
53
|
+
// channel via `channels.tools` — e.g. add "edit","write","bash" and each risky call
|
|
54
|
+
// prompts in-chat for a yes/no.
|
|
55
|
+
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
56
|
+
|
|
57
|
+
// A channel's posture governs how an ADMIN's risky actions are handled (members are
|
|
58
|
+
// always capped to read-only — see effectivePosture). Config + restart only; there
|
|
59
|
+
// is deliberately no in-chat toggle.
|
|
60
|
+
// readonly — deny every write/edit/bash/fetch (reads still run)
|
|
61
|
+
// approve — each risky action prompts in-chat for a yes/no (default)
|
|
62
|
+
// auto — non-dangerous actions run unattended; dangerous shell + destructive
|
|
63
|
+
// actions still prompt
|
|
64
|
+
type Posture = "readonly" | "approve" | "auto";
|
|
65
|
+
const POSTURES: Posture[] = ["readonly", "approve", "auto"];
|
|
66
|
+
|
|
67
|
+
// Bound the live session map so a long-running daemon can't grow without limit or
|
|
68
|
+
// hold stale context forever.
|
|
69
|
+
const MAX_SESSIONS = 500;
|
|
70
|
+
const SESSION_IDLE_MS = 30 * 60 * 1000; // evict a conversation unused for 30 min
|
|
71
|
+
const SESSION_SWEEP_MS = 5 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
// Per-turn context. AsyncLocalStorage carries it across the async tool-call hooks so
|
|
74
|
+
// the SHARED gate knows which conversation to prompt and the EFFECTIVE posture for
|
|
75
|
+
// this turn (which already folds in the triggering user's role), even with several
|
|
76
|
+
// chats running concurrently.
|
|
77
|
+
interface ApprovalContext {
|
|
78
|
+
bridge: { requestApproval(chatId: string, req: any, signal?: AbortSignal): Promise<"allow" | "deny"> };
|
|
79
|
+
chatId: string;
|
|
80
|
+
posture: Posture;
|
|
81
|
+
}
|
|
82
|
+
const approvalCtx = new AsyncLocalStorage<ApprovalContext>();
|
|
83
|
+
|
|
84
|
+
function parseSpec(spec: string): { provider: string; modelId: string } {
|
|
85
|
+
const i = spec.indexOf(":");
|
|
86
|
+
const j = spec.indexOf("/");
|
|
87
|
+
const sep = i === -1 ? j : j === -1 ? i : Math.min(i, j);
|
|
88
|
+
if (sep <= 0) return { provider: spec, modelId: "" };
|
|
89
|
+
return { provider: spec.slice(0, sep), modelId: spec.slice(sep + 1) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function log(msg: string): void {
|
|
93
|
+
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizePosture(v: unknown): Posture | undefined {
|
|
97
|
+
return typeof v === "string" && (POSTURES as string[]).includes(v) ? (v as Posture) : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const { readFileSync, appendFileSync } = await import("node:fs");
|
|
102
|
+
const { join } = await import("node:path");
|
|
103
|
+
const {
|
|
104
|
+
createAgentSessionServices,
|
|
105
|
+
createAgentSessionFromServices,
|
|
106
|
+
SessionManager,
|
|
107
|
+
} = await import("@earendil-works/pi-coding-agent");
|
|
108
|
+
const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
|
|
109
|
+
const { makePermissionGate } = await import("../ext/permissionGate.ts");
|
|
110
|
+
type GateController = import("../ext/permissionGate.ts").GateController;
|
|
111
|
+
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
112
|
+
const { makeAccountProvider } = await import("../providers/account.ts");
|
|
113
|
+
const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
|
|
114
|
+
const { redactText, collectSecrets } = await import("../util/redact.ts");
|
|
115
|
+
const { MessagingBridge } = await import("./bridge.ts");
|
|
116
|
+
type TurnRunner = import("./bridge.ts").TurnRunner;
|
|
117
|
+
const { TelegramAdapter } = await import("./telegram.ts");
|
|
118
|
+
const { SlackAdapter } = await import("./slack.ts");
|
|
119
|
+
const { DiscordAdapter } = await import("./discord.ts");
|
|
120
|
+
const { WhatsAppAdapter } = await import("./whatsapp.ts");
|
|
121
|
+
const { writeChannelsStatus, HEARTBEAT_MS } = await import("./status.ts");
|
|
122
|
+
type ChannelAdapter = import("./types.ts").ChannelAdapter;
|
|
123
|
+
|
|
124
|
+
// ── config ──────────────────────────────────────────────────────────────────
|
|
125
|
+
let cfg: any = {};
|
|
126
|
+
try {
|
|
127
|
+
cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
128
|
+
} catch {
|
|
129
|
+
log(`no config at ${configPath()} — add a channels block (see run.ts header).`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
const ch = cfg.channels ?? {};
|
|
133
|
+
const defaultModel: string = ch.model ?? cfg.defaultModel ?? "openrouter/openai/gpt-4o-mini";
|
|
134
|
+
const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length ? ch.tools : SAFE_TOOLS;
|
|
135
|
+
const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
|
|
136
|
+
const cwd: string = ch.cwd ?? process.cwd();
|
|
137
|
+
const secrets = collectSecrets(cfg.providers);
|
|
138
|
+
const redact = (t: string) => redactText(t, secrets);
|
|
139
|
+
|
|
140
|
+
// Append-only security audit log — every prompt, approval request/decision, and
|
|
141
|
+
// refused non-admin approval, one JSON object per line.
|
|
142
|
+
const auditPath = join(globalDir(), "channels-audit.log");
|
|
143
|
+
const onAudit = (e: any) => {
|
|
144
|
+
try {
|
|
145
|
+
appendFileSync(auditPath, JSON.stringify(e) + "\n");
|
|
146
|
+
} catch {
|
|
147
|
+
/* best effort — never let auditing break a turn */
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ── shared Pi session services (one registry/auth; sessions created per chat) ─
|
|
152
|
+
//
|
|
153
|
+
// The gate reads the EFFECTIVE posture for the current turn from the ALS store
|
|
154
|
+
// (which already folded in the triggering user's role): a member always resolves
|
|
155
|
+
// to "readonly". Mode "default" makes every write/edit/bash/fetch classify as
|
|
156
|
+
// "ask"; "plan" (readonly) hard-denies them. getRemote() is always true so asks
|
|
157
|
+
// route to the in-chat approver rather than a (non-existent) terminal. localAsk
|
|
158
|
+
// stays deny as a fail-closed backstop.
|
|
159
|
+
const posture = () => approvalCtx.getStore()?.posture;
|
|
160
|
+
const gate: GateController = {
|
|
161
|
+
getMode: () => (posture() === "readonly" ? "plan" : "default"),
|
|
162
|
+
setMode: () => {},
|
|
163
|
+
allowlist: [],
|
|
164
|
+
allowedOutsideRoots: [],
|
|
165
|
+
cwd,
|
|
166
|
+
confineToCwd: true,
|
|
167
|
+
getRemote: () => true,
|
|
168
|
+
getNoQuarter: () => posture() === "auto",
|
|
169
|
+
async localAsk() {
|
|
170
|
+
return "deny";
|
|
171
|
+
},
|
|
172
|
+
async remoteAsk(req, signal) {
|
|
173
|
+
const store = approvalCtx.getStore();
|
|
174
|
+
if (!store) return "deny"; // no chat context → fail closed
|
|
175
|
+
if (store.posture === "readonly") return "deny"; // read-only: deny, don't prompt
|
|
176
|
+
return store.bridge.requestApproval(store.chatId, req, signal);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const services = await createAgentSessionServices({
|
|
180
|
+
cwd,
|
|
181
|
+
agentDir: agentDir(),
|
|
182
|
+
resourceLoaderOptions: {
|
|
183
|
+
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const modelCache = new Map<string, any>();
|
|
188
|
+
function resolveModel(spec: string): any {
|
|
189
|
+
let m = modelCache.get(spec);
|
|
190
|
+
if (m === undefined) {
|
|
191
|
+
const { provider, modelId } = parseSpec(spec);
|
|
192
|
+
m = (services.modelRegistry as any).find(provider, modelId) ?? null;
|
|
193
|
+
modelCache.set(spec, m);
|
|
194
|
+
}
|
|
195
|
+
return m;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// One persistent session per conversation, keyed "<platform>:<chatId>" so chat
|
|
199
|
+
// ids can't collide across platforms. A single subscription per session routes
|
|
200
|
+
// streamed text to whichever turn is running (safe: the bridge serializes turns
|
|
201
|
+
// per chat).
|
|
202
|
+
interface SessionEntry {
|
|
203
|
+
session: any;
|
|
204
|
+
holder: { onText: (t: string) => void; error?: string };
|
|
205
|
+
lastUsed: number;
|
|
206
|
+
}
|
|
207
|
+
const sessions = new Map<string, SessionEntry>();
|
|
208
|
+
|
|
209
|
+
async function sessionFor(key: string, model: any, tools: string[]): Promise<SessionEntry> {
|
|
210
|
+
let entry = sessions.get(key);
|
|
211
|
+
if (!entry) {
|
|
212
|
+
const { session } = await createAgentSessionFromServices({
|
|
213
|
+
services,
|
|
214
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
215
|
+
model,
|
|
216
|
+
tools,
|
|
217
|
+
} as any);
|
|
218
|
+
const adapter = createEngineEventAdapter();
|
|
219
|
+
const holder: SessionEntry["holder"] = { onText: () => {}, error: undefined };
|
|
220
|
+
session.subscribe((ev: any) => {
|
|
221
|
+
for (const ee of adapter.toEngineEvents(ev)) {
|
|
222
|
+
if (ee.type === "text") holder.onText(ee.text);
|
|
223
|
+
else if (ee.type === "error") holder.error = ee.error;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
entry = { session, holder, lastUsed: Date.now() };
|
|
227
|
+
sessions.set(key, entry);
|
|
228
|
+
// Hard cap: evict the least-recently-used conversation if we're over budget.
|
|
229
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
230
|
+
let oldestKey: string | undefined;
|
|
231
|
+
let oldest = Infinity;
|
|
232
|
+
for (const [k, e] of sessions) {
|
|
233
|
+
if (e.lastUsed < oldest) {
|
|
234
|
+
oldest = e.lastUsed;
|
|
235
|
+
oldestKey = k;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (oldestKey && oldestKey !== key) sessions.delete(oldestKey);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
entry.lastUsed = Date.now();
|
|
242
|
+
return entry;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Idle sweep: drop conversations untouched for SESSION_IDLE_MS. A dropped chat's
|
|
246
|
+
// next message just starts a fresh session (memory reset), so this is safe — turns
|
|
247
|
+
// are serialized, so an in-flight turn keeps its session recently-used.
|
|
248
|
+
const sweep = setInterval(() => {
|
|
249
|
+
const cutoff = Date.now() - SESSION_IDLE_MS;
|
|
250
|
+
for (const [k, e] of sessions) if (e.lastUsed < cutoff) sessions.delete(k);
|
|
251
|
+
}, SESSION_SWEEP_MS);
|
|
252
|
+
sweep.unref?.();
|
|
253
|
+
|
|
254
|
+
// ── build a bridge per configured platform ───────────────────────────────────
|
|
255
|
+
const bridges: { stop(): void }[] = [];
|
|
256
|
+
// Platforms with a live bridge — written to the heartbeat file so the app's
|
|
257
|
+
// channels manager (running on the daemon's relay, a separate process) can show
|
|
258
|
+
// a live/offline badge without talking to this process.
|
|
259
|
+
const startedPlatforms: string[] = [];
|
|
260
|
+
|
|
261
|
+
async function startChannel(platform: string, adapter: ChannelAdapter, block: any) {
|
|
262
|
+
// Roles. Legacy `allowFrom` is treated as admins (its prior meaning: the sole
|
|
263
|
+
// fully-capable users). Members are chat-only + read-only and can't approve.
|
|
264
|
+
const admins = new Set<string>((block.admins ?? block.allowFrom ?? []).map(String));
|
|
265
|
+
const members = new Set<string>((block.members ?? []).map(String));
|
|
266
|
+
if (admins.size === 0 && members.size === 0) {
|
|
267
|
+
log(`${platform}: no admins/members configured — skipping (fail-closed).`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const modelSpec: string = block.model ?? defaultModel;
|
|
272
|
+
const model = resolveModel(modelSpec);
|
|
273
|
+
if (!model) {
|
|
274
|
+
log(`${platform}: model "${modelSpec}" not found — skipping. Check the spec / provider keys.`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Per-channel tool ceiling + posture. The ceiling is what admins CAN reach; the
|
|
278
|
+
// gate caps members to read-only regardless.
|
|
279
|
+
const chTools: string[] = Array.isArray(block.tools) && block.tools.length ? block.tools : defaultTools;
|
|
280
|
+
const chPosture: Posture = normalizePosture(block.posture) ?? defaultPosture;
|
|
281
|
+
|
|
282
|
+
// The runner references its own bridge (for approval routing via ALS), so the
|
|
283
|
+
// bridge is declared first and assigned just below.
|
|
284
|
+
let bridge: InstanceType<typeof MessagingBridge>;
|
|
285
|
+
const runTurn: TurnRunner = async (chatId, text, onText, _signal, meta) => {
|
|
286
|
+
const { session, holder } = await sessionFor(`${platform}:${chatId}`, model, chTools);
|
|
287
|
+
holder.onText = onText;
|
|
288
|
+
holder.error = undefined;
|
|
289
|
+
// A member's turn is always read-only, whatever the channel posture.
|
|
290
|
+
const effectivePosture: Posture = meta.isAdmin ? chPosture : "readonly";
|
|
291
|
+
try {
|
|
292
|
+
await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () => session.prompt(text));
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
295
|
+
} finally {
|
|
296
|
+
holder.onText = () => {};
|
|
297
|
+
}
|
|
298
|
+
return holder.error ? { ok: false, error: holder.error } : { ok: true };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
bridge = new MessagingBridge({
|
|
302
|
+
adapter,
|
|
303
|
+
runTurn,
|
|
304
|
+
isAllowed: (m) => admins.has(m.userId) || members.has(m.userId),
|
|
305
|
+
isAdmin: (m) => admins.has(m.userId),
|
|
306
|
+
redact,
|
|
307
|
+
onLog: log,
|
|
308
|
+
onAudit: (e) => onAudit({ ...e, platform }),
|
|
309
|
+
});
|
|
310
|
+
await bridge.start();
|
|
311
|
+
bridges.push(bridge);
|
|
312
|
+
startedPlatforms.push(platform);
|
|
313
|
+
log(
|
|
314
|
+
`${platform} up — model ${modelSpec}, ceiling [${chTools.join(", ")}], posture ${chPosture}, ` +
|
|
315
|
+
`${admins.size} admin(s)/${members.size} member(s), cwd ${cwd}.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (ch.telegram?.botToken) {
|
|
320
|
+
await startChannel(
|
|
321
|
+
"telegram",
|
|
322
|
+
new TelegramAdapter({ botToken: ch.telegram.botToken, onLog: log }),
|
|
323
|
+
ch.telegram,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (ch.slack?.appToken && ch.slack?.botToken) {
|
|
327
|
+
await startChannel(
|
|
328
|
+
"slack",
|
|
329
|
+
new SlackAdapter({ appToken: ch.slack.appToken, botToken: ch.slack.botToken, onLog: log }),
|
|
330
|
+
ch.slack,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (ch.discord?.botToken) {
|
|
334
|
+
await startChannel(
|
|
335
|
+
"discord",
|
|
336
|
+
new DiscordAdapter({ botToken: ch.discord.botToken, intents: ch.discord.intents, onLog: log }),
|
|
337
|
+
ch.discord,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (ch.whatsapp?.phoneNumberId && ch.whatsapp?.accessToken && ch.whatsapp?.verifyToken) {
|
|
341
|
+
await startChannel(
|
|
342
|
+
"whatsapp",
|
|
343
|
+
new WhatsAppAdapter({
|
|
344
|
+
phoneNumberId: ch.whatsapp.phoneNumberId,
|
|
345
|
+
accessToken: ch.whatsapp.accessToken,
|
|
346
|
+
verifyToken: ch.whatsapp.verifyToken,
|
|
347
|
+
appSecret: ch.whatsapp.appSecret,
|
|
348
|
+
port: ch.whatsapp.port,
|
|
349
|
+
path: ch.whatsapp.path,
|
|
350
|
+
onLog: log,
|
|
351
|
+
}),
|
|
352
|
+
ch.whatsapp,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (bridges.length === 0) {
|
|
357
|
+
log("no channels started — configure a channels.<platform> block in config.json.");
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Heartbeat: announce the live platforms now and refresh on a cadence so the app's
|
|
362
|
+
// channels manager can tell running from merely-configured. A stale/absent file
|
|
363
|
+
// reads as offline (see channels/status.ts).
|
|
364
|
+
writeChannelsStatus(startedPlatforms);
|
|
365
|
+
const heartbeat = setInterval(() => writeChannelsStatus(startedPlatforms), HEARTBEAT_MS);
|
|
366
|
+
heartbeat.unref?.();
|
|
367
|
+
|
|
368
|
+
const shutdown = () => {
|
|
369
|
+
log("shutting down");
|
|
370
|
+
clearInterval(sweep);
|
|
371
|
+
clearInterval(heartbeat);
|
|
372
|
+
writeChannelsStatus([]); // clear presence immediately, don't wait for staleness
|
|
373
|
+
for (const b of bridges) b.stop();
|
|
374
|
+
process.exit(0);
|
|
375
|
+
};
|
|
376
|
+
process.on("SIGINT", shutdown);
|
|
377
|
+
process.on("SIGTERM", shutdown);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
main().catch((err) => {
|
|
381
|
+
process.stderr.write(`${err?.stack ?? err}\n`);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
});
|
|
@@ -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
|
+
}
|