@chenacc/agentbridge 0.0.0-stage → 0.1.1
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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +308 -2
- package/bin/agentbridge.js +7 -0
- package/docs/RELEASING.md +47 -0
- package/package.json +65 -4
- package/public/adapter-capabilities.js +29 -0
- package/public/app.js +2211 -0
- package/public/background.jpg +0 -0
- package/public/command-id.js +16 -0
- package/public/highlight.js +125 -0
- package/public/index.html +339 -0
- package/public/markdown.js +291 -0
- package/public/styles.css +1073 -0
- package/public/transcript.js +368 -0
- package/public/workspace-navigation.js +241 -0
- package/src/adapter-registry.js +23 -0
- package/src/adapters/capabilities.js +36 -0
- package/src/adapters/claude.js +493 -0
- package/src/adapters/codex.js +529 -0
- package/src/adapters/deepseek-harness.js +413 -0
- package/src/adapters/dsh-desktop.js +322 -0
- package/src/adapters/echo.js +57 -0
- package/src/adapters/process-utils.js +125 -0
- package/src/adapters/unavailable.js +30 -0
- package/src/adapters/zcode-native.js +40 -0
- package/src/attachments.js +187 -0
- package/src/cc-switch-models.js +86 -0
- package/src/cli.js +199 -0
- package/src/config.js +195 -0
- package/src/conversations/conversation-catalog.js +283 -0
- package/src/dsh-desktop-channels.js +460 -0
- package/src/event-store.js +330 -0
- package/src/notifier.js +266 -0
- package/src/pairing.js +167 -0
- package/src/server.js +964 -0
- package/src/session-manager.js +1355 -0
- package/src/workspaces/workspace-catalog.js +196 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { appendFile, mkdir, open, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
|
|
6
|
+
// The log is append-only and grows without bound, so startup reads a bounded
|
|
7
|
+
// tail. It is still compacted while scanning, because applying the window to raw
|
|
8
|
+
// log lines would let an on-disk delta flood truncate the conversation again.
|
|
9
|
+
const MAX_INIT_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function deltaKey(event) {
|
|
12
|
+
return `${event.sessionId ?? ""}\u0000${event.turnId ?? ""}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Streaming deltas are progress, not history. Keeping every token in the
|
|
16
|
+
// replay window is what previously pushed real conversation out of it: a single
|
|
17
|
+
// long turn could emit thousands of message.delta events, so a phone that
|
|
18
|
+
// reconnected only ever received the token tail of the newest turn instead of
|
|
19
|
+
// the messages. `retain` therefore keeps at most one delta per turn, merging
|
|
20
|
+
// later chunks into it so a reconnecting client still receives the whole
|
|
21
|
+
// in-progress text.
|
|
22
|
+
function retain(events, deltaByTurn, event) {
|
|
23
|
+
if (event.kind !== "message.delta") {
|
|
24
|
+
events.push(event);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const key = deltaKey(event);
|
|
28
|
+
const previous = deltaByTurn.get(key);
|
|
29
|
+
if (previous) {
|
|
30
|
+
const index = events.indexOf(previous);
|
|
31
|
+
if (index >= 0) events.splice(index, 1);
|
|
32
|
+
const payload = event.payload ?? {};
|
|
33
|
+
event.payload = {
|
|
34
|
+
...payload,
|
|
35
|
+
text: `${previous.payload?.text ?? ""}${payload.text ?? ""}`,
|
|
36
|
+
offset: previous.payload?.offset ?? 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
events.push(event);
|
|
40
|
+
deltaByTurn.set(key, event);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class EventStore {
|
|
44
|
+
#events = [];
|
|
45
|
+
#seq = 0;
|
|
46
|
+
#writes = Promise.resolve();
|
|
47
|
+
#emitter = new EventEmitter();
|
|
48
|
+
#appendFile;
|
|
49
|
+
#deltaByTurn = new Map();
|
|
50
|
+
// Set once the in-memory window has dropped anything. Until then the window
|
|
51
|
+
// still holds every event, so a per-session history read never needs the log.
|
|
52
|
+
#evicted = false;
|
|
53
|
+
|
|
54
|
+
constructor({ filePath, maxMemory = 5000, appendFileImpl = appendFile }) {
|
|
55
|
+
this.filePath = filePath;
|
|
56
|
+
this.maxMemory = maxMemory;
|
|
57
|
+
this.#appendFile = appendFileImpl;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async init() {
|
|
61
|
+
await mkdir(path.dirname(this.filePath), { recursive: true });
|
|
62
|
+
let content;
|
|
63
|
+
try {
|
|
64
|
+
content = await this.#readLog();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code !== "ENOENT") throw error;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.#events = [];
|
|
70
|
+
this.#deltaByTurn = new Map();
|
|
71
|
+
let lastSeq = 0;
|
|
72
|
+
for (const line of content.split(/\r?\n/)) {
|
|
73
|
+
if (!line) continue;
|
|
74
|
+
let event;
|
|
75
|
+
try {
|
|
76
|
+
event = JSON.parse(line);
|
|
77
|
+
} catch {
|
|
78
|
+
// A process that was killed mid-append leaves one torn line at the tail.
|
|
79
|
+
// Refusing to start over that would make the log itself the reason the
|
|
80
|
+
// bridge cannot come back — every read path below already skips unparsable
|
|
81
|
+
// lines, and the event they describe was never acknowledged anyway.
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (Number.isSafeInteger(event.seq)) lastSeq = Math.max(lastSeq, event.seq);
|
|
85
|
+
retain(this.#events, this.#deltaByTurn, event);
|
|
86
|
+
while (this.#events.length > this.maxMemory) this.#evictOldest();
|
|
87
|
+
}
|
|
88
|
+
this.#seq = lastSeq;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #readLog(maxBytes = MAX_INIT_BYTES) {
|
|
92
|
+
const handle = await open(this.filePath, "r");
|
|
93
|
+
try {
|
|
94
|
+
const { size } = await handle.stat();
|
|
95
|
+
if (size <= maxBytes) return await readFile(this.filePath, "utf8");
|
|
96
|
+
const start = size - maxBytes;
|
|
97
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
98
|
+
await handle.read(buffer, 0, maxBytes, start);
|
|
99
|
+
const text = buffer.toString("utf8");
|
|
100
|
+
// The first line of a mid-file slice is almost certainly partial.
|
|
101
|
+
const firstBreak = text.indexOf("\n");
|
|
102
|
+
return firstBreak === -1 ? "" : text.slice(firstBreak + 1);
|
|
103
|
+
} finally {
|
|
104
|
+
await handle.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get lastSeq() {
|
|
109
|
+
return this.#seq;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#evictOldest() {
|
|
113
|
+
const dropped = this.#events.shift();
|
|
114
|
+
if (!dropped) return;
|
|
115
|
+
this.#evicted = true;
|
|
116
|
+
if (dropped.kind === "message.delta") {
|
|
117
|
+
const key = deltaKey(dropped);
|
|
118
|
+
if (this.#deltaByTurn.get(key) === dropped) this.#deltaByTurn.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async append(kind, payload, context = {}) {
|
|
123
|
+
const event = {
|
|
124
|
+
v: 1,
|
|
125
|
+
eventId: randomUUID(),
|
|
126
|
+
seq: ++this.#seq,
|
|
127
|
+
time: new Date().toISOString(),
|
|
128
|
+
machineId: context.machineId ?? "local",
|
|
129
|
+
sessionId: context.sessionId ?? null,
|
|
130
|
+
turnId: context.turnId ?? null,
|
|
131
|
+
kind,
|
|
132
|
+
actor: context.actor ?? { type: "bridge" },
|
|
133
|
+
payload,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Persist the chunk exactly as it arrived. Merging happens on the in-memory
|
|
137
|
+
// copy only; writing the merged text would store a growing prefix per line
|
|
138
|
+
// and make the log grow quadratically.
|
|
139
|
+
const persisted = { ...event };
|
|
140
|
+
retain(this.#events, this.#deltaByTurn, event);
|
|
141
|
+
while (this.#events.length > this.maxMemory) this.#evictOldest();
|
|
142
|
+
// A transient disk error must not permanently reject the serialized queue.
|
|
143
|
+
// Recover the chain before scheduling the next append, while preserving order.
|
|
144
|
+
const write = this.#writes
|
|
145
|
+
.catch(() => undefined)
|
|
146
|
+
.then(() => this.#appendFile(this.filePath, `${JSON.stringify(persisted)}\n`, "utf8"));
|
|
147
|
+
this.#writes = write;
|
|
148
|
+
try {
|
|
149
|
+
await write;
|
|
150
|
+
} catch (cause) {
|
|
151
|
+
this.#events = this.#events.filter((item) => item.eventId !== event.eventId);
|
|
152
|
+
const error = new Error("AgentBridge event log is temporarily unavailable; please retry");
|
|
153
|
+
error.statusCode = 503;
|
|
154
|
+
error.cause = cause;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
this.#emitter.emit("event", event);
|
|
158
|
+
return event;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
listAfter(seq = 0, { sessionId } = {}) {
|
|
162
|
+
return this.#events.filter((event) => event.seq > seq && (!sessionId || event.sessionId === sessionId));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* History for one session, independent of what else shares the window.
|
|
167
|
+
*
|
|
168
|
+
* The replay window is global, so a busy session can evict a quiet one and a
|
|
169
|
+
* phone that switches to it would render an empty transcript. Once anything
|
|
170
|
+
* has been evicted the log tail is read back for that one session, which makes
|
|
171
|
+
* switching always land on real history instead of on a hole.
|
|
172
|
+
*
|
|
173
|
+
* `beforeSeq` pages backwards: the page holds the newest events strictly older
|
|
174
|
+
* than that cursor, so "加载更早" walks one window at a time instead of asking
|
|
175
|
+
* for everything at once.
|
|
176
|
+
*/
|
|
177
|
+
async readSessionHistory(sessionId, { limit = 300, beforeSeq = null } = {}) {
|
|
178
|
+
const wanted = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 1_000) : 300;
|
|
179
|
+
const cursor = Number.isSafeInteger(beforeSeq) && beforeSeq > 0 ? beforeSeq : null;
|
|
180
|
+
const withinCursor = (event) => cursor === null || event.seq < cursor;
|
|
181
|
+
const inMemory = this.listAfter(0, { sessionId }).filter(withinCursor);
|
|
182
|
+
let events = inMemory;
|
|
183
|
+
let fromLog = false;
|
|
184
|
+
if (this.#evicted) {
|
|
185
|
+
const persisted = await this.#readSessionFromLog(sessionId);
|
|
186
|
+
if (persisted.length) {
|
|
187
|
+
fromLog = true;
|
|
188
|
+
const bySeq = new Map();
|
|
189
|
+
for (const event of persisted) {
|
|
190
|
+
if (withinCursor(event)) bySeq.set(event.seq, event);
|
|
191
|
+
}
|
|
192
|
+
for (const event of inMemory) bySeq.set(event.seq, event);
|
|
193
|
+
events = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const hasMore = events.length > wanted;
|
|
197
|
+
const page = hasMore ? events.slice(-wanted) : events;
|
|
198
|
+
return {
|
|
199
|
+
events: page,
|
|
200
|
+
truncated: hasMore,
|
|
201
|
+
hasMore,
|
|
202
|
+
oldestSeq: page.length ? page[0].seq : null,
|
|
203
|
+
lastSeq: page.length ? page.at(-1).seq : 0,
|
|
204
|
+
fromLog,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async #readSessionFromLog(sessionId) {
|
|
209
|
+
let content;
|
|
210
|
+
try {
|
|
211
|
+
content = await this.#readLog();
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error.code === "ENOENT") return [];
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
const events = [];
|
|
217
|
+
const deltaByTurn = new Map();
|
|
218
|
+
for (const line of content.split(/\r?\n/)) {
|
|
219
|
+
if (!line) continue;
|
|
220
|
+
let event;
|
|
221
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
222
|
+
if (event.sessionId !== sessionId || !Number.isSafeInteger(event.seq)) continue;
|
|
223
|
+
// Same coalescing as the live window: history is read for display, and a
|
|
224
|
+
// raw delta flood would push the conversation out of it again.
|
|
225
|
+
retain(events, deltaByTurn, event);
|
|
226
|
+
}
|
|
227
|
+
return events;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Case-insensitive search across conversation text.
|
|
232
|
+
*
|
|
233
|
+
* Long-running agents produce conversations you cannot re-read from the top, so
|
|
234
|
+
* "which conversation did I agree to that in" has to be answerable. Only message
|
|
235
|
+
* text is searched (what a person remembers), the newest hits win, and every
|
|
236
|
+
* result carries the session and sequence so the phone can jump straight there.
|
|
237
|
+
*/
|
|
238
|
+
async search(query, { limit = 30, perSession = 5, maxLogBytes = 24 * 1024 * 1024 } = {}) {
|
|
239
|
+
const needle = String(query ?? "").trim().toLowerCase();
|
|
240
|
+
const wanted = Math.min(Math.max(Number(limit) || 30, 1), 100);
|
|
241
|
+
const hits = [];
|
|
242
|
+
if (needle.length < 2) return { hits, scanned: 0, truncated: false };
|
|
243
|
+
|
|
244
|
+
const consider = (event, source) => {
|
|
245
|
+
if (!event || !Number.isSafeInteger(event.seq)) return;
|
|
246
|
+
const payload = event.payload ?? {};
|
|
247
|
+
let text = "";
|
|
248
|
+
let role = "agent";
|
|
249
|
+
if (event.kind === "message.completed") {
|
|
250
|
+
text = String(payload.text ?? "");
|
|
251
|
+
role = payload.role === "user" ? "user" : "assistant";
|
|
252
|
+
} else if (event.kind === "tool.started" || event.kind === "tool.completed") {
|
|
253
|
+
text = [payload.command, payload.type].filter(Boolean).join(" ");
|
|
254
|
+
role = "tool";
|
|
255
|
+
} else {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!text) return;
|
|
259
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
260
|
+
if (at < 0) return;
|
|
261
|
+
const padded = text.slice(Math.max(0, at - 40), Math.min(text.length, at + needle.length + 60)).replace(/\s+/g, " ").trim();
|
|
262
|
+
hits.push({
|
|
263
|
+
sessionId: event.sessionId ?? null,
|
|
264
|
+
seq: event.seq,
|
|
265
|
+
at: event.time ?? event.at ?? null,
|
|
266
|
+
kind: event.kind,
|
|
267
|
+
role,
|
|
268
|
+
snippet: padded,
|
|
269
|
+
source,
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Newest first, so a cap keeps the most relevant recent matches.
|
|
274
|
+
const memory = this.listAfter(0);
|
|
275
|
+
let scanned = 0;
|
|
276
|
+
for (let index = memory.length - 1; index >= 0 && hits.length < wanted * 3; index -= 1) {
|
|
277
|
+
scanned += 1;
|
|
278
|
+
consider(memory[index], "memory");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let truncated = false;
|
|
282
|
+
if (this.#evicted) {
|
|
283
|
+
let content = null;
|
|
284
|
+
try {
|
|
285
|
+
content = await this.#readLog(maxLogBytes);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error.code !== "ENOENT") throw error;
|
|
288
|
+
}
|
|
289
|
+
if (content === null) truncated = true;
|
|
290
|
+
if (content) {
|
|
291
|
+
const lines = content.split(/\r?\n/);
|
|
292
|
+
for (let index = lines.length - 1; index >= 0 && hits.length < wanted * 3; index -= 1) {
|
|
293
|
+
const line = lines[index];
|
|
294
|
+
if (!line) continue;
|
|
295
|
+
let event;
|
|
296
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
297
|
+
scanned += 1;
|
|
298
|
+
consider(event, "log");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// De-duplicate the same event seen in both places, then keep the newest.
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
const unique = [];
|
|
306
|
+
for (const hit of hits) {
|
|
307
|
+
const key = `${hit.sessionId}:${hit.seq}`;
|
|
308
|
+
if (seen.has(key)) continue;
|
|
309
|
+
seen.add(key);
|
|
310
|
+
unique.push(hit);
|
|
311
|
+
}
|
|
312
|
+
unique.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0));
|
|
313
|
+
|
|
314
|
+
const perSessionCount = new Map();
|
|
315
|
+
const balanced = [];
|
|
316
|
+
for (const hit of unique) {
|
|
317
|
+
const count = perSessionCount.get(hit.sessionId) ?? 0;
|
|
318
|
+
if (count >= perSession) continue;
|
|
319
|
+
perSessionCount.set(hit.sessionId, count + 1);
|
|
320
|
+
balanced.push(hit);
|
|
321
|
+
if (balanced.length >= wanted) break;
|
|
322
|
+
}
|
|
323
|
+
return { hits: balanced, scanned, truncated };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
subscribe(listener) {
|
|
327
|
+
this.#emitter.on("event", listener);
|
|
328
|
+
return () => this.#emitter.off("event", listener);
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/notifier.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound notifications for the moments a phone user cannot afford to miss.
|
|
3
|
+
*
|
|
4
|
+
* The whole product promise is "let the agent run while you are away", but nothing
|
|
5
|
+
* can be away-proof if the only signal is a page that has to be open. This module
|
|
6
|
+
* pushes the three moments that actually block progress — a pending approval, a
|
|
7
|
+
* pending question, and a finished or failed turn — to a channel the user already
|
|
8
|
+
* watches (a WeCom bot, Telegram, ServerChan, or any endpoint that accepts JSON).
|
|
9
|
+
*
|
|
10
|
+
* Design rules, learned the hard way elsewhere in this codebase:
|
|
11
|
+
* - Never throw: a dead webhook must not fail the turn it is describing.
|
|
12
|
+
* - Never block: the caller fires and forgets; `send` resolves to a result object.
|
|
13
|
+
* - Never storm: transcripts emit many events, so identical (session, kind) pairs
|
|
14
|
+
* are rate limited, and only the five meaningful kinds are forwarded at all.
|
|
15
|
+
* - Never leak the access key unless the operator explicitly opts in.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const FORMATS = new Set(["json", "wecom", "telegram", "serverchan", "pushplus"]);
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TITLES = {
|
|
21
|
+
"approval.requested": "需要批准",
|
|
22
|
+
"input.requested": "等你回答",
|
|
23
|
+
"turn.completed": "回合结束",
|
|
24
|
+
"turn.failed": "回合失败",
|
|
25
|
+
"session.failed": "会话失败",
|
|
26
|
+
"notify.test": "通知测试",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function clip(value, limit) {
|
|
30
|
+
const text = typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
|
31
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build the human-readable body of a notification, in the app's own voice. */
|
|
35
|
+
export function describeNotification(kind, payload = {}, context = {}) {
|
|
36
|
+
const sessionLabel = clip(context.sessionTitle || "未命名会话", 60);
|
|
37
|
+
const adapter = clip(context.adapterLabel ?? context.adapterId ?? "", 40);
|
|
38
|
+
const where = [sessionLabel, adapter].filter(Boolean).join(" · ");
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case "approval.requested": {
|
|
41
|
+
const method = clip(payload.request?.toolName ?? payload.method ?? "工具", 60);
|
|
42
|
+
return {
|
|
43
|
+
title: `⚠️ 需要批准:${method}`,
|
|
44
|
+
text: [`${where}`, `Agent 想执行 ${method},等你确认。`, clip(payload.request?.reason ?? "", 200)].filter(Boolean).join("\n"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
case "input.requested": {
|
|
48
|
+
const first = payload.questions?.[0];
|
|
49
|
+
const question = clip(first?.question ?? first?.header ?? "一个问题", 200);
|
|
50
|
+
const more = (payload.questions?.length ?? 1) > 1 ? `(共 ${payload.questions.length} 个问题)` : "";
|
|
51
|
+
return { title: `❓ 等你回答${more}`, text: [`${where}`, question].filter(Boolean).join("\n") };
|
|
52
|
+
}
|
|
53
|
+
case "turn.completed": {
|
|
54
|
+
const last = clip(context.lastAssistantText ?? "", 160);
|
|
55
|
+
return { title: `✅ 回合结束:${sessionLabel}`, text: [where, last].filter(Boolean).join("\n") };
|
|
56
|
+
}
|
|
57
|
+
case "turn.failed": {
|
|
58
|
+
return { title: `❌ 回合失败:${sessionLabel}`, text: [where, clip(payload.error ?? "未知错误", 300)].filter(Boolean).join("\n") };
|
|
59
|
+
}
|
|
60
|
+
case "session.failed": {
|
|
61
|
+
return { title: `❌ 会话失败:${sessionLabel}`, text: [where, clip(payload.error ?? "未知错误", 300)].filter(Boolean).join("\n") };
|
|
62
|
+
}
|
|
63
|
+
case "notify.test": {
|
|
64
|
+
return { title: "🔔 AgentBridge 通知测试", text: [`${where}`, "如果你看到这条消息,通知链路是通的。"].filter(Boolean).join("\n") };
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
return { title: DEFAULT_TITLES[kind] ?? "AgentBridge", text: where };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Shape the request for one provider. Kept separate so it can be unit tested. */
|
|
72
|
+
export function buildWebhookRequest({ format, webhookUrl, chatId, token, channel, title, text, payload }) {
|
|
73
|
+
if (format === "wecom") {
|
|
74
|
+
return { url: webhookUrl, body: { msgtype: "text", text: { content: `${title}\n${text}` } } };
|
|
75
|
+
}
|
|
76
|
+
if (format === "serverchan") {
|
|
77
|
+
return { url: webhookUrl, body: { title, desp: text.replace(/\n/g, "\n\n") } };
|
|
78
|
+
}
|
|
79
|
+
if (format === "pushplus") {
|
|
80
|
+
// PushPlus takes `token` in the body (or in the URL as /send/{token}), plus a
|
|
81
|
+
// title and content; `txt` keeps the newlines a chat message needs.
|
|
82
|
+
const tokenInUrl = /[?&]token=|\/send\/[^/?]+/.test(webhookUrl);
|
|
83
|
+
return {
|
|
84
|
+
url: webhookUrl,
|
|
85
|
+
body: {
|
|
86
|
+
...(!tokenInUrl && token ? { token } : {}),
|
|
87
|
+
title,
|
|
88
|
+
content: `${title}\n${text}`,
|
|
89
|
+
template: "txt",
|
|
90
|
+
// PushPlus delivers through a chosen channel; `clawbot` is the 微信ClawBot one.
|
|
91
|
+
...(channel ? { channel } : {}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (format === "telegram") {
|
|
96
|
+
return {
|
|
97
|
+
url: webhookUrl,
|
|
98
|
+
body: {
|
|
99
|
+
...(chatId ? { chat_id: chatId } : {}),
|
|
100
|
+
text: `${title}\n${text}`,
|
|
101
|
+
disable_web_page_preview: true,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Generic JSON: a superset that custom receivers, Discord and Slack-style
|
|
106
|
+
// endpoints can all read (`text` is the common denominator).
|
|
107
|
+
return { url: webhookUrl, body: { ...payload, title, text: `${title}\n${text}` } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Providers answer HTTP 200 and put the real result in the body.
|
|
112
|
+
*
|
|
113
|
+
* This is the difference between "the test button said sent" and a message that
|
|
114
|
+
* actually arrived: WeCom returns `errcode`, PushPlus returns `code`, ServerChan
|
|
115
|
+
* returns `code`/`data.errno`, Telegram returns `ok`. The generic `json` format is
|
|
116
|
+
* left alone because a custom endpoint's body is its own business.
|
|
117
|
+
*/
|
|
118
|
+
export function providerProblem(format, body) {
|
|
119
|
+
if (!body || typeof body !== "object") return null;
|
|
120
|
+
const describe = (code, message) => `provider-${code}${message ? `:${String(message).slice(0, 120)}` : ""}`;
|
|
121
|
+
if (format === "wecom" && Number(body.errcode) > 0) return describe(body.errcode, body.errmsg);
|
|
122
|
+
if (format === "pushplus") {
|
|
123
|
+
const code = Number(body.code);
|
|
124
|
+
if (Number.isFinite(code) && code !== 200) return describe(code, body.msg ?? body.message);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (format === "serverchan") {
|
|
128
|
+
if (Number(body.code) > 0) return describe(body.code, body.message);
|
|
129
|
+
if (body.data && Number(body.data.errno) > 0) return describe(body.data.errno, body.data.errmsg);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (format === "telegram" && body.ok === false) return describe(body.error_code ?? "error", body.description);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class Notifier {
|
|
137
|
+
#fmt = "json";
|
|
138
|
+
#webhookUrl = null;
|
|
139
|
+
#chatId = null;
|
|
140
|
+
#token = null;
|
|
141
|
+
#channel = null;
|
|
142
|
+
#muted = false;
|
|
143
|
+
#linkUrl = null;
|
|
144
|
+
#fetch = null;
|
|
145
|
+
#now = () => Date.now();
|
|
146
|
+
#logger = console;
|
|
147
|
+
#timeoutMs = 5_000;
|
|
148
|
+
#minIntervalMs = 15_000;
|
|
149
|
+
#lastSent = new Map();
|
|
150
|
+
|
|
151
|
+
constructor(options = {}) {
|
|
152
|
+
const format = String(options.format ?? "json").toLowerCase();
|
|
153
|
+
this.#fmt = FORMATS.has(format) ? format : "json";
|
|
154
|
+
this.#webhookUrl = typeof options.webhookUrl === "string" && options.webhookUrl.trim() ? options.webhookUrl.trim() : null;
|
|
155
|
+
this.#chatId = options.chatId ? String(options.chatId) : null;
|
|
156
|
+
this.#token = options.token ? String(options.token) : null;
|
|
157
|
+
this.#channel = options.channel ? String(options.channel) : null;
|
|
158
|
+
this.#muted = options.muted === true;
|
|
159
|
+
this.#linkUrl = typeof options.linkUrl === "string" && options.linkUrl.trim() ? options.linkUrl.trim() : null;
|
|
160
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
161
|
+
if (typeof options.now === "function") this.#now = options.now;
|
|
162
|
+
if (options.logger) this.#logger = options.logger;
|
|
163
|
+
if (Number.isFinite(options.timeoutMs)) this.#timeoutMs = options.timeoutMs;
|
|
164
|
+
if (Number.isFinite(options.minIntervalMs)) this.#minIntervalMs = options.minIntervalMs;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get enabled() {
|
|
168
|
+
return Boolean(this.#webhookUrl);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get format() {
|
|
172
|
+
return this.#fmt;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The provider-specific delivery channel, when one is configured. */
|
|
176
|
+
get channel() {
|
|
177
|
+
return this.#channel;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Muted means configured-but-silent: the phone can stop the buzzing without
|
|
182
|
+
* throwing away the webhook, the token or the channel.
|
|
183
|
+
*/
|
|
184
|
+
get muted() {
|
|
185
|
+
return this.#muted;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
setMuted(value) {
|
|
189
|
+
this.#muted = value === true;
|
|
190
|
+
return this.#muted;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Send one notification. Always resolves: `{ sent, reason }` describes what
|
|
195
|
+
* happened, and a failure is logged instead of thrown.
|
|
196
|
+
*/
|
|
197
|
+
async send(kind, payload, context = {}) {
|
|
198
|
+
if (!this.enabled) return { sent: false, reason: "disabled" };
|
|
199
|
+
if (this.#muted) return { sent: false, reason: "muted" };
|
|
200
|
+
if (kind !== "notify.test") {
|
|
201
|
+
const key = `${context.sessionId ?? "-"}:${kind}`;
|
|
202
|
+
const last = this.#lastSent.get(key);
|
|
203
|
+
// The first signal for a pair always goes out; only repeats are damped.
|
|
204
|
+
if (last !== undefined && this.#now() - last < this.#minIntervalMs) {
|
|
205
|
+
return { sent: false, reason: "rate-limited" };
|
|
206
|
+
}
|
|
207
|
+
this.#lastSent.set(key, this.#now());
|
|
208
|
+
if (this.#lastSent.size > 500) this.#lastSent.delete(this.#lastSent.keys().next().value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { title, text } = describeNotification(kind, payload, context);
|
|
212
|
+
const body = {
|
|
213
|
+
kind,
|
|
214
|
+
machine: clip(context.machineName ?? "", 60),
|
|
215
|
+
sessionId: context.sessionId ?? null,
|
|
216
|
+
sessionTitle: clip(context.sessionTitle ?? "", 100),
|
|
217
|
+
adapter: context.adapterId ?? null,
|
|
218
|
+
at: new Date(this.#now()).toISOString(),
|
|
219
|
+
...(this.#linkUrl ? { url: this.#linkUrl } : {}),
|
|
220
|
+
};
|
|
221
|
+
const request = buildWebhookRequest({
|
|
222
|
+
format: this.#fmt,
|
|
223
|
+
webhookUrl: this.#webhookUrl,
|
|
224
|
+
chatId: this.#chatId,
|
|
225
|
+
token: this.#token,
|
|
226
|
+
channel: this.#channel,
|
|
227
|
+
title,
|
|
228
|
+
text,
|
|
229
|
+
payload: body,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const response = await this.#fetch(request.url, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "content-type": "application/json" },
|
|
236
|
+
body: JSON.stringify(request.body),
|
|
237
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
238
|
+
});
|
|
239
|
+
if (!response?.ok) {
|
|
240
|
+
this.#logger.warn?.(`AgentBridge notification rejected (${response?.status ?? "no response"})`);
|
|
241
|
+
return { sent: false, reason: `http-${response?.status ?? "error"}` };
|
|
242
|
+
}
|
|
243
|
+
// A 200 with an error code in the body is the normal failure mode of these
|
|
244
|
+
// providers, and reporting it as "sent" would make the test button lie.
|
|
245
|
+
let providerBody = null;
|
|
246
|
+
try {
|
|
247
|
+
if (typeof response.text === "function") {
|
|
248
|
+
const raw = await response.text();
|
|
249
|
+
if (raw) providerBody = JSON.parse(raw);
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
providerBody = null;
|
|
253
|
+
}
|
|
254
|
+
const problem = providerProblem(this.#fmt, providerBody);
|
|
255
|
+
if (problem) {
|
|
256
|
+
this.#logger.warn?.(`AgentBridge notification rejected by the provider (${problem})`);
|
|
257
|
+
return { sent: false, reason: problem };
|
|
258
|
+
}
|
|
259
|
+
return { sent: true };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
// A notification is best-effort by definition: never let it break a turn.
|
|
262
|
+
this.#logger.warn?.(`AgentBridge notification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
+
return { sent: false, reason: "error" };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|