@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +15 -0
  3. package/README.md +56 -2
  4. package/client.js +4189 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/coexistence.md +112 -0
  7. package/docs/dsh-feishu-channels-plan.md +1406 -0
  8. package/docs/ui-revamp-plan.md +263 -0
  9. package/docs/verification.md +120 -0
  10. package/icon.svg +17 -0
  11. package/locale/en.json +6 -0
  12. package/locale/zh.json +6 -0
  13. package/package.json +76 -4
  14. package/src/channels/connection-state.js +12 -0
  15. package/src/channels/diagnostics.js +77 -0
  16. package/src/channels/factory.js +20 -0
  17. package/src/channels/feishu-app.js +267 -0
  18. package/src/channels/feishu.js +399 -0
  19. package/src/channels/media.js +9 -0
  20. package/src/channels/meta.js +24 -0
  21. package/src/channels/qr/feishu.js +84 -0
  22. package/src/channels/qr/hub.js +229 -0
  23. package/src/channels/qr/render.js +8 -0
  24. package/src/channels/qr/shared.js +50 -0
  25. package/src/engine/abort.js +35 -0
  26. package/src/engine/access.js +23 -0
  27. package/src/engine/agent-options.js +19 -0
  28. package/src/engine/approval.js +70 -0
  29. package/src/engine/assistant-settings.js +38 -0
  30. package/src/engine/assistant-text.js +11 -0
  31. package/src/engine/atomic-file.js +45 -0
  32. package/src/engine/chat-commands.js +752 -0
  33. package/src/engine/choice-delivery.js +20 -0
  34. package/src/engine/choices.js +103 -0
  35. package/src/engine/command-locale.js +70 -0
  36. package/src/engine/command-messages.js +434 -0
  37. package/src/engine/command-permissions.js +38 -0
  38. package/src/engine/command-replies.js +59 -0
  39. package/src/engine/credentials.js +54 -0
  40. package/src/engine/deferred-delivery.js +260 -0
  41. package/src/engine/delivery-history.js +39 -0
  42. package/src/engine/file-delivery.js +215 -0
  43. package/src/engine/file-input.js +52 -0
  44. package/src/engine/file-log.js +25 -0
  45. package/src/engine/gateway.js +1337 -0
  46. package/src/engine/image-input.js +31 -0
  47. package/src/engine/json-state.js +25 -0
  48. package/src/engine/keyed-queue.js +20 -0
  49. package/src/engine/merge.js +56 -0
  50. package/src/engine/message-progress.js +274 -0
  51. package/src/engine/question.js +201 -0
  52. package/src/engine/quiet-logger.js +25 -0
  53. package/src/engine/reply-stream.js +86 -0
  54. package/src/engine/router.js +713 -0
  55. package/src/engine/secure-file.js +8 -0
  56. package/src/engine/seen-store.js +43 -0
  57. package/src/engine/session-export.js +64 -0
  58. package/src/engine/session-history.js +30 -0
  59. package/src/engine/session-id.js +62 -0
  60. package/src/engine/session-store.js +109 -0
  61. package/src/engine/session-title.js +29 -0
  62. package/src/engine/split.js +291 -0
  63. package/src/engine/workspace-path.js +9 -0
  64. package/src/index.js +62 -0
  65. package/src/manager.js +1325 -0
@@ -0,0 +1,260 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { writeFileAtomicSync } from './atomic-file.js';
3
+ import { KeyedSerialQueue } from './keyed-queue.js';
4
+ import { assistantText } from './assistant-text.js';
5
+ /** 只从精确请求所在回合恢复最终文字,绝不调用模型或重放工具。 */
6
+ export function recoverTurn(events, requestId, skipInterimText = false) {
7
+ let turn;
8
+ let matched = false;
9
+ let target;
10
+ const replies = [];
11
+ for (const event of events) {
12
+ const data = event.data;
13
+ if (event.type === 'turn/start') {
14
+ if (matched && data?.turn !== target)
15
+ return undefined;
16
+ turn = data?.turn;
17
+ }
18
+ if (!matched && event.type === 'user/message' && (data?.id === requestId || data?.source?.rpcId === requestId)) {
19
+ if (!Number.isSafeInteger(turn))
20
+ return;
21
+ matched = true;
22
+ target = turn;
23
+ }
24
+ if (!matched)
25
+ continue;
26
+ if (event.type === 'assistant/message' && data?.turn === target && !data?.interrupted) {
27
+ const text = assistantText(data?.message?.content, skipInterimText);
28
+ if (text)
29
+ replies.push(text);
30
+ if (replies.reduce((size, value) => size + value.length, 0) > 1_000_000)
31
+ return undefined;
32
+ }
33
+ if (event.type === 'turn/end' && data?.turn === target)
34
+ return { turn: target, text: replies.join('\n\n'), kind: data?.reason?.kind ?? 'unknown' };
35
+ }
36
+ }
37
+ export class DeferredDelivery {
38
+ file;
39
+ entries = [];
40
+ queue = new KeyedSerialQueue();
41
+ active = new Set();
42
+ quarantined = new Set();
43
+ constructor(file) {
44
+ this.file = file;
45
+ if (!file)
46
+ return;
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
49
+ if (parsed.version !== 1 || !Array.isArray(parsed.entries))
50
+ throw new Error('Invalid delivery journal');
51
+ for (const entry of parsed.entries) {
52
+ if (typeof entry.id !== 'string' || typeof entry.sessionId !== 'string' || typeof entry.channelId !== 'string'
53
+ || typeof entry.message?.chatId !== 'string' || !['dm', 'group'].includes(entry.message.kind)
54
+ || !Number.isFinite(entry.createdAt) || !Number.isSafeInteger(entry.offset) || entry.offset < 0
55
+ || !['waiting', 'ready', 'sending', 'sent', 'unknown', 'blocked', 'expired', 'rejected'].includes(entry.status)
56
+ || (entry.message.userId !== undefined && typeof entry.message.userId !== 'string')
57
+ || (entry.turn !== undefined && !Number.isSafeInteger(entry.turn))
58
+ || this.entries.some(e => e.id === entry.id)
59
+ || (entry.parts !== undefined && (!Array.isArray(entry.parts) || !entry.parts.every((p) => typeof p === 'string') || entry.offset > entry.parts.length))
60
+ || (entry.text !== undefined && typeof entry.text !== 'string'))
61
+ throw new Error('Invalid delivery record');
62
+ if (entry.status === 'sending')
63
+ entry.status = 'unknown';
64
+ this.entries.push(entry);
65
+ }
66
+ this.flush();
67
+ }
68
+ catch (error) {
69
+ if (error.code !== 'ENOENT')
70
+ throw error;
71
+ }
72
+ }
73
+ flush() { if (this.file)
74
+ writeFileAtomicSync(this.file, JSON.stringify({ version: 1, entries: this.entries }), 0o600); }
75
+ list(channelId, message) {
76
+ return this.entries.filter(e => (!channelId || e.channelId === channelId) && (!message || (e.message.chatId === message.chatId && e.message.kind === (message.kind ?? 'dm') && e.message.userId === message.userId))).map(e => structuredClone(e));
77
+ }
78
+ turnEntries(sessionId, turn) {
79
+ return this.entries.filter(e => e.sessionId === sessionId && e.turn === turn).map(e => ({ ...e, text: undefined, parts: undefined, message: { ...e.message } }));
80
+ }
81
+ begin(id, sessionId, channelId, message) {
82
+ const previous = this.entries;
83
+ this.entries = this.entries.filter(e => Date.now() - e.createdAt < 7 * 86400_000);
84
+ while (this.entries.length >= 1000 && this.entries.some(e => ['sent', 'rejected'].includes(e.status)))
85
+ this.entries.splice(this.entries.findIndex(e => ['sent', 'rejected'].includes(e.status)), 1);
86
+ if (this.entries.length >= 1000) {
87
+ this.entries = previous;
88
+ throw new Error('Delivery journal full; inspect /delivery');
89
+ }
90
+ this.entries.push({ id, sessionId, channelId, message: { chatId: message.chatId, userId: message.userId, kind: message.kind ?? 'dm', text: '', addressed: true }, createdAt: Date.now(), status: 'waiting', offset: 0 });
91
+ try {
92
+ this.flush();
93
+ }
94
+ catch (error) {
95
+ this.entries = previous;
96
+ throw error;
97
+ }
98
+ this.active.add(id);
99
+ }
100
+ reject(id, definite = false) {
101
+ try {
102
+ this.patch(id, { status: definite ? 'rejected' : 'unknown' });
103
+ }
104
+ catch (error) {
105
+ this.quarantined.add(id);
106
+ throw error;
107
+ }
108
+ finally {
109
+ this.active.delete(id);
110
+ }
111
+ }
112
+ patch(id, update) {
113
+ const entry = this.entries.find(e => e.id === id);
114
+ if (!entry)
115
+ return;
116
+ if (Object.entries(update).every(([key, value]) => Object.is(entry[key], value)))
117
+ return;
118
+ const previous = { ...entry };
119
+ Object.assign(entry, update);
120
+ try {
121
+ this.flush();
122
+ }
123
+ catch (error) {
124
+ for (const key of Object.keys(entry))
125
+ delete entry[key];
126
+ Object.assign(entry, previous);
127
+ throw error;
128
+ }
129
+ }
130
+ claim(sessionId, turn, requestId) {
131
+ for (const e of this.entries)
132
+ if (e.sessionId === sessionId && e.id === requestId && e.turn === undefined)
133
+ this.patch(e.id, { turn });
134
+ }
135
+ liveStart(sessionId, turn) {
136
+ const ids = this.entries.filter(e => e.sessionId === sessionId && e.turn === turn && this.active.has(e.id) && !['blocked', 'expired'].includes(e.status)).map(e => e.id);
137
+ for (const id of ids)
138
+ this.patch(id, { status: 'unknown' });
139
+ return ids;
140
+ }
141
+ /**
142
+ * 实时投递收口。`ok` 表示全部确认送达;`rejected` 表示一片都没送到、
143
+ * 且每次失败都是平台明确拒绝(服务端已应答),此时不存在「可能已送达」
144
+ * 的歧义,直接置为 `ready`,交回 30s 巡检自动补发。
145
+ * 其余失败保持 `unknown`,不猜测、不自动重发。
146
+ */
147
+ complete(sessionId, turn, ok, rejected = false) {
148
+ for (const e of this.entries)
149
+ if (e.sessionId === sessionId && e.turn === turn && this.active.has(e.id)) {
150
+ // waiting 表示实时路径根本未发送,留给冷读取;unknown 不猜测为失败。
151
+ if (e.status === 'unknown' && ok)
152
+ this.patch(e.id, { status: 'sent' });
153
+ else if (e.status === 'unknown' && rejected)
154
+ this.patch(e.id, { status: 'ready' });
155
+ this.active.delete(e.id);
156
+ }
157
+ }
158
+ block(channelId) {
159
+ for (const e of this.entries)
160
+ if (e.channelId === channelId && !['sent', 'rejected'].includes(e.status))
161
+ this.patch(e.id, { status: 'blocked' });
162
+ this.release(undefined, channelId);
163
+ }
164
+ coldTurn(sessionId, turn) {
165
+ return turn !== undefined && this.entries.some(e => e.sessionId === sessionId && e.turn === turn && !this.active.has(e.id));
166
+ }
167
+ release(sessionId, channelId) {
168
+ for (const e of this.entries)
169
+ if ((!sessionId || e.sessionId === sessionId) && (!channelId || e.channelId === channelId))
170
+ this.active.delete(e.id);
171
+ }
172
+ async recover(id, read, valid, send, chunks, explicit = false) {
173
+ const sessionId = this.entries.find(e => e.id === id)?.sessionId ?? id;
174
+ return this.queue.run(sessionId, async () => {
175
+ const entry = this.entries.find(e => e.id === id);
176
+ if (!entry || this.active.has(id) || this.quarantined.has(id))
177
+ return;
178
+ const originalStatus = entry.status;
179
+ const pausedRetry = explicit && ['unknown', 'blocked'].includes(originalStatus);
180
+ if (!pausedRetry && !['waiting', 'ready'].includes(entry.status))
181
+ return;
182
+ if (Date.now() - entry.createdAt > 7 * 86400_000) {
183
+ this.patch(id, { status: 'expired' });
184
+ return;
185
+ }
186
+ if (!valid(entry)) {
187
+ this.patch(id, { status: 'blocked' });
188
+ return;
189
+ }
190
+ if (!entry.text) {
191
+ let result;
192
+ try {
193
+ result = await read({ ...entry });
194
+ }
195
+ catch (error) {
196
+ if (explicit)
197
+ return 'unavailable';
198
+ throw error;
199
+ }
200
+ if (!result)
201
+ return explicit ? 'missing' : undefined;
202
+ if (entry.status !== originalStatus)
203
+ return;
204
+ this.patch(id, { text: result.text, turn: result.turn ?? entry.turn, status: 'ready' });
205
+ }
206
+ if (!valid(entry)) {
207
+ this.patch(id, { status: 'blocked' });
208
+ return;
209
+ }
210
+ const duplicate = this.entries.find(e => e.id !== id && e.sessionId === entry.sessionId && e.turn !== undefined && e.turn === entry.turn && ['sent', 'unknown', 'sending'].includes(e.status));
211
+ if (duplicate && !explicit) {
212
+ this.patch(id, { status: duplicate.status === 'sent' ? 'sent' : 'unknown' });
213
+ return;
214
+ }
215
+ if (!entry.parts)
216
+ this.patch(id, { parts: chunks(entry.text) });
217
+ const parts = entry.parts;
218
+ if (!parts.length) {
219
+ this.patch(id, { status: 'sent' });
220
+ return;
221
+ }
222
+ for (let index = entry.offset; index < parts.length; index++) {
223
+ if (!valid(entry)) {
224
+ this.patch(id, { status: 'blocked' });
225
+ return;
226
+ }
227
+ this.patch(id, { status: 'sending' });
228
+ try {
229
+ await send(entry, parts[index]);
230
+ }
231
+ catch (error) {
232
+ // 只有适配器明确确认请求未提交时才保留自动恢复资格。
233
+ this.patch(id, { status: error instanceof DeliveryUnavailable ? 'ready' : 'unknown' });
234
+ return;
235
+ }
236
+ const blocked = entry.status === 'blocked';
237
+ this.patch(id, { offset: index + 1, status: blocked ? 'blocked' : index + 1 === parts.length ? 'sent' : 'ready' });
238
+ if (blocked)
239
+ return;
240
+ }
241
+ });
242
+ }
243
+ }
244
+ export class DeliveryUnavailable extends Error {
245
+ }
246
+ /**
247
+ * 渠道显式声明「平台已应答且明确拒收,本次发送没有落地」。
248
+ * 只有抛出该类型时,引擎才会退避重试并允许自动补发;
249
+ * 其余错误(超时、网络中断、本地校验或上传失败)一律按「送达未知」处理。
250
+ */
251
+ export class DeliveryRejected extends Error {
252
+ name = 'DeliveryRejected';
253
+ }
254
+ /**
255
+ * 渠道内部分多片发送时,已有分片送达后再被拒收,就不再是「整条未送达」:
256
+ * 整条重发会让用户看到重复内容,因此退化为结果不明的普通错误。
257
+ */
258
+ export function afterPartialDelivery(error) {
259
+ return error instanceof DeliveryRejected ? new Error(error.message, { cause: error }) : error;
260
+ }
@@ -0,0 +1,39 @@
1
+ import { recoverTurn } from './deferred-delivery.js';
2
+ /** 使用 Chat 的冷历史读取接口,不 resolveAgent,不恢复执行。读取窗口有界。 */
3
+ export async function readDeliveryHistory(host, sessionId, requestId, signal, skipInterimText = false) {
4
+ const reader = host.get('sessionController');
5
+ if (!reader?.follow)
6
+ return undefined;
7
+ const address = { kind: 'session', sessionId };
8
+ let snapshot;
9
+ for await (const frame of await reader.follow({ address, maxMessages: 100 }, signal)) {
10
+ if (frame.type !== 'snapshot')
11
+ return undefined;
12
+ snapshot = frame;
13
+ break;
14
+ }
15
+ if (!snapshot)
16
+ return undefined;
17
+ let records = snapshot.records;
18
+ let hasMore = snapshot.hasMore;
19
+ for (let count = 0; count < 20 && !signal.aborted; count++) {
20
+ const events = records.flatMap(record => record.type === 'event' && record.event ? [record.event] : []);
21
+ const result = recoverTurn(events, requestId, skipInterimText);
22
+ if (result)
23
+ return result;
24
+ if (!hasMore || !reader.page)
25
+ return undefined;
26
+ const beforeSeq = events[0]?.seq;
27
+ if (!Number.isSafeInteger(beforeSeq))
28
+ return undefined;
29
+ const page = await reader.page({ address, throughSeq: snapshot.cursor, beforeSeq, maxMessages: 100 }, signal);
30
+ const first = page.records.find(record => record.type === 'event')?.event?.seq;
31
+ if (first === undefined || first >= beforeSeq)
32
+ return undefined;
33
+ records = [...page.records, ...records];
34
+ if (records.length > 20000)
35
+ return undefined;
36
+ hasMore = page.hasMore;
37
+ }
38
+ return undefined;
39
+ }
@@ -0,0 +1,215 @@
1
+ import { basename, resolve } from 'node:path';
2
+ import { KeyedSerialQueue } from './keyed-queue.js';
3
+ import { replyText, withReplyLocale } from './command-locale.js';
4
+ import { readLiveSessionHistory } from './session-history.js';
5
+ /** 与 Chat 的成功文件变更规则保持一致;不从助手回复中猜路径。 */
6
+ function mutationPath(name, raw) {
7
+ if (typeof raw !== 'string')
8
+ return;
9
+ let args;
10
+ try {
11
+ args = JSON.parse(raw);
12
+ }
13
+ catch {
14
+ return;
15
+ }
16
+ if (!args || typeof args !== 'object')
17
+ return;
18
+ const path = name === 'str_replace_editor' ? args.path : args.file_path;
19
+ if (typeof path !== 'string' || !path.trim())
20
+ return;
21
+ if (name === 'write' && typeof args.content === 'string')
22
+ return path;
23
+ if (name === 'edit' && typeof args.old_string === 'string' && args.old_string.length > 0
24
+ && typeof args.new_string === 'string' && args.old_string !== args.new_string
25
+ && (args.replace_all === undefined || typeof args.replace_all === 'boolean'))
26
+ return path;
27
+ if (name !== 'str_replace_editor')
28
+ return;
29
+ if (args.command === 'create' && typeof args.file_text === 'string')
30
+ return path;
31
+ if (args.command === 'str_replace' && typeof args.old_str === 'string' && args.old_str.length > 0
32
+ && (args.new_str === undefined || typeof args.new_str === 'string'))
33
+ return path;
34
+ if (args.command === 'insert' && Number.isInteger(args.insert_line) && args.insert_line >= 0 && typeof args.new_str === 'string')
35
+ return path;
36
+ }
37
+ /** 只选当前回复所在回合、已成功产生或明确 present 的文件。 */
38
+ export function filesForReply(events, closing) {
39
+ let end = events.findIndex(event => event === closing || (closing.seq !== undefined && event.seq === closing.seq));
40
+ if (end < 0)
41
+ end = events.length;
42
+ if (closing.surfaceOp !== 'append' || closing.data?.interrupted
43
+ || closing.data?.message?.content?.some((part) => part.type === 'tool-call'))
44
+ return;
45
+ const prefix = events.slice(0, end);
46
+ const turn = closing.data?.turn;
47
+ if (!Number.isSafeInteger(turn))
48
+ return;
49
+ const calls = new Map();
50
+ const paths = new Set();
51
+ for (const event of prefix) {
52
+ const data = event.data;
53
+ if (event.type === 'tool/call' && data?.turn === turn) {
54
+ const path = mutationPath(data.name, data.arguments);
55
+ if (path && typeof data.callId === 'string')
56
+ calls.set(data.callId, path);
57
+ }
58
+ else if (event.type === 'tool/result' && data?.turn === turn && event.surfaceOp === 'append') {
59
+ const path = calls.get(data?.message?.source?.callId);
60
+ if (path && !toolResultFailed(data))
61
+ paths.add(path);
62
+ }
63
+ else if (event.type === 'deliverables/presented' && data?.turn === turn && Array.isArray(data.files)) {
64
+ for (const file of data.files)
65
+ if (typeof file?.path === 'string' && file.path.trim())
66
+ paths.add(file.path);
67
+ }
68
+ }
69
+ return { turn, paths: [...paths] };
70
+ }
71
+ /** V4 把 isError 放到 message 上;旧日志仍在 tool-result 内容块上。 */
72
+ export function toolResultFailed(data) {
73
+ if (data?.message?.isError === true)
74
+ return true;
75
+ return data?.message?.content?.[0]?.isError === true;
76
+ }
77
+ /** 文件在投递前已被删除(例如同一回合写入后又被清理)不是投递失败:静默跳过,不打扰用户。 */
78
+ export function missingFileError(error) {
79
+ const code = error?.code;
80
+ if (typeof code === 'string') {
81
+ const tail = code.slice(code.lastIndexOf('/') + 1);
82
+ if (tail === 'not-found')
83
+ return true;
84
+ }
85
+ const message = error instanceof Error ? error.message : '';
86
+ return message === 'file-not-found';
87
+ }
88
+ /** 复用 Chat 完整文件读取服务,宿主负责路径、文件类型和大小限制。 */
89
+ export class FileDelivery {
90
+ host;
91
+ log;
92
+ queue = new KeyedSerialQueue();
93
+ sent = new Map();
94
+ lifetime = new AbortController();
95
+ constructor(host, log) {
96
+ this.host = host;
97
+ this.log = log;
98
+ }
99
+ dispose() { this.lifetime.abort(); this.sent.clear(); }
100
+ deliver(session, closing, target, onContent) {
101
+ let ok = true;
102
+ const initial = target();
103
+ return this.queue.run(String(session.id), async () => {
104
+ if (this.lifetime.signal.aborted) {
105
+ ok = false;
106
+ return;
107
+ }
108
+ const selected = filesForReply(readLiveSessionHistory(session) ?? [], closing);
109
+ if (!selected?.paths.length)
110
+ return;
111
+ onContent?.();
112
+ if (!initial) {
113
+ ok = false;
114
+ return;
115
+ }
116
+ const key = `${session.id}:${selected.turn}:${initial.channel.id}:${initial.chatId}`;
117
+ const sent = this.sent.get(key) ?? new Set();
118
+ this.sent.set(key, sent);
119
+ if (this.sent.size > 256)
120
+ this.sent.delete(this.sent.keys().next().value);
121
+ for (const path of selected.paths) {
122
+ if (this.lifetime.signal.aborted) {
123
+ ok = false;
124
+ return;
125
+ }
126
+ const current = target();
127
+ if (!current || current.channel !== initial.channel || current.chatId !== initial.chatId) {
128
+ ok = false;
129
+ return;
130
+ }
131
+ const name = basename(path.replace(/\\/g, '/'));
132
+ try {
133
+ const cwd = session.header?.cwd ?? this.host.get('sandboxPolicy')?.workspaceRoot;
134
+ if (!cwd || !current.channel.sendFile)
135
+ throw new Error('file-delivery-unavailable');
136
+ // 同一文件可能同时以 write 的绝对路径和 present 的相对路径出现。
137
+ // 仅规范化去重键,实际读取仍由 Chat 服务校验原始路径。
138
+ const identity = resolve(cwd, path);
139
+ if (sent.has(identity))
140
+ continue;
141
+ const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(120_000)]);
142
+ const result = await readWorkspaceFile(this.host, String(session.id), cwd, path, signal);
143
+ signal.throwIfAborted();
144
+ if (!result.eof || result.offset !== 0 || typeof result.data !== 'string')
145
+ throw new Error('incomplete-file');
146
+ const latest = target();
147
+ if (!latest || latest.channel !== initial.channel || latest.chatId !== initial.chatId) {
148
+ ok = false;
149
+ return;
150
+ }
151
+ await latest.channel.sendFile(latest.chatId, { name, data: Buffer.from(result.data, 'base64') }, signal);
152
+ sent.add(identity);
153
+ }
154
+ catch (error) {
155
+ if (this.lifetime.signal.aborted) {
156
+ ok = false;
157
+ return;
158
+ }
159
+ if (missingFileError(error))
160
+ continue;
161
+ ok = false;
162
+ const code = typeof error?.code === 'string' ? ` code=${error.code}` : '';
163
+ this.log(`[${initial.channel.id}] 文件回传失败(${error instanceof Error ? error.name : 'Error'}${code};敏感详情已隐藏)`);
164
+ const latest = target();
165
+ if (!latest || latest.channel !== initial.channel || latest.chatId !== initial.chatId) {
166
+ ok = false;
167
+ return;
168
+ }
169
+ const text = withReplyLocale(this.host, () => replyText('文件“{0}”未能发送,请在网页 Chat 中打开该文件;也可让助手重新交付。', name));
170
+ await initial.channel.send(initial.chatId, text).catch(() => undefined);
171
+ }
172
+ }
173
+ }).then(() => ok).catch(error => { this.log(`[file-delivery] ${error instanceof Error ? error.message : String(error)}`); return false; });
174
+ }
175
+ }
176
+ /** `readBytes(scope, path, {}, signal)` 不带 range:要么整文件,要么 too-large 抛错,不返回分片。0.1.7-alpha.1、alpha.2 和 rc.1 该分支写的是 `offset: 0, eof: true`。缺字段按完整文件处理;显式 `eof: false` 或非零 offset 仍拒绝。 */
177
+ function encodeFileBytes(result) {
178
+ const offset = result.offset ?? 0;
179
+ const eof = result.eof !== false;
180
+ if (typeof result.data === 'string')
181
+ return { data: result.data, eof, offset };
182
+ if (result.data instanceof Uint8Array)
183
+ return { data: Buffer.from(result.data).toString('base64'), eof, offset };
184
+ throw new Error('incomplete-file');
185
+ }
186
+ /** 0.1.5 用 readAll;0.1.7 起改为 readBytes({}, 完整文件);0.1.2-rc.1 没有该服务,走本机 fs。0.1.6 已不在 peer 里,不再单独分支。 */
187
+ async function readWorkspaceFile(host, sessionId, cwd, path, signal) {
188
+ const files = host.get('workspaceFiles');
189
+ if (typeof files?.readAll === 'function') {
190
+ return encodeFileBytes(await files.readAll({ sessionId, workspaceRoot: cwd }, path, signal));
191
+ }
192
+ if (typeof files?.readBytes === 'function') {
193
+ return encodeFileBytes(await files.readBytes({ sessionId, workspaceRoot: cwd }, path, {}, signal));
194
+ }
195
+ return readLegacyFile(host, cwd, path, signal);
196
+ }
197
+ /** 旧 Chat 只有本机路径打开;使用宿主 fs,沿用新版完整下载默认 32 MiB 上限。 */
198
+ async function readLegacyFile(host, cwd, path, signal) {
199
+ const fs = host.get('fs');
200
+ if (!fs?.readBytes)
201
+ throw new Error('file-service-unavailable');
202
+ const limit = 32 * 1024 * 1024;
203
+ if ((await fs.lstat(path, { cwd }, signal))?.type !== 'file')
204
+ throw new Error('not-regular-file');
205
+ const target = await fs.resolve(path, { cwd, signal });
206
+ const info = await fs.stat(target, signal);
207
+ if (info?.type !== 'file')
208
+ throw new Error('not-regular-file');
209
+ if (info.size !== undefined && info.size > limit)
210
+ throw new Error('file-too-large');
211
+ const bytes = await fs.readBytes(target, signal, limit);
212
+ if (bytes.byteLength > limit)
213
+ throw new Error('file-too-large');
214
+ return { data: Buffer.from(bytes).toString('base64'), offset: 0, eof: true };
215
+ }
@@ -0,0 +1,52 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { replyText } from './command-locale.js';
3
+ import { fileOperation, timeoutSignal } from './abort.js';
4
+ export class FileInputError extends Error {
5
+ }
6
+ export const MAX_INPUT_FILE_BYTES = 20 * 1024 * 1024;
7
+ /** 渠道只提交字节及显示名;持久化、可读范围和模型呈现由 Chat 上传服务决定。 */
8
+ export async function filePromptParts(host, sessionId, media, parent) {
9
+ const signal = timeoutSignal(120_000, parent);
10
+ signal.throwIfAborted();
11
+ const upload = host.get('fileUploads');
12
+ if (!upload?.uploadStream)
13
+ throw new FileInputError(replyText('当前 DSH 不支持 Chat 文件输入,请升级到 0.1.5-rc.1 或更新的兼容版本后重新发送。'));
14
+ if (media.length > 4)
15
+ throw new FileInputError(replyText('一次最多发送 4 个文件,累计不超过 20 MiB。'));
16
+ let total = 0;
17
+ const parts = [];
18
+ try {
19
+ for (const file of media) {
20
+ signal.throwIfAborted();
21
+ if (file.data && file.data.byteLength > MAX_INPUT_FILE_BYTES - total)
22
+ throw new Error('file-size');
23
+ let data = file.data ? Buffer.from(file.data) : undefined;
24
+ if (!data && file.path) {
25
+ const handle = await open(file.path, 'r');
26
+ try {
27
+ const stat = await handle.stat();
28
+ if (!stat.isFile() || stat.size > MAX_INPUT_FILE_BYTES - total)
29
+ throw new Error('file-size');
30
+ data = await handle.readFile({ signal });
31
+ }
32
+ finally {
33
+ await handle.close();
34
+ }
35
+ }
36
+ if (!data || !data.length || (total += data.length) > MAX_INPUT_FILE_BYTES)
37
+ throw new Error('file-size');
38
+ const value = await fileOperation(upload.uploadStream({ sessionId, name: file.name,
39
+ data: (async function* () { signal.throwIfAborted(); yield data; })(), signal }), signal);
40
+ signal.throwIfAborted();
41
+ if (typeof value.receiptId !== 'string' || !value.receiptId)
42
+ throw new Error('missing-receipt');
43
+ parts.push({ type: 'file', receiptId: value.receiptId });
44
+ }
45
+ return parts;
46
+ }
47
+ catch (error) {
48
+ if (signal.aborted)
49
+ throw signal.reason;
50
+ throw new FileInputError(replyText('文件输入失败,整条消息未提交。请检查文件大小和格式后重新发送,或在网页 Chat 上传。'), { cause: error });
51
+ }
52
+ }
@@ -0,0 +1,25 @@
1
+ import { appendFile, chmod, rename, rm, stat } from 'node:fs/promises';
2
+ /** 串行异步追加;当前文件超限时只保留一个上一代归档,限定磁盘占用。 */
3
+ export function createRotatingFileAppender(file, maxBytes = 5 * 1024 * 1024) {
4
+ let queue = Promise.resolve();
5
+ const write = async (line) => {
6
+ const bytes = Buffer.byteLength(line);
7
+ const current = await stat(file).then((value) => value.size).catch(() => 0);
8
+ if (current > 0 && current + bytes > maxBytes) {
9
+ const archive = `${file}.1`;
10
+ await rm(archive, { force: true }).catch(() => undefined);
11
+ await rename(file, archive).catch((error) => {
12
+ if (error.code !== 'ENOENT')
13
+ throw error;
14
+ });
15
+ }
16
+ await appendFile(file, line, { encoding: 'utf8', mode: 0o600 });
17
+ await chmod(file, 0o600).catch(() => undefined);
18
+ };
19
+ return {
20
+ append(line) {
21
+ queue = queue.then(() => write(line)).catch(() => undefined);
22
+ },
23
+ flush() { return queue; },
24
+ };
25
+ }