jeo-code 0.12.7 → 0.12.8
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/CHANGELOG.md +4 -0
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +4 -2
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/notify/discord-api.ts +125 -28
- package/src/agent/notify/discord-artifacts.ts +126 -0
- package/src/agent/notify/discord-attachments.ts +116 -0
- package/src/agent/notify/discord-daemon.ts +213 -39
- package/src/agent/notify/session-endpoint.ts +127 -5
- package/src/agent/session.ts +7 -5
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/launch/agy-turn.ts +20 -7
- package/src/commands/launch/confirm.ts +78 -0
- package/src/commands/launch/flags.ts +7 -0
- package/src/commands/launch/input.ts +18 -1
- package/src/commands/launch.ts +320 -40
- package/src/commands/messenger.ts +146 -0
- package/src/commands/notify.ts +3 -0
- package/src/messenger/activity.ts +30 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +145 -0
- package/src/messenger/discord-setup.ts +568 -0
- package/src/messenger/discord.ts +707 -0
- package/src/messenger/gateway.ts +444 -0
- package/src/messenger/http.ts +88 -0
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +109 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +241 -0
- package/src/messenger/types.ts +82 -0
- package/src/tui/components/slash.ts +1 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { MessengerHttpError } from "./http";
|
|
2
|
+
import type {
|
|
3
|
+
MessengerActivityReporter,
|
|
4
|
+
MessengerAdapter,
|
|
5
|
+
MessengerConfig,
|
|
6
|
+
MessengerMessage,
|
|
7
|
+
MessengerReply,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { MessengerFatalError } from "./types";
|
|
10
|
+
|
|
11
|
+
const MAX_MESSAGE_LENGTH = 32_000;
|
|
12
|
+
const MAX_QUEUED_MESSAGES = 100;
|
|
13
|
+
const MAX_SEEN_MESSAGES = 10_000;
|
|
14
|
+
const STATUS_TIMEOUT_MS = 1000;
|
|
15
|
+
const MAX_STATUS_IN_FLIGHT = 8;
|
|
16
|
+
const MAX_ACTIVITY_LENGTH = 120;
|
|
17
|
+
// Status edits are cosmetic: keep them sparse so they never compete with reply delivery for rate limits.
|
|
18
|
+
const ACTIVITY_INTERVAL_MS = 2_000;
|
|
19
|
+
const ACTIVITY_TIMEOUT_MS = 3_000;
|
|
20
|
+
|
|
21
|
+
/** Collapse to one bounded line: drop invisible format characters, fold control and whitespace runs. */
|
|
22
|
+
export function activityLine(text: string): string {
|
|
23
|
+
const line = text
|
|
24
|
+
.replace(/\p{Cf}/gu, "")
|
|
25
|
+
.replace(/[\p{Cc}\p{Z}\s]+/gu, " ")
|
|
26
|
+
.trim();
|
|
27
|
+
if (line.length <= MAX_ACTIVITY_LENGTH) return line;
|
|
28
|
+
let end = MAX_ACTIVITY_LENGTH - 1;
|
|
29
|
+
const last = line.charCodeAt(end - 1);
|
|
30
|
+
if (last >= 0xd800 && last <= 0xdbff) end--;
|
|
31
|
+
return `${line.slice(0, end).trimEnd()}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActivityReporter {
|
|
35
|
+
report: MessengerActivityReporter;
|
|
36
|
+
/** Stop updates and remove any shown line; idempotent and never rejects. */
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NO_ACTIVITY: ActivityReporter = { report() {}, close: () => Promise.resolve() };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bound a cosmetic adapter call: a stalled provider request must not hold up the request queue. A timeout
|
|
44
|
+
* rejects so callers can retry; cancellation through `signal` resolves because the request is ending.
|
|
45
|
+
*/
|
|
46
|
+
async function boundedActivity(run: (signal: AbortSignal) => Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
47
|
+
const timeout = new AbortController();
|
|
48
|
+
const callSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
|
49
|
+
const stopped = Promise.withResolvers<void>();
|
|
50
|
+
const onStop = (): void => {
|
|
51
|
+
if (timeout.signal.aborted) stopped.reject(new Error("Messenger activity update timed out"));
|
|
52
|
+
else stopped.resolve();
|
|
53
|
+
};
|
|
54
|
+
callSignal.addEventListener("abort", onStop, { once: true });
|
|
55
|
+
const timer = setTimeout(() => timeout.abort(), ACTIVITY_TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
await Promise.race([Promise.resolve().then(() => run(callSignal)), stopped.promise]);
|
|
58
|
+
} finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
callSignal.removeEventListener("abort", onStop);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Coalesce a request's activity into one status line: show the first report at once, then at most the
|
|
66
|
+
* latest line per interval. Removal skips the request signal so an aborted request still retires its line.
|
|
67
|
+
*/
|
|
68
|
+
export function createActivityReporter(
|
|
69
|
+
adapter: MessengerAdapter,
|
|
70
|
+
destination: MessengerMessage,
|
|
71
|
+
signal: AbortSignal,
|
|
72
|
+
intervalMs: number,
|
|
73
|
+
): ActivityReporter {
|
|
74
|
+
const activity = adapter.activity;
|
|
75
|
+
if (!activity) return NO_ACTIVITY;
|
|
76
|
+
let latest: string | undefined;
|
|
77
|
+
let sent: string | undefined;
|
|
78
|
+
let shown = false;
|
|
79
|
+
let closed = false;
|
|
80
|
+
let pumping = false;
|
|
81
|
+
let pump = Promise.resolve();
|
|
82
|
+
let nextAt = 0;
|
|
83
|
+
let wake: (() => void) | undefined;
|
|
84
|
+
let closing: Promise<void> | undefined;
|
|
85
|
+
const pause = async (ms: number): Promise<void> => {
|
|
86
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
87
|
+
const timer = setTimeout(resolve, ms);
|
|
88
|
+
wake = () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve();
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
await promise;
|
|
94
|
+
} finally {
|
|
95
|
+
wake = undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const run = async (): Promise<void> => {
|
|
99
|
+
try {
|
|
100
|
+
while (!closed && !signal.aborted && latest !== sent) {
|
|
101
|
+
const delay = nextAt - Date.now();
|
|
102
|
+
if (delay > 0) {
|
|
103
|
+
await pause(delay);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const text = latest;
|
|
107
|
+
sent = text;
|
|
108
|
+
shown = true;
|
|
109
|
+
let spacing = intervalMs;
|
|
110
|
+
try {
|
|
111
|
+
await boundedActivity(bounded => activity.call(adapter, destination, text, bounded), signal);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof MessengerHttpError && error.status === 429) {
|
|
114
|
+
// Keep the line pending: a rate-limited update is retried unless a newer line replaces it.
|
|
115
|
+
sent = undefined;
|
|
116
|
+
spacing = Math.max(spacing, error.retryAfterMs ?? 1_000);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
nextAt = Date.now() + spacing;
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
pumping = false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
report(text) {
|
|
127
|
+
if (closed || signal.aborted) return;
|
|
128
|
+
const line = activityLine(text);
|
|
129
|
+
if (!line) return;
|
|
130
|
+
latest = line;
|
|
131
|
+
if (pumping) return;
|
|
132
|
+
pumping = true;
|
|
133
|
+
pump = run();
|
|
134
|
+
},
|
|
135
|
+
close() {
|
|
136
|
+
closing ??= (async () => {
|
|
137
|
+
closed = true;
|
|
138
|
+
wake?.();
|
|
139
|
+
await pump;
|
|
140
|
+
if (!shown) return;
|
|
141
|
+
for (let attempt = 0; ; attempt++) {
|
|
142
|
+
try {
|
|
143
|
+
await boundedActivity(bounded => activity.call(adapter, destination, undefined, bounded));
|
|
144
|
+
return;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const wait =
|
|
147
|
+
error instanceof MessengerHttpError && error.status === 429
|
|
148
|
+
? (error.retryAfterMs ?? 1_000)
|
|
149
|
+
: 1_000;
|
|
150
|
+
// One bounded retry: removal holds the request queue.
|
|
151
|
+
if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) return;
|
|
152
|
+
await Bun.sleep(wait);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
return closing;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type OptionalIdentity = readonly ["absent"] | readonly ["present", string];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Canonical, ordered remote-session identity. `thread` and `conversation` are mutually exclusive
|
|
165
|
+
* alternatives; Discord supplies `thread`, while adapters with another conversation primitive may
|
|
166
|
+
* supply `conversation`. IDs are opaque and therefore are never trimmed or case-folded.
|
|
167
|
+
*/
|
|
168
|
+
export type MessengerSessionIdentity = readonly [
|
|
169
|
+
version: "messenger-session-v1",
|
|
170
|
+
platform: MessengerMessage["platform"],
|
|
171
|
+
account: string,
|
|
172
|
+
guild: OptionalIdentity,
|
|
173
|
+
channel: string,
|
|
174
|
+
thread: OptionalIdentity,
|
|
175
|
+
conversation: OptionalIdentity,
|
|
176
|
+
user: string,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
function optionalIdentity(name: string, value: string | undefined): OptionalIdentity {
|
|
180
|
+
if (value === undefined) return ["absent"];
|
|
181
|
+
if (!value || value.trim() !== value) throw new Error(`Messenger ${name} identifier is invalid`);
|
|
182
|
+
return ["present", value];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function messengerSessionIdentity(message: MessengerMessage): MessengerSessionIdentity {
|
|
186
|
+
if (message.threadId !== undefined && message.conversationId !== undefined) {
|
|
187
|
+
throw new Error("Messenger thread and conversation identifiers are mutually exclusive");
|
|
188
|
+
}
|
|
189
|
+
for (const [name, value] of [
|
|
190
|
+
["account", message.accountId],
|
|
191
|
+
["channel", message.parentChannelId ?? message.channelId],
|
|
192
|
+
["user", message.senderId],
|
|
193
|
+
] as const) {
|
|
194
|
+
if (!value || value.trim() !== value) throw new Error(`Messenger ${name} identifier is invalid`);
|
|
195
|
+
}
|
|
196
|
+
return [
|
|
197
|
+
"messenger-session-v1",
|
|
198
|
+
message.platform,
|
|
199
|
+
message.accountId,
|
|
200
|
+
optionalIdentity("guild", message.guildId),
|
|
201
|
+
message.parentChannelId ?? message.channelId,
|
|
202
|
+
optionalIdentity("thread", message.threadId),
|
|
203
|
+
optionalIdentity("conversation", message.conversationId),
|
|
204
|
+
message.senderId,
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Collision-safe JSON serialization of the canonical identity tuple. */
|
|
209
|
+
export function messengerConversationKey(message: MessengerMessage): string {
|
|
210
|
+
return JSON.stringify(messengerSessionIdentity(message));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Stable per-conversation route shared by the persisted session file and its session process. */
|
|
214
|
+
export function messengerSessionRoute(cwd: string, message: MessengerMessage): string {
|
|
215
|
+
return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
219
|
+
if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
|
|
220
|
+
return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
221
|
+
}
|
|
222
|
+
return messengerConversationKey(message);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
|
|
226
|
+
const platform = config.platforms[message.platform];
|
|
227
|
+
return Boolean(
|
|
228
|
+
platform &&
|
|
229
|
+
message.accountId &&
|
|
230
|
+
message.channelId &&
|
|
231
|
+
message.senderId &&
|
|
232
|
+
message.messageId &&
|
|
233
|
+
(message.text.trim() ||
|
|
234
|
+
(message.platform === "discord" && config.platforms.discord?.artifactDir && message.attachments?.length)) &&
|
|
235
|
+
message.text.length <= MAX_MESSAGE_LENGTH &&
|
|
236
|
+
platform.allowedUserIds.includes(message.senderId) &&
|
|
237
|
+
(!platform.allowedChannelIds ||
|
|
238
|
+
platform.allowedChannelIds.includes(message.channelId) ||
|
|
239
|
+
(Boolean(message.parentChannelId) && platform.allowedChannelIds.includes(message.parentChannelId!))) &&
|
|
240
|
+
(message.direct || message.mentioned || (message.platform === "discord" && Boolean(message.parentChannelId))),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface PendingMessage {
|
|
245
|
+
message: MessengerMessage;
|
|
246
|
+
adapter: MessengerAdapter;
|
|
247
|
+
signal: AbortSignal;
|
|
248
|
+
resolve: () => void;
|
|
249
|
+
/** Requests sharing a key never run at the same time. */
|
|
250
|
+
key: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface MessengerGateway {
|
|
254
|
+
run(signal: AbortSignal): Promise<void>;
|
|
255
|
+
handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface MessengerGatewayOptions {
|
|
259
|
+
/** Minimum spacing between in-place activity edits for one request. */
|
|
260
|
+
activityIntervalMs?: number;
|
|
261
|
+
/**
|
|
262
|
+
* Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
|
|
263
|
+
* order; above 1 the reply implementation must accept concurrent turns for different conversations.
|
|
264
|
+
*/
|
|
265
|
+
concurrency?: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function createMessengerGateway(
|
|
269
|
+
config: MessengerConfig,
|
|
270
|
+
adapters: MessengerAdapter[],
|
|
271
|
+
reply: MessengerReply,
|
|
272
|
+
options: MessengerGatewayOptions = {},
|
|
273
|
+
): MessengerGateway {
|
|
274
|
+
const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
|
|
275
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 1));
|
|
276
|
+
const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
|
|
277
|
+
if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
|
|
278
|
+
for (const platform of Object.keys(config.platforms)) {
|
|
279
|
+
if (!adapters.some(adapter => adapter.platform === platform))
|
|
280
|
+
throw new Error("Missing configured messenger adapter");
|
|
281
|
+
}
|
|
282
|
+
const seen = new Set<string>();
|
|
283
|
+
const queue: PendingMessage[] = [];
|
|
284
|
+
const activeKeys = new Set<string>();
|
|
285
|
+
const turns = new Set<Promise<void>>();
|
|
286
|
+
/** Settles when the latest request of a prepared conversation has delivered its reply. */
|
|
287
|
+
const conversationTails = new Map<string, Promise<void>>();
|
|
288
|
+
const lifetime = new AbortController();
|
|
289
|
+
let running = false;
|
|
290
|
+
let fatalError: MessengerFatalError | undefined;
|
|
291
|
+
let statusInFlight = 0;
|
|
292
|
+
|
|
293
|
+
async function processRequest(pending: PendingMessage): Promise<void> {
|
|
294
|
+
const { message, adapter, signal, resolve } = pending;
|
|
295
|
+
let failed = false;
|
|
296
|
+
let activity = NO_ACTIVITY;
|
|
297
|
+
let release = (): void => {};
|
|
298
|
+
let tail: Promise<void> | undefined;
|
|
299
|
+
let conversation = "";
|
|
300
|
+
let statusChain = Promise.resolve();
|
|
301
|
+
const status = (state: "processing" | "success" | "failure"): void => {
|
|
302
|
+
if (!adapter.status) return;
|
|
303
|
+
statusChain = statusChain.then(async () => {
|
|
304
|
+
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
305
|
+
const timeout = new AbortController();
|
|
306
|
+
const statusSignal = AbortSignal.any([signal, timeout.signal]);
|
|
307
|
+
const stopped = Promise.withResolvers<void>();
|
|
308
|
+
const onAbort = (): void => stopped.resolve();
|
|
309
|
+
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
310
|
+
statusInFlight++;
|
|
311
|
+
const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
|
|
312
|
+
void operation.then(
|
|
313
|
+
() => {
|
|
314
|
+
statusInFlight--;
|
|
315
|
+
},
|
|
316
|
+
() => {
|
|
317
|
+
statusInFlight--;
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
321
|
+
try {
|
|
322
|
+
await Promise.race([operation, stopped.promise]);
|
|
323
|
+
} catch {
|
|
324
|
+
/* best-effort status reaction */
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
statusSignal.removeEventListener("abort", onAbort);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
if (signal.aborted) return;
|
|
333
|
+
status("processing");
|
|
334
|
+
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
335
|
+
conversation = messengerConversationKey(destination);
|
|
336
|
+
const previous = conversationTails.get(conversation);
|
|
337
|
+
const done = Promise.withResolvers<void>();
|
|
338
|
+
release = done.resolve;
|
|
339
|
+
tail = done.promise;
|
|
340
|
+
conversationTails.set(conversation, tail);
|
|
341
|
+
await previous;
|
|
342
|
+
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
343
|
+
let text: string;
|
|
344
|
+
try {
|
|
345
|
+
text = await reply(destination, signal, activity.report);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
failed = true;
|
|
348
|
+
if (error instanceof MessengerFatalError) {
|
|
349
|
+
fatalError = error;
|
|
350
|
+
status("failure");
|
|
351
|
+
lifetime.abort();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (signal.aborted) return;
|
|
355
|
+
text = "The request could not be completed. Please try again.";
|
|
356
|
+
} finally {
|
|
357
|
+
// Retire the status line alongside reply delivery rather than delaying the reply on it.
|
|
358
|
+
void activity.close();
|
|
359
|
+
}
|
|
360
|
+
if (!signal.aborted && text.trim()) {
|
|
361
|
+
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
362
|
+
await adapter.send(destination, text, signal);
|
|
363
|
+
}
|
|
364
|
+
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
365
|
+
} catch {
|
|
366
|
+
if (!signal.aborted) status("failure");
|
|
367
|
+
} finally {
|
|
368
|
+
await activity.close();
|
|
369
|
+
release();
|
|
370
|
+
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
371
|
+
resolve();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Start queued requests in arrival order while capacity allows, skipping conversations already running. */
|
|
376
|
+
function schedule(): void {
|
|
377
|
+
for (let index = 0; index < queue.length && turns.size < concurrency; ) {
|
|
378
|
+
const pending = queue[index]!;
|
|
379
|
+
if (activeKeys.has(pending.key)) {
|
|
380
|
+
index++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
queue.splice(index, 1);
|
|
384
|
+
activeKeys.add(pending.key);
|
|
385
|
+
const turn = processRequest(pending).finally(() => {
|
|
386
|
+
activeKeys.delete(pending.key);
|
|
387
|
+
turns.delete(turn);
|
|
388
|
+
schedule();
|
|
389
|
+
});
|
|
390
|
+
turns.add(turn);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
395
|
+
const adapter = byPlatform.get(message.platform);
|
|
396
|
+
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
397
|
+
if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
|
|
398
|
+
const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
399
|
+
if (seen.has(id) || queue.length >= MAX_QUEUED_MESSAGES) return Promise.resolve();
|
|
400
|
+
seen.add(id);
|
|
401
|
+
if (seen.size > MAX_SEEN_MESSAGES) {
|
|
402
|
+
const oldest = seen.values().next().value;
|
|
403
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
404
|
+
}
|
|
405
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
406
|
+
queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
|
|
407
|
+
schedule();
|
|
408
|
+
return promise;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
handle,
|
|
413
|
+
async run(signal) {
|
|
414
|
+
if (running || lifetime.signal.aborted) throw new Error("Messenger gateway cannot be restarted");
|
|
415
|
+
running = true;
|
|
416
|
+
const abort = (): void => lifetime.abort();
|
|
417
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
418
|
+
if (signal.aborted) abort();
|
|
419
|
+
const starts: Promise<void>[] = [];
|
|
420
|
+
try {
|
|
421
|
+
if (lifetime.signal.aborted) return;
|
|
422
|
+
for (const adapter of adapters) {
|
|
423
|
+
starts.push(
|
|
424
|
+
Promise.resolve().then(async () => {
|
|
425
|
+
await adapter.start(msg => handle(msg, lifetime.signal), lifetime.signal);
|
|
426
|
+
if (!lifetime.signal.aborted) throw new Error(`${adapter.platform} adapter stopped unexpectedly`);
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
await Promise.all(starts);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
lifetime.abort();
|
|
433
|
+
await Promise.allSettled(starts);
|
|
434
|
+
throw fatalError ?? error;
|
|
435
|
+
} finally {
|
|
436
|
+
lifetime.abort();
|
|
437
|
+
signal.removeEventListener("abort", abort);
|
|
438
|
+
while (queue.length > 0) queue.shift()?.resolve();
|
|
439
|
+
await Promise.allSettled(turns);
|
|
440
|
+
if (fatalError) throw fatalError;
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export class MessengerHttpError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
readonly status: number,
|
|
4
|
+
readonly retryAfterMs?: number,
|
|
5
|
+
) {
|
|
6
|
+
super(`Messenger request failed (HTTP ${status})`);
|
|
7
|
+
this.name = "MessengerHttpError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Provider limits count UTF-16 units; do not bisect supplementary characters. */
|
|
12
|
+
export function splitMessengerText(text: string, limit: number): string[] {
|
|
13
|
+
if (!Number.isSafeInteger(limit) || limit < 2)
|
|
14
|
+
throw new Error("Message chunk limit must be an integer of at least two");
|
|
15
|
+
const chunks: string[] = [];
|
|
16
|
+
for (let start = 0; start < text.length; ) {
|
|
17
|
+
let end = Math.min(start + limit, text.length);
|
|
18
|
+
const last = text.charCodeAt(end - 1);
|
|
19
|
+
if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--;
|
|
20
|
+
chunks.push(text.slice(start, end));
|
|
21
|
+
start = end;
|
|
22
|
+
}
|
|
23
|
+
return chunks;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function messengerFetchJson(
|
|
27
|
+
url: string,
|
|
28
|
+
init: RequestInit,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
fetchImpl: typeof fetch = fetch,
|
|
31
|
+
): Promise<unknown> {
|
|
32
|
+
const signals: AbortSignal[] = [AbortSignal.timeout(35_000)];
|
|
33
|
+
if (signal) signals.push(signal);
|
|
34
|
+
if (init.signal) signals.push(init.signal);
|
|
35
|
+
const requestSignal = AbortSignal.any(signals);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchImpl(url, { ...init, redirect: "error", signal: requestSignal });
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
let retryAfterMs: number | undefined;
|
|
40
|
+
if (response.status === 429) {
|
|
41
|
+
let seconds = Number(response.headers.get("retry-after"));
|
|
42
|
+
if (!seconds) {
|
|
43
|
+
try {
|
|
44
|
+
const body: unknown = await response.json();
|
|
45
|
+
if (typeof body === "object" && body !== null) {
|
|
46
|
+
if ("retry_after" in body && typeof body.retry_after === "number") {
|
|
47
|
+
seconds = body.retry_after;
|
|
48
|
+
} else if (
|
|
49
|
+
"parameters" in body &&
|
|
50
|
+
typeof body.parameters === "object" &&
|
|
51
|
+
body.parameters !== null &&
|
|
52
|
+
"retry_after" in body.parameters &&
|
|
53
|
+
typeof body.parameters.retry_after === "number"
|
|
54
|
+
) {
|
|
55
|
+
seconds = body.parameters.retry_after;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* Retry metadata is optional; never surface provider response text. */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Larger delays overflow native timers into immediate retries.
|
|
63
|
+
if (Number.isFinite(seconds) && seconds > 0) retryAfterMs = Math.min(seconds * 1000, 2_147_483_647);
|
|
64
|
+
}
|
|
65
|
+
if (!response.bodyUsed) await response.body?.cancel();
|
|
66
|
+
throw new MessengerHttpError(response.status, retryAfterMs);
|
|
67
|
+
}
|
|
68
|
+
if (response.status === 204) return undefined;
|
|
69
|
+
return await response.json();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof MessengerHttpError) throw error;
|
|
72
|
+
if (signal?.aborted || init.signal?.aborted) throw new Error("Messenger request cancelled");
|
|
73
|
+
throw new Error("Messenger request failed");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function waitForMessengerRetry(ms: number, signal: AbortSignal): Promise<void> {
|
|
78
|
+
if (signal.aborted) return;
|
|
79
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
80
|
+
const done = (): void => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
signal.removeEventListener("abort", done);
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
const timer = setTimeout(done, ms);
|
|
86
|
+
signal.addEventListener("abort", done, { once: true });
|
|
87
|
+
await promise;
|
|
88
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
|
|
6
|
+
/** A per-user background service that keeps one messenger profile's gateway running. */
|
|
7
|
+
export interface MessengerServicePlan {
|
|
8
|
+
/** Stable service name derived from the profile directory. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Profile directory: working directory and home of the run script and log. */
|
|
11
|
+
directory: string;
|
|
12
|
+
runScript: string;
|
|
13
|
+
logFile: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function messengerServicePlan(directory: string, runScript: string): MessengerServicePlan {
|
|
17
|
+
const slug =
|
|
18
|
+
path
|
|
19
|
+
.basename(directory)
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
22
|
+
.replace(/^-+|-+$/g, "") || "default";
|
|
23
|
+
return {
|
|
24
|
+
label: `dev.jeo.messenger.${slug}`,
|
|
25
|
+
directory,
|
|
26
|
+
runScript,
|
|
27
|
+
logFile: path.join(directory, "gateway.log"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function xml(value: string): string {
|
|
32
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** launchd job: starts at login, restarts after a crash (throttled), logs to the profile directory. */
|
|
36
|
+
export function launchdPlist(plan: MessengerServicePlan): string {
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
39
|
+
<plist version="1.0">
|
|
40
|
+
<dict>
|
|
41
|
+
<key>Label</key><string>${xml(plan.label)}</string>
|
|
42
|
+
<key>ProgramArguments</key>
|
|
43
|
+
<array><string>/bin/sh</string><string>${xml(plan.runScript)}</string></array>
|
|
44
|
+
<key>WorkingDirectory</key><string>${xml(plan.directory)}</string>
|
|
45
|
+
<key>RunAtLoad</key><true/>
|
|
46
|
+
<key>KeepAlive</key><true/>
|
|
47
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
48
|
+
<key>StandardOutPath</key><string>${xml(plan.logFile)}</string>
|
|
49
|
+
<key>StandardErrorPath</key><string>${xml(plan.logFile)}</string>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** systemd --user unit with the same lifecycle as the launchd job. */
|
|
56
|
+
export function systemdUnit(plan: MessengerServicePlan): string {
|
|
57
|
+
const quoted = (value: string): string => `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
58
|
+
return `[Unit]
|
|
59
|
+
Description=jeo messenger gateway (${plan.label})
|
|
60
|
+
After=network-online.target
|
|
61
|
+
|
|
62
|
+
[Service]
|
|
63
|
+
WorkingDirectory=${quoted(plan.directory)}
|
|
64
|
+
ExecStart=/bin/sh ${quoted(plan.runScript)}
|
|
65
|
+
Restart=always
|
|
66
|
+
RestartSec=30
|
|
67
|
+
StandardOutput=append:${plan.logFile}
|
|
68
|
+
StandardError=append:${plan.logFile}
|
|
69
|
+
|
|
70
|
+
[Install]
|
|
71
|
+
WantedBy=default.target
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Install (or replace) and start the service, then confirm the platform reports it running.
|
|
77
|
+
* Returns a one-line description of where it was installed.
|
|
78
|
+
*/
|
|
79
|
+
export async function installMessengerService(
|
|
80
|
+
plan: MessengerServicePlan,
|
|
81
|
+
platform: NodeJS.Platform = process.platform,
|
|
82
|
+
home: string = os.homedir(),
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
if (platform === "darwin") {
|
|
85
|
+
const file = path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
|
|
86
|
+
await Bun.write(file, launchdPlist(plan));
|
|
87
|
+
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
88
|
+
await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
|
|
89
|
+
// bootstrap can fail briefly while a replaced job is still exiting.
|
|
90
|
+
let loaded = false;
|
|
91
|
+
for (let attempt = 0; attempt < 5 && !loaded; attempt++) {
|
|
92
|
+
if (attempt) await Bun.sleep(1_000);
|
|
93
|
+
loaded = (await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode === 0;
|
|
94
|
+
}
|
|
95
|
+
if (!loaded) throw new Error(`launchctl could not load ${file}`);
|
|
96
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
97
|
+
const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
|
|
98
|
+
if (status.exitCode === 0 && /\bstate = running\b/.test(status.text()))
|
|
99
|
+
return `launchd job ${plan.label} (${file})`;
|
|
100
|
+
await Bun.sleep(500);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
|
|
103
|
+
}
|
|
104
|
+
if (platform === "linux") {
|
|
105
|
+
const unit = `${plan.label}.service`;
|
|
106
|
+
const file = path.join(home, ".config", "systemd", "user", unit);
|
|
107
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
|
+
await Bun.write(file, systemdUnit(plan));
|
|
109
|
+
const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
|
|
110
|
+
const enable = await $`systemctl --user enable ${unit}`.quiet().nothrow();
|
|
111
|
+
const restart = await $`systemctl --user restart ${unit}`.quiet().nothrow();
|
|
112
|
+
if (reload.exitCode !== 0 || enable.exitCode !== 0 || restart.exitCode !== 0) {
|
|
113
|
+
throw new Error(`systemctl --user could not start ${unit}`);
|
|
114
|
+
}
|
|
115
|
+
const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
|
|
116
|
+
if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
|
|
117
|
+
return `systemd user unit ${unit} (${file})`;
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Background services are supported on macOS (launchd) and Linux (systemd --user); run ${plan.runScript} under your own process manager`,
|
|
121
|
+
);
|
|
122
|
+
}
|