@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 +3 -2
- package/dist/adapter-factory.js +2 -2
- package/dist/channel.d.ts +13 -1
- package/dist/chat-gateway.js +67 -21
- package/dist/cli.js +5 -1
- package/dist/delivery-queue.d.ts +21 -4
- package/dist/delivery-queue.js +125 -26
- package/dist/ingress.js +4 -1
- package/dist/line.js +11 -1
- package/dist/matrix.d.ts +3 -0
- package/dist/matrix.js +13 -1
- package/dist/media.js +10 -4
- package/dist/notification-progress.js +13 -4
- package/dist/slack.d.ts +2 -2
- package/dist/slack.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -331,8 +331,9 @@ Desktop 的「编辑配置」会生成包含所有渠道的模板;在模板里
|
|
|
331
331
|
系统只接受 `0600` 权限。
|
|
332
332
|
|
|
333
333
|
可序列化的文字入站会先原子写入 `~/.code-shell/im-gateway/inbox.json`,webhook 平台随后才会
|
|
334
|
-
|
|
335
|
-
|
|
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 初始化了全新的
|
package/dist/adapter-factory.js
CHANGED
|
@@ -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;
|
package/dist/chat-gateway.js
CHANGED
|
@@ -59,7 +59,9 @@ export class ChatGateway {
|
|
|
59
59
|
const context = {
|
|
60
60
|
message,
|
|
61
61
|
adapter,
|
|
62
|
-
reply: (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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/delivery-queue.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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. */
|
package/dist/delivery-queue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
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: [
|