@cjhyy/code-shell-chat 0.9.44 → 0.9.46

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
  }
@@ -62,7 +62,13 @@ export declare class DeliveryQueue {
62
62
  private stopped;
63
63
  constructor(config: DeliveryQueueConfig, deliver: (adapterId: string, message: ChannelMessage) => Promise<void>, onError: (error: unknown, message: ChannelMessage) => void);
64
64
  start(): Promise<void>;
65
- stop(): void;
65
+ /**
66
+ * Stop accepting and scheduling work. Resolves once no state write is in
67
+ * progress; afterwards this instance never writes the inbox again, so the
68
+ * caller may release its single-instance lease to a new owner. Deliveries
69
+ * still running keep their durable record and are replayed by that owner.
70
+ */
71
+ stop(): Promise<void>;
66
72
  status(): DeliveryQueueStatus;
67
73
  enqueue(adapterId: string, message: ChannelMessage): Promise<"queued" | "duplicate">;
68
74
  private pump;
@@ -73,9 +79,11 @@ export declare class DeliveryQueue {
73
79
  /**
74
80
  * Materialize a message's attachments to disk so the record can be persisted.
75
81
  *
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.
82
+ * Returns undefined when there is nothing to spool, or when the attachments
83
+ * cannot be materialized within the spool policy (too many, too large, or the
84
+ * platform download failed) — the caller then keeps the message memory-only.
85
+ * A local spool I/O failure throws instead: the durable inbox cannot record
86
+ * an ordinary attachment, so the platform must not be told it was accepted.
79
87
  */
80
88
  private spoolAttachments;
81
89
  /** Rebuild attachments whose bytes live in the spool. */
@@ -61,7 +61,13 @@ export class DeliveryQueue {
61
61
  }
62
62
  this.pump();
63
63
  }
64
- stop() {
64
+ /**
65
+ * Stop accepting and scheduling work. Resolves once no state write is in
66
+ * progress; afterwards this instance never writes the inbox again, so the
67
+ * caller may release its single-instance lease to a new owner. Deliveries
68
+ * still running keep their durable record and are replayed by that owner.
69
+ */
70
+ async stop() {
65
71
  this.stopped = true;
66
72
  if (this.retryTimer)
67
73
  clearTimeout(this.retryTimer);
@@ -69,6 +75,11 @@ export class DeliveryQueue {
69
75
  if (this.persistRetryTimer)
70
76
  clearTimeout(this.persistRetryTimer);
71
77
  this.persistRetryTimer = undefined;
78
+ let tail;
79
+ do {
80
+ tail = this.mutation;
81
+ await tail;
82
+ } while (tail !== this.mutation);
72
83
  }
73
84
  status() {
74
85
  const now = Date.now();
@@ -81,7 +92,8 @@ export class DeliveryQueue {
81
92
  async enqueue(adapterId, message) {
82
93
  if (this.stopped)
83
94
  throw new Error("Chat Gateway inbox is stopped");
84
- const dedupeKey = deliveryDedupeKey(message);
95
+ const dedupeKey = deliveryDedupeKey(adapterId, message);
96
+ const legacyDedupeKey = legacyDeliveryDedupeKey(message);
85
97
  const id = randomUUID();
86
98
  const target = targetKey(message);
87
99
  let previousPreparation;
@@ -93,6 +105,9 @@ export class DeliveryQueue {
93
105
  this.pruneCompleted();
94
106
  if (dedupeKey &&
95
107
  (this.completed.has(dedupeKey) ||
108
+ // Completed keys persisted before keys named the account cannot say
109
+ // which account accepted them; stay conservative until they expire.
110
+ (legacyDedupeKey !== undefined && this.completed.has(legacyDedupeKey)) ||
96
111
  this.pending.some((entry) => entry.dedupeKey === dedupeKey) ||
97
112
  [...this.preparing.values()].some((candidate) => candidate === dedupeKey))) {
98
113
  return false;
@@ -110,13 +125,14 @@ export class DeliveryQueue {
110
125
  });
111
126
  if (!reserved)
112
127
  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;
128
+ let spooled;
118
129
  let record;
119
130
  try {
131
+ // attachment.load() may perform an unbounded network read. The reservation
132
+ // above preserves dedupe/backpressure, while doing the I/O here prevents one
133
+ // slow attachment from blocking every enqueue and terminal state write.
134
+ spooled = await this.spoolAttachments(id, message);
135
+ await previousPreparation;
120
136
  await this.withMutation(async () => {
121
137
  try {
122
138
  if (this.stopped)
@@ -153,6 +169,7 @@ export class DeliveryQueue {
153
169
  });
154
170
  }
155
171
  catch (error) {
172
+ this.preparing.delete(id);
156
173
  await this.discardSpool(record ?? {
157
174
  id,
158
175
  adapterId,
@@ -165,6 +182,9 @@ export class DeliveryQueue {
165
182
  throw error;
166
183
  }
167
184
  finally {
185
+ // Same-target successors wait on this preparation. Even on failure,
186
+ // release it only after every earlier one so nothing overtakes them.
187
+ await previousPreparation;
168
188
  releasePreparation?.();
169
189
  if (this.preparationByTarget.get(target) === preparation) {
170
190
  this.preparationByTarget.delete(target);
@@ -220,6 +240,11 @@ export class DeliveryQueue {
220
240
  this.targetInFlight.delete(key);
221
241
  else
222
242
  this.targetInFlight.set(key, targetCount);
243
+ // A stopped queue may already have handed the inbox to a new owner.
244
+ // Writing this instance's snapshot would erase that owner's accepted
245
+ // messages; leave the record (and its spool) for the owner to replay.
246
+ if (this.stopped)
247
+ return;
223
248
  if (terminal) {
224
249
  // Success, or a permanently unroutable record: drop it so a message
225
250
  // that can never be delivered does not retry (and re-log) forever.
@@ -262,6 +287,8 @@ export class DeliveryQueue {
262
287
  if (this.stopped)
263
288
  return;
264
289
  void this.withMutation(async () => {
290
+ if (this.stopped)
291
+ return;
265
292
  await this.persist();
266
293
  for (const [id, pendingCleanup] of this.persistRetryCleanups) {
267
294
  await this.discardSpool(pendingCleanup.entry);
@@ -284,9 +311,11 @@ export class DeliveryQueue {
284
311
  /**
285
312
  * Materialize a message's attachments to disk so the record can be persisted.
286
313
  *
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.
314
+ * Returns undefined when there is nothing to spool, or when the attachments
315
+ * cannot be materialized within the spool policy (too many, too large, or the
316
+ * platform download failed) — the caller then keeps the message memory-only.
317
+ * A local spool I/O failure throws instead: the durable inbox cannot record
318
+ * an ordinary attachment, so the platform must not be told it was accepted.
290
319
  */
291
320
  async spoolAttachments(recordId, message) {
292
321
  const attachments = message.attachments;
@@ -294,18 +323,30 @@ export class DeliveryQueue {
294
323
  if (!attachments?.length || !dir)
295
324
  return undefined;
296
325
  const limit = this.config.maxSpooledAttachmentBytes ?? 16 * 1024 * 1024;
326
+ if (attachments.length > MAX_SPOOLED_ATTACHMENTS)
327
+ return undefined;
297
328
  const written = [];
329
+ const discardWritten = () => Promise.all(written.map((target) => rm(target, { force: true }).catch(() => undefined)));
298
330
  try {
299
- if (attachments.length > MAX_SPOOLED_ATTACHMENTS)
300
- return undefined;
301
331
  await ensureRealDirectory(dir);
302
332
  const spooled = [];
303
333
  for (const [index, attachment] of attachments.entries()) {
304
- const bytes = await attachment.load();
334
+ let bytes;
335
+ try {
336
+ bytes = await attachment.load();
337
+ }
338
+ catch {
339
+ // A failed platform download keeps the memory-only path; delivery
340
+ // retries the original load().
341
+ await discardWritten();
342
+ return undefined;
343
+ }
305
344
  // Oversized payloads stay in memory: spooling them would trade a lost
306
345
  // message for unbounded disk use.
307
- if (bytes.byteLength > limit)
308
- throw new Error("attachment exceeds spool limit");
346
+ if (bytes.byteLength > limit) {
347
+ await discardWritten();
348
+ return undefined;
349
+ }
309
350
  const file = `${recordId}.${index}`;
310
351
  const target = `${dir}/${file}`;
311
352
  await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
@@ -321,10 +362,9 @@ export class DeliveryQueue {
321
362
  }
322
363
  return spooled;
323
364
  }
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;
365
+ catch (error) {
366
+ await discardWritten();
367
+ throw error;
328
368
  }
329
369
  }
330
370
  /** Rebuild attachments whose bytes live in the spool. */
@@ -429,6 +469,12 @@ export class DeliveryQueue {
429
469
  const restored = this.rehydrateAttachments(entry.spooled);
430
470
  this.pending.push({
431
471
  ...entry,
472
+ // Records persisted before dedupe keys named the account carry the
473
+ // legacy key; the stored adapterId lets them join the new namespace.
474
+ ...(entry.dedupeKey !== undefined &&
475
+ entry.dedupeKey === legacyDeliveryDedupeKey(entry.message)
476
+ ? { dedupeKey: deliveryDedupeKey(entry.adapterId, entry.message) }
477
+ : {}),
432
478
  message: restored ? { ...entry.message, attachments: restored } : entry.message,
433
479
  persistent: true,
434
480
  nextAttemptAt: Date.now(),
@@ -513,7 +559,17 @@ export class DeliveryQueue {
513
559
  }
514
560
  }
515
561
  }
516
- function deliveryDedupeKey(message) {
562
+ /**
563
+ * Platform message ids are only unique within one account: two accounts on the
564
+ * same channel can legitimately receive the same id, so the adapter is part of
565
+ * the key.
566
+ */
567
+ function deliveryDedupeKey(adapterId, message) {
568
+ const legacy = legacyDeliveryDedupeKey(message);
569
+ return legacy === undefined ? undefined : `${adapterId}\0${legacy}`;
570
+ }
571
+ /** Account-less key written by earlier versions; read-only compatibility. */
572
+ function legacyDeliveryDedupeKey(message) {
517
573
  return message.messageId
518
574
  ? `${message.channel}\0${message.target}\0${message.senderId}\0${message.messageId}`
519
575
  : 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.46",
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",