@cjhyy/code-shell-chat 0.8.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +416 -0
  3. package/dist/adapter-factory.d.ts +30 -0
  4. package/dist/adapter-factory.js +198 -0
  5. package/dist/attachments.d.ts +13 -0
  6. package/dist/attachments.js +74 -0
  7. package/dist/channel.d.ts +112 -0
  8. package/dist/channel.js +60 -0
  9. package/dist/chat-gateway.d.ts +58 -0
  10. package/dist/chat-gateway.js +232 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +259 -0
  13. package/dist/codeshell.d.ts +6 -0
  14. package/dist/codeshell.js +6 -0
  15. package/dist/config.d.ts +125 -0
  16. package/dist/config.js +469 -0
  17. package/dist/delivery-queue.d.ts +93 -0
  18. package/dist/delivery-queue.js +401 -0
  19. package/dist/desktop-control-client.d.ts +51 -0
  20. package/dist/desktop-control-client.js +309 -0
  21. package/dist/dingtalk.d.ts +19 -0
  22. package/dist/dingtalk.js +93 -0
  23. package/dist/discord.d.ts +23 -0
  24. package/dist/discord.js +134 -0
  25. package/dist/gateway.d.ts +34 -0
  26. package/dist/gateway.js +287 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.js +13 -0
  29. package/dist/ingress.d.ts +8 -0
  30. package/dist/ingress.js +61 -0
  31. package/dist/instance-lock.d.ts +24 -0
  32. package/dist/instance-lock.js +100 -0
  33. package/dist/lark.d.ts +20 -0
  34. package/dist/lark.js +184 -0
  35. package/dist/lifecycle.d.ts +7 -0
  36. package/dist/lifecycle.js +16 -0
  37. package/dist/line.d.ts +17 -0
  38. package/dist/line.js +123 -0
  39. package/dist/matrix.d.ts +21 -0
  40. package/dist/matrix.js +208 -0
  41. package/dist/mattermost.d.ts +18 -0
  42. package/dist/mattermost.js +170 -0
  43. package/dist/media.d.ts +39 -0
  44. package/dist/media.js +223 -0
  45. package/dist/notification-progress.d.ts +46 -0
  46. package/dist/notification-progress.js +299 -0
  47. package/dist/notification-relay.d.ts +43 -0
  48. package/dist/notification-relay.js +285 -0
  49. package/dist/platform-canary.d.ts +22 -0
  50. package/dist/platform-canary.js +55 -0
  51. package/dist/protocol.d.ts +102 -0
  52. package/dist/protocol.js +1 -0
  53. package/dist/service-manager.d.ts +47 -0
  54. package/dist/service-manager.js +222 -0
  55. package/dist/slack.d.ts +17 -0
  56. package/dist/slack.js +136 -0
  57. package/dist/teams.d.ts +26 -0
  58. package/dist/teams.js +193 -0
  59. package/dist/telegram.d.ts +33 -0
  60. package/dist/telegram.js +263 -0
  61. package/dist/webhook.d.ts +26 -0
  62. package/dist/webhook.js +91 -0
  63. package/dist/wechat-login.d.ts +21 -0
  64. package/dist/wechat-login.js +97 -0
  65. package/dist/wechat-storage.d.ts +61 -0
  66. package/dist/wechat-storage.js +251 -0
  67. package/dist/wechat.d.ts +129 -0
  68. package/dist/wechat.js +1082 -0
  69. package/dist/wecom.d.ts +16 -0
  70. package/dist/wecom.js +149 -0
  71. package/dist/whatsapp.d.ts +27 -0
  72. package/dist/whatsapp.js +218 -0
  73. package/package.json +123 -0
@@ -0,0 +1,401 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ export class DeliveryBackpressureError extends Error {
5
+ constructor(limit) {
6
+ super(`Chat Gateway inbox 已满(上限 ${limit})`);
7
+ this.name = "DeliveryBackpressureError";
8
+ }
9
+ }
10
+ /**
11
+ * Thrown by the deliver callback when a record can never be routed (e.g. a
12
+ * persisted message references an adapter that no longer exists after a config
13
+ * change). The queue drops such records instead of retrying them forever.
14
+ */
15
+ export class UnroutableDeliveryError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "UnroutableDeliveryError";
19
+ }
20
+ }
21
+ /**
22
+ * Durable, bounded inbox. Text webhook deliveries are acknowledged only after
23
+ * this queue has atomically recorded them; processing happens out of band with
24
+ * per-target ordering and retry. Lazy attachment functions stay in-memory.
25
+ */
26
+ export class DeliveryQueue {
27
+ config;
28
+ deliver;
29
+ onError;
30
+ pending = [];
31
+ completed = new Map();
32
+ inFlight = new Set();
33
+ targetInFlight = new Map();
34
+ mutation = Promise.resolve();
35
+ retryTimer;
36
+ stopped = true;
37
+ constructor(config, deliver, onError) {
38
+ this.config = config;
39
+ this.deliver = deliver;
40
+ this.onError = onError;
41
+ }
42
+ async start() {
43
+ this.stopped = false;
44
+ if (this.config.path) {
45
+ await this.load();
46
+ // Reclaim spool files no surviving record points at.
47
+ await this.sweepOrphanedSpool();
48
+ }
49
+ this.pump();
50
+ }
51
+ stop() {
52
+ this.stopped = true;
53
+ if (this.retryTimer)
54
+ clearTimeout(this.retryTimer);
55
+ this.retryTimer = undefined;
56
+ }
57
+ status() {
58
+ const now = Date.now();
59
+ return {
60
+ pending: this.pending.length,
61
+ inFlight: this.inFlight.size,
62
+ delayed: this.pending.filter((entry) => entry.nextAttemptAt > now).length,
63
+ };
64
+ }
65
+ async enqueue(adapterId, message) {
66
+ if (this.stopped)
67
+ throw new Error("Chat Gateway inbox is stopped");
68
+ const dedupeKey = deliveryDedupeKey(message);
69
+ let result = "queued";
70
+ await this.withMutation(async () => {
71
+ this.pruneCompleted();
72
+ if (dedupeKey &&
73
+ (this.completed.has(dedupeKey) ||
74
+ this.pending.some((entry) => entry.dedupeKey === dedupeKey))) {
75
+ result = "duplicate";
76
+ return;
77
+ }
78
+ if (this.pending.length >= this.config.maxPending) {
79
+ throw new DeliveryBackpressureError(this.config.maxPending);
80
+ }
81
+ const id = randomUUID();
82
+ // Attachments used to make a record non-persistent outright, because
83
+ // `load()` cannot be serialized — so the message lived in memory only while
84
+ // the adapter had already acked it. Spool the bytes instead: the record
85
+ // then persists like any other, carrying file paths in place of closures.
86
+ const spooled = await this.spoolAttachments(id, message);
87
+ const persistent = Boolean(this.config.path && (!message.attachments?.length || spooled !== undefined));
88
+ const safeMessage = persistent ? serializableMessage(message, spooled) : message;
89
+ this.pending.push({
90
+ id,
91
+ ...(dedupeKey ? { dedupeKey } : {}),
92
+ adapterId,
93
+ message: safeMessage,
94
+ attempts: 0,
95
+ nextAttemptAt: Date.now(),
96
+ persistent,
97
+ ...(spooled ? { spooled } : {}),
98
+ });
99
+ if (persistent)
100
+ await this.persist();
101
+ });
102
+ this.pump();
103
+ return result;
104
+ }
105
+ pump() {
106
+ if (this.stopped)
107
+ return;
108
+ if (this.retryTimer)
109
+ clearTimeout(this.retryTimer);
110
+ this.retryTimer = undefined;
111
+ const now = Date.now();
112
+ while (this.inFlight.size < this.config.maxConcurrent) {
113
+ const entry = this.pending.find((candidate) => {
114
+ if (this.inFlight.has(candidate.id) || candidate.nextAttemptAt > now)
115
+ return false;
116
+ return ((this.targetInFlight.get(targetKey(candidate.message)) ?? 0) < this.config.maxPerTarget);
117
+ });
118
+ if (!entry)
119
+ break;
120
+ this.inFlight.add(entry.id);
121
+ const key = targetKey(entry.message);
122
+ this.targetInFlight.set(key, (this.targetInFlight.get(key) ?? 0) + 1);
123
+ void this.process(entry);
124
+ }
125
+ const next = this.pending
126
+ .filter((entry) => !this.inFlight.has(entry.id) && entry.nextAttemptAt > now)
127
+ .reduce((earliest, entry) => (earliest === undefined ? entry.nextAttemptAt : Math.min(earliest, entry.nextAttemptAt)), undefined);
128
+ if (next !== undefined) {
129
+ this.retryTimer = setTimeout(() => this.pump(), Math.max(1, next - Date.now()));
130
+ this.retryTimer.unref?.();
131
+ }
132
+ }
133
+ async process(entry) {
134
+ let error;
135
+ try {
136
+ await this.deliver(entry.adapterId, entry.message);
137
+ }
138
+ catch (caught) {
139
+ error = caught;
140
+ this.onError(caught, entry.message);
141
+ }
142
+ await this.withMutation(async () => {
143
+ this.inFlight.delete(entry.id);
144
+ const key = targetKey(entry.message);
145
+ const targetCount = (this.targetInFlight.get(key) ?? 1) - 1;
146
+ if (targetCount <= 0)
147
+ this.targetInFlight.delete(key);
148
+ else
149
+ this.targetInFlight.set(key, targetCount);
150
+ if (error === undefined || error instanceof UnroutableDeliveryError) {
151
+ // Success, or a permanently unroutable record: drop it so a message
152
+ // that can never be delivered does not retry (and re-log) forever.
153
+ const index = this.pending.findIndex((candidate) => candidate.id === entry.id);
154
+ if (index >= 0)
155
+ this.pending.splice(index, 1);
156
+ if (error === undefined && entry.dedupeKey)
157
+ this.completed.set(entry.dedupeKey, Date.now());
158
+ // Terminal outcome — the bytes will never be needed again. Cleaning up
159
+ // here (rather than on a TTL sweep) keeps the spool bounded by what is
160
+ // actually pending.
161
+ await this.discardSpool(entry);
162
+ }
163
+ else {
164
+ entry.attempts += 1;
165
+ entry.nextAttemptAt =
166
+ Date.now() +
167
+ Math.min(this.config.retryMaxMs, this.config.retryBaseMs * 2 ** Math.min(entry.attempts - 1, 10));
168
+ }
169
+ this.pruneCompleted();
170
+ if (entry.persistent)
171
+ await this.persist();
172
+ });
173
+ this.pump();
174
+ }
175
+ /** Directory holding spooled attachment bytes, or undefined when not durable. */
176
+ spoolDir() {
177
+ if (!this.config.path)
178
+ return undefined;
179
+ return this.config.attachmentSpoolDir ?? `${this.config.path}.attachments`;
180
+ }
181
+ /**
182
+ * Materialize a message's attachments to disk so the record can be persisted.
183
+ *
184
+ * Returns undefined when there is nothing to spool, or when spooling fails —
185
+ * in which case the caller falls back to the old memory-only behaviour rather
186
+ * than dropping the message.
187
+ */
188
+ async spoolAttachments(recordId, message) {
189
+ const attachments = message.attachments;
190
+ const dir = this.spoolDir();
191
+ if (!attachments?.length || !dir)
192
+ return undefined;
193
+ const limit = this.config.maxSpooledAttachmentBytes ?? 16 * 1024 * 1024;
194
+ try {
195
+ await mkdir(dir, { recursive: true, mode: 0o700 });
196
+ const spooled = [];
197
+ for (const [index, attachment] of attachments.entries()) {
198
+ const bytes = await attachment.load();
199
+ // Oversized payloads stay in memory: spooling them would trade a lost
200
+ // message for unbounded disk use.
201
+ if (bytes.byteLength > limit)
202
+ return undefined;
203
+ const file = `${recordId}.${index}`;
204
+ await writeFile(`${dir}/${file}`, bytes, { mode: 0o600 });
205
+ spooled.push({
206
+ id: attachment.id,
207
+ kind: attachment.kind,
208
+ ...(attachment.name ? { name: attachment.name } : {}),
209
+ ...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}),
210
+ size: bytes.byteLength,
211
+ file,
212
+ });
213
+ }
214
+ return spooled;
215
+ }
216
+ catch {
217
+ // Spooling is an availability improvement, never a new failure mode.
218
+ return undefined;
219
+ }
220
+ }
221
+ /** Rebuild attachments whose bytes live in the spool. */
222
+ rehydrateAttachments(spooled) {
223
+ const dir = this.spoolDir();
224
+ if (!spooled?.length || !dir)
225
+ return undefined;
226
+ return spooled.map((entry) => ({
227
+ id: entry.id,
228
+ kind: entry.kind,
229
+ ...(entry.name ? { name: entry.name } : {}),
230
+ ...(entry.mimeType ? { mimeType: entry.mimeType } : {}),
231
+ size: entry.size,
232
+ load: async () => new Uint8Array(await readFile(`${dir}/${entry.file}`)),
233
+ }));
234
+ }
235
+ /**
236
+ * Delete spool files that no pending record references.
237
+ *
238
+ * `discardSpool` covers the normal path, but files can still be orphaned: a
239
+ * crash between writing the bytes and persisting the record, or the
240
+ * oversized-attachment fallback that spools nothing yet may have written
241
+ * earlier attachments of the same message. Without this the directory only
242
+ * grows, since nothing else ever looks at it.
243
+ *
244
+ * Runs once at load(), when the set of live records is exactly known.
245
+ */
246
+ async sweepOrphanedSpool() {
247
+ const dir = this.spoolDir();
248
+ if (!dir)
249
+ return;
250
+ const referenced = new Set();
251
+ for (const entry of this.pending) {
252
+ for (const attachment of entry.spooled ?? [])
253
+ referenced.add(attachment.file);
254
+ }
255
+ let files;
256
+ try {
257
+ files = await readdir(dir);
258
+ }
259
+ catch {
260
+ return; // No spool directory yet — nothing to sweep.
261
+ }
262
+ await Promise.all(files
263
+ .filter((file) => !referenced.has(file))
264
+ .map((file) => rm(`${dir}/${file}`, { force: true }).catch(() => undefined)));
265
+ }
266
+ /** Delete a record's spooled files once it will never be delivered again. */
267
+ async discardSpool(entry) {
268
+ const dir = this.spoolDir();
269
+ if (!entry.spooled?.length || !dir)
270
+ return;
271
+ await Promise.all(entry.spooled.map((attachment) => rm(`${dir}/${attachment.file}`, { force: true }).catch(() => undefined)));
272
+ }
273
+ async load() {
274
+ if (!this.config.path)
275
+ return;
276
+ try {
277
+ const parsed = JSON.parse(await readFile(this.config.path, "utf-8"));
278
+ if (parsed.version !== 1 || !Array.isArray(parsed.pending) || !parsed.completed)
279
+ return;
280
+ for (const entry of parsed.pending) {
281
+ if (!validStoredEntry(entry))
282
+ continue;
283
+ // Turn spooled metadata back into real attachments whose load() reads
284
+ // the file. This is the whole point of the spool: after a restart the
285
+ // upstream will not resend, so the bytes have to come from disk.
286
+ const restored = this.rehydrateAttachments(entry.spooled);
287
+ this.pending.push({
288
+ ...entry,
289
+ message: restored ? { ...entry.message, attachments: restored } : entry.message,
290
+ persistent: true,
291
+ nextAttemptAt: Date.now(),
292
+ });
293
+ }
294
+ for (const [key, timestamp] of Object.entries(parsed.completed)) {
295
+ if (typeof timestamp === "number")
296
+ this.completed.set(key, timestamp);
297
+ }
298
+ this.pruneCompleted();
299
+ }
300
+ catch (error) {
301
+ if (error.code !== "ENOENT") {
302
+ throw new Error(`无法读取 Chat Gateway inbox:${String(error)}`, { cause: error });
303
+ }
304
+ }
305
+ }
306
+ async persist() {
307
+ const path = this.config.path;
308
+ if (!path)
309
+ return;
310
+ const state = {
311
+ version: 1,
312
+ pending: this.pending
313
+ .filter((entry) => entry.persistent)
314
+ .map(({ persistent: _persistent, ...entry }) => {
315
+ // Never write live attachments: JSON drops their `load()`, leaving a
316
+ // record that claims attachments it cannot produce — and whose
317
+ // presence would stop the loader from rehydrating from the spool.
318
+ // `entry.spooled` is the durable representation.
319
+ if (!entry.message.attachments?.length)
320
+ return entry;
321
+ const { attachments: _attachments, ...message } = entry.message;
322
+ return { ...entry, message };
323
+ }),
324
+ completed: Object.fromEntries(this.completed),
325
+ };
326
+ const serialized = `${JSON.stringify(state)}\n`;
327
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
328
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
329
+ try {
330
+ await writeFile(temporary, serialized, { encoding: "utf-8", mode: 0o600 });
331
+ await rename(temporary, path);
332
+ await chmod(path, 0o600).catch(() => undefined);
333
+ }
334
+ catch (error) {
335
+ await rm(temporary, { force: true }).catch(() => undefined);
336
+ throw error;
337
+ }
338
+ }
339
+ pruneCompleted() {
340
+ const cutoff = Date.now() - this.config.completedTtlMs;
341
+ for (const [key, timestamp] of this.completed) {
342
+ if (timestamp < cutoff)
343
+ this.completed.delete(key);
344
+ }
345
+ }
346
+ async withMutation(operation) {
347
+ const previous = this.mutation;
348
+ let release;
349
+ this.mutation = new Promise((resolve) => {
350
+ release = resolve;
351
+ });
352
+ await previous;
353
+ try {
354
+ return await operation();
355
+ }
356
+ finally {
357
+ release();
358
+ }
359
+ }
360
+ }
361
+ function deliveryDedupeKey(message) {
362
+ return message.messageId
363
+ ? `${message.channel}\0${message.target}\0${message.senderId}\0${message.messageId}`
364
+ : undefined;
365
+ }
366
+ function targetKey(message) {
367
+ return `${message.channel}\0${message.target}`;
368
+ }
369
+ function serializableMessage(message, spooled) {
370
+ // Drop `attachments` before serializing: each carries a `load()` closure that
371
+ // JSON silently discards, which would leave a persisted message claiming
372
+ // attachments it can no longer produce. Spooled metadata is stored on the
373
+ // RECORD instead, and rehydrated into real load() functions on restart.
374
+ const { attachments: _attachments, ...rest } = message;
375
+ const serialized = JSON.stringify(rest);
376
+ if (serialized === undefined || Buffer.byteLength(serialized, "utf-8") > 1_048_576) {
377
+ throw new Error("Chat Gateway message is not persistable or exceeds 1 MiB");
378
+ }
379
+ const parsed = JSON.parse(serialized);
380
+ // Keep the live attachments for THIS process; the spool only matters after a
381
+ // restart, and re-reading from disk while the originals are in hand is waste.
382
+ if (message.attachments?.length && spooled) {
383
+ return { ...parsed, attachments: message.attachments };
384
+ }
385
+ return parsed;
386
+ }
387
+ function validStoredEntry(entry) {
388
+ if (!entry || typeof entry !== "object")
389
+ return false;
390
+ const value = entry;
391
+ return (typeof value.id === "string" &&
392
+ typeof value.adapterId === "string" &&
393
+ typeof value.attempts === "number" &&
394
+ typeof value.nextAttemptAt === "number" &&
395
+ Boolean(value.message) &&
396
+ typeof value.message?.channel === "string" &&
397
+ typeof value.message?.target === "string" &&
398
+ typeof value.message?.senderId === "string" &&
399
+ typeof value.message?.text === "string" &&
400
+ !value.message?.attachments);
401
+ }
@@ -0,0 +1,51 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import type { DesktopGatewayConfig } from "./config.js";
3
+ import { type DesktopControlDescriptor, type DesktopControlEvent, type DesktopControlEventPage, type MobileRemoteOpenResult, type MobileRemoteStatus, type PetChatRequest, type PetChatResult } from "./protocol.js";
4
+ type SpawnFn = (command: string, args: readonly string[], options: {
5
+ detached: boolean;
6
+ stdio: "ignore";
7
+ }) => ChildProcess;
8
+ export interface DesktopControlClientOptions {
9
+ fetch?: typeof fetch;
10
+ spawn?: SpawnFn;
11
+ readDescriptor?: () => Promise<string>;
12
+ sleep?: (ms: number) => Promise<void>;
13
+ }
14
+ export interface DesktopEventWatchOptions {
15
+ checkpointPath?: string;
16
+ onError?: (error: unknown) => void;
17
+ onRecovered?: () => void;
18
+ retryBaseMs?: number;
19
+ retryMaxMs?: number;
20
+ }
21
+ export interface DesktopEventContext {
22
+ streamId: string;
23
+ }
24
+ export declare class DesktopControlUnavailableError extends Error {
25
+ constructor(message?: string);
26
+ }
27
+ export declare class DesktopControlOperationError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ export declare class DesktopControlClient {
31
+ private readonly config;
32
+ private readonly fetchFn;
33
+ private readonly spawnFn;
34
+ private readonly readDescriptorFn;
35
+ private readonly sleepFn;
36
+ constructor(config: DesktopGatewayConfig, opts?: DesktopControlClientOptions);
37
+ open(): Promise<MobileRemoteOpenResult>;
38
+ close(): Promise<void>;
39
+ status(): Promise<MobileRemoteStatus>;
40
+ pairingUrl(): Promise<{
41
+ pairingUrl: string;
42
+ expiresAt: number;
43
+ }>;
44
+ petChat(input: PetChatRequest): Promise<PetChatResult>;
45
+ events(after?: number, waitMs?: number, signal?: AbortSignal): Promise<DesktopControlEventPage>;
46
+ watchEvents(signal: AbortSignal, handle: (event: DesktopControlEvent, context: DesktopEventContext) => Promise<void>, options?: DesktopEventWatchOptions): Promise<void>;
47
+ private ensureDesktopAvailable;
48
+ private request;
49
+ }
50
+ export declare function parseDescriptor(raw: string): DesktopControlDescriptor;
51
+ export {};