@cjhyy/code-shell-chat 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +416 -0
  3. package/dist/adapter-factory.d.ts +30 -0
  4. package/dist/adapter-factory.js +198 -0
  5. package/dist/attachments.d.ts +13 -0
  6. package/dist/attachments.js +74 -0
  7. package/dist/channel.d.ts +112 -0
  8. package/dist/channel.js +60 -0
  9. package/dist/chat-gateway.d.ts +58 -0
  10. package/dist/chat-gateway.js +232 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +259 -0
  13. package/dist/codeshell.d.ts +6 -0
  14. package/dist/codeshell.js +6 -0
  15. package/dist/config.d.ts +125 -0
  16. package/dist/config.js +469 -0
  17. package/dist/delivery-queue.d.ts +93 -0
  18. package/dist/delivery-queue.js +401 -0
  19. package/dist/desktop-control-client.d.ts +51 -0
  20. package/dist/desktop-control-client.js +309 -0
  21. package/dist/dingtalk.d.ts +19 -0
  22. package/dist/dingtalk.js +93 -0
  23. package/dist/discord.d.ts +23 -0
  24. package/dist/discord.js +134 -0
  25. package/dist/gateway.d.ts +34 -0
  26. package/dist/gateway.js +287 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.js +13 -0
  29. package/dist/ingress.d.ts +8 -0
  30. package/dist/ingress.js +61 -0
  31. package/dist/instance-lock.d.ts +24 -0
  32. package/dist/instance-lock.js +100 -0
  33. package/dist/lark.d.ts +20 -0
  34. package/dist/lark.js +184 -0
  35. package/dist/lifecycle.d.ts +7 -0
  36. package/dist/lifecycle.js +16 -0
  37. package/dist/line.d.ts +17 -0
  38. package/dist/line.js +123 -0
  39. package/dist/matrix.d.ts +21 -0
  40. package/dist/matrix.js +208 -0
  41. package/dist/mattermost.d.ts +18 -0
  42. package/dist/mattermost.js +170 -0
  43. package/dist/media.d.ts +39 -0
  44. package/dist/media.js +223 -0
  45. package/dist/notification-progress.d.ts +46 -0
  46. package/dist/notification-progress.js +299 -0
  47. package/dist/notification-relay.d.ts +43 -0
  48. package/dist/notification-relay.js +285 -0
  49. package/dist/platform-canary.d.ts +22 -0
  50. package/dist/platform-canary.js +55 -0
  51. package/dist/protocol.d.ts +102 -0
  52. package/dist/protocol.js +1 -0
  53. package/dist/service-manager.d.ts +47 -0
  54. package/dist/service-manager.js +222 -0
  55. package/dist/slack.d.ts +17 -0
  56. package/dist/slack.js +136 -0
  57. package/dist/teams.d.ts +26 -0
  58. package/dist/teams.js +193 -0
  59. package/dist/telegram.d.ts +33 -0
  60. package/dist/telegram.js +263 -0
  61. package/dist/webhook.d.ts +26 -0
  62. package/dist/webhook.js +91 -0
  63. package/dist/wechat-login.d.ts +21 -0
  64. package/dist/wechat-login.js +97 -0
  65. package/dist/wechat-storage.d.ts +61 -0
  66. package/dist/wechat-storage.js +251 -0
  67. package/dist/wechat.d.ts +129 -0
  68. package/dist/wechat.js +1082 -0
  69. package/dist/wecom.d.ts +16 -0
  70. package/dist/wecom.js +149 -0
  71. package/dist/whatsapp.d.ts +27 -0
  72. package/dist/whatsapp.js +218 -0
  73. package/package.json +123 -0
@@ -0,0 +1,299 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ const EVENT_KEY_PATTERN = /^[a-f0-9]{32}:\d{1,16}$/u;
6
+ const TARGET_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
7
+ const MAX_PROGRESS_EVENTS = 200;
8
+ const MAX_PROGRESS_TARGETS = 32;
9
+ const MAX_TOTAL_PROGRESS_TARGETS = MAX_PROGRESS_EVENTS * MAX_PROGRESS_TARGETS * 2;
10
+ const MAX_CHUNKS_PER_TARGET = 10_000;
11
+ const MAX_PROGRESS_FILE_BYTES = 2 * 1024 * 1024;
12
+ /** Keep transport progress next to, but independent from, the Desktop event cursor. */
13
+ export function notificationDeliveryProgressPath(eventCursorPath) {
14
+ return `${eventCursorPath}.deliveries`;
15
+ }
16
+ /** Opaque key: the progress file never needs to disclose a raw owner destination. */
17
+ export function notificationTargetProgressKey(channel, target) {
18
+ return createHash("sha256").update(channel).update("\0").update(target).digest("base64url");
19
+ }
20
+ /** Owner-only, atomic persistence for partially delivered notification events. */
21
+ export class FileNotificationDeliveryProgressStore {
22
+ path;
23
+ constructor(path) {
24
+ this.path = path;
25
+ }
26
+ async load() {
27
+ let handle;
28
+ try {
29
+ // Inspect before reading: the size bound must prevent the allocation, not
30
+ // merely reject it afterwards. lstat also rejects a symlink that could
31
+ // substitute attacker-controlled "already delivered" markers.
32
+ const info = await lstat(this.path);
33
+ if (!info.isFile())
34
+ throw new Error(`Notification progress is not a file: ${this.path}`);
35
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
36
+ throw new Error(`Notification progress permissions must be 0600: ${this.path}`);
37
+ }
38
+ if (info.size > MAX_PROGRESS_FILE_BYTES) {
39
+ throw new Error(`Notification progress is too large: ${this.path}`);
40
+ }
41
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
42
+ handle = await open(this.path, constants.O_RDONLY | noFollow);
43
+ const openedInfo = await handle.stat();
44
+ if (!openedInfo.isFile() || openedInfo.size > MAX_PROGRESS_FILE_BYTES) {
45
+ throw new Error(`Notification progress is not a bounded regular file: ${this.path}`);
46
+ }
47
+ const raw = await handle.readFile("utf-8");
48
+ if (Buffer.byteLength(raw, "utf-8") > MAX_PROGRESS_FILE_BYTES) {
49
+ throw new Error(`Notification progress is too large: ${this.path}`);
50
+ }
51
+ return parseNotificationDeliveryProgress(raw, this.path);
52
+ }
53
+ catch (error) {
54
+ if (error.code === "ENOENT")
55
+ return undefined;
56
+ throw error;
57
+ }
58
+ finally {
59
+ await handle?.close();
60
+ }
61
+ }
62
+ async save(state) {
63
+ const validated = parseNotificationDeliveryProgress(JSON.stringify(state), this.path);
64
+ const parent = dirname(this.path);
65
+ await mkdir(parent, { recursive: true, mode: 0o700 });
66
+ const parentInfo = await lstat(parent);
67
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
68
+ throw new Error(`Notification progress parent is not a regular directory: ${parent}`);
69
+ }
70
+ await chmod(parent, 0o700).catch(() => undefined);
71
+ const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
72
+ try {
73
+ await writeFile(temporary, `${JSON.stringify(validated)}\n`, {
74
+ encoding: "utf-8",
75
+ mode: 0o600,
76
+ flag: "wx",
77
+ });
78
+ await rename(temporary, this.path);
79
+ await chmod(this.path, 0o600).catch(() => undefined);
80
+ }
81
+ catch (error) {
82
+ await rm(temporary, { force: true }).catch(() => undefined);
83
+ throw error;
84
+ }
85
+ }
86
+ }
87
+ /**
88
+ * Serializes progress mutations made by targets delivering in parallel. A
89
+ * successful transport call is recorded before the event handler returns, so
90
+ * a later target failure or ordinary process restart resumes at the owed part.
91
+ */
92
+ export class NotificationDeliveryProgress {
93
+ store;
94
+ state;
95
+ loadTask;
96
+ writeTail = Promise.resolve();
97
+ constructor(store) {
98
+ this.store = store;
99
+ }
100
+ async begin(eventKey) {
101
+ if (!EVENT_KEY_PATTERN.test(eventKey))
102
+ throw new Error("Invalid notification event key");
103
+ await this.ensureLoaded();
104
+ const operation = this.writeTail
105
+ .catch(() => undefined)
106
+ .then(async () => {
107
+ const state = this.state ?? { version: 3, events: {} };
108
+ this.state = state;
109
+ if (state.events[eventKey])
110
+ return;
111
+ state.events[eventKey] = { chunks: {}, attachments: {} };
112
+ while (Object.keys(state.events).length > MAX_PROGRESS_EVENTS) {
113
+ const oldest = Object.keys(state.events)[0];
114
+ if (!oldest)
115
+ break;
116
+ delete state.events[oldest];
117
+ }
118
+ await this.persistCurrent();
119
+ });
120
+ this.writeTail = operation;
121
+ await operation;
122
+ }
123
+ chunkIndex(eventKey, targetKey) {
124
+ return this.requireEventState(eventKey).chunks[targetKey] ?? 0;
125
+ }
126
+ attachmentIndex(eventKey, targetKey) {
127
+ return this.requireEventState(eventKey).attachments[targetKey] ?? 0;
128
+ }
129
+ async mark(eventKey, targetKey, chunkIndex, attachmentIndex) {
130
+ if (!EVENT_KEY_PATTERN.test(eventKey))
131
+ throw new Error("Invalid notification event key");
132
+ if (!TARGET_KEY_PATTERN.test(targetKey))
133
+ throw new Error("Invalid notification target key");
134
+ if (!Number.isSafeInteger(chunkIndex) || chunkIndex < 0 || chunkIndex > MAX_CHUNKS_PER_TARGET) {
135
+ throw new Error("Invalid notification chunk progress");
136
+ }
137
+ if (attachmentIndex !== undefined &&
138
+ (!Number.isSafeInteger(attachmentIndex) || attachmentIndex < 0 || attachmentIndex > 4)) {
139
+ throw new Error("Invalid notification attachment progress");
140
+ }
141
+ const operation = this.writeTail
142
+ .catch(() => undefined)
143
+ .then(async () => {
144
+ const eventState = this.requireEventState(eventKey);
145
+ eventState.chunks[targetKey] = Math.max(eventState.chunks[targetKey] ?? 0, chunkIndex);
146
+ if (attachmentIndex !== undefined) {
147
+ eventState.attachments[targetKey] = Math.max(eventState.attachments[targetKey] ?? 0, attachmentIndex);
148
+ }
149
+ const state = this.requireState();
150
+ validateProgressShape(state, "memory");
151
+ await this.store?.save(cloneProgress(state));
152
+ });
153
+ this.writeTail = operation;
154
+ await operation;
155
+ }
156
+ /** Retry a previously failed state write before the outer event is acknowledged. */
157
+ async flush() {
158
+ const operation = this.writeTail.catch(() => undefined).then(() => this.persistCurrent());
159
+ this.writeTail = operation;
160
+ await operation;
161
+ }
162
+ ensureLoaded() {
163
+ this.loadTask ??= (async () => {
164
+ const loaded = await this.store?.load();
165
+ this.state = loaded ? cloneProgress(loaded) : undefined;
166
+ })();
167
+ return this.loadTask;
168
+ }
169
+ async persistCurrent() {
170
+ const state = this.requireState();
171
+ validateProgressShape(state, "memory");
172
+ await this.store?.save(cloneProgress(state));
173
+ }
174
+ requireState() {
175
+ if (!this.state)
176
+ throw new Error("Notification progress has not started");
177
+ return this.state;
178
+ }
179
+ requireEventState(eventKey) {
180
+ const state = this.requireState();
181
+ const eventState = state.events[eventKey];
182
+ if (!eventState)
183
+ throw new Error("Notification event progress has not started");
184
+ return eventState;
185
+ }
186
+ }
187
+ function parseNotificationDeliveryProgress(raw, source) {
188
+ let parsed;
189
+ try {
190
+ parsed = JSON.parse(raw);
191
+ }
192
+ catch (error) {
193
+ throw new Error(`Invalid notification progress JSON: ${source}`, { cause: error });
194
+ }
195
+ const migrated = migrateLegacyProgress(parsed, source);
196
+ validateProgressShape(migrated, source);
197
+ return cloneProgress(migrated);
198
+ }
199
+ function validateProgressShape(value, source) {
200
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
201
+ throw new Error(`Invalid notification progress: ${source}`);
202
+ }
203
+ const candidate = value;
204
+ if (candidate.version !== 3 ||
205
+ !candidate.events ||
206
+ typeof candidate.events !== "object" ||
207
+ Array.isArray(candidate.events)) {
208
+ throw new Error(`Invalid notification progress: ${source}`);
209
+ }
210
+ const events = Object.entries(candidate.events);
211
+ if (events.length > MAX_PROGRESS_EVENTS ||
212
+ events.some(([eventKey, eventState]) => !EVENT_KEY_PATTERN.test(eventKey) || !isValidEventProgress(eventState)) ||
213
+ events.reduce((total, [, eventState]) => total + Object.keys(eventState.chunks).length + Object.keys(eventState.attachments).length, 0) > MAX_TOTAL_PROGRESS_TARGETS) {
214
+ throw new Error(`Invalid notification progress: ${source}`);
215
+ }
216
+ }
217
+ function isValidEventProgress(value) {
218
+ if (!value || typeof value !== "object" || Array.isArray(value))
219
+ return false;
220
+ const candidate = value;
221
+ if (!candidate.chunks ||
222
+ typeof candidate.chunks !== "object" ||
223
+ Array.isArray(candidate.chunks) ||
224
+ !candidate.attachments ||
225
+ typeof candidate.attachments !== "object" ||
226
+ Array.isArray(candidate.attachments)) {
227
+ return false;
228
+ }
229
+ const chunks = Object.entries(candidate.chunks);
230
+ const attachments = Object.entries(candidate.attachments);
231
+ return (chunks.length <= MAX_PROGRESS_TARGETS &&
232
+ chunks.every(([key, count]) => TARGET_KEY_PATTERN.test(key) &&
233
+ Number.isSafeInteger(count) &&
234
+ count >= 0 &&
235
+ count <= MAX_CHUNKS_PER_TARGET) &&
236
+ attachments.length <= MAX_PROGRESS_TARGETS &&
237
+ attachments.every(([key, count]) => TARGET_KEY_PATTERN.test(key) && Number.isSafeInteger(count) && count >= 0 && count <= 4));
238
+ }
239
+ function migrateLegacyProgress(value, source) {
240
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
241
+ throw new Error(`Invalid notification progress: ${source}`);
242
+ }
243
+ const legacy = value;
244
+ if (legacy.version === 2) {
245
+ if (!legacy.events || typeof legacy.events !== "object" || Array.isArray(legacy.events)) {
246
+ throw new Error(`Invalid notification progress: ${source}`);
247
+ }
248
+ const events = Object.fromEntries(Object.entries(legacy.events).map(([eventKey, rawEvent]) => {
249
+ if (!rawEvent || typeof rawEvent !== "object" || Array.isArray(rawEvent)) {
250
+ throw new Error(`Invalid notification progress: ${source}`);
251
+ }
252
+ const event = rawEvent;
253
+ if (!Array.isArray(event.attachments)) {
254
+ throw new Error(`Invalid notification progress: ${source}`);
255
+ }
256
+ return [
257
+ eventKey,
258
+ {
259
+ chunks: event.chunks,
260
+ // v2 recorded only an all-attachments boolean. Four is the
261
+ // protocol maximum, so every possible descriptor remains skipped.
262
+ attachments: Object.fromEntries(event.attachments.map((key) => [key, 4])),
263
+ },
264
+ ];
265
+ }));
266
+ return { version: 3, events };
267
+ }
268
+ if (legacy.version !== 1)
269
+ return value;
270
+ if (typeof legacy.eventKey !== "string" ||
271
+ !EVENT_KEY_PATTERN.test(legacy.eventKey) ||
272
+ !legacy.chunks ||
273
+ typeof legacy.chunks !== "object" ||
274
+ Array.isArray(legacy.chunks) ||
275
+ !Array.isArray(legacy.attachments)) {
276
+ throw new Error(`Invalid notification progress: ${source}`);
277
+ }
278
+ return {
279
+ version: 3,
280
+ events: {
281
+ [legacy.eventKey]: {
282
+ chunks: { ...legacy.chunks },
283
+ attachments: Object.fromEntries(legacy.attachments.map((key) => [key, 4])),
284
+ },
285
+ },
286
+ };
287
+ }
288
+ function cloneProgress(state) {
289
+ return {
290
+ version: 3,
291
+ events: Object.fromEntries(Object.entries(state.events).map(([eventKey, eventState]) => [
292
+ eventKey,
293
+ {
294
+ chunks: { ...eventState.chunks },
295
+ attachments: { ...eventState.attachments },
296
+ },
297
+ ])),
298
+ };
299
+ }
@@ -0,0 +1,43 @@
1
+ import { type ChannelAdapter, type ChatAttachmentKind, type ChannelCapabilities, type OutgoingAttachment } from "./channel.js";
2
+ import type { GatewayNotificationTarget } from "./config.js";
3
+ import type { DesktopEventContext } from "./desktop-control-client.js";
4
+ import type { DesktopControlEvent } from "./protocol.js";
5
+ import { type NotificationDeliveryProgressStore } from "./notification-progress.js";
6
+ /** Split without breaking UTF-16 surrogate pairs; prefer readable line/word boundaries. */
7
+ export declare function splitNotificationText(text: string, maximum?: number): string[];
8
+ /**
9
+ * Personal WeChat accepts the adapter's declared 8k text payload as one
10
+ * visible message. Other notification adapters keep the conservative shared
11
+ * limit until their platform-specific limits are represented precisely.
12
+ */
13
+ export declare function splitNotificationTextForChannel(text: string, channel: string, capabilities: ChannelCapabilities): string[];
14
+ /**
15
+ * Builds an at-least-once Desktop event sender. Successful targets are kept in
16
+ * memory while a failed target is retried, so one unhealthy adapter does not
17
+ * duplicate notifications on the adapters that already accepted the event.
18
+ */
19
+ export declare function createDesktopNotificationHandler(adapters: readonly ChannelAdapter[], targets: readonly GatewayNotificationTarget[], options?: {
20
+ progressStore?: NotificationDeliveryProgressStore;
21
+ /** Revalidate current owner authorization immediately before transport. */
22
+ authorizeTarget?: (target: GatewayNotificationTarget) => boolean | Promise<boolean>;
23
+ }): (event: DesktopControlEvent, context: DesktopEventContext) => Promise<void>;
24
+ /**
25
+ * Transport-neutral descriptor for one already-authorized host-local file.
26
+ * Path authorization remains a host concern; this layer verifies immutable
27
+ * file metadata before exposing bytes to a channel adapter.
28
+ */
29
+ export interface LocalOutgoingAttachmentDescriptor {
30
+ kind: ChatAttachmentKind;
31
+ path: string;
32
+ name: string;
33
+ mimeType: string;
34
+ size: number;
35
+ }
36
+ /**
37
+ * Read validated host-local attachments from disk into outgoing bytes.
38
+ * Shared by event relays, Mimi replies, and proactive sends; throws when
39
+ * metadata is invalid or the file changed before the transport accepts it.
40
+ */
41
+ export declare function materializeOutgoingAttachments(attachments: readonly LocalOutgoingAttachmentDescriptor[]): Promise<OutgoingAttachment[]>;
42
+ /** @deprecated Use materializeOutgoingAttachments for transport-neutral code. */
43
+ export declare const materializeEventAttachments: typeof materializeOutgoingAttachments;
@@ -0,0 +1,285 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ import { channelCapabilities, } from "./channel.js";
5
+ import { NotificationDeliveryProgress, notificationTargetProgressKey, } from "./notification-progress.js";
6
+ // Keep below Discord's 2,000-character content cap as well as Telegram's
7
+ // 4,096-character cap. A single conservative relay limit makes every adapter
8
+ // safe without letting product events depend on channel-specific truncation.
9
+ const MAX_NOTIFICATION_CHUNK_LENGTH = 1_800;
10
+ const MAX_NOTIFICATION_TEXT_LENGTH = 100_000;
11
+ const MAX_OUTGOING_ATTACHMENT_BYTES = 10 * 1024 * 1024;
12
+ const NOTIFICATION_CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
13
+ const NOTIFICATION_DELIVERY_KEY_RE = /^[a-f0-9]{64}$/u;
14
+ /** Split without breaking UTF-16 surrogate pairs; prefer readable line/word boundaries. */
15
+ export function splitNotificationText(text, maximum = MAX_NOTIFICATION_CHUNK_LENGTH) {
16
+ if (!Number.isSafeInteger(maximum) || maximum < 2) {
17
+ throw new Error("Notification chunk length must be an integer of at least 2");
18
+ }
19
+ if (text.length <= maximum)
20
+ return [text];
21
+ const chunks = [];
22
+ let start = 0;
23
+ while (start < text.length) {
24
+ let end = Math.min(start + maximum, text.length);
25
+ if (end < text.length) {
26
+ const previous = text.charCodeAt(end - 1);
27
+ const next = text.charCodeAt(end);
28
+ if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
29
+ end -= 1;
30
+ }
31
+ const minimumReadableBreak = start + Math.floor(maximum * 0.6);
32
+ const newline = text.lastIndexOf("\n", end - 1);
33
+ const space = text.lastIndexOf(" ", end - 1);
34
+ const readableBreak = Math.max(newline, space);
35
+ if (readableBreak >= minimumReadableBreak)
36
+ end = readableBreak + 1;
37
+ }
38
+ chunks.push(text.slice(start, end));
39
+ start = end;
40
+ }
41
+ return chunks;
42
+ }
43
+ /**
44
+ * Personal WeChat accepts the adapter's declared 8k text payload as one
45
+ * visible message. Other notification adapters keep the conservative shared
46
+ * limit until their platform-specific limits are represented precisely.
47
+ */
48
+ export function splitNotificationTextForChannel(text, channel, capabilities) {
49
+ const declaredMaximum = capabilities.outbound.maxTextLength;
50
+ const safeDeclaredMaximum = Number.isSafeInteger(declaredMaximum) && (declaredMaximum ?? 0) >= 2
51
+ ? declaredMaximum
52
+ : MAX_NOTIFICATION_CHUNK_LENGTH;
53
+ const maximum = channel === "wechat"
54
+ ? safeDeclaredMaximum
55
+ : Math.min(MAX_NOTIFICATION_CHUNK_LENGTH, safeDeclaredMaximum);
56
+ return splitNotificationText(text, maximum);
57
+ }
58
+ /**
59
+ * Builds an at-least-once Desktop event sender. Successful targets are kept in
60
+ * memory while a failed target is retried, so one unhealthy adapter does not
61
+ * duplicate notifications on the adapters that already accepted the event.
62
+ */
63
+ export function createDesktopNotificationHandler(adapters, targets, options = {}) {
64
+ const adapterByChannel = new Map(adapters.map((adapter) => [adapter.channel, adapter]));
65
+ const progress = new NotificationDeliveryProgress(options.progressStore);
66
+ const configuredTargetKeys = new Set(targets.map(({ channel, target }) => `${channel}\0${target}`));
67
+ const authorizeTarget = options.authorizeTarget ??
68
+ (({ channel, target }) => configuredTargetKeys.has(`${channel}\0${target}`));
69
+ return async (event, context) => {
70
+ validateDesktopNotificationEvent(event);
71
+ const requestedTargets = event.target ? [event.target] : targets;
72
+ const authorizations = await Promise.all(requestedTargets.map((target) => authorizeTarget(target)));
73
+ const eventTargets = requestedTargets.filter((_, index) => authorizations[index] === true);
74
+ // Revoking a target is an owner decision, not a transient transport
75
+ // failure. Treat a fully-revoked event as handled so it cannot block every
76
+ // later outbox event while still guaranteeing no send occurs.
77
+ if (eventTargets.length === 0)
78
+ return;
79
+ // A semantic key survives Desktop/event-server restarts. Keep the on-disk
80
+ // progress schema compact by using its first 128 bits plus a reserved zero
81
+ // event suffix; the producer supplies a full SHA-256 key.
82
+ const eventKey = event.deliveryKey
83
+ ? `${event.deliveryKey.slice(0, 32)}:0`
84
+ : `${context.streamId}:${event.id}`;
85
+ await progress.begin(eventKey);
86
+ const results = await Promise.allSettled(eventTargets.map(async ({ channel, target }) => {
87
+ const targetKey = notificationTargetProgressKey(channel, target);
88
+ const adapter = adapterByChannel.get(channel);
89
+ if (!adapter)
90
+ throw new Error(`Notification adapter is unavailable: ${channel}`);
91
+ const capabilities = channelCapabilities(adapter);
92
+ const supportedEventAttachments = (event.attachments ?? []).filter((attachment) => capabilities.outbound.attachments.includes(attachment.kind));
93
+ const chunks = splitNotificationTextForChannel(event.text, channel, capabilities);
94
+ let attachmentIndex = Math.min(progress.attachmentIndex(eventKey, targetKey), supportedEventAttachments.length);
95
+ let combinedAttachments = [];
96
+ let attachmentError;
97
+ let chunkIndex = progress.chunkIndex(eventKey, targetKey);
98
+ // Only adapters that explicitly guarantee one visible platform request
99
+ // may combine the final text chunk with media. Everyone else sends and
100
+ // checkpoints each attachment independently, so a later media failure
101
+ // cannot repeat already accepted text or earlier attachments.
102
+ const canCombine = adapter.combinesTextAndAttachmentsAtomically === true &&
103
+ attachmentIndex === 0 &&
104
+ supportedEventAttachments.length > 0 &&
105
+ chunkIndex < chunks.length;
106
+ if (canCombine) {
107
+ try {
108
+ combinedAttachments = await materializeOutgoingAttachments(supportedEventAttachments);
109
+ }
110
+ catch (error) {
111
+ attachmentError = error;
112
+ }
113
+ }
114
+ const combineAttachments = combinedAttachments.length > 0;
115
+ while (chunkIndex < chunks.length) {
116
+ const isLast = chunkIndex === chunks.length - 1;
117
+ await adapter.send(target, {
118
+ text: chunks[chunkIndex],
119
+ ...(chunkIndex === 0 && event.title ? { title: event.title } : {}),
120
+ ...(isLast && event.button ? { button: event.button } : {}),
121
+ ...(isLast && combineAttachments ? { attachments: combinedAttachments } : {}),
122
+ });
123
+ chunkIndex += 1;
124
+ if (isLast && combineAttachments) {
125
+ attachmentIndex = supportedEventAttachments.length;
126
+ }
127
+ await progress.mark(eventKey, targetKey, chunkIndex, isLast && combineAttachments ? attachmentIndex : undefined);
128
+ }
129
+ if (attachmentError)
130
+ throw attachmentError;
131
+ while (attachmentIndex < supportedEventAttachments.length) {
132
+ const descriptor = supportedEventAttachments[attachmentIndex];
133
+ const [attachment] = await materializeOutgoingAttachments([descriptor]);
134
+ if (!attachment)
135
+ throw new Error("Outgoing attachment could not be materialized");
136
+ await adapter.send(target, { text: "", attachments: [attachment] });
137
+ attachmentIndex += 1;
138
+ await progress.mark(eventKey, targetKey, chunkIndex, attachmentIndex);
139
+ }
140
+ }));
141
+ const failures = results
142
+ .filter((result) => result.status === "rejected")
143
+ .map(({ reason }) => reason);
144
+ if (failures.length > 0) {
145
+ throw new AggregateError(failures, `Desktop event ${eventKey} notification failed`);
146
+ }
147
+ await progress.flush();
148
+ };
149
+ }
150
+ function validateDesktopNotificationEvent(event) {
151
+ if (typeof event.text !== "string" ||
152
+ !event.text.trim() ||
153
+ event.text.length > MAX_NOTIFICATION_TEXT_LENGTH ||
154
+ NOTIFICATION_CONTROL_CHARACTER_RE.test(event.text) ||
155
+ (event.deliveryKey !== undefined &&
156
+ (typeof event.deliveryKey !== "string" ||
157
+ !NOTIFICATION_DELIVERY_KEY_RE.test(event.deliveryKey))) ||
158
+ (event.title !== undefined &&
159
+ (typeof event.title !== "string" ||
160
+ !event.title.trim() ||
161
+ event.title.length > 256 ||
162
+ NOTIFICATION_CONTROL_CHARACTER_RE.test(event.title))) ||
163
+ (event.target !== undefined &&
164
+ (!event.target ||
165
+ typeof event.target !== "object" ||
166
+ typeof event.target.channel !== "string" ||
167
+ !/^[a-z0-9_-]{1,64}$/u.test(event.target.channel) ||
168
+ typeof event.target.target !== "string" ||
169
+ !event.target.target.trim() ||
170
+ event.target.target.length > 4_096 ||
171
+ NOTIFICATION_CONTROL_CHARACTER_RE.test(event.target.target))) ||
172
+ (event.button !== undefined && !isValidNotificationButton(event.button)) ||
173
+ !Array.isArray(event.attachments ?? []) ||
174
+ (event.attachments?.length ?? 0) > 4 ||
175
+ (event.attachments ?? []).some((attachment) => !isValidAttachmentDescriptor(attachment))) {
176
+ throw new Error("Desktop notification event is invalid");
177
+ }
178
+ }
179
+ function isValidNotificationButton(button) {
180
+ if (!button ||
181
+ typeof button !== "object" ||
182
+ !("text" in button) ||
183
+ !("url" in button) ||
184
+ typeof button.text !== "string" ||
185
+ !button.text.trim() ||
186
+ button.text.length > 256 ||
187
+ NOTIFICATION_CONTROL_CHARACTER_RE.test(button.text) ||
188
+ typeof button.url !== "string" ||
189
+ button.url.length > 2_048) {
190
+ return false;
191
+ }
192
+ try {
193
+ const url = new URL(button.url);
194
+ return ((url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password);
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
200
+ function isValidAttachmentDescriptor(attachment) {
201
+ if (!attachment || typeof attachment !== "object")
202
+ return false;
203
+ return ("kind" in attachment &&
204
+ "path" in attachment &&
205
+ "name" in attachment &&
206
+ "mimeType" in attachment &&
207
+ "size" in attachment &&
208
+ typeof attachment.kind === "string" &&
209
+ ["image", "file", "audio", "video"].includes(attachment.kind) &&
210
+ typeof attachment.path === "string" &&
211
+ isAbsolute(attachment.path) &&
212
+ typeof attachment.name === "string" &&
213
+ Boolean(attachment.name.trim()) &&
214
+ attachment.name.length <= 255 &&
215
+ !/[\\/\u0000-\u001f\u007f]/u.test(attachment.name) &&
216
+ typeof attachment.mimeType === "string" &&
217
+ Boolean(attachment.mimeType.trim()) &&
218
+ attachment.mimeType.length <= 255 &&
219
+ !/[\u0000-\u001f\u007f]/u.test(attachment.mimeType) &&
220
+ (attachment.kind === "image"
221
+ ? attachment.mimeType.startsWith("image/")
222
+ : attachment.kind === "audio"
223
+ ? attachment.mimeType.startsWith("audio/")
224
+ : attachment.kind === "video"
225
+ ? attachment.mimeType.startsWith("video/")
226
+ : true) &&
227
+ typeof attachment.size === "number" &&
228
+ Number.isSafeInteger(attachment.size) &&
229
+ attachment.size >= 1 &&
230
+ attachment.size <= MAX_OUTGOING_ATTACHMENT_BYTES);
231
+ }
232
+ /**
233
+ * Read validated host-local attachments from disk into outgoing bytes.
234
+ * Shared by event relays, Mimi replies, and proactive sends; throws when
235
+ * metadata is invalid or the file changed before the transport accepts it.
236
+ */
237
+ export async function materializeOutgoingAttachments(attachments) {
238
+ if (attachments.length > 4)
239
+ throw new Error("Outgoing attachment metadata is invalid");
240
+ const output = [];
241
+ for (const attachment of attachments) {
242
+ if (!isValidAttachmentDescriptor(attachment)) {
243
+ throw new Error("Outgoing attachment metadata is invalid");
244
+ }
245
+ const data = await readStableOutgoingAttachment(attachment.path, attachment.size);
246
+ output.push({
247
+ kind: attachment.kind,
248
+ name: attachment.name.slice(0, 255) ||
249
+ (attachment.kind === "image"
250
+ ? "generated-image"
251
+ : attachment.kind === "audio"
252
+ ? "audio"
253
+ : attachment.kind === "video"
254
+ ? "video"
255
+ : "attachment"),
256
+ mimeType: attachment.mimeType,
257
+ data,
258
+ });
259
+ }
260
+ return output;
261
+ }
262
+ async function readStableOutgoingAttachment(path, expectedSize) {
263
+ try {
264
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
265
+ const handle = await open(path, constants.O_RDONLY | noFollow);
266
+ try {
267
+ const info = await handle.stat();
268
+ if (!info.isFile() || info.size !== expectedSize) {
269
+ throw new Error("file metadata changed");
270
+ }
271
+ const data = await handle.readFile();
272
+ if (data.byteLength !== expectedSize)
273
+ throw new Error("file size changed while reading");
274
+ return data;
275
+ }
276
+ finally {
277
+ await handle.close();
278
+ }
279
+ }
280
+ catch (error) {
281
+ throw new Error("Outgoing attachment changed before delivery", { cause: error });
282
+ }
283
+ }
284
+ /** @deprecated Use materializeOutgoingAttachments for transport-neutral code. */
285
+ export const materializeEventAttachments = materializeOutgoingAttachments;
@@ -0,0 +1,22 @@
1
+ import type { ChannelAdapter } from "./channel.js";
2
+ import { ChatGateway, type ChatAllowlistRule } from "./chat-gateway.js";
3
+ export interface PlatformCanaryOptions {
4
+ adapters: ChannelAdapter[];
5
+ allowlists: Readonly<Record<string, ChatAllowlistRule>>;
6
+ webhook?: ConstructorParameters<typeof ChatGateway>[0]["webhook"];
7
+ delivery?: ConstructorParameters<typeof ChatGateway>[0]["delivery"];
8
+ timeoutMs?: number;
9
+ nonce?: string;
10
+ onReady?: (instruction: string) => void;
11
+ }
12
+ export interface PlatformCanaryResult {
13
+ nonce: string;
14
+ channels: string[];
15
+ completedAt: number;
16
+ }
17
+ /**
18
+ * Credential-backed canary: starts the real adapters and ingress, then waits
19
+ * for one allowlisted inbound `/canary <nonce>` per configured channel and
20
+ * replies through that same platform. Nothing is sent to users unprompted.
21
+ */
22
+ export declare function runPlatformCanary(options: PlatformCanaryOptions): Promise<PlatformCanaryResult>;