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
package/src/auth/privateer.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync }
|
|
|
16
16
|
import { hostname, userInfo } from "node:os";
|
|
17
17
|
import { globalDir, credentialsPath } from "../config/paths.ts";
|
|
18
18
|
import { isAccountCapCode } from "../engine/errors.ts";
|
|
19
|
+
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
20
|
+
import { pinAccountSignKey, clearAccountSignKey } from "../crypto/accountTrust.ts";
|
|
19
21
|
|
|
20
22
|
// Default Privateer API host. NOTE: this is still the legacy "helix" Render
|
|
21
23
|
// hostname the mobile/web client also points at (client/config/environment.ts);
|
|
@@ -118,13 +120,21 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
|
118
120
|
let _account: { accessToken: string } | null = null;
|
|
119
121
|
|
|
120
122
|
export function loadCredentials(): Credentials | null {
|
|
121
|
-
|
|
123
|
+
// Only a successfully-read credential is memoized. A NEGATIVE result (file absent
|
|
124
|
+
// or unreadable) is deliberately NOT cached: the credential can appear mid-session
|
|
125
|
+
// — the account /login writes credentials.json AFTER the extensions have already
|
|
126
|
+
// booted, and under jiti each extension gets its OWN module instance of this file,
|
|
127
|
+
// so a login's saveCredentials() never reaches another extension's `_cache`. If we
|
|
128
|
+
// memoized the pre-login "absent" as null, that instance would report "not signed
|
|
129
|
+
// in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
|
|
130
|
+
// disk on each miss lets a later call see what a sign-in just wrote.
|
|
131
|
+
if (_cache) return _cache;
|
|
122
132
|
const path = credentialsPath();
|
|
123
|
-
if (!existsSync(path)) return
|
|
133
|
+
if (!existsSync(path)) return null;
|
|
124
134
|
try {
|
|
125
135
|
_cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
|
126
136
|
} catch {
|
|
127
|
-
|
|
137
|
+
return null;
|
|
128
138
|
}
|
|
129
139
|
return _cache;
|
|
130
140
|
}
|
|
@@ -145,6 +155,9 @@ export function clearCredentials(): void {
|
|
|
145
155
|
} catch {
|
|
146
156
|
/* nothing to remove */
|
|
147
157
|
}
|
|
158
|
+
// Drop the pinned account signing key too — it belongs to the account that just
|
|
159
|
+
// signed out; a different account must re-pin its own at link.
|
|
160
|
+
clearAccountSignKey();
|
|
148
161
|
_cache = null;
|
|
149
162
|
_child = null;
|
|
150
163
|
_account = null;
|
|
@@ -192,6 +205,19 @@ function notifySessionExpired(): void {
|
|
|
192
205
|
}
|
|
193
206
|
}
|
|
194
207
|
|
|
208
|
+
// A server-pushed `session_revoked` relay frame arrived: the account signed this
|
|
209
|
+
// terminal out (from the app's Linked Devices). Treat it exactly like a lazily-
|
|
210
|
+
// detected expiry — wipe the local machine login and announce it via
|
|
211
|
+
// onSessionExpired — but PROMPTLY, while the process is live, instead of waiting
|
|
212
|
+
// for the next authedFetch/launch to hit a 401. The relay owner additionally
|
|
213
|
+
// drops remote access. No-op if we're already signed out, so a duplicate frame
|
|
214
|
+
// (or a frame racing an in-flight /signout) doesn't fire a spurious notice.
|
|
215
|
+
export function handleServerRevoke(): void {
|
|
216
|
+
if (!hasCredentials()) return;
|
|
217
|
+
clearCredentials();
|
|
218
|
+
notifySessionExpired();
|
|
219
|
+
}
|
|
220
|
+
|
|
195
221
|
// ── Sign-in notification ─────────────────────────────────────────────────────
|
|
196
222
|
// Fired when a Privateer login completes on this terminal — the cue for the UI to
|
|
197
223
|
// re-render its header/badge to the signed-in state. It reaches every sign-in entry
|
|
@@ -251,7 +277,17 @@ async function postJson(base: string, path: string, body: unknown, init: Request
|
|
|
251
277
|
// Step 1: ask the server for a device + user code the human will approve in-app.
|
|
252
278
|
export async function requestDeviceCode(deviceLabel = defaultDeviceLabel()): Promise<DeviceCode> {
|
|
253
279
|
const base = serverBaseUrl();
|
|
254
|
-
|
|
280
|
+
// This terminal's public key rides the grant so the app can PIN it on approval
|
|
281
|
+
// (TOFU) — the trust anchor for later sealing secrets (channel tokens) that only
|
|
282
|
+
// this machine can open. Best-effort: if keygen fails we just omit it and the app
|
|
283
|
+
// falls back to terminal-only secret entry rather than blocking login.
|
|
284
|
+
let terminalPub: string | undefined;
|
|
285
|
+
try {
|
|
286
|
+
terminalPub = terminalPublicKeyBase64();
|
|
287
|
+
} catch {
|
|
288
|
+
/* no key → no app-sealed secrets for this terminal; login still proceeds */
|
|
289
|
+
}
|
|
290
|
+
const res = await postJson(base, "/auth/device/code", { deviceLabel, terminalPub });
|
|
255
291
|
if (!res.ok) {
|
|
256
292
|
throw new Error(`Couldn't start login (${res.status}). Check your connection or PRIVATEER_SERVER_URL.`);
|
|
257
293
|
}
|
|
@@ -280,9 +316,12 @@ export async function pollForToken(
|
|
|
280
316
|
const res = await postJson(base, "/auth/device/token", { device_code: code.device_code });
|
|
281
317
|
|
|
282
318
|
if (res.ok) {
|
|
283
|
-
const data = (await res.json()) as Omit<Credentials, "serverBaseUrl"
|
|
284
|
-
const creds: Credentials = {
|
|
319
|
+
const data = (await res.json()) as Omit<Credentials, "serverBaseUrl"> & { accountSignPub?: string };
|
|
320
|
+
const creds: Credentials = { accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user, serverBaseUrl: base };
|
|
285
321
|
saveCredentials(creds);
|
|
322
|
+
// Pin the account's signing public key (TOFU) so channel-config from the app can
|
|
323
|
+
// be verified as genuinely coming from the account, not a forging relay (F7/F8).
|
|
324
|
+
pinAccountSignKey(data.accountSignPub);
|
|
286
325
|
notifySignedIn();
|
|
287
326
|
return creds;
|
|
288
327
|
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The transport-agnostic core of the messaging channels — the analog of
|
|
2
|
+
// RemoteBridge for the relay. It owns the policy that must be identical on every
|
|
3
|
+
// platform:
|
|
4
|
+
// - allowlist (who may drive the agent; fail-closed + silent to strangers)
|
|
5
|
+
// - serialization (one turn per conversation at a time; extra messages queue)
|
|
6
|
+
// - redaction (chat platforms are external egress — scrub before send)
|
|
7
|
+
// - chunking (respect the platform's per-message length cap)
|
|
8
|
+
//
|
|
9
|
+
// The agent itself is injected as `runTurn`, so this file stays Pi-free and
|
|
10
|
+
// unit-testable against a fake adapter + fake runner (see tests/channels.test.ts).
|
|
11
|
+
// The Pi-backed runner lives in ./run.ts.
|
|
12
|
+
|
|
13
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
// A minimal view of the permission request the gate hands us (see
|
|
16
|
+
// src/permissions/gate.ts PermissionRequest). Kept local so the bridge doesn't
|
|
17
|
+
// depend on the gate module.
|
|
18
|
+
export interface ApprovalRequest {
|
|
19
|
+
kind: string;
|
|
20
|
+
title: string;
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Run one agent turn for a conversation. `onText` receives streamed text deltas as
|
|
25
|
+
// they arrive; the bridge buffers/coalesces them. Resolves when the turn is done.
|
|
26
|
+
// `signal` aborts a queued/in-flight turn (e.g. the user sent "/stop").
|
|
27
|
+
export type TurnRunner = (
|
|
28
|
+
chatId: string,
|
|
29
|
+
text: string,
|
|
30
|
+
onText: (delta: string) => void,
|
|
31
|
+
signal: AbortSignal,
|
|
32
|
+
// The triggering user + their role, so the runner (and the gate it drives) can
|
|
33
|
+
// cap a member to read-only regardless of the channel's posture.
|
|
34
|
+
meta: TurnMeta,
|
|
35
|
+
) => Promise<{ ok: boolean; error?: string }>;
|
|
36
|
+
|
|
37
|
+
export interface TurnMeta {
|
|
38
|
+
userId: string;
|
|
39
|
+
isAdmin: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A security-audit event. The bridge emits these at authorization-relevant moments;
|
|
43
|
+
// run.ts appends them to an on-disk log. `detail` is redacted before it's emitted.
|
|
44
|
+
export interface AuditEvent {
|
|
45
|
+
at: string;
|
|
46
|
+
event: "prompt" | "approval_request" | "approval_decision" | "interrupt" | "denied";
|
|
47
|
+
chatId: string;
|
|
48
|
+
userId?: string;
|
|
49
|
+
role?: "admin" | "member";
|
|
50
|
+
detail?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MessagingBridgeConfig {
|
|
54
|
+
adapter: ChannelAdapter;
|
|
55
|
+
runTurn: TurnRunner;
|
|
56
|
+
// Who may interact at all (admin OR member). False → ignored (fail-closed and
|
|
57
|
+
// SILENT: we don't confirm the bot exists to un-allowlisted senders).
|
|
58
|
+
isAllowed: (msg: InboundMessage) => boolean;
|
|
59
|
+
// Is this user an admin? Admins are governed by the channel posture and are the
|
|
60
|
+
// ONLY users whose yes/no resolves an approval. Members are read-only and can't
|
|
61
|
+
// approve.
|
|
62
|
+
isAdmin: (msg: InboundMessage) => boolean;
|
|
63
|
+
// Scrub secrets from every outbound message. Wired to redactText in ./run.ts.
|
|
64
|
+
redact?: (text: string) => string;
|
|
65
|
+
onLog?: (msg: string) => void;
|
|
66
|
+
// Optional append-only security audit sink.
|
|
67
|
+
onAudit?: (event: AuditEvent) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stay comfortably under Telegram's 4096-char hard cap (Slack ~40k, Discord 2000 —
|
|
71
|
+
// pick the tightest common bound for the shared path; a platform with a smaller cap
|
|
72
|
+
// can override in its adapter later).
|
|
73
|
+
const MAX_MSG = 1900;
|
|
74
|
+
|
|
75
|
+
// How long to wait for a yes/no approval reply before failing closed (deny).
|
|
76
|
+
const APPROVAL_TIMEOUT_MS = 120_000;
|
|
77
|
+
|
|
78
|
+
const YES = new Set(["yes", "y", "allow", "ok", "okay", "approve", "approved", "👍", "✅"]);
|
|
79
|
+
const NO = new Set(["no", "n", "deny", "denied", "stop", "cancel", "reject", "👎", "❌"]);
|
|
80
|
+
|
|
81
|
+
// Interpret an approval reply. Returns null for anything that isn't a clear
|
|
82
|
+
// yes/no, so the bridge can re-prompt instead of guessing (fail-safe: never treat
|
|
83
|
+
// ambiguous text as allow).
|
|
84
|
+
export function approvalDecision(text: string): "allow" | "deny" | null {
|
|
85
|
+
const t = text.trim().toLowerCase();
|
|
86
|
+
if (YES.has(t)) return "allow";
|
|
87
|
+
if (NO.has(t)) return "deny";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The message a user sees when the agent wants to run a gated action.
|
|
92
|
+
export function approvalPrompt(req: ApprovalRequest): string {
|
|
93
|
+
const detail = req.detail.length > 600 ? req.detail.slice(0, 600) + "\n…(truncated)" : req.detail;
|
|
94
|
+
return `⚠️ Approval needed — ${req.title} (${req.kind})\n\n${detail}\n\nReply "yes" to allow or "no" to deny (times out in 2 min).`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Split text into <=max chunks, preferring newline boundaries so code/paragraphs
|
|
98
|
+
// aren't cut mid-line when possible.
|
|
99
|
+
export function chunkText(text: string, max = MAX_MSG): string[] {
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
let rest = text;
|
|
102
|
+
while (rest.length > max) {
|
|
103
|
+
let cut = rest.lastIndexOf("\n", max);
|
|
104
|
+
if (cut < max * 0.5) cut = max; // no usable newline in the back half → hard cut
|
|
105
|
+
out.push(rest.slice(0, cut));
|
|
106
|
+
rest = rest.slice(cut).replace(/^\n/, "");
|
|
107
|
+
}
|
|
108
|
+
if (rest) out.push(rest);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class MessagingBridge {
|
|
113
|
+
// Per-chat promise tail: each new turn chains onto the previous so turns in the
|
|
114
|
+
// same conversation never interleave (they share one agent session downstream).
|
|
115
|
+
private readonly tails = new Map<string, Promise<void>>();
|
|
116
|
+
// Per-chat abort handle for the in-flight turn, so "/stop" can interrupt it.
|
|
117
|
+
private readonly aborts = new Map<string, AbortController>();
|
|
118
|
+
// Per-chat pending tool approval awaiting a yes/no reply. At most one at a time
|
|
119
|
+
// per chat (turns are serialized and a turn's tool calls are sequential).
|
|
120
|
+
private readonly approvals = new Map<string, (decision: "allow" | "deny") => void>();
|
|
121
|
+
|
|
122
|
+
constructor(private readonly cfg: MessagingBridgeConfig) {}
|
|
123
|
+
|
|
124
|
+
async start(): Promise<void> {
|
|
125
|
+
await this.cfg.adapter.start((m) => this.onMessage(m));
|
|
126
|
+
this.log(`channel "${this.cfg.adapter.name}" listening`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop(): void {
|
|
130
|
+
this.cfg.adapter.stop();
|
|
131
|
+
for (const a of this.aborts.values()) a.abort();
|
|
132
|
+
this.aborts.clear();
|
|
133
|
+
// Fail any pending approvals closed so no turn hangs on shutdown.
|
|
134
|
+
for (const resolve of this.approvals.values()) resolve("deny");
|
|
135
|
+
this.approvals.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Ask the user in `chatId` to approve a gated tool action, and await their yes/no
|
|
139
|
+
// reply. Wired to the permission gate's remote approver (see channels/run.ts): the
|
|
140
|
+
// gate suspends the tool until this resolves. Fail-closed — timeout, abort (/stop),
|
|
141
|
+
// or shutdown all resolve to "deny". Public because the gate calls it directly
|
|
142
|
+
// (via an AsyncLocalStorage handle to this bridge + the current chat id).
|
|
143
|
+
requestApproval(chatId: string, req: ApprovalRequest, signal?: AbortSignal): Promise<"allow" | "deny"> {
|
|
144
|
+
// Only one outstanding approval per chat; deny any stale one first.
|
|
145
|
+
this.approvals.get(chatId)?.("deny");
|
|
146
|
+
|
|
147
|
+
const prompt = approvalPrompt(req);
|
|
148
|
+
const detail = `${req.title}: ${req.detail}`;
|
|
149
|
+
this.cfg.onAudit?.({
|
|
150
|
+
at: new Date().toISOString(),
|
|
151
|
+
event: "approval_request",
|
|
152
|
+
chatId,
|
|
153
|
+
role: "admin", // approvals only arise from admin turns (members are read-only)
|
|
154
|
+
detail: this.cfg.redact ? this.cfg.redact(detail) : detail,
|
|
155
|
+
});
|
|
156
|
+
void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt);
|
|
157
|
+
|
|
158
|
+
return new Promise<"allow" | "deny">((resolve) => {
|
|
159
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
160
|
+
const settle = (decision: "allow" | "deny") => {
|
|
161
|
+
if (this.approvals.get(chatId) !== settle) return; // already settled
|
|
162
|
+
this.approvals.delete(chatId);
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
resolve(decision);
|
|
166
|
+
};
|
|
167
|
+
const onAbort = () => {
|
|
168
|
+
void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.");
|
|
169
|
+
settle("deny");
|
|
170
|
+
};
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.");
|
|
173
|
+
settle("deny");
|
|
174
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
175
|
+
this.approvals.set(chatId, settle);
|
|
176
|
+
if (signal) {
|
|
177
|
+
if (signal.aborted) return onAbort();
|
|
178
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private log(msg: string): void {
|
|
184
|
+
this.cfg.onLog?.(msg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private audit(m: InboundMessage, event: AuditEvent["event"], detail?: string): void {
|
|
188
|
+
if (!this.cfg.onAudit) return;
|
|
189
|
+
const red = detail && this.cfg.redact ? this.cfg.redact(detail) : detail;
|
|
190
|
+
this.cfg.onAudit({
|
|
191
|
+
at: new Date().toISOString(),
|
|
192
|
+
event,
|
|
193
|
+
chatId: m.chatId,
|
|
194
|
+
userId: m.userId,
|
|
195
|
+
role: this.cfg.isAdmin(m) ? "admin" : "member",
|
|
196
|
+
detail: red,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private onMessage(m: InboundMessage): void {
|
|
201
|
+
const text = m.text?.trim();
|
|
202
|
+
if (!text) return;
|
|
203
|
+
|
|
204
|
+
if (!this.cfg.isAllowed(m)) {
|
|
205
|
+
// Fail closed and stay silent — replying would confirm the bot to strangers.
|
|
206
|
+
this.log(`ignored message from unauthorized user ${m.userId} in chat ${m.chatId}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A pending tool approval in this chat consumes the next message as its answer —
|
|
211
|
+
// BEFORE the per-chat queue, because the turn awaiting approval is itself holding
|
|
212
|
+
// that queue open (routing the reply through the queue would deadlock it).
|
|
213
|
+
const pending = this.approvals.get(m.chatId);
|
|
214
|
+
if (pending) {
|
|
215
|
+
// Only admins may answer an approval. A member's reply is refused (and audited)
|
|
216
|
+
// — never silently treated as a decision.
|
|
217
|
+
if (!this.cfg.isAdmin(m)) {
|
|
218
|
+
this.audit(m, "denied", "non-admin attempted to answer an approval");
|
|
219
|
+
void this.cfg.adapter.sendText(m.chatId, "Only an admin can approve the pending action.");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (text === "/stop") {
|
|
223
|
+
this.audit(m, "approval_decision", "deny (/stop)");
|
|
224
|
+
pending("deny"); // interrupt while awaiting approval → deny it
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const decision = approvalDecision(text);
|
|
228
|
+
if (decision === null) {
|
|
229
|
+
void this.cfg.adapter.sendText(m.chatId, 'Reply "yes" to allow or "no" to deny the pending action.');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.audit(m, "approval_decision", decision);
|
|
233
|
+
pending(decision);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// "/stop" interrupts the running turn instead of queueing another.
|
|
238
|
+
if (text === "/stop") {
|
|
239
|
+
this.aborts.get(m.chatId)?.abort();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Serialize per conversation: chain onto this chat's tail.
|
|
244
|
+
const prev = this.tails.get(m.chatId) ?? Promise.resolve();
|
|
245
|
+
const next = prev
|
|
246
|
+
.then(() => this.handle(m))
|
|
247
|
+
.catch((e) => this.log(`turn error: ${e instanceof Error ? e.message : String(e)}`));
|
|
248
|
+
this.tails.set(m.chatId, next);
|
|
249
|
+
// Drop the tail once this was the last queued turn, so the map doesn't grow.
|
|
250
|
+
void next.finally(() => {
|
|
251
|
+
if (this.tails.get(m.chatId) === next) this.tails.delete(m.chatId);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async handle(m: InboundMessage): Promise<void> {
|
|
256
|
+
const { chatId } = m;
|
|
257
|
+
const isAdmin = this.cfg.isAdmin(m);
|
|
258
|
+
this.audit(m, "prompt", m.text.trim().slice(0, 200));
|
|
259
|
+
const ac = new AbortController();
|
|
260
|
+
this.aborts.set(chatId, ac);
|
|
261
|
+
this.cfg.adapter.sendTyping?.(chatId);
|
|
262
|
+
|
|
263
|
+
// Buffer the whole turn's text, then send once (coalesced) — the simplest
|
|
264
|
+
// correct choice. Streaming partial edits back to the channel is a future
|
|
265
|
+
// enhancement; buffering avoids a race between deltas and async sends and
|
|
266
|
+
// keeps this unit-testable without timers.
|
|
267
|
+
let buf = "";
|
|
268
|
+
let result: { ok: boolean; error?: string };
|
|
269
|
+
try {
|
|
270
|
+
result = await this.cfg.runTurn(chatId, m.text.trim(), (d) => (buf += d), ac.signal, {
|
|
271
|
+
userId: m.userId,
|
|
272
|
+
isAdmin,
|
|
273
|
+
});
|
|
274
|
+
} catch (e) {
|
|
275
|
+
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
276
|
+
} finally {
|
|
277
|
+
this.aborts.delete(chatId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const body = this.cfg.redact ? this.cfg.redact(buf) : buf;
|
|
281
|
+
|
|
282
|
+
// Deliver any text the turn produced (even on error — a partial answer is
|
|
283
|
+
// useful), then an error line if it failed.
|
|
284
|
+
if (body.trim()) {
|
|
285
|
+
for (const chunk of chunkText(body)) await this.cfg.adapter.sendText(chatId, chunk);
|
|
286
|
+
} else if (result.ok) {
|
|
287
|
+
await this.cfg.adapter.sendText(chatId, "✓ done (no text output).");
|
|
288
|
+
}
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
await this.cfg.adapter.sendText(chatId, `⚠️ ${result.error ?? "the agent hit an error"}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Discord channel adapter — templated off the others against the same
|
|
2
|
+
// ChannelAdapter interface. Talks the Discord Gateway (a WebSocket) directly for
|
|
3
|
+
// receive and the REST API for send, so — like Telegram/Slack — it needs NO public
|
|
4
|
+
// inbound endpoint. Zero new dependencies: `ws` is already a dep (relayClient.ts)
|
|
5
|
+
// and REST is fetch.
|
|
6
|
+
//
|
|
7
|
+
// Discord app setup (one-time):
|
|
8
|
+
// - Create a bot, copy its token → `botToken`.
|
|
9
|
+
// - Enable the MESSAGE CONTENT intent (privileged) in the dev portal, else
|
|
10
|
+
// `content` arrives empty.
|
|
11
|
+
// - Invite the bot to your server, or DM it. `allowFrom` lists Discord user ids.
|
|
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 GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
20
|
+
const API = "https://discord.com/api/v10";
|
|
21
|
+
const RECONNECT_MS = 3000;
|
|
22
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
23
|
+
|
|
24
|
+
// Gateway intent bits we need: guild messages, DMs, and (privileged) message
|
|
25
|
+
// content. https://discord.com/developers/docs/topics/gateway#gateway-intents
|
|
26
|
+
const INTENT_GUILD_MESSAGES = 1 << 9;
|
|
27
|
+
const INTENT_DIRECT_MESSAGES = 1 << 12;
|
|
28
|
+
const INTENT_MESSAGE_CONTENT = 1 << 15;
|
|
29
|
+
const DEFAULT_INTENTS = INTENT_GUILD_MESSAGES | INTENT_DIRECT_MESSAGES | INTENT_MESSAGE_CONTENT;
|
|
30
|
+
|
|
31
|
+
interface DiscordMessage {
|
|
32
|
+
channel_id?: string;
|
|
33
|
+
content?: string;
|
|
34
|
+
author?: { id?: string; username?: string; bot?: boolean };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Pure mapping: a MESSAGE_CREATE payload → normalized InboundMessage, or null.
|
|
38
|
+
// Drops messages from ANY bot (`author.bot`, which includes our own) so the agent
|
|
39
|
+
// never talks to itself. Extracted so it's unit-testable without the Gateway.
|
|
40
|
+
export function messageFromDiscord(d: DiscordMessage): InboundMessage | null {
|
|
41
|
+
if (!d || !d.author || d.author.bot) return null;
|
|
42
|
+
if (typeof d.content !== "string" || !d.content || !d.channel_id || !d.author.id) return null;
|
|
43
|
+
return { chatId: d.channel_id, userId: d.author.id, userName: d.author.username, text: d.content };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DiscordOptions {
|
|
47
|
+
botToken: string;
|
|
48
|
+
intents?: number;
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
onLog?: (msg: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class DiscordAdapter implements ChannelAdapter {
|
|
54
|
+
readonly name = "discord";
|
|
55
|
+
private readonly token: string;
|
|
56
|
+
private readonly intents: number;
|
|
57
|
+
private readonly fetchImpl: typeof fetch;
|
|
58
|
+
private readonly onLog?: (msg: string) => void;
|
|
59
|
+
private ws?: WebSocket;
|
|
60
|
+
private running = false;
|
|
61
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
62
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
63
|
+
private lastSeq: number | null = null; // last dispatch sequence (for heartbeats)
|
|
64
|
+
private acked = true; // did the server ack our last heartbeat? (zombie detection)
|
|
65
|
+
private onMessage?: (m: InboundMessage) => void;
|
|
66
|
+
|
|
67
|
+
constructor(opts: DiscordOptions) {
|
|
68
|
+
this.token = opts.botToken;
|
|
69
|
+
this.intents = opts.intents ?? DEFAULT_INTENTS;
|
|
70
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
71
|
+
this.onLog = opts.onLog;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
75
|
+
if (this.running) return;
|
|
76
|
+
this.running = true;
|
|
77
|
+
this.onMessage = onMessage;
|
|
78
|
+
this.connect();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stop(): void {
|
|
82
|
+
this.running = false;
|
|
83
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
84
|
+
this.clearHeartbeat();
|
|
85
|
+
try {
|
|
86
|
+
this.ws?.close();
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
this.ws = undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async sendText(chatId: string, text: string): Promise<void> {
|
|
94
|
+
if (!text) return;
|
|
95
|
+
const res = await this.fetchImpl(`${API}/channels/${chatId}/messages`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { authorization: `Bot ${this.token}`, "content-type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ content: text }),
|
|
99
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) this.log(`create-message failed: HTTP ${res.status}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private log(msg: string): void {
|
|
105
|
+
this.onLog?.(`discord: ${msg}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private connect(): void {
|
|
109
|
+
if (!this.running) return;
|
|
110
|
+
const ws = new WebSocket(GATEWAY);
|
|
111
|
+
this.ws = ws;
|
|
112
|
+
ws.on("message", (raw) => this.handleFrame(ws, raw));
|
|
113
|
+
ws.on("close", () => {
|
|
114
|
+
if (this.ws === ws) this.ws = undefined;
|
|
115
|
+
this.clearHeartbeat();
|
|
116
|
+
if (this.running) this.scheduleReconnect();
|
|
117
|
+
});
|
|
118
|
+
ws.on("error", (err: Error) => this.log(`ws error: ${err?.message ?? err}`));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private scheduleReconnect(): void {
|
|
122
|
+
if (!this.running || this.reconnectTimer) return;
|
|
123
|
+
this.reconnectTimer = setTimeout(() => {
|
|
124
|
+
this.reconnectTimer = undefined;
|
|
125
|
+
this.connect();
|
|
126
|
+
}, RECONNECT_MS);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private handleFrame(ws: WebSocket, raw: WebSocket.RawData): void {
|
|
130
|
+
let f: { op?: number; t?: string; s?: number | null; d?: any };
|
|
131
|
+
try {
|
|
132
|
+
f = JSON.parse(raw.toString());
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (typeof f.s === "number") this.lastSeq = f.s;
|
|
137
|
+
switch (f.op) {
|
|
138
|
+
case 10: // Hello — begin heartbeating, then identify
|
|
139
|
+
this.startHeartbeat(ws, f.d?.heartbeat_interval ?? 41_250);
|
|
140
|
+
this.identify(ws);
|
|
141
|
+
break;
|
|
142
|
+
case 11: // Heartbeat ACK
|
|
143
|
+
this.acked = true;
|
|
144
|
+
break;
|
|
145
|
+
case 1: // server requested a heartbeat now
|
|
146
|
+
this.sendHeartbeat(ws);
|
|
147
|
+
break;
|
|
148
|
+
case 7: // Reconnect
|
|
149
|
+
case 9: // Invalid Session — drop and re-identify fresh (no resume, for simplicity)
|
|
150
|
+
try {
|
|
151
|
+
ws.close();
|
|
152
|
+
} catch {
|
|
153
|
+
/* ignore */
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
case 0: // Dispatch
|
|
157
|
+
if (f.t === "MESSAGE_CREATE") {
|
|
158
|
+
const m = messageFromDiscord(f.d);
|
|
159
|
+
if (m) this.onMessage?.(m);
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private identify(ws: WebSocket): void {
|
|
166
|
+
this.send(ws, {
|
|
167
|
+
op: 2,
|
|
168
|
+
d: {
|
|
169
|
+
token: this.token,
|
|
170
|
+
intents: this.intents,
|
|
171
|
+
properties: { os: "linux", browser: "privateer", device: "privateer" },
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private startHeartbeat(ws: WebSocket, intervalMs: number): void {
|
|
177
|
+
this.clearHeartbeat();
|
|
178
|
+
this.acked = true;
|
|
179
|
+
this.heartbeat = setInterval(() => {
|
|
180
|
+
if (!this.acked) {
|
|
181
|
+
// No ACK since the last beat → zombie connection; drop it to reconnect.
|
|
182
|
+
try {
|
|
183
|
+
ws.close();
|
|
184
|
+
} catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.sendHeartbeat(ws);
|
|
190
|
+
}, intervalMs);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private sendHeartbeat(ws: WebSocket): void {
|
|
194
|
+
this.acked = false;
|
|
195
|
+
this.send(ws, { op: 1, d: this.lastSeq });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private clearHeartbeat(): void {
|
|
199
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
200
|
+
this.heartbeat = undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private send(ws: WebSocket, payload: unknown): void {
|
|
204
|
+
try {
|
|
205
|
+
ws.send(JSON.stringify(payload));
|
|
206
|
+
} catch {
|
|
207
|
+
/* socket dying */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|