@cjhyy/code-shell-chat 0.9.44 → 0.9.47

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/README.md CHANGED
@@ -331,8 +331,9 @@ Desktop 的「编辑配置」会生成包含所有渠道的模板;在模板里
331
331
  系统只接受 `0600` 权限。
332
332
 
333
333
  可序列化的文字入站会先原子写入 `~/.code-shell/im-gateway/inbox.json`,webhook 平台随后才会
334
- 收到确认;相同平台 message ID 会持久去重。处理采用有界队列、全局并发和 per-target 串行,
335
- 暂时失败按指数退避重试。含惰性媒体 loader 的消息保留在内存队列,媒体仍受平台重投策略约束。
334
+ 收到确认;同一账号的相同平台 message ID 会持久去重。处理采用有界队列、全局并发和 per-target
335
+ 串行,暂时失败按指数退避重试。附件字节先写入 inbox 旁的 spool 再确认;spool 本地写入失败时
336
+ 拒绝确认,交由平台重投。超出 spool 上限或下载失败的媒体保留在内存队列,仍受平台重投策略约束。
336
337
  `notifications.targets` 最多 32 个,只能引用已启用渠道且必须同时位于该渠道 target 白名单。Desktop 主动
337
338
  通知游标会按事件流实例原子写入 `~/.code-shell/im-gateway/desktop-events.json`(可用
338
339
  `runtime.eventCursorPath` 覆盖);Gateway 和 Desktop 正常重启后都会续传。只有 Desktop 初始化了全新的
@@ -173,9 +173,9 @@ class LazyChannelAdapter {
173
173
  const adapter = await this.load();
174
174
  return adapter.run(handler, signal);
175
175
  }
176
- async send(target, message) {
176
+ async send(target, message, options) {
177
177
  const adapter = await this.load();
178
- return adapter.send(target, message);
178
+ return options ? adapter.send(target, message, options) : adapter.send(target, message);
179
179
  }
180
180
  load() {
181
181
  this.adapterPromise ??= this.loader();
package/dist/channel.d.ts CHANGED
@@ -86,9 +86,21 @@ export interface OutgoingAttachment {
86
86
  mimeType: string;
87
87
  data: Uint8Array;
88
88
  }
89
+ /** Placement of a reply within the target conversation, copied from the inbound message. */
90
+ export interface ChannelSendOptions {
91
+ /** Platform thread to reply in; adapters without threads ignore it. */
92
+ threadId?: string;
93
+ }
89
94
  export type ChannelMessageHandler = (message: ChannelMessage) => Promise<void>;
90
95
  export interface ChannelAdapter {
91
96
  readonly channel: string;
97
+ /**
98
+ * Stable account identity among adapters sharing this channel. A durable
99
+ * gateway binds accepted messages to it, so reordering the adapter list can
100
+ * never replay one account's input through another. Required when a durable
101
+ * gateway runs several adapters for the same channel.
102
+ */
103
+ readonly accountId?: string;
92
104
  readonly capabilities?: ChannelCapabilities;
93
105
  /**
94
106
  * True only when one visible platform request atomically publishes both the
@@ -99,7 +111,7 @@ export interface ChannelAdapter {
99
111
  /** @deprecated Use capabilities.outbound.attachments or supportsOutgoingAttachment(). */
100
112
  readonly supportsOutgoingAttachments?: boolean;
101
113
  run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
102
- send(target: string, message: OutgoingMessage): Promise<void>;
114
+ send(target: string, message: OutgoingMessage, options?: ChannelSendOptions): Promise<void>;
103
115
  }
104
116
  export declare function channelCapabilities(adapter: ChannelAdapter): ChannelCapabilities;
105
117
  export declare function supportsOutgoingAttachment(adapter: ChannelAdapter, kind: ChatAttachmentKind): boolean;
@@ -59,7 +59,9 @@ export class ChatGateway {
59
59
  const context = {
60
60
  message,
61
61
  adapter,
62
- reply: (outgoing) => adapter.send(message.target, outgoing),
62
+ reply: (outgoing) => message.threadId
63
+ ? adapter.send(message.target, outgoing, { threadId: message.threadId })
64
+ : adapter.send(message.target, outgoing),
63
65
  };
64
66
  let index = -1;
65
67
  const next = async (position) => {
@@ -75,6 +77,7 @@ export class ChatGateway {
75
77
  async run(signal) {
76
78
  if (this.startedAt)
77
79
  throw new Error("ChatGateway is already running");
80
+ const adapterById = routableAdapterIds(this.adapters, Boolean(this.delivery.path));
78
81
  this.startedAt = Date.now();
79
82
  const ownedAbort = new AbortController();
80
83
  const stopOwned = () => ownedAbort.abort();
@@ -83,26 +86,8 @@ export class ChatGateway {
83
86
  else
84
87
  signal.addEventListener("abort", stopOwned, { once: true });
85
88
  const webhooks = [];
86
- const adapterById = new Map();
87
- // Key adapters by a per-channel ordinal (channel:0, channel:1, …) rather
88
- // than the global array index. Delivery-queue records persist this id and
89
- // are replayed on the next run; a global index would shift when an
90
- // unrelated channel is removed from config, orphaning durable messages
91
- // against an id that no longer maps to their adapter.
92
- const perChannelCount = new Map();
93
- for (const adapter of this.adapters) {
94
- const ordinal = perChannelCount.get(adapter.channel) ?? 0;
95
- perChannelCount.set(adapter.channel, ordinal + 1);
96
- adapterById.set(`${adapter.channel}:${ordinal}`, adapter);
97
- }
98
89
  const queue = new DeliveryQueue(this.delivery, async (adapterId, message) => {
99
- const adapter = adapterById.get(adapterId);
100
- if (!adapter) {
101
- // A durable record survived a config change that removed its adapter.
102
- // Signal unroutable so the queue drops it instead of retrying forever.
103
- throw new UnroutableDeliveryError(`Chat adapter no longer exists: ${adapterId}`);
104
- }
105
- await this.dispatch(adapter, message);
90
+ await this.dispatch(resolveDurableAdapter(adapterById, adapterId), message);
106
91
  }, this.onError);
107
92
  this.queue = queue;
108
93
  await queue.start();
@@ -125,7 +110,10 @@ export class ChatGateway {
125
110
  }
126
111
  finally {
127
112
  ownedAbort.abort();
128
- queue.stop();
113
+ // Settle inbox writes before run() resolves: callers release the
114
+ // single-instance lease afterwards, and a late write from this queue
115
+ // would overwrite the next owner's accepted messages.
116
+ await queue.stop();
129
117
  this.queue = undefined;
130
118
  this.startedAt = undefined;
131
119
  signal.removeEventListener("abort", stopOwned);
@@ -173,6 +161,64 @@ export class ChatGateway {
173
161
  this.onAdapterState?.({ ...state });
174
162
  }
175
163
  }
164
+ const MAX_ACCOUNT_ID_LENGTH = 128;
165
+ /**
166
+ * Delivery-queue records persist the adapter id and are replayed on the next
167
+ * run, so the id must keep naming the same account across config edits.
168
+ * Adapters with an `accountId` use `channel@account`. Others keep the historic
169
+ * per-channel ordinal (`channel:0`), which is stable only while the channel has
170
+ * one adapter — a durable gateway therefore requires account identities for a
171
+ * multi-account channel instead of letting a reorder swap accounts.
172
+ */
173
+ function routableAdapterIds(adapters, durable) {
174
+ const perChannel = new Map();
175
+ for (const adapter of adapters) {
176
+ const accountId = adapter.accountId;
177
+ if (accountId !== undefined &&
178
+ (typeof accountId !== "string" ||
179
+ !accountId ||
180
+ accountId.length > MAX_ACCOUNT_ID_LENGTH ||
181
+ /[\u0000-\u001f\u007f]/u.test(accountId))) {
182
+ throw new Error(`Chat adapter ${adapter.channel} has an invalid accountId`);
183
+ }
184
+ perChannel.set(adapter.channel, [...(perChannel.get(adapter.channel) ?? []), adapter]);
185
+ }
186
+ const byId = new Map();
187
+ for (const [channel, members] of perChannel) {
188
+ if (durable && members.length > 1 && members.some(({ accountId }) => !accountId)) {
189
+ throw new Error(`Durable chat delivery needs a unique accountId for every ${channel} adapter`);
190
+ }
191
+ for (const [ordinal, adapter] of members.entries()) {
192
+ const id = adapter.accountId ? `${channel}@${adapter.accountId}` : `${channel}:${ordinal}`;
193
+ if (byId.has(id)) {
194
+ throw new Error(`Duplicate ${channel} adapter accountId: ${adapter.accountId}`);
195
+ }
196
+ byId.set(id, adapter);
197
+ }
198
+ }
199
+ return byId;
200
+ }
201
+ /** Resolve a persisted adapter id without ever falling back to another account. */
202
+ function resolveDurableAdapter(adapterById, adapterId) {
203
+ const exact = adapterById.get(adapterId);
204
+ if (exact)
205
+ return exact;
206
+ const legacy = /^(.+):(\d+)$/u.exec(adapterId);
207
+ const channel = legacy?.[1] ?? adapterId.slice(0, Math.max(0, adapterId.indexOf("@")));
208
+ const members = [...adapterById.values()].filter((adapter) => adapter.channel === channel);
209
+ // A legacy ordinal record written while this channel had a single adapter
210
+ // still belongs to that adapter after it gains an accountId.
211
+ if (legacy?.[2] === "0" && members.length === 1)
212
+ return members[0];
213
+ if (legacy && members.length > 0) {
214
+ // The ordinal no longer identifies an account. Keep the durable record for
215
+ // the operator rather than dropping it or delivering through a guess.
216
+ throw new Error(`Chat adapter ${adapterId} is ambiguous; configure account identities`);
217
+ }
218
+ // A durable record survived a config change that removed its adapter.
219
+ // Signal unroutable so the queue drops it instead of retrying forever.
220
+ throw new UnroutableDeliveryError(`Chat adapter no longer exists: ${adapterId}`);
221
+ }
176
222
  /** Deny-by-default allowlist middleware for multi-channel gateways. */
177
223
  export function createAllowlistMiddleware(rules) {
178
224
  const normalized = new Map(Object.entries(rules).map(([channel, rule]) => [
package/dist/cli.js CHANGED
@@ -54,6 +54,7 @@ async function main(args = process.argv.slice(2)) {
54
54
  const stop = () => shutdown.abort();
55
55
  process.once("SIGINT", stop);
56
56
  process.once("SIGTERM", stop);
57
+ let gatewayTask;
57
58
  try {
58
59
  const desktop = new DesktopControlClient(config.desktop);
59
60
  const adapters = await Promise.all(config.channels.map((channel) => createChannelAdapterAsync(channel, { discordCommands: CODE_SHELL_REMOTE_COMMANDS })));
@@ -80,7 +81,7 @@ async function main(args = process.argv.slice(2)) {
80
81
  gateway.use(createBoundSessionChat({ desktop }));
81
82
  gateway.use(createMimiPetChat({ desktop, channels: adapters }));
82
83
  console.log(`[code-shell-chat] gateway 已启动:${config.channels.map(({ channel }) => channel).join(", ")}`);
83
- const gatewayTask = gateway.run(shutdown.signal);
84
+ gatewayTask = gateway.run(shutdown.signal);
84
85
  const notificationTask = desktop.watchEvents(shutdown.signal, createDesktopNotificationHandler(adapters, config.notifications, {
85
86
  progressStore: new FileNotificationDeliveryProgressStore(notificationDeliveryProgressPath(config.runtime.eventCursorPath)),
86
87
  authorizeTarget: ({ channel, target }) => {
@@ -97,6 +98,9 @@ async function main(args = process.argv.slice(2)) {
97
98
  shutdown.abort();
98
99
  process.off("SIGINT", stop);
99
100
  process.off("SIGTERM", stop);
101
+ // The inbox belongs to this lease until the gateway has settled its writes,
102
+ // even when the notification watcher failed first.
103
+ await gatewayTask?.catch(() => undefined);
100
104
  lease.release();
101
105
  }
102
106
  }
@@ -7,6 +7,13 @@ export interface DeliveryQueueConfig {
7
7
  retryBaseMs: number;
8
8
  retryMaxMs: number;
9
9
  completedTtlMs: number;
10
+ /**
11
+ * Failed attempts during which a retrying record keeps later same-target
12
+ * messages waiting. Past this, it keeps retrying without holding its target,
13
+ * so one message that always fails cannot block a conversation forever.
14
+ * Defaults to {@link DEFAULT_RETRY_HOLD_ATTEMPTS}.
15
+ */
16
+ retryHoldAttempts?: number;
10
17
  /**
11
18
  * Directory for spooled attachment bytes. Defaults to `<path>.attachments`.
12
19
  *
@@ -21,6 +28,8 @@ export interface DeliveryQueueConfig {
21
28
  /** Largest single attachment to spool. Bigger ones fall back to memory-only. */
22
29
  maxSpooledAttachmentBytes?: number;
23
30
  }
31
+ /** With default backoff (1s doubling to 30s) this holds a target for about two minutes. */
32
+ export declare const DEFAULT_RETRY_HOLD_ATTEMPTS = 8;
24
33
  export interface DeliveryQueueStatus {
25
34
  pending: number;
26
35
  inFlight: number;
@@ -62,7 +71,13 @@ export declare class DeliveryQueue {
62
71
  private stopped;
63
72
  constructor(config: DeliveryQueueConfig, deliver: (adapterId: string, message: ChannelMessage) => Promise<void>, onError: (error: unknown, message: ChannelMessage) => void);
64
73
  start(): Promise<void>;
65
- stop(): void;
74
+ /**
75
+ * Stop accepting and scheduling work. Resolves once no state write is in
76
+ * progress; afterwards this instance never writes the inbox again, so the
77
+ * caller may release its single-instance lease to a new owner. Deliveries
78
+ * still running keep their durable record and are replayed by that owner.
79
+ */
80
+ stop(): Promise<void>;
66
81
  status(): DeliveryQueueStatus;
67
82
  enqueue(adapterId: string, message: ChannelMessage): Promise<"queued" | "duplicate">;
68
83
  private pump;
@@ -73,9 +88,11 @@ export declare class DeliveryQueue {
73
88
  /**
74
89
  * Materialize a message's attachments to disk so the record can be persisted.
75
90
  *
76
- * Returns undefined when there is nothing to spool, or when spooling fails —
77
- * in which case the caller falls back to the old memory-only behaviour rather
78
- * than dropping the message.
91
+ * Returns undefined when there is nothing to spool, or when the attachments
92
+ * cannot be materialized within the spool policy (too many, too large, or the
93
+ * platform download failed) — the caller then keeps the message memory-only.
94
+ * A local spool I/O failure throws instead: the durable inbox cannot record
95
+ * an ordinary attachment, so the platform must not be told it was accepted.
79
96
  */
80
97
  private spoolAttachments;
81
98
  /** Rebuild attachments whose bytes live in the spool. */
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, lstat, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ /** With default backoff (1s doubling to 30s) this holds a target for about two minutes. */
6
+ export const DEFAULT_RETRY_HOLD_ATTEMPTS = 8;
5
7
  const MAX_DELIVERY_STATE_BYTES = 64 * 1024 * 1024;
6
8
  const MAX_COMPLETED_KEYS = 100_000;
7
9
  const MAX_SPOOLED_ATTACHMENTS = 32;
@@ -61,14 +63,30 @@ export class DeliveryQueue {
61
63
  }
62
64
  this.pump();
63
65
  }
64
- stop() {
66
+ /**
67
+ * Stop accepting and scheduling work. Resolves once no state write is in
68
+ * progress; afterwards this instance never writes the inbox again, so the
69
+ * caller may release its single-instance lease to a new owner. Deliveries
70
+ * still running keep their durable record and are replayed by that owner.
71
+ */
72
+ async stop() {
65
73
  this.stopped = true;
74
+ // A duplicate must not be acknowledged while the original is still loading
75
+ // attachments. Wake those waiters without waiting on unbounded media I/O.
76
+ for (const preparation of this.preparing.values()) {
77
+ preparation.reject(new Error("Chat Gateway inbox is stopped"));
78
+ }
66
79
  if (this.retryTimer)
67
80
  clearTimeout(this.retryTimer);
68
81
  this.retryTimer = undefined;
69
82
  if (this.persistRetryTimer)
70
83
  clearTimeout(this.persistRetryTimer);
71
84
  this.persistRetryTimer = undefined;
85
+ let tail;
86
+ do {
87
+ tail = this.mutation;
88
+ await tail;
89
+ } while (tail !== this.mutation);
72
90
  }
73
91
  status() {
74
92
  const now = Date.now();
@@ -81,26 +99,47 @@ export class DeliveryQueue {
81
99
  async enqueue(adapterId, message) {
82
100
  if (this.stopped)
83
101
  throw new Error("Chat Gateway inbox is stopped");
84
- const dedupeKey = deliveryDedupeKey(message);
102
+ const dedupeKey = deliveryDedupeKey(adapterId, message);
103
+ const legacyDedupeKey = legacyDeliveryDedupeKey(message);
85
104
  const id = randomUUID();
86
105
  const target = targetKey(message);
87
106
  let previousPreparation;
88
107
  let preparation;
89
108
  let releasePreparation;
109
+ let acceptance;
110
+ let duplicateAcceptance;
90
111
  const reserved = await this.withMutation(async () => {
91
112
  if (this.stopped)
92
113
  throw new Error("Chat Gateway inbox is stopped");
93
114
  this.pruneCompleted();
94
115
  if (dedupeKey &&
95
116
  (this.completed.has(dedupeKey) ||
96
- this.pending.some((entry) => entry.dedupeKey === dedupeKey) ||
97
- [...this.preparing.values()].some((candidate) => candidate === dedupeKey))) {
117
+ // Completed keys persisted before keys named the account cannot say
118
+ // which account accepted them; stay conservative until they expire.
119
+ (legacyDedupeKey !== undefined && this.completed.has(legacyDedupeKey)) ||
120
+ this.pending.some((entry) => entry.dedupeKey === dedupeKey))) {
98
121
  return false;
99
122
  }
123
+ if (dedupeKey) {
124
+ const duplicate = [...this.preparing.values()].find((candidate) => candidate.dedupeKey === dedupeKey);
125
+ if (duplicate) {
126
+ duplicateAcceptance = duplicate.accepted;
127
+ return false;
128
+ }
129
+ }
100
130
  if (this.pending.length + this.preparing.size >= this.config.maxPending) {
101
131
  throw new DeliveryBackpressureError(this.config.maxPending);
102
132
  }
103
- this.preparing.set(id, dedupeKey);
133
+ let resolve;
134
+ let reject;
135
+ const accepted = new Promise((yes, no) => {
136
+ resolve = yes;
137
+ reject = no;
138
+ });
139
+ // The original enqueue may have no duplicate waiting on this promise.
140
+ void accepted.catch(() => undefined);
141
+ acceptance = { dedupeKey, accepted, resolve, reject };
142
+ this.preparing.set(id, acceptance);
104
143
  previousPreparation = this.preparationByTarget.get(target);
105
144
  preparation = new Promise((resolve) => {
106
145
  releasePreparation = resolve;
@@ -108,15 +147,19 @@ export class DeliveryQueue {
108
147
  this.preparationByTarget.set(target, preparation);
109
148
  return true;
110
149
  });
111
- if (!reserved)
150
+ if (!reserved) {
151
+ // Never hold the mutation lock while waiting for the original to persist.
152
+ await duplicateAcceptance;
112
153
  return "duplicate";
113
- // attachment.load() may perform an unbounded network read. The reservation
114
- // above preserves dedupe/backpressure, while doing the I/O here prevents one
115
- // slow attachment from blocking every enqueue and terminal state write.
116
- const spooled = await this.spoolAttachments(id, message);
117
- await previousPreparation;
154
+ }
155
+ let spooled;
118
156
  let record;
119
157
  try {
158
+ // attachment.load() may perform an unbounded network read. The reservation
159
+ // above preserves dedupe/backpressure, while doing the I/O here prevents one
160
+ // slow attachment from blocking every enqueue and terminal state write.
161
+ spooled = await this.spoolAttachments(id, message);
162
+ await previousPreparation;
120
163
  await this.withMutation(async () => {
121
164
  try {
122
165
  if (this.stopped)
@@ -138,6 +181,7 @@ export class DeliveryQueue {
138
181
  this.pending.push(record);
139
182
  if (persistent)
140
183
  await this.persist();
184
+ acceptance.resolve();
141
185
  }
142
186
  catch (error) {
143
187
  if (record) {
@@ -153,6 +197,8 @@ export class DeliveryQueue {
153
197
  });
154
198
  }
155
199
  catch (error) {
200
+ acceptance.reject(error);
201
+ this.preparing.delete(id);
156
202
  await this.discardSpool(record ?? {
157
203
  id,
158
204
  adapterId,
@@ -165,6 +211,9 @@ export class DeliveryQueue {
165
211
  throw error;
166
212
  }
167
213
  finally {
214
+ // Same-target successors wait on this preparation. Even on failure,
215
+ // release it only after every earlier one so nothing overtakes them.
216
+ await previousPreparation;
168
217
  releasePreparation?.();
169
218
  if (this.preparationByTarget.get(target) === preparation) {
170
219
  this.preparationByTarget.delete(target);
@@ -180,11 +229,25 @@ export class DeliveryQueue {
180
229
  clearTimeout(this.retryTimer);
181
230
  this.retryTimer = undefined;
182
231
  const now = Date.now();
232
+ const holdAttempts = this.config.retryHoldAttempts ?? DEFAULT_RETRY_HOLD_ATTEMPTS;
183
233
  while (this.inFlight.size < this.config.maxConcurrent) {
234
+ const delayedTargets = new Set();
184
235
  const entry = this.pending.find((candidate) => {
185
- if (this.inFlight.has(candidate.id) || candidate.nextAttemptAt > now)
236
+ if (this.inFlight.has(candidate.id))
237
+ return false;
238
+ const key = targetKey(candidate.message);
239
+ if (delayedTargets.has(key))
186
240
  return false;
187
- return ((this.targetInFlight.get(targetKey(candidate.message)) ?? 0) < this.config.maxPerTarget);
241
+ if (candidate.nextAttemptAt > now) {
242
+ // Bounded hold: a record that keeps failing stops blocking its
243
+ // conversation but stays queued for its own retries.
244
+ if (candidate.attempts < holdAttempts)
245
+ delayedTargets.add(key);
246
+ return false;
247
+ }
248
+ // Already-running peers may share a target when configured, but a
249
+ // queued retry must not be overtaken by later messages for that target.
250
+ return (this.targetInFlight.get(key) ?? 0) < this.config.maxPerTarget;
188
251
  });
189
252
  if (!entry)
190
253
  break;
@@ -220,6 +283,11 @@ export class DeliveryQueue {
220
283
  this.targetInFlight.delete(key);
221
284
  else
222
285
  this.targetInFlight.set(key, targetCount);
286
+ // A stopped queue may already have handed the inbox to a new owner.
287
+ // Writing this instance's snapshot would erase that owner's accepted
288
+ // messages; leave the record (and its spool) for the owner to replay.
289
+ if (this.stopped)
290
+ return;
223
291
  if (terminal) {
224
292
  // Success, or a permanently unroutable record: drop it so a message
225
293
  // that can never be delivered does not retry (and re-log) forever.
@@ -262,6 +330,8 @@ export class DeliveryQueue {
262
330
  if (this.stopped)
263
331
  return;
264
332
  void this.withMutation(async () => {
333
+ if (this.stopped)
334
+ return;
265
335
  await this.persist();
266
336
  for (const [id, pendingCleanup] of this.persistRetryCleanups) {
267
337
  await this.discardSpool(pendingCleanup.entry);
@@ -284,9 +354,11 @@ export class DeliveryQueue {
284
354
  /**
285
355
  * Materialize a message's attachments to disk so the record can be persisted.
286
356
  *
287
- * Returns undefined when there is nothing to spool, or when spooling fails —
288
- * in which case the caller falls back to the old memory-only behaviour rather
289
- * than dropping the message.
357
+ * Returns undefined when there is nothing to spool, or when the attachments
358
+ * cannot be materialized within the spool policy (too many, too large, or the
359
+ * platform download failed) — the caller then keeps the message memory-only.
360
+ * A local spool I/O failure throws instead: the durable inbox cannot record
361
+ * an ordinary attachment, so the platform must not be told it was accepted.
290
362
  */
291
363
  async spoolAttachments(recordId, message) {
292
364
  const attachments = message.attachments;
@@ -294,18 +366,30 @@ export class DeliveryQueue {
294
366
  if (!attachments?.length || !dir)
295
367
  return undefined;
296
368
  const limit = this.config.maxSpooledAttachmentBytes ?? 16 * 1024 * 1024;
369
+ if (attachments.length > MAX_SPOOLED_ATTACHMENTS)
370
+ return undefined;
297
371
  const written = [];
372
+ const discardWritten = () => Promise.all(written.map((target) => rm(target, { force: true }).catch(() => undefined)));
298
373
  try {
299
- if (attachments.length > MAX_SPOOLED_ATTACHMENTS)
300
- return undefined;
301
374
  await ensureRealDirectory(dir);
302
375
  const spooled = [];
303
376
  for (const [index, attachment] of attachments.entries()) {
304
- const bytes = await attachment.load();
377
+ let bytes;
378
+ try {
379
+ bytes = await attachment.load();
380
+ }
381
+ catch {
382
+ // A failed platform download keeps the memory-only path; delivery
383
+ // retries the original load().
384
+ await discardWritten();
385
+ return undefined;
386
+ }
305
387
  // Oversized payloads stay in memory: spooling them would trade a lost
306
388
  // message for unbounded disk use.
307
- if (bytes.byteLength > limit)
308
- throw new Error("attachment exceeds spool limit");
389
+ if (bytes.byteLength > limit) {
390
+ await discardWritten();
391
+ return undefined;
392
+ }
309
393
  const file = `${recordId}.${index}`;
310
394
  const target = `${dir}/${file}`;
311
395
  await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
@@ -321,10 +405,9 @@ export class DeliveryQueue {
321
405
  }
322
406
  return spooled;
323
407
  }
324
- catch {
325
- // Spooling is an availability improvement, never a new failure mode.
326
- await Promise.all(written.map((target) => rm(target, { force: true }).catch(() => undefined)));
327
- return undefined;
408
+ catch (error) {
409
+ await discardWritten();
410
+ throw error;
328
411
  }
329
412
  }
330
413
  /** Rebuild attachments whose bytes live in the spool. */
@@ -429,6 +512,12 @@ export class DeliveryQueue {
429
512
  const restored = this.rehydrateAttachments(entry.spooled);
430
513
  this.pending.push({
431
514
  ...entry,
515
+ // Records persisted before dedupe keys named the account carry the
516
+ // legacy key; the stored adapterId lets them join the new namespace.
517
+ ...(entry.dedupeKey !== undefined &&
518
+ entry.dedupeKey === legacyDeliveryDedupeKey(entry.message)
519
+ ? { dedupeKey: deliveryDedupeKey(entry.adapterId, entry.message) }
520
+ : {}),
432
521
  message: restored ? { ...entry.message, attachments: restored } : entry.message,
433
522
  persistent: true,
434
523
  nextAttemptAt: Date.now(),
@@ -513,7 +602,17 @@ export class DeliveryQueue {
513
602
  }
514
603
  }
515
604
  }
516
- function deliveryDedupeKey(message) {
605
+ /**
606
+ * Platform message ids are only unique within one account: two accounts on the
607
+ * same channel can legitimately receive the same id, so the adapter is part of
608
+ * the key.
609
+ */
610
+ function deliveryDedupeKey(adapterId, message) {
611
+ const legacy = legacyDeliveryDedupeKey(message);
612
+ return legacy === undefined ? undefined : `${adapterId}\0${legacy}`;
613
+ }
614
+ /** Account-less key written by earlier versions; read-only compatibility. */
615
+ function legacyDeliveryDedupeKey(message) {
517
616
  return message.messageId
518
617
  ? `${message.channel}\0${message.target}\0${message.senderId}\0${message.messageId}`
519
618
  : undefined;
package/dist/ingress.js CHANGED
@@ -48,8 +48,11 @@ function validateHost(value) {
48
48
  }
49
49
  function validateUpstream(value) {
50
50
  const url = new URL(`http://${value}`);
51
+ // URL drops an explicit default port (`:80` becomes ""), so a missing port
52
+ // must be judged from the authority the operator actually wrote.
53
+ const authority = value.split(/[/?#]/u, 1)[0] ?? "";
51
54
  if (!["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname) ||
52
- !url.port ||
55
+ (!url.port && !/:\d+$/u.test(authority)) ||
53
56
  url.pathname !== "/" ||
54
57
  url.username ||
55
58
  url.password ||
package/dist/line.js CHANGED
@@ -3,6 +3,9 @@ import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
3
3
  import { waitForAbort } from "./lifecycle.js";
4
4
  import { remoteAttachment, safeAttachmentName } from "./media.js";
5
5
  import { readRequestBody, sendResponse } from "./webhook.js";
6
+ /** Messaging API limits for a buttons-template URI action. */
7
+ const LINE_ACTION_LABEL_MAX = 20;
8
+ const LINE_URI_ACTION_MAX = 1_000;
6
9
  export class LineAdapter {
7
10
  config;
8
11
  fetchFn;
@@ -85,7 +88,14 @@ export class LineAdapter {
85
88
  }
86
89
  async send(target, message) {
87
90
  const messages = [{ type: "text", text: message.text }];
88
- if (message.button) {
91
+ if (message.button &&
92
+ (message.button.text.length > LINE_ACTION_LABEL_MAX ||
93
+ message.button.url.length > LINE_URI_ACTION_MAX)) {
94
+ // LINE rejects the whole push when an action exceeds its field limits,
95
+ // which would also lose the reply text. Deliver the link as text instead.
96
+ messages.push({ type: "text", text: `${message.button.text}\n${message.button.url}` });
97
+ }
98
+ else if (message.button) {
89
99
  messages.push({
90
100
  type: "template",
91
101
  altText: message.button.text,
package/dist/matrix.d.ts CHANGED
@@ -10,6 +10,8 @@ export declare class MatrixAdapter implements ChannelAdapter {
10
10
  readonly channel = "matrix";
11
11
  readonly capabilities: import("./channel.js").ChannelCapabilities;
12
12
  private readonly delivery;
13
+ /** The access token's own user; its room messages must never become input. */
14
+ private selfUserId?;
13
15
  constructor(config: MatrixAdapterConfig, fetchFn?: typeof fetch);
14
16
  run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
15
17
  send(target: string, message: OutgoingMessage): Promise<void>;
@@ -17,5 +19,6 @@ export declare class MatrixAdapter implements ChannelAdapter {
17
19
  private uploadAttachment;
18
20
  private toMessages;
19
21
  private matrixAttachment;
22
+ private whoami;
20
23
  private call;
21
24
  }
package/dist/matrix.js CHANGED
@@ -6,6 +6,8 @@ export class MatrixAdapter {
6
6
  channel = "matrix";
7
7
  capabilities = BUILTIN_CHANNEL_CAPABILITIES.matrix;
8
8
  delivery = new OutgoingDeliveryTracker();
9
+ /** The access token's own user; its room messages must never become input. */
10
+ selfUserId;
9
11
  constructor(config, fetchFn = fetch) {
10
12
  this.config = config;
11
13
  this.fetchFn = fetchFn;
@@ -15,6 +17,10 @@ export class MatrixAdapter {
15
17
  let retryMs = 1_000;
16
18
  while (!signal.aborted) {
17
19
  try {
20
+ // Every sync returns this bot's own replies. Without the token's real
21
+ // identity they would re-enter as input and answer themselves, so the
22
+ // optional botUserId falls back to an authenticated whoami lookup.
23
+ this.selfUserId ??= this.config.botUserId ?? (await this.whoami(signal));
18
24
  const query = new URLSearchParams({ timeout: "30000" });
19
25
  if (since)
20
26
  query.set("since", since);
@@ -106,7 +112,7 @@ export class MatrixAdapter {
106
112
  if (event.type !== "m.room.message" ||
107
113
  !content?.msgtype ||
108
114
  !event.sender ||
109
- event.sender === this.config.botUserId ||
115
+ event.sender === this.selfUserId ||
110
116
  (event.origin_server_ts !== undefined && now - event.origin_server_ts > 5 * 60_000)) {
111
117
  continue;
112
118
  }
@@ -155,6 +161,12 @@ export class MatrixAdapter {
155
161
  }),
156
162
  };
157
163
  }
164
+ async whoami(signal) {
165
+ const { user_id: userId } = await this.call("/_matrix/client/v3/account/whoami", { method: "GET", signal });
166
+ if (typeof userId !== "string" || !userId)
167
+ throw new Error("Matrix whoami 未返回 user_id");
168
+ return userId;
169
+ }
158
170
  async call(path, init) {
159
171
  const response = await this.fetchFn(`${this.config.homeserverUrl}${path}`, {
160
172
  ...init,
package/dist/media.js CHANGED
@@ -184,14 +184,20 @@ export function outgoingAttachments(message, supported) {
184
184
  return attachments;
185
185
  }
186
186
  export function safeAttachmentName(value, fallback = "attachment") {
187
- const normalized = (value ?? "")
187
+ const normalized = truncateLabel((value ?? "")
188
188
  .replaceAll("\\", "/")
189
189
  .split("/")
190
190
  .at(-1)
191
191
  ?.replace(/[\u0000-\u001f\u007f]/gu, "")
192
- .trim()
193
- .slice(0, 255);
194
- return (normalized || fallback.replace(/[\u0000-\u001f\u007f]/gu, "").slice(0, 255) || "attachment");
192
+ .trim() ?? "");
193
+ return (normalized || truncateLabel(fallback.replace(/[\u0000-\u001f\u007f]/gu, "")) || "attachment");
194
+ }
195
+ /** Cap at 255 UTF-16 units without leaving half of a surrogate pair behind. */
196
+ function truncateLabel(value, maximum = 255) {
197
+ if (value.length <= maximum)
198
+ return value;
199
+ const last = value.charCodeAt(maximum - 1);
200
+ return value.slice(0, last >= 0xd800 && last <= 0xdbff ? maximum - 1 : maximum);
195
201
  }
196
202
  /**
197
203
  * Resume a multi-request adapter send at the first unfinished visible step.
@@ -160,10 +160,19 @@ export class NotificationDeliveryProgress {
160
160
  await operation;
161
161
  }
162
162
  ensureLoaded() {
163
- this.loadTask ??= (async () => {
164
- const loaded = await this.store?.load();
165
- this.state = loaded ? cloneProgress(loaded) : undefined;
166
- })();
163
+ if (!this.loadTask) {
164
+ const task = (async () => {
165
+ const loaded = await this.store?.load();
166
+ this.state = loaded ? cloneProgress(loaded) : undefined;
167
+ })();
168
+ // Forget a failed load so the watcher's next retry reads the store again
169
+ // instead of replaying the cached rejection forever.
170
+ task.catch(() => {
171
+ if (this.loadTask === task)
172
+ this.loadTask = undefined;
173
+ });
174
+ this.loadTask = task;
175
+ }
167
176
  return this.loadTask;
168
177
  }
169
178
  async persistCurrent() {
package/dist/slack.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChannelAdapter, ChannelMessageHandler, OutgoingMessage } from "./channel.js";
1
+ import type { ChannelAdapter, ChannelMessageHandler, ChannelSendOptions, OutgoingMessage } from "./channel.js";
2
2
  export interface SlackAdapterConfig {
3
3
  botToken: string;
4
4
  appToken: string;
@@ -13,5 +13,5 @@ export declare class SlackAdapter implements ChannelAdapter {
13
13
  private readonly fetchFn;
14
14
  constructor(config: SlackAdapterConfig, fetchFn?: typeof fetch);
15
15
  run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
16
- send(target: string, message: OutgoingMessage): Promise<void>;
16
+ send(target: string, message: OutgoingMessage, options?: ChannelSendOptions): Promise<void>;
17
17
  }
package/dist/slack.js CHANGED
@@ -63,6 +63,11 @@ export class SlackAdapter {
63
63
  ...(event.client_msg_id || event.ts
64
64
  ? { messageId: String(event.client_msg_id ?? event.ts) }
65
65
  : {}),
66
+ // Replies must carry the parent thread_ts, or Slack posts them at the
67
+ // channel top level instead of in the thread the user is using.
68
+ ...(typeof event.thread_ts === "string" && event.thread_ts
69
+ ? { threadId: event.thread_ts }
70
+ : {}),
66
71
  ...(attachments.length > 0 ? { attachments } : {}),
67
72
  });
68
73
  await ack();
@@ -104,13 +109,15 @@ export class SlackAdapter {
104
109
  await this.socket.disconnect();
105
110
  }
106
111
  }
107
- async send(target, message) {
112
+ async send(target, message, options = {}) {
108
113
  const attachments = outgoingAttachments(message, this.capabilities.outbound.attachments);
114
+ const thread = options.threadId ? { thread_ts: options.threadId } : {};
109
115
  if (attachments.length > 0) {
110
116
  // Slack's v2 upload can publish all files and the comment atomically,
111
117
  // avoiding a text-first partial send that would duplicate on retry.
112
118
  await this.web.filesUploadV2({
113
119
  channel_id: target,
120
+ ...thread,
114
121
  initial_comment: formatOutgoingMarkdown(message.text, message.button),
115
122
  file_uploads: attachments.map((attachment) => ({
116
123
  file: Buffer.from(attachment.data),
@@ -122,6 +129,7 @@ export class SlackAdapter {
122
129
  await this.web.chat.postMessage({
123
130
  channel: target,
124
131
  text: message.text,
132
+ ...thread,
125
133
  ...(message.button
126
134
  ? {
127
135
  blocks: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-chat",
3
- "version": "0.9.44",
3
+ "version": "0.9.47",
4
4
  "description": "A standalone, transport-agnostic multi-channel chat gateway for Node.js and Bun.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",