@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.
- package/LICENSE +201 -0
- package/NOTICE +15 -0
- package/README.md +56 -2
- package/client.js +4189 -0
- package/cordis.patch.yml +4 -0
- package/docs/coexistence.md +112 -0
- package/docs/dsh-feishu-channels-plan.md +1406 -0
- package/docs/ui-revamp-plan.md +263 -0
- package/docs/verification.md +120 -0
- package/icon.svg +17 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -4
- package/src/channels/connection-state.js +12 -0
- package/src/channels/diagnostics.js +77 -0
- package/src/channels/factory.js +20 -0
- package/src/channels/feishu-app.js +267 -0
- package/src/channels/feishu.js +399 -0
- package/src/channels/media.js +9 -0
- package/src/channels/meta.js +24 -0
- package/src/channels/qr/feishu.js +84 -0
- package/src/channels/qr/hub.js +229 -0
- package/src/channels/qr/render.js +8 -0
- package/src/channels/qr/shared.js +50 -0
- package/src/engine/abort.js +35 -0
- package/src/engine/access.js +23 -0
- package/src/engine/agent-options.js +19 -0
- package/src/engine/approval.js +70 -0
- package/src/engine/assistant-settings.js +38 -0
- package/src/engine/assistant-text.js +11 -0
- package/src/engine/atomic-file.js +45 -0
- package/src/engine/chat-commands.js +752 -0
- package/src/engine/choice-delivery.js +20 -0
- package/src/engine/choices.js +103 -0
- package/src/engine/command-locale.js +70 -0
- package/src/engine/command-messages.js +434 -0
- package/src/engine/command-permissions.js +38 -0
- package/src/engine/command-replies.js +59 -0
- package/src/engine/credentials.js +54 -0
- package/src/engine/deferred-delivery.js +260 -0
- package/src/engine/delivery-history.js +39 -0
- package/src/engine/file-delivery.js +215 -0
- package/src/engine/file-input.js +52 -0
- package/src/engine/file-log.js +25 -0
- package/src/engine/gateway.js +1337 -0
- package/src/engine/image-input.js +31 -0
- package/src/engine/json-state.js +25 -0
- package/src/engine/keyed-queue.js +20 -0
- package/src/engine/merge.js +56 -0
- package/src/engine/message-progress.js +274 -0
- package/src/engine/question.js +201 -0
- package/src/engine/quiet-logger.js +25 -0
- package/src/engine/reply-stream.js +86 -0
- package/src/engine/router.js +713 -0
- package/src/engine/secure-file.js +8 -0
- package/src/engine/seen-store.js +43 -0
- package/src/engine/session-export.js +64 -0
- package/src/engine/session-history.js +30 -0
- package/src/engine/session-id.js +62 -0
- package/src/engine/session-store.js +109 -0
- package/src/engine/session-title.js +29 -0
- package/src/engine/split.js +291 -0
- package/src/engine/workspace-path.js +9 -0
- package/src/index.js +62 -0
- package/src/manager.js +1325 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { replyText } from './command-locale.js';
|
|
3
|
+
export class ImageInputError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Safe feedback: never forward raw host errors (paths, tokens, or bytes) to IM. */
|
|
6
|
+
export function imageInputFailure(error) {
|
|
7
|
+
if (error instanceof ImageInputError)
|
|
8
|
+
return replyText(error.message);
|
|
9
|
+
const reason = error?.details?.reason;
|
|
10
|
+
if (reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES')
|
|
11
|
+
return replyText('当前会话模型不支持图片输入,请在 Chat 中切换支持视觉的模型后重新发送。');
|
|
12
|
+
return replyText('图片输入失败,消息未提交。请检查图片格式、大小或当前会话模型后重新发送;详情见本机日志。');
|
|
13
|
+
}
|
|
14
|
+
/** Encode a channel download for Chat's public prompt API. Host owns validation. */
|
|
15
|
+
export async function imagePromptPart(media) {
|
|
16
|
+
const data = media.data ? Buffer.from(media.data) : media.path ? await readFile(media.path).catch(() => { throw new ImageInputError('图片读取失败,请重新发送。'); }) : Buffer.alloc(0);
|
|
17
|
+
if (data.length === 0)
|
|
18
|
+
throw new ImageInputError('图片下载失败或内容为空,请重新发送。');
|
|
19
|
+
let mediaType = media.mediaType;
|
|
20
|
+
// Encrypted CDNs may report octet-stream or the adapter may guess from a
|
|
21
|
+
// filename. Actual image bytes take precedence; Host still fully decodes them.
|
|
22
|
+
if (data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
23
|
+
mediaType = 'image/png';
|
|
24
|
+
else if (data[0] === 255 && data[1] === 216 && data[2] === 255)
|
|
25
|
+
mediaType = 'image/jpeg';
|
|
26
|
+
else if (['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('ascii')))
|
|
27
|
+
mediaType = 'image/gif';
|
|
28
|
+
else if (data.subarray(0, 4).toString('ascii') === 'RIFF' && data.subarray(8, 12).toString('ascii') === 'WEBP')
|
|
29
|
+
mediaType = 'image/webp';
|
|
30
|
+
return { type: 'image', data: data.toString('base64'), mediaType, ...(media.name ? { name: media.name } : {}) };
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { backupCorruptFileSync, writeFileAtomicSync } from './atomic-file.js';
|
|
3
|
+
export class JsonStateFile {
|
|
4
|
+
file;
|
|
5
|
+
fallback;
|
|
6
|
+
constructor(file, fallback) {
|
|
7
|
+
this.file = file;
|
|
8
|
+
this.fallback = fallback;
|
|
9
|
+
}
|
|
10
|
+
read() {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
13
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
14
|
+
throw new TypeError('状态文件顶层必须是对象');
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
backupCorruptFileSync(this.file);
|
|
19
|
+
return structuredClone(this.fallback);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
write(value) {
|
|
23
|
+
writeFileAtomicSync(this.file, `${JSON.stringify(value, null, 2)}\n`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** 对同一 key 串行、不同 key 并行的轻量异步操作队列。 */
|
|
2
|
+
export class KeyedSerialQueue {
|
|
3
|
+
operations = new Map();
|
|
4
|
+
keys() {
|
|
5
|
+
return [...this.operations.keys()];
|
|
6
|
+
}
|
|
7
|
+
async run(key, operation) {
|
|
8
|
+
const previous = this.operations.get(key) ?? Promise.resolve();
|
|
9
|
+
const result = previous.catch(() => undefined).then(operation);
|
|
10
|
+
const tracked = result.then(() => undefined, () => undefined);
|
|
11
|
+
this.operations.set(key, tracked);
|
|
12
|
+
try {
|
|
13
|
+
return await result;
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
if (this.operations.get(key) === tracked)
|
|
17
|
+
this.operations.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function stripControlSuffix(text) {
|
|
2
|
+
if (text.endsWith('!!'))
|
|
3
|
+
return { text: text.slice(0, -2), control: 'commit' };
|
|
4
|
+
if (text.endsWith('..'))
|
|
5
|
+
return { text: text.slice(0, -2), control: 'continue' };
|
|
6
|
+
return { text, control: 'none' };
|
|
7
|
+
}
|
|
8
|
+
export class SessionMerger {
|
|
9
|
+
mergeTimeoutMs;
|
|
10
|
+
onFlush;
|
|
11
|
+
buffers = new Map();
|
|
12
|
+
constructor(mergeTimeoutMs, onFlush) {
|
|
13
|
+
this.mergeTimeoutMs = mergeTimeoutMs;
|
|
14
|
+
this.onFlush = onFlush;
|
|
15
|
+
}
|
|
16
|
+
ingest(key, raw) {
|
|
17
|
+
const { text, control } = stripControlSuffix(raw);
|
|
18
|
+
if (text.trim() === '' && control === 'none')
|
|
19
|
+
return { kind: 'ignored' };
|
|
20
|
+
const existing = this.buffers.get(key);
|
|
21
|
+
if (control === 'continue') {
|
|
22
|
+
this.setBuffer(key, existing ? `${existing.text}${text}` : text);
|
|
23
|
+
return { kind: 'buffered' };
|
|
24
|
+
}
|
|
25
|
+
if (existing) {
|
|
26
|
+
this.clear(key);
|
|
27
|
+
return { kind: 'flushed', text: `${existing.text}${text}` };
|
|
28
|
+
}
|
|
29
|
+
if (control === 'commit')
|
|
30
|
+
return { kind: 'flushed', text };
|
|
31
|
+
this.setBuffer(key, text);
|
|
32
|
+
return { kind: 'buffered' };
|
|
33
|
+
}
|
|
34
|
+
dispose() {
|
|
35
|
+
for (const key of [...this.buffers.keys()])
|
|
36
|
+
this.clear(key);
|
|
37
|
+
}
|
|
38
|
+
has(key) { return this.buffers.has(key); }
|
|
39
|
+
cancel(key) { this.clear(key); }
|
|
40
|
+
setBuffer(key, text) {
|
|
41
|
+
this.clear(key);
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
this.clear(key);
|
|
44
|
+
this.onFlush(key, text);
|
|
45
|
+
}, this.mergeTimeoutMs);
|
|
46
|
+
timer.unref?.();
|
|
47
|
+
this.buffers.set(key, { text, timer });
|
|
48
|
+
}
|
|
49
|
+
clear(key) {
|
|
50
|
+
const entry = this.buffers.get(key);
|
|
51
|
+
if (!entry)
|
|
52
|
+
return;
|
|
53
|
+
clearTimeout(entry.timer);
|
|
54
|
+
this.buffers.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { replyText, withReplyLocale } from './command-locale.js';
|
|
2
|
+
import { fileOperation } from './abort.js';
|
|
3
|
+
/** 单条原消息的状态请求独立排队,失败和超时不阻塞正文。 */
|
|
4
|
+
export class MessageProgress {
|
|
5
|
+
channel;
|
|
6
|
+
message;
|
|
7
|
+
host;
|
|
8
|
+
log;
|
|
9
|
+
tail = Promise.resolve();
|
|
10
|
+
reaction;
|
|
11
|
+
state;
|
|
12
|
+
terminal = false;
|
|
13
|
+
releaseTyping;
|
|
14
|
+
expiry;
|
|
15
|
+
constructor(channel, message, host, log, initial = 'processing') {
|
|
16
|
+
this.channel = channel;
|
|
17
|
+
this.message = message;
|
|
18
|
+
this.host = host;
|
|
19
|
+
this.log = log;
|
|
20
|
+
this.expiry = setTimeout(() => this.finish('cleared'), 30 * 60_000);
|
|
21
|
+
this.expiry.unref?.();
|
|
22
|
+
this.update(initial);
|
|
23
|
+
}
|
|
24
|
+
async safely(operation) {
|
|
25
|
+
const signal = AbortSignal.timeout(2000);
|
|
26
|
+
try {
|
|
27
|
+
await fileOperation(operation(signal), signal);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
this.log(`[${this.channel.id}] 消息状态更新失败,正文继续处理`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
update(state) {
|
|
34
|
+
if (this.terminal || this.state === state)
|
|
35
|
+
return;
|
|
36
|
+
this.transition(state);
|
|
37
|
+
this.releaseTyping?.();
|
|
38
|
+
this.releaseTyping = state === 'processing'
|
|
39
|
+
? acquireTyping(this.channel, this.message.chatId, operation => this.safely(operation)) : undefined;
|
|
40
|
+
}
|
|
41
|
+
finish(state) {
|
|
42
|
+
if (this.terminal)
|
|
43
|
+
return;
|
|
44
|
+
this.terminal = true;
|
|
45
|
+
clearTimeout(this.expiry);
|
|
46
|
+
this.releaseTyping?.();
|
|
47
|
+
this.releaseTyping = undefined;
|
|
48
|
+
this.transition(state);
|
|
49
|
+
}
|
|
50
|
+
isFinished() { return this.terminal; }
|
|
51
|
+
settled() { return this.tail; }
|
|
52
|
+
transition(state) {
|
|
53
|
+
this.state = state;
|
|
54
|
+
this.tail = this.tail.then(async () => {
|
|
55
|
+
if (this.state !== state)
|
|
56
|
+
return;
|
|
57
|
+
if (this.reaction) {
|
|
58
|
+
const old = this.reaction;
|
|
59
|
+
this.reaction = undefined;
|
|
60
|
+
await this.safely(async (signal) => { await this.channel.removeStatusReaction?.(this.message, old, signal); });
|
|
61
|
+
}
|
|
62
|
+
if (state === 'cleared' || !this.channel.addStatusReaction || !this.message.messageId)
|
|
63
|
+
return;
|
|
64
|
+
const label = withReplyLocale(this.host, () => ({
|
|
65
|
+
queued: replyText('⏳排队中'), processing: replyText('🤔思考中'), waiting: replyText('⏳等待确认'), success: replyText('✅已完成'),
|
|
66
|
+
ended: replyText('✅已结束'), error: replyText('❌处理失败'), cancelled: replyText('🚫已取消'),
|
|
67
|
+
})[state]);
|
|
68
|
+
await this.safely(async (signal) => {
|
|
69
|
+
const reaction = await this.channel.addStatusReaction(this.message, state, label, signal);
|
|
70
|
+
// 不清理超时请求的迟到响应:平台的撤销会清空新状态。
|
|
71
|
+
if (!signal.aborted)
|
|
72
|
+
this.reaction = reaction;
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** 以 user/message 的 id 或 source.rpcId 认领回合,不按聊天或 FIFO 猜测任务归属。 */
|
|
78
|
+
export class ProgressTracker {
|
|
79
|
+
onComplete;
|
|
80
|
+
onSettled;
|
|
81
|
+
groups = new Set();
|
|
82
|
+
ended = new Set();
|
|
83
|
+
turns = new Map();
|
|
84
|
+
currentTurn = new Map();
|
|
85
|
+
constructor(onComplete = () => { }, onSettled = () => { }) {
|
|
86
|
+
this.onComplete = onComplete;
|
|
87
|
+
this.onSettled = onSettled;
|
|
88
|
+
}
|
|
89
|
+
hasTurn(sessionId, turn) {
|
|
90
|
+
return turn !== undefined && (this.turns.has(`${sessionId}:${turn}`) || this.ended.has(`${sessionId}:${turn}`));
|
|
91
|
+
}
|
|
92
|
+
hasNewerTurn(sessionId, turn) {
|
|
93
|
+
const current = this.currentTurn.get(sessionId);
|
|
94
|
+
return current !== undefined && current > turn;
|
|
95
|
+
}
|
|
96
|
+
begin(sessionId, requestId, items) {
|
|
97
|
+
for (const old of this.groups)
|
|
98
|
+
if (old.items.every(item => item.isFinished()))
|
|
99
|
+
this.groups.delete(old);
|
|
100
|
+
const group = { sessionId, requestId, items };
|
|
101
|
+
this.groups.add(group);
|
|
102
|
+
return () => { for (const item of items)
|
|
103
|
+
item.finish('error'); this.groups.delete(group); };
|
|
104
|
+
}
|
|
105
|
+
event(sessionId, event) {
|
|
106
|
+
if (![...this.groups].some(group => group.sessionId === sessionId))
|
|
107
|
+
return;
|
|
108
|
+
const data = event.data;
|
|
109
|
+
if (event.type === 'turn/start' && Number.isSafeInteger(data?.turn))
|
|
110
|
+
this.currentTurn.set(sessionId, data.turn);
|
|
111
|
+
if (event.type === 'user/message' && event.surfaceOp === 'append') {
|
|
112
|
+
const turn = this.currentTurn.get(sessionId);
|
|
113
|
+
if (turn === undefined)
|
|
114
|
+
return;
|
|
115
|
+
for (const group of this.groups) {
|
|
116
|
+
if (group.sessionId !== sessionId || group.turn !== undefined
|
|
117
|
+
|| (group.requestId !== data?.id && group.requestId !== data?.source?.rpcId))
|
|
118
|
+
continue;
|
|
119
|
+
group.turn = turn;
|
|
120
|
+
for (const item of group.items)
|
|
121
|
+
item.update('processing');
|
|
122
|
+
const key = `${sessionId}:${turn}`;
|
|
123
|
+
let active = this.turns.get(key);
|
|
124
|
+
if (!active)
|
|
125
|
+
this.turns.set(key, active = { groups: [], deliveries: [] });
|
|
126
|
+
active.groups.push(group);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (event.type === 'turn/end') {
|
|
130
|
+
const key = `${sessionId}:${data?.turn}`;
|
|
131
|
+
const active = this.turns.get(key);
|
|
132
|
+
if (!active)
|
|
133
|
+
return;
|
|
134
|
+
this.turns.delete(key);
|
|
135
|
+
if (this.ended.size >= 512)
|
|
136
|
+
this.ended.delete(this.ended.values().next().value);
|
|
137
|
+
this.ended.add(key);
|
|
138
|
+
void Promise.all(active.deliveries).then(deliveries => {
|
|
139
|
+
const results = deliveries.filter((value) => value !== undefined);
|
|
140
|
+
const allSent = results.length > 0 && results.every(value => value === true);
|
|
141
|
+
// 全部都是「平台明确拒绝」才能自动补发;混进任何结果不明的失败都不行。
|
|
142
|
+
const allRejected = results.length > 0 && results.every(value => value === 'rejected');
|
|
143
|
+
const state = data?.reason?.kind === 'error' ? 'error'
|
|
144
|
+
: data?.reason?.kind !== 'completed' ? 'cancelled'
|
|
145
|
+
: results.length === 0 ? 'ended' : allSent ? 'success' : 'error';
|
|
146
|
+
// 交付确认不依赖是否仍适合发送下一步导航,新回合不能遗留旧请求。
|
|
147
|
+
this.onSettled(sessionId, data.turn, data?.reason?.kind === 'completed' && allSent, allRejected);
|
|
148
|
+
const superseded = this.hasNewerTurn(sessionId, data.turn);
|
|
149
|
+
const live = active.groups.filter(group => this.groups.has(group) && group.items.some(item => !item.isFinished()));
|
|
150
|
+
for (const group of live) {
|
|
151
|
+
for (const item of group.items)
|
|
152
|
+
item.finish(state);
|
|
153
|
+
this.groups.delete(group);
|
|
154
|
+
}
|
|
155
|
+
if (![...this.groups].some(group => group.sessionId === sessionId))
|
|
156
|
+
this.currentTurn.delete(sessionId);
|
|
157
|
+
if (live.length && !superseded)
|
|
158
|
+
this.onComplete({ sessionId, turn: data.turn, items: live.flatMap(group => group.items),
|
|
159
|
+
status: data?.reason?.kind === 'error' ? 'error' : data?.reason?.kind !== 'completed' ? 'cancelled'
|
|
160
|
+
: results.length === 0 ? 'empty' : allSent ? 'completed' : 'delivery-failed' });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
inbox(kind, payload) {
|
|
165
|
+
const sessionId = String(payload.agent?.session?.id ?? payload.agent?.id ?? '');
|
|
166
|
+
const message = payload.message;
|
|
167
|
+
if (kind === 'claimed' && Number.isSafeInteger(payload.turn)) {
|
|
168
|
+
this.event(sessionId, { type: 'turn/start', data: { turn: payload.turn } });
|
|
169
|
+
this.event(sessionId, { type: 'user/message', surfaceOp: 'append', data: message });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const group of this.groups) {
|
|
173
|
+
if (group.sessionId !== sessionId || group.turn !== undefined
|
|
174
|
+
|| (group.requestId !== message?.id && group.requestId !== message?.source?.rpcId))
|
|
175
|
+
continue;
|
|
176
|
+
const revision = group.revision = (group.revision ?? 0) + 1;
|
|
177
|
+
if (kind === 'discarded')
|
|
178
|
+
queueMicrotask(() => {
|
|
179
|
+
// queue steer 会先移除再插入;同一同步操作中的重插入不算取消。
|
|
180
|
+
if (group.revision !== revision || group.turn !== undefined || !this.groups.has(group))
|
|
181
|
+
return;
|
|
182
|
+
for (const item of group.items)
|
|
183
|
+
item.finish('cancelled');
|
|
184
|
+
this.groups.delete(group);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
delivery(sessionId, turn, work) {
|
|
189
|
+
if (turn !== undefined)
|
|
190
|
+
this.turns.get(`${sessionId}:${turn}`)?.deliveries.push(work.catch(() => false));
|
|
191
|
+
}
|
|
192
|
+
waiting(sessionId, waiting) {
|
|
193
|
+
const turn = this.currentTurn.get(sessionId);
|
|
194
|
+
const active = [...this.groups].filter(group => group.sessionId === sessionId && group.turn !== undefined && group.turn === turn);
|
|
195
|
+
const update = (delta) => {
|
|
196
|
+
for (const group of active) {
|
|
197
|
+
group.waiting = Math.max(0, (group.waiting ?? 0) + delta);
|
|
198
|
+
for (const item of group.items)
|
|
199
|
+
item.update(group.waiting > 0 ? 'waiting' : 'processing');
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
update(waiting ? 1 : -1);
|
|
203
|
+
let released = false;
|
|
204
|
+
return () => {
|
|
205
|
+
if (released || !waiting)
|
|
206
|
+
return;
|
|
207
|
+
released = true;
|
|
208
|
+
update(-1);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
cancel(channelId, sessionId) {
|
|
212
|
+
for (const group of this.groups) {
|
|
213
|
+
if (sessionId && group.sessionId !== sessionId)
|
|
214
|
+
continue;
|
|
215
|
+
if (channelId && !group.items.some(item => item.channel.id === channelId))
|
|
216
|
+
continue;
|
|
217
|
+
for (const item of group.items)
|
|
218
|
+
item.finish('cancelled');
|
|
219
|
+
this.groups.delete(group);
|
|
220
|
+
}
|
|
221
|
+
for (const [key, turn] of this.turns)
|
|
222
|
+
if (turn.groups.every(group => !this.groups.has(group)))
|
|
223
|
+
this.turns.delete(key);
|
|
224
|
+
if (!channelId && !sessionId)
|
|
225
|
+
this.currentTurn.clear();
|
|
226
|
+
else if (sessionId)
|
|
227
|
+
this.currentTurn.delete(sessionId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const typingGroups = new WeakMap();
|
|
231
|
+
/** 同一账号同一聊天只有一个 typing 定时器;旧任务结束不能停掉新任务。 */
|
|
232
|
+
function acquireTyping(channel, chatId, safely) {
|
|
233
|
+
if (!channel.sendAction)
|
|
234
|
+
return () => { };
|
|
235
|
+
let chats = typingGroups.get(channel);
|
|
236
|
+
if (!chats)
|
|
237
|
+
typingGroups.set(channel, chats = new Map());
|
|
238
|
+
let group = chats.get(chatId);
|
|
239
|
+
if (!group) {
|
|
240
|
+
group = { users: 0 };
|
|
241
|
+
chats.set(chatId, group);
|
|
242
|
+
const current = group;
|
|
243
|
+
const pulse = () => {
|
|
244
|
+
if (current.pending)
|
|
245
|
+
return;
|
|
246
|
+
current.pending = Promise.resolve().then(() => channel.sendAction(chatId, 'typing'))
|
|
247
|
+
.catch(() => { }).finally(() => { current.pending = undefined; });
|
|
248
|
+
void safely(() => current.pending);
|
|
249
|
+
};
|
|
250
|
+
pulse();
|
|
251
|
+
if (channel.typingIntervalMs) {
|
|
252
|
+
current.timer = setInterval(pulse, channel.typingIntervalMs);
|
|
253
|
+
current.timer.unref?.();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
group.users++;
|
|
257
|
+
const current = group;
|
|
258
|
+
let released = false;
|
|
259
|
+
return () => {
|
|
260
|
+
if (released)
|
|
261
|
+
return;
|
|
262
|
+
released = true;
|
|
263
|
+
if (--current.users > 0)
|
|
264
|
+
return;
|
|
265
|
+
if (current.timer)
|
|
266
|
+
clearInterval(current.timer);
|
|
267
|
+
chats.delete(chatId);
|
|
268
|
+
// 等本轮 start 返回后再 stop,且不打断已启动的新一组。
|
|
269
|
+
void (current.pending ?? Promise.resolve()).then(() => {
|
|
270
|
+
if (!chats.has(chatId))
|
|
271
|
+
return safely(async () => { await channel.stopAction?.(chatId); });
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { replyText } from './command-locale.js';
|
|
2
|
+
function nonEmptyText(value) {
|
|
3
|
+
if (typeof value !== 'string')
|
|
4
|
+
return undefined;
|
|
5
|
+
const text = value.trim();
|
|
6
|
+
return text || undefined;
|
|
7
|
+
}
|
|
8
|
+
export function validUserQuestion(question) {
|
|
9
|
+
if (!question || typeof question !== 'object')
|
|
10
|
+
return false;
|
|
11
|
+
const item = question;
|
|
12
|
+
if (typeof item.id !== 'string' || typeof item.question !== 'string')
|
|
13
|
+
return false;
|
|
14
|
+
if (item.header !== undefined && typeof item.header !== 'string')
|
|
15
|
+
return false;
|
|
16
|
+
if (item.detail !== undefined && typeof item.detail !== 'string')
|
|
17
|
+
return false;
|
|
18
|
+
if (item.multiSelect !== undefined && typeof item.multiSelect !== 'boolean')
|
|
19
|
+
return false;
|
|
20
|
+
if (item.intent !== undefined) {
|
|
21
|
+
if (!item.intent || typeof item.intent !== 'object')
|
|
22
|
+
return false;
|
|
23
|
+
const intent = item.intent;
|
|
24
|
+
if (typeof intent.kind !== 'string')
|
|
25
|
+
return false;
|
|
26
|
+
if (intent.approve !== undefined && typeof intent.approve !== 'string')
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (item.options !== undefined) {
|
|
30
|
+
if (!Array.isArray(item.options))
|
|
31
|
+
return false;
|
|
32
|
+
for (const option of item.options) {
|
|
33
|
+
if (!option || typeof option !== 'object')
|
|
34
|
+
return false;
|
|
35
|
+
const value = option;
|
|
36
|
+
if (typeof value.label !== 'string')
|
|
37
|
+
return false;
|
|
38
|
+
if (value.description !== undefined && typeof value.description !== 'string')
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
export function formatUserQuestion(question, index, total, options = {}) {
|
|
45
|
+
const progress = total > 1 ? `(${index + 1}/${total})` : '';
|
|
46
|
+
const lines = [replyText('DeepSeek Harness 需要你补充信息{0}:', progress)];
|
|
47
|
+
const header = nonEmptyText(question.header);
|
|
48
|
+
const prompt = nonEmptyText(question.question) ?? replyText('请输入你的回答。');
|
|
49
|
+
const detail = nonEmptyText(question.detail);
|
|
50
|
+
if (header)
|
|
51
|
+
lines.push('', header);
|
|
52
|
+
lines.push('', prompt);
|
|
53
|
+
if (detail)
|
|
54
|
+
lines.push('', detail);
|
|
55
|
+
const choices = question.options ?? [];
|
|
56
|
+
if (choices.length > 0) {
|
|
57
|
+
lines.push('');
|
|
58
|
+
choices.forEach((choice, choiceIndex) => {
|
|
59
|
+
const description = nonEmptyText(choice.description);
|
|
60
|
+
const checked = question.multiSelect && options.selected?.has(choiceIndex) ? '✓ ' : '';
|
|
61
|
+
lines.push(`${choiceIndex + 1}. ${checked}${choice.label}${description ? ` — ${description}` : ''}`);
|
|
62
|
+
});
|
|
63
|
+
// 提交项属于选项列表,不能接在回复说明之后;部分平台按钮只有数字。
|
|
64
|
+
if (question.multiSelect === true && options.selected?.size)
|
|
65
|
+
lines.push(`${choices.length + 1}. ${replyText('提交所选')}`);
|
|
66
|
+
lines.push('', question.multiSelect === true
|
|
67
|
+
? replyText('请回复选项序号或文字;多选用逗号分隔,也可补充其他内容。')
|
|
68
|
+
: replyText('请回复一个选项序号或文字,也可直接输入其他答案。'));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lines.push('', replyText('请直接回复你的答案。'));
|
|
72
|
+
}
|
|
73
|
+
if (options.requiresMention)
|
|
74
|
+
lines.push('', replyText('群聊中请 @机器人 后发送答案。'));
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
function optionLabel(token, options) {
|
|
78
|
+
const value = token.trim();
|
|
79
|
+
if (!value)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (/^\d+$/.test(value))
|
|
82
|
+
return options[Number(value) - 1]?.label;
|
|
83
|
+
return options.find((option) => option.label === value)?.label;
|
|
84
|
+
}
|
|
85
|
+
export function answerUserQuestion(question, input) {
|
|
86
|
+
const text = input.trim();
|
|
87
|
+
const options = question.options ?? [];
|
|
88
|
+
if (options.length === 0)
|
|
89
|
+
return { id: question.id, selected: [], custom: text };
|
|
90
|
+
const whole = optionLabel(text, options);
|
|
91
|
+
if (question.multiSelect !== true) {
|
|
92
|
+
return whole
|
|
93
|
+
? { id: question.id, selected: [whole] }
|
|
94
|
+
: { id: question.id, selected: [], custom: text };
|
|
95
|
+
}
|
|
96
|
+
if (whole)
|
|
97
|
+
return { id: question.id, selected: [whole] };
|
|
98
|
+
const selected = [];
|
|
99
|
+
const custom = [];
|
|
100
|
+
for (const raw of text.split(/[,,、;;\n]+/)) {
|
|
101
|
+
const value = raw.trim();
|
|
102
|
+
if (!value)
|
|
103
|
+
continue;
|
|
104
|
+
const label = optionLabel(value, options);
|
|
105
|
+
if (label) {
|
|
106
|
+
if (!selected.includes(label))
|
|
107
|
+
selected.push(label);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
custom.push(value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
id: question.id,
|
|
115
|
+
selected,
|
|
116
|
+
...(custom.length > 0 ? { custom: custom.join('、') } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export class QuestionBroker {
|
|
120
|
+
pending = new Map();
|
|
121
|
+
has(key) {
|
|
122
|
+
return this.pending.has(key);
|
|
123
|
+
}
|
|
124
|
+
current(key) {
|
|
125
|
+
const pending = this.pending.get(key);
|
|
126
|
+
const question = pending?.questions[pending.index];
|
|
127
|
+
if (!pending || !question)
|
|
128
|
+
return undefined;
|
|
129
|
+
return { question, index: pending.index, total: pending.questions.length };
|
|
130
|
+
}
|
|
131
|
+
signal(key) {
|
|
132
|
+
return this.pending.get(key)?.signal;
|
|
133
|
+
}
|
|
134
|
+
begin(key, questions, signal) {
|
|
135
|
+
if (this.pending.has(key) || signal?.aborted)
|
|
136
|
+
return undefined;
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const pending = {
|
|
139
|
+
questions,
|
|
140
|
+
answers: [],
|
|
141
|
+
index: 0,
|
|
142
|
+
accepting: false,
|
|
143
|
+
resolve,
|
|
144
|
+
reject,
|
|
145
|
+
};
|
|
146
|
+
if (signal) {
|
|
147
|
+
pending.signal = signal;
|
|
148
|
+
pending.onAbort = () => this.cancel(key, signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
149
|
+
signal.addEventListener('abort', pending.onAbort, { once: true });
|
|
150
|
+
}
|
|
151
|
+
this.pending.set(key, pending);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
isReady(key) { return this.pending.get(key)?.accepting === true; }
|
|
155
|
+
activate(key) {
|
|
156
|
+
const pending = this.pending.get(key);
|
|
157
|
+
if (!pending)
|
|
158
|
+
return false;
|
|
159
|
+
pending.accepting = true;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
answer(key, text) {
|
|
163
|
+
const pending = this.pending.get(key);
|
|
164
|
+
const question = pending?.questions[pending.index];
|
|
165
|
+
if (!pending || !question)
|
|
166
|
+
return { handled: false };
|
|
167
|
+
if (!pending.accepting)
|
|
168
|
+
return { handled: true, waitingPresentation: true };
|
|
169
|
+
pending.answers.push(answerUserQuestion(question, text));
|
|
170
|
+
pending.index += 1;
|
|
171
|
+
const next = pending.questions[pending.index];
|
|
172
|
+
if (next) {
|
|
173
|
+
pending.accepting = false;
|
|
174
|
+
return {
|
|
175
|
+
handled: true,
|
|
176
|
+
next: { question: next, index: pending.index, total: pending.questions.length },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
this.cleanup(pending);
|
|
180
|
+
this.pending.delete(key);
|
|
181
|
+
pending.resolve({ answers: pending.answers });
|
|
182
|
+
return { handled: true, completed: true };
|
|
183
|
+
}
|
|
184
|
+
cancel(key, reason = new DOMException('Aborted', 'AbortError')) {
|
|
185
|
+
const pending = this.pending.get(key);
|
|
186
|
+
if (!pending)
|
|
187
|
+
return false;
|
|
188
|
+
this.cleanup(pending);
|
|
189
|
+
this.pending.delete(key);
|
|
190
|
+
pending.reject(reason);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
dispose() {
|
|
194
|
+
for (const key of [...this.pending.keys()])
|
|
195
|
+
this.cancel(key);
|
|
196
|
+
}
|
|
197
|
+
cleanup(pending) {
|
|
198
|
+
if (pending.signal && pending.onAbort)
|
|
199
|
+
pending.signal.removeEventListener('abort', pending.onAbort);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** 渠道 SDK 日志:只上报告警和错误,不打印握手 info。 */
|
|
2
|
+
export function quietSdkLogger(log, prefix) {
|
|
3
|
+
const emit = (args) => {
|
|
4
|
+
const text = args.map((item) => typeof item === 'string' ? item : safe(item)).join(' ').trim();
|
|
5
|
+
if (text)
|
|
6
|
+
log(`[${prefix}] ${text}`);
|
|
7
|
+
};
|
|
8
|
+
return {
|
|
9
|
+
debug: () => undefined,
|
|
10
|
+
info: () => undefined,
|
|
11
|
+
trace: () => undefined,
|
|
12
|
+
warn: (...args) => emit(args),
|
|
13
|
+
error: (...args) => emit(args),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function safe(value) {
|
|
17
|
+
if (value instanceof Error)
|
|
18
|
+
return value.message;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
25
|
+
}
|