mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
feat: add private chat batch input commands
This commit is contained in:
parent
f6cc1cae30
commit
068cf57259
28 changed files with 2665 additions and 219 deletions
|
|
@ -150,6 +150,9 @@ Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only
|
|||
| `/preset --default` | Clear this bot's explicit selection so later new Sessions follow the Host default. |
|
||||
| `/stop` | Immediately stop this chat's running task while preserving work that has not started. |
|
||||
| `/steer <additional instruction>` | Inject an additional instruction into this chat's running task. |
|
||||
| `/batch` | Start batch input in a direct chat and collect up to 10 text messages. |
|
||||
| `/send` | Submit the collected messages, in order, as one input. |
|
||||
| `/cancel` | Cancel batch input and discard its collected messages. |
|
||||
| `/compact` | Immediately compact older context in the Session bound to the current chat. |
|
||||
| `/workspace <absolute workspace path>` | Switch the current bot's Harness workspace. |
|
||||
| `/workspacelist` | List workspace absolute paths that still exist on the current Harness Host. |
|
||||
|
|
@ -177,6 +180,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
|
|||
- An Agent Preset change is bot-wide: it affects future new Sessions in every chat for this bot, but never modifies, stops, unbinds, or rebuilds an existing Session and never runs `/new` automatically. If this chat already has a Session, ordinary messages keep using it; the first ordinary message after `/new` creates a Session with the new setting. Presets can still be queried or changed while a task is running or awaiting interaction because the command does not touch that Session.
|
||||
- `/stop` and `/steer` control only a running task started by this chat. Even when multiple chats bind the same Session, they do not intentionally control another chat's task. `/stop` does not delete the Session or its history, preserves queued work that has not started, and is safe to repeat.
|
||||
- `/steer` accepts text only, including multiple lines. It neither creates another Session nor starts a second task. Send an ordinary message when no task is running; while an approval or question is pending, answer it first or use `/stop`.
|
||||
- `/batch`, `/send`, and `/cancel` are available only in a direct chat with the bot. After `/batch`, subsequent text-only messages are held temporarily, up to 10 messages. The tenth is collected and prompts you to submit; later messages are rejected and the batch is never submitted automatically. `/send` processes the collected messages in their original order as one input, while `/cancel` discards them. Images, files, and other commands are not collected. An unsubmitted batch is lost if the bot restarts. Ordinary chat behavior is unchanged when batch input is not active.
|
||||
- `/compact` acts only on the Harness Session already bound to the current chat and is never sent to the model. The bot reports the applicable status when the chat has no Session yet, the Session is generating a reply, or there is no compactable history.
|
||||
- The path must be an existing absolute directory. The bot returns an actionable error and the correct usage when validation fails.
|
||||
- `/workspacelist` takes no arguments. It combines the Harness global registry with the current bot's path. When that current path still exists and is safe to display, it appears first and is marked as current. Any listed path can be copied directly into `/workspace`.
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ dsh web
|
|||
| `/preset --default` | 清除当前机器人的显式选择,让后续新 Session 跟随 Host 默认。 |
|
||||
| `/stop` | 立即停止当前聊天正在运行的任务,并保留尚未开始的排队消息。 |
|
||||
| `/steer <补充指令>` | 把补充指令立即加入当前聊天正在运行的任务。 |
|
||||
| `/batch` | 在私聊中开启批量输入,最多收集 10 条纯文字消息。 |
|
||||
| `/send` | 将已收集的消息按原顺序作为一次输入提交。 |
|
||||
| `/cancel` | 取消批量输入并丢弃已收集的消息。 |
|
||||
| `/compact` | 立即压缩当前聊天绑定会话的较早上下文。 |
|
||||
| `/workspace <工作区绝对路径>` | 切换当前机器人的 Harness 工作区。 |
|
||||
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
|
||||
|
|
@ -180,6 +183,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
|
|||
- Agent Preset 修改是机器人级配置,会影响该机器人所有聊天以后创建的新 Session,但不会修改、停止、解绑或重建已有 Session,也不会自动执行 `/new`。若当前聊天已有会话,继续发送消息仍使用原 Session;发送 `/new` 后的下一条普通消息才会按新设置创建 Session。任务正在运行或等待交互时也可查询或修改 Preset,因为命令不会触碰当前 Session。
|
||||
- `/stop` 和 `/steer` 只控制当前聊天自己发起的运行任务,即使多个聊天绑定同一个 Session,也不会有意控制其他聊天的任务。`/stop` 不删除会话或历史,并保留尚未开始的排队消息;重复发送是安全的。
|
||||
- `/steer` 只接受文字,可包含多行;它不会创建新会话或第二个任务。没有运行任务时请直接发送普通消息;等待审批或问题回答时请先处理交互,或使用 `/stop`。
|
||||
- `/batch`、`/send` 和 `/cancel` 仅在与机器人的私聊中可用。发送 `/batch` 后,接下来的纯文字消息会暂存,最多 10 条;第 10 条仍会收录并提示提交,之后的消息不会收录,也不会自动提交。发送 `/send` 后,机器人会按原顺序将整批内容作为一次输入处理;发送 `/cancel` 会直接丢弃当前批次。图片、文件和其他命令不会被收录。机器人重启会丢失尚未提交的批次。未进入批量输入模式时,普通聊天流程不变。
|
||||
- `/compact` 只作用于当前聊天已经绑定的 Harness 会话,不会把命令发送给模型。当前聊天尚未创建会话、会话正在生成回复或没有可压缩历史时,机器人会直接返回对应状态。
|
||||
- 只接受已经存在的绝对目录;路径无效时机器人会返回具体提示和正确用法。
|
||||
- `/workspacelist` 不需要参数。它合并 Harness 全局登记项与当前机器人的路径;当前路径仍存在且可安全显示时会排在首位并标记为“当前”。结果可直接复制到 `/workspace` 命令。
|
||||
|
|
|
|||
400
lib/index.js
400
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -24,6 +24,12 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
batchInputGroupUnsupportedMessage,
|
||||
isBatchInputCommand,
|
||||
} from '../shared/batch-input.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
|
|
@ -67,6 +73,9 @@ const HELP_TEXT_LINES = [
|
|||
'/preset --default 跟随 Host 默认',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/batch 开始批量输入(仅私聊,最多 10 条文字)',
|
||||
'/send 提交当前批次',
|
||||
'/cancel 取消当前批次',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
];
|
||||
|
|
@ -344,6 +353,7 @@ export class DingtalkHarnessBridge {
|
|||
#commandTasks = new Set();
|
||||
#acceptedMessageIds = new Set();
|
||||
#approvals;
|
||||
#batchInputs = new BatchInputManager();
|
||||
|
||||
constructor({
|
||||
api,
|
||||
|
|
@ -416,12 +426,52 @@ export class DingtalkHarnessBridge {
|
|||
clientSecret: this.#clientSecret,
|
||||
});
|
||||
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
||||
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
||||
const direct = String(message.conversationType) !== '2';
|
||||
const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
|
||||
&& isBatchInputCommand(commandText);
|
||||
const batchStatus = this.#batchInputs.status(key);
|
||||
if (batchCommand && !direct && sessionWebhook && addressed) {
|
||||
return this.#finishBatchResult(
|
||||
messageId,
|
||||
sessionWebhook,
|
||||
{ message: batchInputGroupUnsupportedMessage() },
|
||||
);
|
||||
}
|
||||
if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
|
||||
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
||||
const result = exactBatchStart
|
||||
&& batchStatus.phase === 'idle'
|
||||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: Boolean(commandText)
|
||||
&& String(message?.msgtype).toLowerCase() === 'text'
|
||||
&& !hasInboundFiles(promptMessage)
|
||||
&& !hasInboundImages(promptMessage),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
return this.#enqueueMessage(
|
||||
{
|
||||
...message,
|
||||
msgtype: 'text',
|
||||
text: { content: result.prompt },
|
||||
},
|
||||
messageId,
|
||||
sender,
|
||||
key,
|
||||
{ batchSubmission: result },
|
||||
);
|
||||
}
|
||||
return this.#finishBatchResult(messageId, sessionWebhook, result);
|
||||
}
|
||||
}
|
||||
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
||||
if (commandRunner && sessionWebhook && addressed) {
|
||||
let task;
|
||||
task = this.#processFastCommand(
|
||||
|
|
@ -521,6 +571,7 @@ export class DingtalkHarnessBridge {
|
|||
#enqueueMessage(message, messageId, sender, key, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
let hasSafeReplyRoute = false;
|
||||
try {
|
||||
|
|
@ -543,6 +594,7 @@ export class DingtalkHarnessBridge {
|
|||
.then(() => this.#process(message, messageId, sender, key, {
|
||||
alreadyRecorded,
|
||||
preparedMessage,
|
||||
batchSubmission,
|
||||
}))
|
||||
.finally(() => {
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -595,9 +647,32 @@ export class DingtalkHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
}
|
||||
|
||||
#finishBatchResult(messageId, sessionWebhook, result) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
increment(this.#status, 'messagesReceived');
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (result.message) await this.#send(sessionWebhook, result.message);
|
||||
this.#status.lastError = null;
|
||||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = t('钉钉命令处理失败。');
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to process a batch input message', safeErrorDiagnostic(error));
|
||||
await this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
});
|
||||
this.#commandTasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async #process(message, messageId, sender, key, {
|
||||
alreadyRecorded = false,
|
||||
preparedMessage,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
this.#signal?.throwIfAborted();
|
||||
if (!alreadyRecorded) {
|
||||
|
|
@ -633,6 +708,7 @@ export class DingtalkHarnessBridge {
|
|||
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
||||
let cardStream = null;
|
||||
let cardStarted = false;
|
||||
let batchSettled = batchSubmission === null;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
|
||||
|
|
@ -717,6 +793,10 @@ export class DingtalkHarnessBridge {
|
|||
files: promptMessage.files,
|
||||
},
|
||||
});
|
||||
if (batchSubmission) {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
batchSettled = true;
|
||||
}
|
||||
const answerText = typeof answer === 'string' && answer.trim()
|
||||
? answer
|
||||
: artifacts.length > 0 ? t('结果文件已生成。') : answer;
|
||||
|
|
@ -753,6 +833,15 @@ export class DingtalkHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
if (!batchSettled && batchSubmission) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
} else {
|
||||
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
|
||||
}
|
||||
batchSettled = true;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
|
||||
return;
|
||||
|
|
@ -767,8 +856,11 @@ export class DingtalkHarnessBridge {
|
|||
const errorText = inboundFileUserMessage(error)
|
||||
?? dingtalkImageErrorUserMessage(error)
|
||||
?? t(CARD_ERROR_TEXT);
|
||||
const streamed = cardStarted && await cardStream.finish(errorText);
|
||||
if (!streamed) await this.#send(sessionWebhook, errorText);
|
||||
const visibleError = batchFailureMessage
|
||||
? `${errorText}\n\n${batchFailureMessage}`
|
||||
: errorText;
|
||||
const streamed = cardStarted && await cardStream.finish(visibleError);
|
||||
if (!streamed) await this.#send(sessionWebhook, visibleError);
|
||||
} catch {
|
||||
this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: String(message.channel_id),
|
||||
content: stripBotMention(message.content ?? '', botId),
|
||||
plainText: (!Array.isArray(message.attachments) || message.attachments.length === 0)
|
||||
&& (!Array.isArray(message.sticker_items) || message.sticker_items.length === 0)
|
||||
&& !message.poll,
|
||||
images: Array.isArray(message.attachments)
|
||||
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
||||
: [],
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
batchInputGroupUnsupportedMessage,
|
||||
isBatchInputCommand,
|
||||
} from '../shared/batch-input.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import {
|
||||
isControlCommand,
|
||||
|
|
@ -366,6 +372,7 @@ export class FeishuHarnessBridge {
|
|||
#harness;
|
||||
#state;
|
||||
#queues = new Map();
|
||||
#batchInputs = new BatchInputManager();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#resolvedQuestionReplies = new Map();
|
||||
|
|
@ -542,6 +549,58 @@ export class FeishuHarnessBridge {
|
|||
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
||||
const commandMessage = extractInboundMessage(event, this.#client);
|
||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
const batchText = event.message.message_type === 'text'
|
||||
? nonEmptyString(extractText(event)) ?? ''
|
||||
: '';
|
||||
const batchCommand = event.message.message_type === 'text'
|
||||
&& isBatchInputCommand(batchText);
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const batchStatus = this.#batchInputs.status(key);
|
||||
if (batchCommand && event.message.chat_type !== 'p2p') {
|
||||
return this.#finishBatchResult(
|
||||
event,
|
||||
messageId,
|
||||
processingReaction,
|
||||
{ message: batchInputGroupUnsupportedMessage() },
|
||||
);
|
||||
}
|
||||
if (event.message.chat_type === 'p2p'
|
||||
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
||||
const exactBatchStart = /^\/batch$/iu.test(batchText);
|
||||
const result = exactBatchStart
|
||||
&& batchStatus.phase === 'idle'
|
||||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, batchText, {
|
||||
plainText: event.message.message_type === 'text' && Boolean(batchText),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
const submissionEvent = {
|
||||
...event,
|
||||
batchSubmission: { token: result.token },
|
||||
message: {
|
||||
...event.message,
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: result.prompt }),
|
||||
mentions: [],
|
||||
},
|
||||
};
|
||||
return this.#enqueueMessage(
|
||||
submissionEvent,
|
||||
messageId,
|
||||
key,
|
||||
processingReaction,
|
||||
);
|
||||
}
|
||||
return this.#finishBatchResult(
|
||||
event,
|
||||
messageId,
|
||||
processingReaction,
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Card commands (/m, /help, /status, etc.) bypass the queue so they
|
||||
// respond immediately even when a harness task is still streaming.
|
||||
if (CARD_COMMAND.test(commandText)) {
|
||||
|
|
@ -599,7 +658,6 @@ export class FeishuHarnessBridge {
|
|||
.finally(() => this.#acceptedMessageIds.delete(messageId));
|
||||
return current;
|
||||
}
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const approvalReply = this.#approvals.claimReply({
|
||||
key,
|
||||
actor: senderOpenId(event),
|
||||
|
|
@ -691,6 +749,32 @@ export class FeishuHarnessBridge {
|
|||
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
||||
}
|
||||
|
||||
#finishBatchResult(event, messageId, processingReaction, result) {
|
||||
let current;
|
||||
current = Promise.resolve()
|
||||
.then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
this.#status.messagesReceived += 1;
|
||||
if (result?.message) await this.#send(event.message.chat_id, result.message);
|
||||
this.#status.lastError = null;
|
||||
})
|
||||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||||
.catch((error) => this.#handleMessageFailure(
|
||||
event,
|
||||
messageId,
|
||||
processingReaction,
|
||||
error,
|
||||
))
|
||||
.finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(current);
|
||||
});
|
||||
this.#commandTasks.add(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
#enqueueMessage(event, messageId, key, processingReaction, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
|
|
@ -725,6 +809,9 @@ export class FeishuHarnessBridge {
|
|||
async #handleMessageFailure(event, messageId, processingReaction, error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
await this.#removeProcessingReaction(messageId, processingReaction);
|
||||
if (error?.batchInputMessage) {
|
||||
await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.#signal?.aborted) {
|
||||
|
|
@ -736,7 +823,8 @@ export class FeishuHarnessBridge {
|
|||
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
||||
await this.#send(
|
||||
event.message.chat_id,
|
||||
inboundFileUserMessage(error)
|
||||
error?.batchInputMessage
|
||||
?? inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
|
||||
).catch(() => undefined);
|
||||
|
|
@ -939,12 +1027,38 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
|
||||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||||
const batchSubmission = event.batchSubmission ?? null;
|
||||
let batchAskCompleted = false;
|
||||
try {
|
||||
const receipt = await this.#answerWithStream(event, key, message);
|
||||
const receipt = await this.#answerWithStream(event, key, message, {
|
||||
onAskComplete: batchSubmission
|
||||
? () => {
|
||||
batchAskCompleted = this.#batchInputs.complete(
|
||||
key,
|
||||
batchSubmission.token,
|
||||
).completed;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (batchSubmission && !batchAskCompleted) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
throw error;
|
||||
}
|
||||
const failed = this.#batchInputs.fail(key, batchSubmission.token);
|
||||
if (failed.retained) {
|
||||
const batchError = new Error(error?.message ?? String(error), { cause: error });
|
||||
batchError.code = error?.code;
|
||||
batchError.batchInputMessage = failed.message;
|
||||
throw batchError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await this.#cancelPendingInteraction(key);
|
||||
await this.#approvals.closeRoute(key);
|
||||
|
|
@ -2937,10 +3051,16 @@ export class FeishuHarnessBridge {
|
|||
};
|
||||
}
|
||||
|
||||
async #answerWithStream(event, key, message) {
|
||||
async #answerWithStream(event, key, message, { onAskComplete } = {}) {
|
||||
const chatId = event.message.chat_id;
|
||||
const messageId = event.message.message_id;
|
||||
const text = message.content;
|
||||
let askCompleted = false;
|
||||
const markAskComplete = () => {
|
||||
if (askCompleted) return;
|
||||
askCompleted = true;
|
||||
onAskComplete?.();
|
||||
};
|
||||
const content = hasInboundImages(message)
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
|
|
@ -2955,6 +3075,7 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||
});
|
||||
markAskComplete();
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
|
|
@ -3009,6 +3130,7 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions,
|
||||
});
|
||||
markAskComplete();
|
||||
completedAnswer = completed.answer;
|
||||
completedArtifacts = completed.artifacts ?? [];
|
||||
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
||||
|
|
@ -3067,6 +3189,7 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||
});
|
||||
markAskComplete();
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -513,6 +513,11 @@ export function menuHelpText() {
|
|||
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
|
||||
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
|
||||
'',
|
||||
'📦 批量输入(仅私聊)',
|
||||
'/batch 开始批量输入(仅私聊,最多 10 条文字)',
|
||||
'/send 提交当前批次',
|
||||
'/cancel 取消当前批次',
|
||||
'',
|
||||
'🎮 任务控制',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 指令 给 Agent 补充指令',
|
||||
|
|
@ -564,6 +569,9 @@ const HELP_TEXT_COMMANDS = [
|
|||
'`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级',
|
||||
'`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级',
|
||||
'`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型',
|
||||
'`/batch` — 开启批量输入(仅私聊,最多 10 条文字)',
|
||||
'`/send` — 提交当前批次',
|
||||
'`/cancel` — 取消当前批次',
|
||||
'`/repair` — 修复卡片按钮',
|
||||
].join('\n');
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ import {
|
|||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
batchInputGroupUnsupportedMessage,
|
||||
isBatchInputCommand,
|
||||
} from '../shared/batch-input.mjs';
|
||||
import {
|
||||
fetchImageBuffer,
|
||||
hasInboundImages,
|
||||
|
|
@ -80,6 +86,9 @@ function helpText() {
|
|||
t('/preset --default 跟随 Host 默认'),
|
||||
t('/stop 停止当前任务'),
|
||||
t('/steer 补充指令 纠偏当前任务'),
|
||||
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
||||
t('/send 提交当前批次'),
|
||||
t('/cancel 取消当前批次'),
|
||||
t('/status 检查连接状态'),
|
||||
t('/help 显示本帮助'),
|
||||
].join('\n');
|
||||
|
|
@ -352,6 +361,7 @@ export class QqHarnessBridge {
|
|||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
#batchInputs = new BatchInputManager();
|
||||
|
||||
constructor({
|
||||
bot,
|
||||
|
|
@ -407,14 +417,47 @@ export class QqHarnessBridge {
|
|||
}
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const commandText = safeText(message);
|
||||
const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
|
||||
const addressed = message.kind !== 'group'
|
||||
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
||||
const batchCommand = isBatchInputCommand(commandText);
|
||||
const batchStatus = this.#batchInputs.status(key);
|
||||
if (batchCommand && allowed && addressed && message.kind === 'group') {
|
||||
return this.#finishBatchResult(
|
||||
message,
|
||||
messageId,
|
||||
{ message: batchInputGroupUnsupportedMessage() },
|
||||
);
|
||||
}
|
||||
if (allowed && message.kind === 'c2c'
|
||||
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
||||
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
||||
const result = exactBatchStart
|
||||
&& batchStatus.phase === 'idle'
|
||||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: Boolean(commandText)
|
||||
&& !hasQqImageAttachments(message)
|
||||
&& !hasQqFileAttachments(message),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
return this.#enqueueMessage(
|
||||
{ ...message, content: result.prompt, attachments: [] },
|
||||
messageId,
|
||||
key,
|
||||
{ batchSubmission: result },
|
||||
);
|
||||
}
|
||||
return this.#finishBatchResult(message, messageId, result);
|
||||
}
|
||||
}
|
||||
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||||
const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
|
||||
const addressed = message.kind !== 'group'
|
||||
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
||||
if (commandRunner && allowed && addressed) {
|
||||
let task;
|
||||
task = this.#processFastCommand(
|
||||
|
|
@ -494,6 +537,7 @@ export class QqHarnessBridge {
|
|||
#enqueueMessage(message, messageId, key, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
|
||||
const addressed = message.kind !== 'group'
|
||||
|
|
@ -507,7 +551,11 @@ export class QqHarnessBridge {
|
|||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
|
||||
.then(() => this.#process(message, key, {
|
||||
alreadyRecorded,
|
||||
preparedMessage,
|
||||
batchSubmission,
|
||||
}))
|
||||
.finally(() => {
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||
|
|
@ -553,6 +601,29 @@ export class QqHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
}
|
||||
|
||||
#finishBatchResult(message, messageId, result) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (result.message) await this.#bot.sendText(message.replyTarget, result.message);
|
||||
this.#status.lastError = null;
|
||||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process a batch input message:', error);
|
||||
await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
});
|
||||
this.#commandTasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
|
||||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
|
|
@ -589,7 +660,11 @@ export class QqHarnessBridge {
|
|||
};
|
||||
}
|
||||
|
||||
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||
async #process(message, key, {
|
||||
alreadyRecorded = false,
|
||||
preparedMessage,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
if (this.#signal?.aborted) return;
|
||||
const messageId = nonEmptyString(message?.messageId);
|
||||
const sender = nonEmptyString(message?.senderId);
|
||||
|
|
@ -614,6 +689,7 @@ export class QqHarnessBridge {
|
|||
const hasImages = hasInboundImages(promptMessage);
|
||||
const hasFiles = hasInboundFiles(promptMessage);
|
||||
let stream = null;
|
||||
let batchSettled = batchSubmission === null;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
|
||||
|
|
@ -710,6 +786,10 @@ export class QqHarnessBridge {
|
|||
files: promptMessage.files,
|
||||
},
|
||||
}));
|
||||
if (batchSubmission) {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
batchSettled = true;
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
this.#cancelPendingInteraction(key),
|
||||
|
|
@ -771,6 +851,15 @@ export class QqHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
if (!batchSettled && batchSubmission) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
} else {
|
||||
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
|
||||
}
|
||||
batchSettled = true;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') {
|
||||
try {
|
||||
stream?.cancel?.();
|
||||
|
|
@ -794,11 +883,12 @@ export class QqHarnessBridge {
|
|||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
||||
try {
|
||||
const errorMessage = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('消息处理失败,请稍后重试。');
|
||||
await this.#bot.sendText(
|
||||
target,
|
||||
inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('消息处理失败,请稍后重试。'),
|
||||
batchFailureMessage ? `${errorMessage}\n\n${batchFailureMessage}` : errorMessage,
|
||||
);
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
|
|
|
|||
209
src/channels/shared/batch-input.mjs
Normal file
209
src/channels/shared/batch-input.mjs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { t } from './i18n.mjs';
|
||||
|
||||
export const BATCH_INPUT_LIMIT = 10;
|
||||
|
||||
const BATCH_COMMAND = /^\/(batch|send|cancel)(?=$|\s)/iu;
|
||||
const EXACT_BATCH_COMMAND = /^\/(batch|send|cancel)$/iu;
|
||||
|
||||
function commandName(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
return BATCH_COMMAND.exec(text.trim())?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function result(kind, message, extra = {}) {
|
||||
return { handled: true, kind, ...(message ? { message } : {}), ...extra };
|
||||
}
|
||||
|
||||
function progressMessage(count) {
|
||||
if (count === BATCH_INPUT_LIMIT) {
|
||||
return t(`当前已处于批量输入模式,已收集 {count}/{limit} 条。
|
||||
请发送 /send 提交或 /cancel 取消。`, { count, limit: BATCH_INPUT_LIMIT });
|
||||
}
|
||||
return t(`当前已处于批量输入模式,已收集 {count}/{limit} 条。
|
||||
完成后发送 /send,取消请发送 /cancel。`, { count, limit: BATCH_INPUT_LIMIT });
|
||||
}
|
||||
|
||||
function submissionPrompt(messages) {
|
||||
const sections = messages.map((message, index) => (
|
||||
`${t('[消息 {index}]', { index: index + 1 })}\n${message}`
|
||||
));
|
||||
return [
|
||||
t('以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。'),
|
||||
...sections,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function isBatchInputCommand(text) {
|
||||
return commandName(text) !== null;
|
||||
}
|
||||
|
||||
export function batchInputGroupUnsupportedMessage() {
|
||||
return t('批量输入模式仅支持私聊,请在与机器人的私聊中使用。');
|
||||
}
|
||||
|
||||
export function batchInputBusyMessage() {
|
||||
return t(`当前聊天有正在运行的任务、待回答问题或待审批请求。
|
||||
请先完成当前交互或发送 /stop,再使用 /batch。`);
|
||||
}
|
||||
|
||||
export class BatchInputManager {
|
||||
#batches = new Map();
|
||||
|
||||
status(key) {
|
||||
const batch = this.#batches.get(key);
|
||||
if (!batch) {
|
||||
return Object.freeze({ phase: 'idle', count: 0, limit: BATCH_INPUT_LIMIT, full: false });
|
||||
}
|
||||
return Object.freeze({
|
||||
phase: batch.phase,
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
full: batch.messages.length === BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
handle(key, text, { plainText = true } = {}) {
|
||||
const batch = this.#batches.get(key);
|
||||
const name = commandName(text);
|
||||
const exact = typeof text === 'string' ? EXACT_BATCH_COMMAND.exec(text.trim()) : null;
|
||||
|
||||
if (name && !exact) {
|
||||
return result('invalid-command', t('用法:/{command}(不带参数)', { command: name }));
|
||||
}
|
||||
|
||||
if (!batch) {
|
||||
if (!name) return { handled: false };
|
||||
if (!plainText) {
|
||||
return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片或文件后重试。'));
|
||||
}
|
||||
if (name === 'send') {
|
||||
return result('no-batch', t('当前没有待提交的批量内容,请先发送 /batch。'));
|
||||
}
|
||||
if (name === 'cancel') {
|
||||
return result('no-batch', t('当前没有正在进行的批量输入。'));
|
||||
}
|
||||
this.#batches.set(key, { phase: 'collecting', messages: [], token: null });
|
||||
return result('started', t(`已进入批量输入模式,最多可发送 {limit} 条文字。
|
||||
完成后发送 /send,取消请发送 /cancel。`, { limit: BATCH_INPUT_LIMIT }), {
|
||||
count: 0,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
if (!plainText && (batch.phase === 'collecting' || name)) {
|
||||
return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
|
||||
请继续发送文字,或使用 /send、/cancel。`), {
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
if (batch.phase === 'submitting') {
|
||||
if (name === 'send') {
|
||||
return result('submitting', t('当前批次正在提交,请勿重复发送 /send。'));
|
||||
}
|
||||
if (name === 'cancel') {
|
||||
return result('submitting', t(`批量内容已经提交,无法取消。
|
||||
如需停止当前任务,请发送 /stop。`));
|
||||
}
|
||||
if (name === 'batch') {
|
||||
return result('submitting', t('当前批次正在提交,请等待处理完成后再开启新批次。'));
|
||||
}
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
if (name === 'batch') {
|
||||
return result('status', progressMessage(batch.messages.length), {
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
if (name === 'cancel') {
|
||||
const count = batch.messages.length;
|
||||
this.#batches.delete(key);
|
||||
return result('cancelled', count === 0
|
||||
? t('已取消批量输入。')
|
||||
: t('已取消批量输入,共丢弃 {count} 条消息。', { count }), { count });
|
||||
}
|
||||
|
||||
if (name === 'send') {
|
||||
if (batch.messages.length === 0) {
|
||||
return result('empty', t('当前批次还没有内容,请先发送文字,或使用 /cancel 取消。'), {
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
const messages = Object.freeze([...batch.messages]);
|
||||
const token = Object.freeze({});
|
||||
batch.phase = 'submitting';
|
||||
batch.token = token;
|
||||
return result('submit', null, {
|
||||
token,
|
||||
messages,
|
||||
prompt: submissionPrompt(messages),
|
||||
count: messages.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
|
||||
请继续发送文字,或使用 /send、/cancel。`), {
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
if (text.trim().startsWith('/')) {
|
||||
return result('blocked-command', t('当前正在批量输入,请先发送 /send 提交或 /cancel 取消。'), {
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
if (batch.messages.length === BATCH_INPUT_LIMIT) {
|
||||
return result('full', t(`当前批次已满,这条消息未收录。
|
||||
请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。`), {
|
||||
count: BATCH_INPUT_LIMIT,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
batch.messages.push(text);
|
||||
const count = batch.messages.length;
|
||||
return result('collected', count === BATCH_INPUT_LIMIT
|
||||
? t('已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。', {
|
||||
count,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
})
|
||||
: null, {
|
||||
count,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
complete(key, token) {
|
||||
const batch = this.#batches.get(key);
|
||||
if (!batch || batch.phase !== 'submitting' || batch.token !== token) {
|
||||
return Object.freeze({ completed: false });
|
||||
}
|
||||
const count = batch.messages.length;
|
||||
this.#batches.delete(key);
|
||||
return Object.freeze({ completed: true, count });
|
||||
}
|
||||
|
||||
fail(key, token) {
|
||||
const batch = this.#batches.get(key);
|
||||
if (!batch || batch.phase !== 'submitting' || batch.token !== token) {
|
||||
return Object.freeze({ retained: false });
|
||||
}
|
||||
batch.phase = 'collecting';
|
||||
batch.token = null;
|
||||
const count = batch.messages.length;
|
||||
return Object.freeze({
|
||||
retained: true,
|
||||
count,
|
||||
message: t(`批量内容提交失败,已保留 {count} 条消息。
|
||||
请再次发送 /send 重试或 /cancel 取消。`, { count }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -234,14 +234,15 @@ export default {
|
|||
'/watch ID 关注会话(完成后推送)': '/watch ID Watch a session (push on completion)',
|
||||
'/watchlist 关注列表': '/watchlist List watched sessions',
|
||||
'/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
|
||||
'📦 批量输入(仅私聊)': '📦 Batch input (direct messages only)',
|
||||
'🤖 预设 / 模型': '🤖 Presets / models',
|
||||
'/models 列出模型': '/models List models',
|
||||
'🎮 任务控制': '🎮 Task controls',
|
||||
'/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
|
||||
'**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
|
||||
'**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
|
||||
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/repair` — 修复卡片按钮':
|
||||
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/repair` — repair card buttons',
|
||||
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 修复卡片按钮':
|
||||
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — repair card buttons',
|
||||
'**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
|
||||
'**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Repair · **6** Help',
|
||||
'从下方下拉选择补充指令;最后一项可自定义输入。':
|
||||
|
|
|
|||
|
|
@ -96,4 +96,53 @@ export default {
|
|||
|
||||
// agent-preset.mjs
|
||||
'Agent Preset 无效。': 'Invalid Agent Preset.',
|
||||
|
||||
// batch-input.mjs
|
||||
'/batch 开始批量输入(仅私聊,最多 10 条文字)':
|
||||
'/batch Start batch input (direct messages only, up to 10 text messages)',
|
||||
'/send 提交当前批次': '/send Submit the current batch',
|
||||
'/cancel 取消当前批次': '/cancel Cancel the current batch',
|
||||
'开始批量输入(仅私聊)': 'Start batch input (direct messages only)',
|
||||
'提交当前批次': 'Submit the current batch',
|
||||
'取消当前批次': 'Cancel the current batch',
|
||||
'当前已处于批量输入模式,已收集 {count}/{limit} 条。\n请发送 /send 提交或 /cancel 取消。':
|
||||
'Batch input is already active with {count}/{limit} messages collected.\nSend /send to submit or /cancel to cancel.',
|
||||
'当前已处于批量输入模式,已收集 {count}/{limit} 条。\n完成后发送 /send,取消请发送 /cancel。':
|
||||
'Batch input is already active with {count}/{limit} messages collected.\nSend /send when finished or /cancel to cancel.',
|
||||
'[消息 {index}]': '[Message {index}]',
|
||||
'以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。':
|
||||
'The user sent the following messages in batch input mode. Process them in order as one input.',
|
||||
'批量输入模式仅支持私聊,请在与机器人的私聊中使用。':
|
||||
'Batch input is available only in direct messages. Please use it in a direct chat with the bot.',
|
||||
'当前聊天有正在运行的任务、待回答问题或待审批请求。\n请先完成当前交互或发送 /stop,再使用 /batch。':
|
||||
'This chat has a running task, unanswered question, or pending approval.\nFinish the current interaction or send /stop before using /batch.',
|
||||
'用法:/{command}(不带参数)': 'Usage: /{command} (without arguments)',
|
||||
'批量输入命令仅支持纯文字,请移除图片或文件后重试。':
|
||||
'Batch input commands support text only. Remove the image or file and try again.',
|
||||
'当前没有待提交的批量内容,请先发送 /batch。':
|
||||
'There is no batch to submit. Send /batch first.',
|
||||
'当前没有正在进行的批量输入。': 'There is no active batch input.',
|
||||
'已进入批量输入模式,最多可发送 {limit} 条文字。\n完成后发送 /send,取消请发送 /cancel。':
|
||||
'Batch input started. You can send up to {limit} text messages.\nSend /send when finished or /cancel to cancel.',
|
||||
'批量输入模式目前仅支持文字,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
|
||||
'Batch input currently supports text only, so this message was not collected.\nContinue with text, or use /send or /cancel.',
|
||||
'当前批次正在提交,请勿重复发送 /send。':
|
||||
'The current batch is being submitted. Do not send /send again.',
|
||||
'批量内容已经提交,无法取消。\n如需停止当前任务,请发送 /stop。':
|
||||
'The batch has already been submitted and cannot be cancelled.\nSend /stop if you need to stop the current task.',
|
||||
'当前批次正在提交,请等待处理完成后再开启新批次。':
|
||||
'The current batch is being submitted. Wait for it to finish before starting another batch.',
|
||||
'已取消批量输入。': 'Batch input cancelled.',
|
||||
'已取消批量输入,共丢弃 {count} 条消息。':
|
||||
'Batch input cancelled; {count} messages were discarded.',
|
||||
'当前批次还没有内容,请先发送文字,或使用 /cancel 取消。':
|
||||
'The current batch is empty. Send some text first or use /cancel.',
|
||||
'当前正在批量输入,请先发送 /send 提交或 /cancel 取消。':
|
||||
'Batch input is active. Send /send to submit or /cancel to cancel first.',
|
||||
'当前批次已满,这条消息未收录。\n请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。':
|
||||
'The current batch is full, so this message was not collected.\nSend /send or /cancel first, then resend this message.',
|
||||
'已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。':
|
||||
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
|
||||
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
|
||||
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ import {
|
|||
} from './preset-command.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
batchInputGroupUnsupportedMessage,
|
||||
isBatchInputCommand,
|
||||
} from './batch-input.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
|
|
@ -119,6 +125,7 @@ export class TextHarnessBridge {
|
|||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
#batches = new BatchInputManager();
|
||||
|
||||
constructor({
|
||||
descriptor,
|
||||
|
|
@ -173,7 +180,54 @@ export class TextHarnessBridge {
|
|||
const key = `${kind}:${conversationId}`;
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const text = cleanText(normalized.content);
|
||||
const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
|
||||
const batchCommand = isBatchInputCommand(text);
|
||||
if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
|
||||
return this.#finishLocalMessage(
|
||||
normalized,
|
||||
messageId,
|
||||
batchInputGroupUnsupportedMessage(),
|
||||
);
|
||||
}
|
||||
if (batchCommand && normalized.kind === 'direct') {
|
||||
const exactBatch = /^\/batch$/iu.test(text);
|
||||
if (exactBatch && (
|
||||
this.#queues.has(key)
|
||||
|| Boolean(pending)
|
||||
|| this.#approvals.hasPending(key)
|
||||
)) {
|
||||
return this.#finishLocalMessage(normalized, messageId, batchInputBusyMessage());
|
||||
}
|
||||
const batch = this.#batches.handle(key, text, {
|
||||
plainText: Boolean(text)
|
||||
&& normalized.plainText !== false
|
||||
&& !hasInboundImages(normalized)
|
||||
&& !hasInboundFiles(normalized),
|
||||
});
|
||||
if (batch.handled) {
|
||||
if (batch.kind === 'submit') {
|
||||
return this.#enqueueMessage({
|
||||
...normalized,
|
||||
content: batch.prompt,
|
||||
batchSubmission: { token: batch.token },
|
||||
}, messageId, senderId, key);
|
||||
}
|
||||
return this.#finishLocalMessage(normalized, messageId, batch.message);
|
||||
}
|
||||
} else if (normalized.kind === 'direct'
|
||||
&& this.#batches.status(key).phase === 'collecting') {
|
||||
const batch = this.#batches.handle(key, text, {
|
||||
plainText: Boolean(text)
|
||||
&& normalized.plainText !== false
|
||||
&& !hasInboundImages(normalized)
|
||||
&& !hasInboundFiles(normalized),
|
||||
});
|
||||
if (batch.handled) {
|
||||
return this.#finishLocalMessage(normalized, messageId, batch.message);
|
||||
}
|
||||
}
|
||||
const collectingBatch = normalized.kind === 'direct'
|
||||
&& this.#batches.status(key).phase === 'collecting';
|
||||
const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
|
||||
? runControlCommand
|
||||
: (isModelCommand(text)
|
||||
? runModelCommand
|
||||
|
|
@ -254,6 +308,30 @@ export class TextHarnessBridge {
|
|||
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
||||
}
|
||||
|
||||
#finishLocalMessage(message, messageId, reply) {
|
||||
let task;
|
||||
task = (async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (reply) await this.#bot.sendText(message.replyTarget, reply);
|
||||
this.#status.lastError = null;
|
||||
})().catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
|
||||
error,
|
||||
);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
});
|
||||
this.#commandTasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
#enqueueMessage(message, messageId, senderId, key, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
|
|
@ -373,6 +451,7 @@ export class TextHarnessBridge {
|
|||
|
||||
const target = message.replyTarget;
|
||||
const text = cleanText(message.content);
|
||||
const batchSubmission = message.batchSubmission;
|
||||
let stream = null;
|
||||
let semanticStream = false;
|
||||
try {
|
||||
|
|
@ -411,6 +490,9 @@ export class TextHarnessBridge {
|
|||
t('/preset --default 跟随 Host 默认'),
|
||||
t('/stop 停止当前任务'),
|
||||
t('/steer 补充指令 纠偏当前任务'),
|
||||
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
||||
t('/send 提交当前批次'),
|
||||
t('/cancel 取消当前批次'),
|
||||
t('/status 检查连接状态'),
|
||||
t('/help 显示本帮助'),
|
||||
].join('\n'));
|
||||
|
|
@ -508,6 +590,9 @@ export class TextHarnessBridge {
|
|||
files: message.files,
|
||||
},
|
||||
});
|
||||
if (batchSubmission) {
|
||||
this.#batches.complete(conversationKey, batchSubmission.token);
|
||||
}
|
||||
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
|
||||
const visibleAnswer = fileOnlyCompletion
|
||||
? t(FILE_ONLY_COMPLETION_TEXT)
|
||||
|
|
@ -579,7 +664,14 @@ export class TextHarnessBridge {
|
|||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
const turnStopped = error?.code === 'turn-stopped';
|
||||
if (batchSubmission && turnStopped) {
|
||||
this.#batches.complete(conversationKey, batchSubmission.token);
|
||||
}
|
||||
const failedBatch = batchSubmission && !turnStopped
|
||||
? this.#batches.fail(conversationKey, batchSubmission.token)
|
||||
: null;
|
||||
if (turnStopped) {
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.finish(t('已停止。'));
|
||||
|
|
@ -607,6 +699,23 @@ export class TextHarnessBridge {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
if (failedBatch?.retained) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
|
||||
error,
|
||||
);
|
||||
if (await presentStreamFailure(failedBatch.message)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, failedBatch.message);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const imageErrorMessage = imagePromptUserMessage(error);
|
||||
if (imageErrorMessage) {
|
||||
if (await presentStreamFailure(imageErrorMessage)) return;
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
|
||||
content: stripBotMention(event.text ?? '', botUserId),
|
||||
plainText: !Array.isArray(event.files) || event.files.length === 0,
|
||||
images: Array.isArray(event.files)
|
||||
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
||||
: [],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
|
|||
{ command: 'preset', description: '查看或设置新会话 Agent Preset' },
|
||||
{ command: 'stop', description: '停止当前任务' },
|
||||
{ command: 'steer', description: '纠偏当前任务' },
|
||||
{ command: 'batch', description: '开始批量输入(仅私聊)' },
|
||||
{ command: 'send', description: '提交当前批次' },
|
||||
{ command: 'cancel', description: '取消当前批次' },
|
||||
{ command: 'status', description: '检查连接状态' },
|
||||
{ command: 'help', description: '显示帮助' },
|
||||
]);
|
||||
|
|
@ -160,6 +163,7 @@ export function normalizeTelegramUpdate(update, {
|
|||
conversationId: messageThreadId === undefined
|
||||
? String(chatId) : `${chatId}:${messageThreadId}`,
|
||||
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
||||
plainText: typeof message.text === 'string',
|
||||
images: image ? [image] : [],
|
||||
files: file ? [file] : [],
|
||||
addressed,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
batchInputGroupUnsupportedMessage,
|
||||
isBatchInputCommand,
|
||||
} from '../shared/batch-input.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import {
|
||||
isControlCommand,
|
||||
|
|
@ -63,6 +69,9 @@ function helpText() {
|
|||
t('/preset --default 跟随 Host 默认'),
|
||||
t('/stop 停止当前任务'),
|
||||
t('/steer 补充指令 纠偏当前任务'),
|
||||
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
||||
t('/send 提交当前批次'),
|
||||
t('/cancel 取消当前批次'),
|
||||
t('/status 检查连接状态'),
|
||||
t('/help 显示本帮助'),
|
||||
].join('\n');
|
||||
|
|
@ -254,6 +263,10 @@ function interactionReplyText(frame) {
|
|||
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
|
||||
}
|
||||
|
||||
function isNativeWecomText(frame) {
|
||||
return bodyOf(frame).msgtype === 'text' && Boolean(nonEmptyString(messageText(frame)));
|
||||
}
|
||||
|
||||
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
|
||||
const source = String(text ?? '').trim();
|
||||
if (!source) return [];
|
||||
|
|
@ -466,6 +479,7 @@ export class WecomHarnessBridge {
|
|||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
#batchInputs = new BatchInputManager();
|
||||
#prefetchedImageCount = 0;
|
||||
|
||||
constructor({
|
||||
|
|
@ -523,6 +537,40 @@ export class WecomHarnessBridge {
|
|||
const pending = this.#pendingInteractions.get(key);
|
||||
const commandMessage = wecomInboundMessage(frame, this.#client);
|
||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
const batchCommand = isBatchInputCommand(commandText);
|
||||
const batchStatus = this.#batchInputs.status(key);
|
||||
if (batchCommand && body.chattype === 'group') {
|
||||
return this.#finishBatchResult(
|
||||
frame,
|
||||
messageId,
|
||||
chatId,
|
||||
{ message: batchInputGroupUnsupportedMessage() },
|
||||
);
|
||||
}
|
||||
if (body.chattype === 'single'
|
||||
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
||||
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
||||
const result = exactBatchStart
|
||||
&& batchStatus.phase === 'idle'
|
||||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: isNativeWecomText(frame),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
return this.#enqueueMessage({
|
||||
...frame,
|
||||
body: {
|
||||
...body,
|
||||
msgtype: 'text',
|
||||
text: { content: result.prompt },
|
||||
},
|
||||
}, messageId, key, { batchSubmission: result });
|
||||
}
|
||||
return this.#finishBatchResult(frame, messageId, chatId, result);
|
||||
}
|
||||
}
|
||||
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText)
|
||||
|
|
@ -616,6 +664,7 @@ export class WecomHarnessBridge {
|
|||
#enqueueMessage(frame, messageId, key, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
// WeCom image URLs expire after five minutes, while a conversation turn
|
||||
// may legally stay queued longer. Start the authenticated SDK download as
|
||||
|
|
@ -637,7 +686,11 @@ export class WecomHarnessBridge {
|
|||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
|
||||
.then(() => this.#process(frame, {
|
||||
alreadyRecorded,
|
||||
preparedMessage,
|
||||
batchSubmission,
|
||||
}))
|
||||
.finally(() => {
|
||||
this.#prefetchedImageCount -= reservedImages;
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -647,6 +700,29 @@ export class WecomHarnessBridge {
|
|||
return current;
|
||||
}
|
||||
|
||||
#finishBatchResult(frame, messageId, chatId, result) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (result.message) await this.#sendImmediate(frame, chatId, result.message);
|
||||
this.#status.lastError = null;
|
||||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:wecom] failed to process a batch input message');
|
||||
await this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
});
|
||||
this.#commandTasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async waitForIdle() {
|
||||
await Promise.allSettled([
|
||||
...this.#queues.values(),
|
||||
|
|
@ -748,7 +824,11 @@ export class WecomHarnessBridge {
|
|||
};
|
||||
}
|
||||
|
||||
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||
async #process(frame, {
|
||||
alreadyRecorded = false,
|
||||
preparedMessage,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
if (this.#signal?.aborted) return;
|
||||
const body = bodyOf(frame);
|
||||
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
|
||||
|
|
@ -767,6 +847,7 @@ export class WecomHarnessBridge {
|
|||
const key = conversationKey(frame);
|
||||
let streamId = null;
|
||||
let streamStarted = false;
|
||||
let batchSettled = batchSubmission === null;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
|
||||
|
|
@ -855,6 +936,10 @@ export class WecomHarnessBridge {
|
|||
files: message.files,
|
||||
},
|
||||
});
|
||||
if (batchSubmission) {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
batchSettled = true;
|
||||
}
|
||||
|
||||
this.#signal?.throwIfAborted();
|
||||
const displayAnswer = answerTextForDelivery(answer, artifacts);
|
||||
|
|
@ -915,6 +1000,15 @@ export class WecomHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
if (!batchSettled && batchSubmission) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
} else {
|
||||
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
|
||||
}
|
||||
batchSettled = true;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') {
|
||||
if (streamStarted && streamId) {
|
||||
await this.#client.replyStream(frame, streamId, t('已停止。'), true)
|
||||
|
|
@ -929,11 +1023,14 @@ export class WecomHarnessBridge {
|
|||
const errorText = inboundFileUserMessage(error)
|
||||
?? imagePromptUserMessage(error)
|
||||
?? t('消息处理失败,请稍后重试。');
|
||||
const visibleError = batchFailureMessage
|
||||
? `${errorText}\n\n${batchFailureMessage}`
|
||||
: errorText;
|
||||
try {
|
||||
if (streamStarted && streamId) {
|
||||
await this.#client.replyStream(frame, streamId, errorText, true);
|
||||
await this.#client.replyStream(frame, streamId, visibleError, true);
|
||||
} else {
|
||||
await this.#sendImmediate(frame, chatId, errorText);
|
||||
await this.#sendImmediate(frame, chatId, visibleError);
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
isBatchInputCommand,
|
||||
} from '../shared/batch-input.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import {
|
||||
isControlCommand,
|
||||
|
|
@ -70,6 +75,9 @@ const HELP_TEXT = () => [
|
|||
t('/preset --default 跟随 Host 默认'),
|
||||
t('/stop 停止当前任务'),
|
||||
t('/steer 补充指令 纠偏当前任务'),
|
||||
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
||||
t('/send 提交当前批次'),
|
||||
t('/cancel 取消当前批次'),
|
||||
t('/status 检查连接状态'),
|
||||
t('/help 显示本帮助'),
|
||||
].join('\n');
|
||||
|
|
@ -104,6 +112,15 @@ function hasWeixinFileItems(message) {
|
|||
&& message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
|
||||
}
|
||||
|
||||
function isNativeWeixinText(message) {
|
||||
return Array.isArray(message?.item_list)
|
||||
&& message.item_list.length > 0
|
||||
&& message.item_list.every((item) => (
|
||||
item?.type === 1 && typeof item.text_item?.text === 'string'
|
||||
))
|
||||
&& Boolean(nonEmptyString(extractWeixinText(message)));
|
||||
}
|
||||
|
||||
function canClaimInteractionReply(message, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.from_user_id) === pending.actor
|
||||
|
|
@ -176,6 +193,7 @@ export class WeixinHarnessBridge {
|
|||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
#batchInputs = new BatchInputManager();
|
||||
|
||||
constructor({
|
||||
api,
|
||||
|
|
@ -227,6 +245,35 @@ export class WeixinHarnessBridge {
|
|||
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
|
||||
const batchCommand = isBatchInputCommand(commandText);
|
||||
const batchStatus = this.#batchInputs.status(key);
|
||||
if (sender === this.#ownerUserId
|
||||
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
||||
const exactBatchStart = /^\/batch$/iu.test(commandText);
|
||||
const result = exactBatchStart
|
||||
&& batchStatus.phase === 'idle'
|
||||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: isNativeWeixinText(message),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
return this.#enqueueMessage({
|
||||
...message,
|
||||
item_list: [{ type: 1, text_item: { text: result.prompt } }],
|
||||
}, messageId, key, { batchSubmission: result });
|
||||
}
|
||||
return this.#finishBatchResult(
|
||||
message,
|
||||
messageId,
|
||||
sender,
|
||||
contextToken,
|
||||
runId,
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText)
|
||||
|
|
@ -314,6 +361,7 @@ export class WeixinHarnessBridge {
|
|||
#enqueueMessage(message, messageId, key, {
|
||||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
const preparedMessage = message.from_user_id === this.#ownerUserId
|
||||
? prefetchInboundFiles(
|
||||
|
|
@ -324,7 +372,11 @@ export class WeixinHarnessBridge {
|
|||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
|
||||
.then(() => this.#process(message, key, {
|
||||
alreadyRecorded,
|
||||
preparedMessage,
|
||||
batchSubmission,
|
||||
}))
|
||||
.finally(() => {
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||
|
|
@ -333,6 +385,33 @@ export class WeixinHarnessBridge {
|
|||
return current;
|
||||
}
|
||||
|
||||
#finishBatchResult(message, messageId, sender, contextToken, runId, result) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (result.message) {
|
||||
await this.#send(sender, result.message, contextToken, runId);
|
||||
}
|
||||
this.#status.lastError = null;
|
||||
this.#status.lastMessageError = null;
|
||||
}).catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#status.lastMessageError = safeMessageError(error);
|
||||
this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
|
||||
await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#commandTasks.delete(task);
|
||||
});
|
||||
this.#commandTasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async waitForIdle() {
|
||||
await Promise.allSettled([
|
||||
...this.#queues.values(),
|
||||
|
|
@ -380,7 +459,11 @@ export class WeixinHarnessBridge {
|
|||
this.#status.lastMessageError = null;
|
||||
}
|
||||
|
||||
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||
async #process(message, key, {
|
||||
alreadyRecorded = false,
|
||||
preparedMessage,
|
||||
batchSubmission = null,
|
||||
} = {}) {
|
||||
this.#signal?.throwIfAborted();
|
||||
const messageId = weixinMessageId(message);
|
||||
const sender = nonEmptyString(message?.from_user_id);
|
||||
|
|
@ -398,6 +481,7 @@ export class WeixinHarnessBridge {
|
|||
|
||||
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
||||
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
||||
let batchSettled = batchSubmission === null;
|
||||
try {
|
||||
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
|
||||
const text = promptMessage.content;
|
||||
|
|
@ -479,6 +563,10 @@ export class WeixinHarnessBridge {
|
|||
files: promptMessage.files,
|
||||
},
|
||||
}));
|
||||
if (batchSubmission) {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
batchSettled = true;
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
this.#cancelPendingInteraction(key),
|
||||
|
|
@ -515,6 +603,15 @@ export class WeixinHarnessBridge {
|
|||
this.#status.lastMessageError = null;
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
let batchFailureMessage = null;
|
||||
if (!batchSettled && batchSubmission) {
|
||||
if (error?.code === 'turn-stopped') {
|
||||
this.#batchInputs.complete(key, batchSubmission.token);
|
||||
} else {
|
||||
batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
|
||||
}
|
||||
batchSettled = true;
|
||||
}
|
||||
if (error?.code === 'turn-stopped') {
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
|
|
@ -529,7 +626,7 @@ export class WeixinHarnessBridge {
|
|||
try {
|
||||
await this.#send(
|
||||
sender,
|
||||
userMessage,
|
||||
batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
|
||||
contextToken,
|
||||
runId,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,23 @@ function delay(ms, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
export function orderWeixinMessages(messages) {
|
||||
if (!Array.isArray(messages) || messages.length < 2) return Array.isArray(messages) ? messages : [];
|
||||
const orderField = ['seq', 'create_time_ms'].find((field) => messages.every((message) => (
|
||||
(typeof message?.[field] === 'number' && Number.isFinite(message[field]))
|
||||
|| (typeof message?.[field] === 'string'
|
||||
&& message[field].trim()
|
||||
&& Number.isFinite(Number(message[field])))
|
||||
)));
|
||||
if (!orderField) return messages;
|
||||
return messages
|
||||
.map((message, index) => ({ message, index, order: Number(message[orderField]) }))
|
||||
.sort((left, right) => (
|
||||
left.order - right.order || left.index - right.index
|
||||
))
|
||||
.map(({ message }) => message);
|
||||
}
|
||||
|
||||
export function createWeixinRuntimeStatus() {
|
||||
return {
|
||||
startedAt: null,
|
||||
|
|
@ -234,7 +251,7 @@ export class WeixinRuntime {
|
|||
this.#status.lastCheckedAt = Date.now();
|
||||
this.#status.lastError = null;
|
||||
|
||||
for (const message of response?.msgs ?? []) {
|
||||
for (const message of orderWeixinMessages(response?.msgs)) {
|
||||
void this.#bridge.accept(message).catch((error) => {
|
||||
if (signal.aborted) return;
|
||||
this.#logger.error?.(
|
||||
|
|
|
|||
|
|
@ -239,6 +239,8 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|||
kind: group ? 'group' : 'direct',
|
||||
conversationId: remoteJid,
|
||||
content: messageText(content),
|
||||
plainText: typeof content?.conversation === 'string'
|
||||
|| typeof content?.extendedTextMessage?.text === 'string',
|
||||
images: image ? [image] : [],
|
||||
files: file ? [file] : [],
|
||||
addressed: !group || fromMe || mentioned || replyToSelf,
|
||||
|
|
|
|||
207
test/batch-input.test.mjs
Normal file
207
test/batch-input.test.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
BATCH_INPUT_LIMIT,
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
batchInputGroupUnsupportedMessage,
|
||||
isBatchInputCommand,
|
||||
} from '../src/channels/shared/batch-input.mjs';
|
||||
|
||||
test('batch commands are reserved case-insensitively, including malformed argument forms', () => {
|
||||
for (const text of [
|
||||
'/batch', ' /BATCH ', '/batch now', '/send', '/SEND later', '/cancel', '/cancel all',
|
||||
]) {
|
||||
assert.equal(isBatchInputCommand(text), true, text);
|
||||
}
|
||||
for (const text of [null, '', 'batch', '/batching', '/sender', '/cancellation', 'hello /batch']) {
|
||||
assert.equal(isBatchInputCommand(text), false, String(text));
|
||||
}
|
||||
});
|
||||
|
||||
test('shared boundary messages cover unsupported groups and busy conversations', () => {
|
||||
assert.match(batchInputGroupUnsupportedMessage(), /仅支持私聊/);
|
||||
assert.match(batchInputBusyMessage(), /\/stop.*\/batch/s);
|
||||
});
|
||||
|
||||
test('idle conversations pass through ordinary messages and handle the three commands', () => {
|
||||
const batches = new BatchInputManager();
|
||||
|
||||
assert.deepEqual(batches.handle('direct:one', 'normal message'), { handled: false });
|
||||
assert.equal(batches.status('direct:one').phase, 'idle');
|
||||
|
||||
const missingSend = batches.handle('direct:one', '/send');
|
||||
assert.equal(missingSend.handled, true);
|
||||
assert.equal(missingSend.kind, 'no-batch');
|
||||
assert.match(missingSend.message, /没有待提交/);
|
||||
|
||||
const missingCancel = batches.handle('direct:one', '/cancel');
|
||||
assert.equal(missingCancel.kind, 'no-batch');
|
||||
assert.match(missingCancel.message, /没有正在进行/);
|
||||
|
||||
const started = batches.handle('direct:one', ' /BATCH ');
|
||||
assert.equal(started.kind, 'started');
|
||||
assert.equal(started.count, 0);
|
||||
assert.equal(started.limit, BATCH_INPUT_LIMIT);
|
||||
assert.match(started.message, /最多可发送 10 条文字/);
|
||||
assert.deepEqual(batches.status('direct:one'), {
|
||||
phase: 'collecting', count: 0, limit: 10, full: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed batch commands are handled without changing state', () => {
|
||||
const batches = new BatchInputManager();
|
||||
|
||||
for (const command of ['/batch now', '/send now', '/cancel now']) {
|
||||
const response = batches.handle('direct:one', command);
|
||||
assert.equal(response.kind, 'invalid-command');
|
||||
assert.match(response.message, /不带参数/);
|
||||
assert.equal(batches.status('direct:one').phase, 'idle');
|
||||
}
|
||||
|
||||
batches.handle('direct:one', '/batch');
|
||||
batches.handle('direct:one', 'first');
|
||||
const invalid = batches.handle('direct:one', '/send now');
|
||||
assert.equal(invalid.kind, 'invalid-command');
|
||||
assert.equal(batches.status('direct:one').count, 1);
|
||||
});
|
||||
|
||||
test('collecting is isolated by conversation and repeated /batch only reports progress', () => {
|
||||
const batches = new BatchInputManager();
|
||||
batches.handle('direct:one', '/batch');
|
||||
|
||||
assert.deepEqual(batches.handle('direct:two', 'ordinary elsewhere'), { handled: false });
|
||||
assert.equal(batches.handle('direct:one', 'first').kind, 'collected');
|
||||
assert.equal(batches.handle('direct:one', 'second').kind, 'collected');
|
||||
|
||||
const progress = batches.handle('direct:one', '/batch');
|
||||
assert.equal(progress.kind, 'status');
|
||||
assert.equal(progress.count, 2);
|
||||
assert.match(progress.message, /已收集 2\/10 条/);
|
||||
assert.equal(batches.status('direct:two').phase, 'idle');
|
||||
});
|
||||
|
||||
test('collecting rejects non-text and other commands without counting them', () => {
|
||||
const batches = new BatchInputManager();
|
||||
batches.handle('direct:one', '/batch');
|
||||
|
||||
const media = batches.handle('direct:one', 'photo caption', { plainText: false });
|
||||
assert.equal(media.kind, 'unsupported-content');
|
||||
assert.match(media.message, /未收录/);
|
||||
|
||||
const mediaCommand = batches.handle('direct:one', '/cancel', { plainText: false });
|
||||
assert.equal(mediaCommand.kind, 'unsupported-content');
|
||||
assert.equal(batches.status('direct:one').phase, 'collecting');
|
||||
|
||||
const command = batches.handle('direct:one', '/stop');
|
||||
assert.equal(command.kind, 'blocked-command');
|
||||
assert.match(command.message, /\/send.*\/cancel/);
|
||||
assert.equal(batches.status('direct:one').count, 0);
|
||||
});
|
||||
|
||||
test('the tenth message is accepted and later messages are rejected without auto-submit', () => {
|
||||
const batches = new BatchInputManager();
|
||||
batches.handle('direct:one', '/batch');
|
||||
|
||||
for (let index = 1; index < BATCH_INPUT_LIMIT; index += 1) {
|
||||
const response = batches.handle('direct:one', `message ${index}`);
|
||||
assert.equal(response.kind, 'collected');
|
||||
assert.equal(response.count, index);
|
||||
assert.equal(response.message, undefined);
|
||||
}
|
||||
|
||||
const tenth = batches.handle('direct:one', 'message 10');
|
||||
assert.equal(tenth.kind, 'collected');
|
||||
assert.equal(tenth.count, 10);
|
||||
assert.match(tenth.message, /10\/10.*已满/);
|
||||
assert.deepEqual(batches.status('direct:one'), {
|
||||
phase: 'collecting', count: 10, limit: 10, full: true,
|
||||
});
|
||||
|
||||
const eleventh = batches.handle('direct:one', 'message 11');
|
||||
assert.equal(eleventh.kind, 'full');
|
||||
assert.equal(eleventh.count, 10);
|
||||
assert.match(eleventh.message, /这条消息未收录/);
|
||||
assert.equal(batches.status('direct:one').phase, 'collecting');
|
||||
assert.equal(batches.status('direct:one').count, 10);
|
||||
});
|
||||
|
||||
test('/send keeps an empty batch collecting and creates one immutable submission snapshot', () => {
|
||||
const batches = new BatchInputManager();
|
||||
batches.handle('direct:one', '/batch');
|
||||
|
||||
const empty = batches.handle('direct:one', '/send');
|
||||
assert.equal(empty.kind, 'empty');
|
||||
assert.equal(batches.status('direct:one').phase, 'collecting');
|
||||
|
||||
batches.handle('direct:one', 'first line\ncontinued');
|
||||
batches.handle('direct:one', 'second');
|
||||
const submission = batches.handle('direct:one', '/send');
|
||||
assert.equal(submission.kind, 'submit');
|
||||
assert.equal(submission.count, 2);
|
||||
assert.deepEqual(submission.messages, ['first line\ncontinued', 'second']);
|
||||
assert.equal(Object.isFrozen(submission.messages), true);
|
||||
assert.throws(() => submission.messages.push('third'), TypeError);
|
||||
assert.equal(submission.prompt, [
|
||||
'以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。',
|
||||
'[消息 1]\nfirst line\ncontinued',
|
||||
'[消息 2]\nsecond',
|
||||
].join('\n\n'));
|
||||
assert.equal(batches.status('direct:one').phase, 'submitting');
|
||||
|
||||
const duplicate = batches.handle('direct:one', '/send');
|
||||
assert.equal(duplicate.kind, 'submitting');
|
||||
assert.equal(duplicate.token, undefined);
|
||||
assert.equal(duplicate.messages, undefined);
|
||||
assert.equal(batches.handle('direct:one', '/batch').kind, 'submitting');
|
||||
assert.match(batches.handle('direct:one', '/cancel').message, /无法取消/);
|
||||
assert.deepEqual(batches.handle('direct:one', 'next normal message'), { handled: false });
|
||||
});
|
||||
|
||||
test('submission failure retains messages for retry while stale tokens cannot change state', () => {
|
||||
const batches = new BatchInputManager();
|
||||
batches.handle('direct:one', '/batch');
|
||||
batches.handle('direct:one', 'first');
|
||||
const first = batches.handle('direct:one', '/send');
|
||||
|
||||
assert.deepEqual(batches.fail('direct:one', Object.freeze({})), { retained: false });
|
||||
assert.equal(batches.status('direct:one').phase, 'submitting');
|
||||
|
||||
const failed = batches.fail('direct:one', first.token);
|
||||
assert.equal(failed.retained, true);
|
||||
assert.equal(failed.count, 1);
|
||||
assert.match(failed.message, /已保留 1 条消息/);
|
||||
assert.equal(batches.status('direct:one').phase, 'collecting');
|
||||
|
||||
batches.handle('direct:one', 'second');
|
||||
const retry = batches.handle('direct:one', '/send');
|
||||
assert.notEqual(retry.token, first.token);
|
||||
assert.deepEqual(retry.messages, ['first', 'second']);
|
||||
assert.deepEqual(batches.complete('direct:one', first.token), { completed: false });
|
||||
assert.equal(batches.status('direct:one').phase, 'submitting');
|
||||
|
||||
assert.deepEqual(batches.complete('direct:one', retry.token), { completed: true, count: 2 });
|
||||
assert.equal(batches.status('direct:one').phase, 'idle');
|
||||
assert.deepEqual(batches.fail('direct:one', retry.token), { retained: false });
|
||||
});
|
||||
|
||||
test('/cancel discards collecting batches, including an empty batch', () => {
|
||||
const batches = new BatchInputManager();
|
||||
|
||||
batches.handle('direct:one', '/batch');
|
||||
const empty = batches.handle('direct:one', '/cancel');
|
||||
assert.equal(empty.kind, 'cancelled');
|
||||
assert.equal(empty.count, 0);
|
||||
assert.match(empty.message, /已取消批量输入/);
|
||||
assert.equal(batches.status('direct:one').phase, 'idle');
|
||||
|
||||
batches.handle('direct:one', '/batch');
|
||||
batches.handle('direct:one', 'one');
|
||||
batches.handle('direct:one', 'two');
|
||||
const populated = batches.handle('direct:one', '/cancel');
|
||||
assert.equal(populated.kind, 'cancelled');
|
||||
assert.equal(populated.count, 2);
|
||||
assert.match(populated.message, /丢弃 2 条消息/);
|
||||
assert.equal(batches.status('direct:one').phase, 'idle');
|
||||
});
|
||||
|
|
@ -1998,3 +1998,138 @@ test('only the actor who started a DingTalk group interaction can answer it', as
|
|||
await Promise.all([first, intruder]);
|
||||
assert.deepEqual(asked, ['甲发起交互', '乙试图代答']);
|
||||
});
|
||||
|
||||
test('DingTalk private batch input submits once, cancels cleanly, and restores normal chat', async () => {
|
||||
const fixture = stateFixture();
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
createSession: async () => 'session-batch',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, prompt) => {
|
||||
asked.push(prompt);
|
||||
return asked.length === 1 ? '批量完成' : '普通完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('batch-start', '/batch'));
|
||||
await bridge.accept(message('batch-one', '第一条'));
|
||||
await bridge.accept(message('batch-two', '第二条'));
|
||||
assert.deepEqual(asked, []);
|
||||
assert.equal(sent.length, 1, 'only /batch acknowledges before submission');
|
||||
|
||||
await bridge.accept(message('batch-send', '/send'));
|
||||
assert.equal(asked.length, 1);
|
||||
assert.match(asked[0], /\[消息 1\]\n第一条/);
|
||||
assert.match(asked[0], /\[消息 2\]\n第二条/);
|
||||
assert.equal(sent.at(-1), '批量完成');
|
||||
|
||||
await bridge.accept(message('cancel-start', '/batch'));
|
||||
await bridge.accept(message('cancel-data', '不得提交'));
|
||||
await bridge.accept(message('cancel-command', '/cancel'));
|
||||
assert.equal(asked.length, 1);
|
||||
assert.match(sent.at(-1), /丢弃 1 条/);
|
||||
|
||||
await bridge.accept(message('normal-after-batch', '普通问题'));
|
||||
assert.equal(asked.at(-1), '普通问题');
|
||||
assert.equal(sent.at(-1), '普通完成');
|
||||
});
|
||||
|
||||
test('DingTalk retains a private batch after Harness ask fails and lets /send retry it', async () => {
|
||||
const fixture = stateFixture();
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
createSession: async () => 'session-batch-retry',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, prompt) => {
|
||||
asked.push(prompt);
|
||||
if (asked.length === 1) throw new Error('temporary Harness failure');
|
||||
return '重试成功';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('batch-retry-start', '/batch'));
|
||||
await bridge.accept(message('batch-retry-data', '需要重试的内容'));
|
||||
await bridge.accept(message('batch-retry-first-send', '/send'));
|
||||
|
||||
assert.equal(asked.length, 1);
|
||||
assert.match(sent.at(-1), /已保留 1 条消息/);
|
||||
assert.match(sent.at(-1), /再次发送 \/send 重试/);
|
||||
|
||||
await bridge.accept(message('batch-retry-second-send', '/send'));
|
||||
|
||||
assert.equal(asked.length, 2);
|
||||
assert.equal(asked[1], asked[0]);
|
||||
assert.equal(sent.at(-1), '重试成功');
|
||||
});
|
||||
|
||||
test('DingTalk clears a private batch after turn-stopped without suggesting a batch retry', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
createSession: async () => 'session-batch-stopped',
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('batch-stopped-start', '/batch'));
|
||||
await bridge.accept(message('batch-stopped-data', '停止后应清除'));
|
||||
await bridge.accept(message('batch-stopped-send', '/send'));
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(sent.some((text) => text.includes('已保留')), false);
|
||||
|
||||
await bridge.accept(message('batch-stopped-send-again', '/send'));
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.match(sent.at(-1), /当前没有待提交的批量内容/);
|
||||
});
|
||||
|
||||
test('DingTalk group batch commands are rejected without reaching Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ask: async () => { asks += 1; } },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('group-batch-command', '/batch', {
|
||||
conversationType: '2',
|
||||
conversationId: 'batch-room',
|
||||
isInAtList: true,
|
||||
}));
|
||||
|
||||
assert.equal(asks, 0);
|
||||
assert.match(sent.at(-1), /仅支持私聊/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -438,8 +438,27 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
}, '1234567890123456789');
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.plainText, true);
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: '222222222222222222' });
|
||||
|
||||
const sticker = normalizeDiscordMessage({
|
||||
id: '111111111111111115',
|
||||
channel_id: '222222222222222222',
|
||||
author: { id: '333333333333333333', bot: false },
|
||||
content: 'sticker caption',
|
||||
sticker_items: [{ id: '555555555555555555', name: 'wave' }],
|
||||
}, '1234567890123456789');
|
||||
assert.equal(sticker.plainText, false);
|
||||
|
||||
const poll = normalizeDiscordMessage({
|
||||
id: '111111111111111116',
|
||||
channel_id: '222222222222222222',
|
||||
author: { id: '333333333333333333', bot: false },
|
||||
content: 'poll caption',
|
||||
poll: { question: { text: 'choose one' } },
|
||||
}, '1234567890123456789');
|
||||
assert.equal(poll.plainText, false);
|
||||
|
||||
const group = normalizeDiscordMessage({
|
||||
id: '111111111111111112',
|
||||
channel_id: '222222222222222223',
|
||||
|
|
@ -988,6 +1007,7 @@ test('Discord keeps image attachments in images and exposes ordinary attachments
|
|||
});
|
||||
|
||||
assert.equal(message.content, '');
|
||||
assert.equal(message.plainText, false);
|
||||
assert.equal(message.images.length, 2);
|
||||
assert.deepEqual({
|
||||
name: message.images[0].name,
|
||||
|
|
|
|||
|
|
@ -389,7 +389,8 @@ test('Feishu lists models and presets without prompting and advertises fast comm
|
|||
const help = sent.at(-1);
|
||||
for (const command of [
|
||||
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
|
||||
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
|
||||
'/presetlist', '/preset', '/preset --default', '/batch', '/send', '/cancel',
|
||||
'/stop', '/steer',
|
||||
]) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
|
|
@ -397,6 +398,280 @@ test('Feishu lists models and presets without prompting and advertises fast comm
|
|||
assert.match(help, /\/preset id:<ID>/);
|
||||
});
|
||||
|
||||
test('Feishu private batch input synchronously collects messages and submits one ordered prompt', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-batch']]);
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const reactions = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {
|
||||
addReaction: async (messageId, emojiType) => {
|
||||
reactions.push({ messageId, emojiType });
|
||||
return `${messageId}-${emojiType}`;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
return '批量处理完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
const operations = [
|
||||
bridge.accept(event('batch-open', '/batch')),
|
||||
bridge.accept(event('batch-one', '第一条')),
|
||||
bridge.accept(event('batch-two', '第二条')),
|
||||
bridge.accept(event('batch-progress', '/batch')),
|
||||
bridge.accept(event('batch-send', '/send')),
|
||||
];
|
||||
await Promise.all(operations);
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(asked, [
|
||||
'以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。\n\n'
|
||||
+ '[消息 1]\n第一条\n\n[消息 2]\n第二条',
|
||||
]);
|
||||
assert.match(sent.join('\n'), /已进入批量输入模式/);
|
||||
assert.match(sent.join('\n'), /已收集 2\/10 条/);
|
||||
assert.match(sent.join('\n'), /批量处理完成/);
|
||||
for (const messageId of ['batch-open', 'batch-one', 'batch-two', 'batch-progress', 'batch-send']) {
|
||||
assert.equal(fixture.seen.has(messageId), true, messageId);
|
||||
assert.deepEqual(
|
||||
reactions.filter((reaction) => reaction.messageId === messageId).map(({ emojiType }) => emojiType),
|
||||
['OnIt', 'DONE'],
|
||||
);
|
||||
}
|
||||
|
||||
await bridge.accept(event('batch-after-success', '恢复普通聊天'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(asked.at(-1), '恢复普通聊天');
|
||||
});
|
||||
|
||||
test('Feishu batch input caps at ten, rejects non-text content, and can be cancelled', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-limit']]);
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
return '不应调用';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('batch-limit-open', '/batch'));
|
||||
for (let index = 1; index <= 10; index += 1) {
|
||||
await bridge.accept(event(`batch-limit-${index}`, `内容 ${index}`));
|
||||
}
|
||||
await bridge.accept(event('batch-limit-overflow', '第十一条'));
|
||||
await bridge.accept(event('batch-limit-cancel', '/cancel'));
|
||||
assert.equal(asks, 0);
|
||||
assert.match(sent.join('\n'), /已收集 10\/10 条/);
|
||||
assert.match(sent.join('\n'), /当前批次已满,这条消息未收录/);
|
||||
assert.match(sent.join('\n'), /共丢弃 10 条消息/);
|
||||
|
||||
await bridge.accept(event('batch-nontext-open', '/batch'));
|
||||
await bridge.accept(event('batch-nontext-image', '', {
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img_batch_rejected' }),
|
||||
}));
|
||||
await bridge.accept(event('batch-nontext-cancel', '/cancel'));
|
||||
assert.equal(asks, 0);
|
||||
assert.match(sent.join('\n'), /目前仅支持文字,这条消息未收录/);
|
||||
assert.match(sent.at(-1), /已取消批量输入/);
|
||||
assert.doesNotMatch(sent.at(-1), /丢弃 1 条/);
|
||||
});
|
||||
|
||||
test('Feishu group batch commands follow the response policy but never reach Harness', async () => {
|
||||
const fixture = stateFixture([['group:oc_batch_group', 'session-group-batch']]);
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
return '不应调用';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
botOpenId: 'ou_bot',
|
||||
groupResponseMode: 'mention',
|
||||
});
|
||||
|
||||
for (const [messageId, command] of [
|
||||
['group-batch', '/batch'],
|
||||
['group-send', '/send'],
|
||||
['group-cancel', '/cancel'],
|
||||
]) {
|
||||
await bridge.accept(event(messageId, `@_bot ${command}`, {
|
||||
chat_type: 'group',
|
||||
chat_id: 'oc_batch_group',
|
||||
mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }],
|
||||
}));
|
||||
}
|
||||
await bridge.accept(event('group-batch-unaddressed', '/batch', {
|
||||
chat_type: 'group',
|
||||
chat_id: 'oc_batch_group',
|
||||
}));
|
||||
bridge.setGroupResponseMode('all');
|
||||
await bridge.accept(event('group-batch-all-mode', '/batch', {
|
||||
chat_type: 'group',
|
||||
chat_id: 'oc_batch_group',
|
||||
}));
|
||||
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(sent.length, 4);
|
||||
assert.equal(sent.every((text) => /仅支持私聊/.test(text)), true);
|
||||
assert.equal(fixture.seen.has('group-batch-unaddressed'), false);
|
||||
assert.equal(fixture.seen.has('group-batch-all-mode'), true);
|
||||
});
|
||||
|
||||
test('Feishu batch start is busy behind a turn and submitting lets later messages use the normal queue', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-queue']]);
|
||||
const firstStarted = deferred();
|
||||
const releaseFirst = deferred();
|
||||
const batchStarted = deferred();
|
||||
const releaseBatch = deferred();
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
if (text === '运行中的普通任务') {
|
||||
firstStarted.resolve();
|
||||
await releaseFirst.promise;
|
||||
} else if (text.includes('[消息 1]\n批量内容')) {
|
||||
batchStarted.resolve();
|
||||
await releaseBatch.promise;
|
||||
}
|
||||
return `完成:${text}`;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
const activeTurn = bridge.accept(event('batch-busy-turn', '运行中的普通任务'));
|
||||
await firstStarted.promise;
|
||||
await bridge.accept(event('batch-busy-open', '/batch'));
|
||||
assert.match(sent.at(-1), /正在运行的任务/);
|
||||
releaseFirst.resolve();
|
||||
await activeTurn;
|
||||
|
||||
await bridge.accept(event('batch-queue-open', '/batch'));
|
||||
await bridge.accept(event('batch-queue-content', '批量内容'));
|
||||
const submission = bridge.accept(event('batch-queue-send', '/send'));
|
||||
await batchStarted.promise;
|
||||
const laterMessage = bridge.accept(event('batch-queue-later', '提交后的普通消息'));
|
||||
await bridge.accept(event('batch-queue-cancel-late', '/cancel'));
|
||||
assert.match(sent.at(-1), /已经提交,无法取消/);
|
||||
releaseBatch.resolve();
|
||||
await Promise.all([submission, laterMessage]);
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(asked.length, 3);
|
||||
assert.equal(asked[0], '运行中的普通任务');
|
||||
assert.match(asked[1], /\[消息 1\]\n批量内容/);
|
||||
assert.equal(asked[2], '提交后的普通消息');
|
||||
});
|
||||
|
||||
test('Feishu retains a batch after an ask failure and retries the same snapshot', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-retry']]);
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const finalReactions = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {
|
||||
addReaction: async (_messageId, emojiType) => {
|
||||
finalReactions.push(emojiType);
|
||||
return `reaction-${emojiType}`;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
if (asked.length === 1) throw new Error('transient batch failure');
|
||||
return '重试成功';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('batch-retry-open', '/batch'));
|
||||
await bridge.accept(event('batch-retry-content', '需要重试'));
|
||||
await bridge.accept(event('batch-retry-first-send', '/send'));
|
||||
assert.match(sent.at(-1), /提交失败,已保留 1 条消息/);
|
||||
assert.equal(finalReactions.at(-1), 'ERROR');
|
||||
|
||||
await bridge.accept(event('batch-retry-status', '/batch'));
|
||||
assert.match(sent.at(-1), /已收集 1\/10 条/);
|
||||
await bridge.accept(event('batch-retry-second-send', '/send'));
|
||||
assert.equal(asked.length, 2);
|
||||
assert.equal(asked[0], asked[1]);
|
||||
assert.equal(sent.at(-1), '重试成功');
|
||||
|
||||
await bridge.accept(event('batch-retry-cancel-after-success', '/cancel'));
|
||||
assert.match(sent.at(-1), /没有正在进行的批量输入/);
|
||||
});
|
||||
|
||||
test('Feishu clears a submitted batch when the Harness turn is stopped', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-stopped']]);
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('stopped-batch-open', '/batch'));
|
||||
await bridge.accept(event('stopped-batch-content', '不要重试'));
|
||||
await bridge.accept(event('stopped-batch-send', '/send'));
|
||||
assert.doesNotMatch(sent.join('\n'), /已保留/);
|
||||
|
||||
await bridge.accept(event('stopped-batch-cancel', '/cancel'));
|
||||
assert.match(sent.at(-1), /没有正在进行的批量输入/);
|
||||
});
|
||||
|
||||
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
|
||||
const sent = [];
|
||||
const reactions = [];
|
||||
|
|
@ -786,7 +1061,7 @@ test('bridge sends Feishu post text and all embedded images as one structured pr
|
|||
assert.deepEqual(sent, ['两张图片都已收到']);
|
||||
});
|
||||
|
||||
test('text inside a Feishu post is a model prompt rather than a local command', async () => {
|
||||
test('text inside a Feishu post is a model prompt rather than a local or batch command', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-post-command']]);
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
|
|
@ -809,11 +1084,18 @@ test('text inside a Feishu post is a model prompt rather than a local command',
|
|||
message_type: 'post',
|
||||
content: JSON.stringify({ content: [[{ tag: 'text', text: '/new' }]] }),
|
||||
}));
|
||||
await bridge.accept(event('om_post_batch_command', '', {
|
||||
message_type: 'post',
|
||||
content: JSON.stringify({ content: [[{ tag: 'text', text: '/batch' }]] }),
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(fixture.sessions.get('p2p:ou_user'), 'session-post-command');
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-post-command', content: '/new' }]);
|
||||
assert.deepEqual(sent, ['按普通内容处理']);
|
||||
assert.deepEqual(asked, [
|
||||
{ sessionId: 'session-post-command', content: '/new' },
|
||||
{ sessionId: 'session-post-command', content: '/batch' },
|
||||
]);
|
||||
assert.deepEqual(sent, ['按普通内容处理', '按普通内容处理']);
|
||||
});
|
||||
|
||||
test('a threaded Feishu reply answers a pending Harness question before the original turn queue', async () => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ test('menu exposes the increased command set and keeps repair number-only', () =
|
|||
assert.equal(actions.includes('repair'), false);
|
||||
});
|
||||
|
||||
test('menu and card help advertise Agent Preset and reasoning commands', () => {
|
||||
test('menu and card help advertise Agent Preset, reasoning, and batch commands', () => {
|
||||
const help = menuHelpText();
|
||||
assert.match(help, /\/presetlist/);
|
||||
assert.match(help, /\/preset \[序号或完整ID\]/);
|
||||
|
|
@ -73,12 +73,18 @@ test('menu and card help advertise Agent Preset and reasoning commands', () => {
|
|||
assert.match(help, /\/reasoninglist 或 \/reasonings/);
|
||||
assert.match(help, /\/reasoning \[序号、等级ID或 --default\]/);
|
||||
assert.match(help, /\/model \[序号或完整模型ID\] \[推理等级ID\]/);
|
||||
assert.match(help, /\/batch/);
|
||||
assert.match(help, /\/send/);
|
||||
assert.match(help, /\/cancel/);
|
||||
|
||||
const card = helpCard();
|
||||
assert.match(card, /\/reasoninglist/);
|
||||
assert.match(card, /\/reasonings/);
|
||||
assert.match(card, /\/reasoning \[序号、等级ID或 --default\]/);
|
||||
assert.match(card, /\/model \[序号或完整模型ID\] \[推理等级ID\]/);
|
||||
assert.match(card, /\/batch/);
|
||||
assert.match(card, /\/send/);
|
||||
assert.match(card, /\/cancel/);
|
||||
});
|
||||
|
||||
test('card-action probe carries only its action and opaque nonce', () => {
|
||||
|
|
|
|||
|
|
@ -2246,3 +2246,154 @@ test('QQ cancellation prevents SDK sendFile', async (t) => {
|
|||
await cancelledBridge.accept(message({ messageId: 'qq-artifact-cancelled' }));
|
||||
assert.equal(fileCalls, 0);
|
||||
});
|
||||
|
||||
test('QQ private batch input submits once, cancels cleanly, and restores normal chat', async () => {
|
||||
const fixture = stateFixture();
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
createSession: async () => 'session-batch',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, prompt) => {
|
||||
asked.push(prompt);
|
||||
return asked.length === 1 ? '批量完成' : '普通完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
const inbound = (messageId, content, extra = {}) => message({
|
||||
messageId,
|
||||
content,
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: messageId },
|
||||
...extra,
|
||||
});
|
||||
|
||||
await bridge.accept(inbound('qq-batch-start', '/batch'));
|
||||
await bridge.accept(inbound('qq-batch-one', '第一条'));
|
||||
await bridge.accept(inbound('qq-batch-two', '第二条'));
|
||||
assert.deepEqual(asked, []);
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
await bridge.accept(inbound('qq-batch-send', '/send'));
|
||||
assert.equal(asked.length, 1);
|
||||
assert.match(asked[0], /\[消息 1\]\n第一条/);
|
||||
assert.match(asked[0], /\[消息 2\]\n第二条/);
|
||||
assert.equal(sent.at(-1), '批量完成');
|
||||
|
||||
await bridge.accept(inbound('qq-cancel-start', '/batch'));
|
||||
await bridge.accept(inbound('qq-cancel-data', '不得提交'));
|
||||
await bridge.accept(inbound('qq-cancel-command', '/cancel'));
|
||||
assert.equal(asked.length, 1);
|
||||
assert.match(sent.at(-1), /丢弃 1 条/);
|
||||
|
||||
await bridge.accept(inbound('qq-normal-after-batch', '普通问题'));
|
||||
assert.equal(asked.at(-1), '普通问题');
|
||||
assert.equal(sent.at(-1), '普通完成');
|
||||
});
|
||||
|
||||
test('QQ retains a private batch after Harness ask fails and lets /send retry it', async () => {
|
||||
const fixture = stateFixture();
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
createSession: async () => 'session-batch-retry',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, prompt) => {
|
||||
asked.push(prompt);
|
||||
if (asked.length === 1) throw new Error('temporary Harness failure');
|
||||
return '重试成功';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
const inbound = (messageId, content) => message({
|
||||
messageId,
|
||||
content,
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: messageId },
|
||||
});
|
||||
|
||||
await bridge.accept(inbound('qq-batch-retry-start', '/batch'));
|
||||
await bridge.accept(inbound('qq-batch-retry-data', '需要重试的内容'));
|
||||
await bridge.accept(inbound('qq-batch-retry-first-send', '/send'));
|
||||
|
||||
assert.equal(asked.length, 1);
|
||||
assert.match(sent.at(-1), /已保留 1 条消息/);
|
||||
assert.match(sent.at(-1), /再次发送 \/send 重试/);
|
||||
|
||||
await bridge.accept(inbound('qq-batch-retry-second-send', '/send'));
|
||||
|
||||
assert.equal(asked.length, 2);
|
||||
assert.equal(asked[1], asked[0]);
|
||||
assert.equal(sent.at(-1), '重试成功');
|
||||
});
|
||||
|
||||
test('QQ clears a private batch after turn-stopped without suggesting a batch retry', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
createSession: async () => 'session-batch-stopped',
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
const inbound = (messageId, content) => message({
|
||||
messageId,
|
||||
content,
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: messageId },
|
||||
});
|
||||
|
||||
await bridge.accept(inbound('qq-batch-stopped-start', '/batch'));
|
||||
await bridge.accept(inbound('qq-batch-stopped-data', '停止后应清除'));
|
||||
await bridge.accept(inbound('qq-batch-stopped-send', '/send'));
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(sent.at(-1), '已停止。');
|
||||
assert.equal(sent.some((text) => text.includes('已保留')), false);
|
||||
|
||||
await bridge.accept(inbound('qq-batch-stopped-send-again', '/send'));
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.match(sent.at(-1), /当前没有待提交的批量内容/);
|
||||
});
|
||||
|
||||
test('QQ group batch commands are rejected without reaching Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: { ask: async () => { asks += 1; } },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
kind: 'group',
|
||||
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
|
||||
groupOpenid: 'batch-group',
|
||||
content: '/batch',
|
||||
messageId: 'qq-group-batch',
|
||||
replyTarget: { scope: 'group', targetId: 'batch-group', msgId: 'qq-group-batch' },
|
||||
}));
|
||||
|
||||
assert.equal(asks, 0);
|
||||
assert.match(sent.at(-1), /仅支持私聊/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -215,6 +215,341 @@ test('all four shared text channels execute /compact outside the model prompt pa
|
|||
}
|
||||
});
|
||||
|
||||
test('all four shared text channels collect a private batch and submit it in one ordered ask', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
['discord', DiscordHarnessBridge],
|
||||
['whatsapp', WhatsappHarnessBridge],
|
||||
]) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asks = [];
|
||||
const bridge = new Bridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
state: fixture.state,
|
||||
harness: {
|
||||
createSession: async () => `session-batch-${name}`,
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, text) => {
|
||||
asks.push({ sessionId, text });
|
||||
return `batch reply ${name}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accepted = [
|
||||
bridge.accept(message(`batch-start-${name}`, '/batch')),
|
||||
bridge.accept(message(`batch-first-${name}`, 'first line')),
|
||||
bridge.accept(message(`batch-last-${name}`, 'last line')),
|
||||
bridge.accept(message(`batch-send-${name}`, '/send')),
|
||||
];
|
||||
await Promise.all(accepted);
|
||||
|
||||
assert.equal(asks.length, 1, `${name} sends one ask`);
|
||||
assert.equal(asks[0].sessionId, `session-batch-${name}`, name);
|
||||
assert.match(asks[0].text, /\[消息 1\]\nfirst line/, name);
|
||||
assert.match(asks[0].text, /\[消息 2\]\nlast line/, name);
|
||||
assert.ok(
|
||||
asks[0].text.indexOf('first line') < asks[0].text.indexOf('last line'),
|
||||
`${name} preserves order`,
|
||||
);
|
||||
assert.equal(sent.filter((text) => text === `batch reply ${name}`).length, 1, name);
|
||||
assert.equal(fixture.seen.has(`batch-first-${name}`), true, `${name} records first item`);
|
||||
assert.equal(fixture.seen.has(`batch-last-${name}`), true, `${name} records last item`);
|
||||
|
||||
await bridge.accept(message(`normal-after-batch-${name}`, 'ordinary message'));
|
||||
assert.equal(asks.length, 2, `${name} resumes ordinary chat`);
|
||||
assert.equal(asks[1].text, 'ordinary message', `${name} ordinary prompt stays unchanged`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared text channels reserve addressed group batch commands without changing group chat', async () => {
|
||||
const commands = ['/batch', '/BATCH now', '/send', '/send later', '/cancel', '/cancel all'];
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
['telegram', TelegramHarnessBridge],
|
||||
['discord', DiscordHarnessBridge],
|
||||
['whatsapp', WhatsappHarnessBridge],
|
||||
]) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asks = [];
|
||||
const bridge = new Bridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
state: fixture.state,
|
||||
harness: {
|
||||
createSession: async () => `group-session-${name}`,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asks.push(text);
|
||||
return `${name} group reply`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, command] of commands.entries()) {
|
||||
await bridge.accept(message(`group-batch-${name}-${index}`, command, {
|
||||
kind: 'group',
|
||||
conversationId: `group-${name}`,
|
||||
addressed: true,
|
||||
}));
|
||||
}
|
||||
assert.equal(asks.length, 0, `${name} never submits a group batch command`);
|
||||
assert.equal(sent.length, commands.length, `${name} replies to every addressed form`);
|
||||
assert.equal(
|
||||
sent.every((text) => text.includes('仅支持私聊')),
|
||||
true,
|
||||
`${name} explains the private-only boundary`,
|
||||
);
|
||||
|
||||
const beforeUnaddressed = sent.length;
|
||||
await bridge.accept(message(`group-unaddressed-${name}`, '/batch', {
|
||||
kind: 'group',
|
||||
conversationId: `group-${name}`,
|
||||
addressed: false,
|
||||
}));
|
||||
assert.equal(sent.length, beforeUnaddressed, `${name} keeps mention rules intact`);
|
||||
assert.equal(asks.length, 0, `${name} ignores an unaddressed group command`);
|
||||
|
||||
await bridge.accept(message(`group-normal-${name}`, 'ordinary addressed group message', {
|
||||
kind: 'group',
|
||||
conversationId: `group-${name}`,
|
||||
addressed: true,
|
||||
}));
|
||||
assert.deepEqual(asks, ['ordinary addressed group message'], `${name} group chat is unchanged`);
|
||||
}
|
||||
});
|
||||
|
||||
test('private batch input enforces text-only collection, command blocking, the limit, and cancel', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'unused-batch-session',
|
||||
ask: async () => { asks += 1; return 'unexpected'; },
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('limited-start', '/batch'));
|
||||
await bridge.accept(message('limited-image', 'image caption', {
|
||||
images: [{ data: Buffer.from('image') }],
|
||||
}));
|
||||
await bridge.accept(message('limited-file', 'file caption', {
|
||||
files: [{ name: 'data.txt', data: Buffer.from('data') }],
|
||||
}));
|
||||
await bridge.accept(message('limited-unsupported-media', 'video caption', {
|
||||
plainText: false,
|
||||
}));
|
||||
await bridge.accept(message('limited-command', '/new'));
|
||||
|
||||
const itemPromises = [];
|
||||
for (let index = 1; index <= 11; index += 1) {
|
||||
itemPromises.push(bridge.accept(message(`limited-item-${index}`, `item ${index}`)));
|
||||
}
|
||||
await Promise.all(itemPromises);
|
||||
await bridge.accept(message('limited-item-10', 'item 10 replay'));
|
||||
await bridge.accept(message('limited-cancel', '/cancel'));
|
||||
|
||||
assert.equal(asks, 0, 'collection and cancellation never call Harness');
|
||||
assert.equal(sent.filter((text) => text.includes('目前仅支持文字')).length, 3);
|
||||
assert.equal(sent.some((text) => text.includes('先发送 /send 提交或 /cancel 取消')), true);
|
||||
assert.equal(sent.some((text) => /10\/10.*已满/.test(text)), true);
|
||||
assert.equal(sent.some((text) => text.includes('这条消息未收录')), true);
|
||||
assert.equal(sent.at(-1).includes('共丢弃 10 条消息'), true);
|
||||
assert.equal(fixture.seen.has('limited-item-11'), true, 'a rejected eleventh item is recorded');
|
||||
|
||||
await bridge.accept(message('normal-after-cancel', 'normal after cancel'));
|
||||
assert.equal(asks, 1, 'cancel restores the ordinary prompt path');
|
||||
});
|
||||
|
||||
test('batch commands cannot answer a pending Harness question or approval', async () => {
|
||||
for (const interactionKind of ['question', 'approval']) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const interactionDone = deferred();
|
||||
let interactionResponses = 0;
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => `session-pending-${interactionKind}`,
|
||||
ask: async (sessionId, _text, options) => {
|
||||
const interaction = interactionKind === 'question'
|
||||
? questionInteraction({
|
||||
id: 'pending-batch-question',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: 'question waiting' }],
|
||||
respond: async () => {
|
||||
interactionResponses += 1;
|
||||
interactionDone.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
})
|
||||
: approvalInteraction({
|
||||
id: 'pending-batch-approval',
|
||||
sessionId,
|
||||
reason: 'approval waiting',
|
||||
respond: async () => {
|
||||
interactionResponses += 1;
|
||||
interactionDone.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await options.onInteraction(interaction);
|
||||
await interactionDone.promise;
|
||||
return `${interactionKind} complete`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message(
|
||||
`pending-${interactionKind}-start`,
|
||||
`start ${interactionKind}`,
|
||||
));
|
||||
await eventually(() => sent.some((text) => text.includes(`${interactionKind} waiting`)));
|
||||
|
||||
await within(
|
||||
Promise.all([
|
||||
bridge.accept(message(`pending-${interactionKind}-batch`, '/batch')),
|
||||
bridge.accept(message(`pending-${interactionKind}-send`, '/send')),
|
||||
bridge.accept(message(`pending-${interactionKind}-cancel`, '/cancel')),
|
||||
]),
|
||||
250,
|
||||
`batch commands waited behind a pending ${interactionKind}`,
|
||||
);
|
||||
assert.equal(interactionResponses, 0, `${interactionKind} was not answered by a batch command`);
|
||||
assert.equal(sent.some((text) => text.includes('先完成当前交互')), true, interactionKind);
|
||||
assert.equal(sent.some((text) => text.includes('没有待提交')), true, interactionKind);
|
||||
assert.equal(sent.some((text) => text.includes('没有正在进行')), true, interactionKind);
|
||||
|
||||
await bridge.accept(message(
|
||||
`pending-${interactionKind}-answer`,
|
||||
interactionKind === 'question' ? 'yes' : '批准',
|
||||
));
|
||||
await processing;
|
||||
assert.equal(interactionResponses, 1, `${interactionKind} accepts its real answer`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a submitting batch rejects duplicate controls while a later ordinary message stays ordinary', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const batchStarted = deferred();
|
||||
const releaseBatch = deferred();
|
||||
const asks = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'session-submitting-batch',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asks.push(text);
|
||||
if (asks.length === 1) {
|
||||
batchStarted.resolve();
|
||||
await releaseBatch.promise;
|
||||
return 'batch complete';
|
||||
}
|
||||
return 'ordinary complete';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('submitting-start', '/batch'));
|
||||
await bridge.accept(message('submitting-item', 'batched item'));
|
||||
const submission = bridge.accept(message('submitting-send', '/send'));
|
||||
await batchStarted.promise;
|
||||
|
||||
await within(
|
||||
Promise.all([
|
||||
bridge.accept(message('submitting-send-again', '/send')),
|
||||
bridge.accept(message('submitting-cancel', '/cancel')),
|
||||
]),
|
||||
250,
|
||||
'submitting controls waited behind the batch ask',
|
||||
);
|
||||
const ordinary = bridge.accept(message('submitting-ordinary', 'ordinary after send'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(asks.length, 1, 'the later ordinary message waits in the existing queue');
|
||||
assert.equal(sent.some((text) => text.includes('请勿重复发送 /send')), true);
|
||||
assert.equal(sent.some((text) => text.includes('已经提交,无法取消')), true);
|
||||
|
||||
releaseBatch.resolve();
|
||||
await Promise.all([submission, ordinary]);
|
||||
assert.equal(asks.length, 2);
|
||||
assert.equal(asks[1], 'ordinary after send');
|
||||
});
|
||||
|
||||
test('a failed batch submission is retained with an explicit retry and can be sent once again', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asks = [];
|
||||
let attempt = 0;
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'session-retry-batch',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asks.push(text);
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error('transient Harness failure');
|
||||
return 'retry complete';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('retry-batch-start', '/batch'));
|
||||
await bridge.accept(message('retry-batch-item', 'keep this item'));
|
||||
await bridge.accept(message('retry-batch-send-one', '/send'));
|
||||
|
||||
assert.equal(asks.length, 1);
|
||||
assert.equal(sent.at(-1).includes('已保留 1 条消息'), true);
|
||||
assert.equal(sent.at(-1).includes('/send 重试'), true);
|
||||
|
||||
await bridge.accept(message('retry-batch-send-two', '/send'));
|
||||
assert.equal(asks.length, 2);
|
||||
assert.equal(asks[1], asks[0], 'retry reuses the retained immutable batch content');
|
||||
assert.equal(sent.at(-1), 'retry complete');
|
||||
|
||||
await bridge.accept(message('retry-no-batch', '/send'));
|
||||
assert.equal(sent.at(-1).includes('没有待提交'), true, 'successful retry clears the batch');
|
||||
});
|
||||
|
||||
test('stopping a submitted batch clears it instead of offering a duplicate retry', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const stopped = new Error('stopped by user');
|
||||
stopped.code = 'turn-stopped';
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'session-stopped-batch',
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
throw stopped;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('stopped-batch-start', '/batch'));
|
||||
await bridge.accept(message('stopped-batch-item', 'do this once'));
|
||||
await bridge.accept(message('stopped-batch-send', '/send'));
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(sent.some((text) => text.includes('已保留')), false);
|
||||
|
||||
await bridge.accept(message('stopped-batch-send-again', '/send'));
|
||||
assert.equal(asks, 1, 'a stopped submission cannot be retried as the same batch');
|
||||
assert.equal(sent.at(-1).includes('没有待提交'), true);
|
||||
});
|
||||
|
||||
test('shared text bridge passes a file-only message through askOptions.files', async () => {
|
||||
const fixture = stateFixture();
|
||||
const source = Object.freeze({
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ test('Enterprise WeChat lists models and presets without prompting and advertise
|
|||
for (const command of [
|
||||
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
|
||||
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
|
||||
'/batch', '/send', '/cancel',
|
||||
]) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
|
|
@ -1890,3 +1891,135 @@ test('Enterprise WeChat cancellation prevents SDK upload', async (t) => {
|
|||
await cancelledBridge.accept(frame({ msgid: 'wecom-artifact-cancelled' }));
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat batch input collects ten texts and submits one ordered Harness turn', async () => {
|
||||
const transport = testClient();
|
||||
const prompts = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'batch-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, prompt) => {
|
||||
prompts.push(prompt);
|
||||
return '批量完成';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'batch-start', text: { content: '/batch' } }));
|
||||
for (let index = 1; index <= 10; index += 1) {
|
||||
await bridge.accept(frame({
|
||||
msgid: `batch-item-${index}`,
|
||||
text: { content: `企微内容 ${index}` },
|
||||
}));
|
||||
}
|
||||
await bridge.accept(frame({ msgid: 'batch-overflow', text: { content: '不会收录' } }));
|
||||
|
||||
assert.equal(prompts.length, 0);
|
||||
assert.equal(transport.streamed.some(({ content }) => /10\/10.*已满/.test(content)), true);
|
||||
assert.equal(transport.streamed.some(({ content }) => /这条消息未收录/.test(content)), true);
|
||||
|
||||
await bridge.accept(frame({ msgid: 'batch-send', text: { content: '/send' } }));
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.match(prompts[0], /\[消息 1\]\n企微内容 1/);
|
||||
assert.match(prompts[0], /\[消息 10\]\n企微内容 10/);
|
||||
assert.doesNotMatch(prompts[0], /不会收录/);
|
||||
assert.equal(transport.streamed.at(-1).content, '批量完成');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat rejects batch commands in groups without invoking Harness', async () => {
|
||||
const transport = testClient();
|
||||
let asks = 0;
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
harness: { ask: async () => { asks += 1; return 'unexpected'; } },
|
||||
state: state(),
|
||||
});
|
||||
|
||||
for (const [index, command] of ['/batch', '/send', '/cancel'].entries()) {
|
||||
await bridge.accept(frame({
|
||||
msgid: `group-batch-${index}`,
|
||||
chattype: 'group',
|
||||
chatid: 'group-batch',
|
||||
text: { content: `@机器人 ${command}` },
|
||||
}));
|
||||
}
|
||||
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(transport.streamed.length, 3);
|
||||
assert.equal(transport.streamed.every(({ content }) => /仅支持私聊/.test(content)), true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat keeps a failed batch for retry and queues later ordinary messages normally', async () => {
|
||||
const transport = testClient();
|
||||
const firstBatch = deferred();
|
||||
const prompts = [];
|
||||
let failFirst = true;
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: (() => { let index = 0; return () => `batch-retry-${++index}`; })(),
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, prompt) => {
|
||||
prompts.push(prompt);
|
||||
if (failFirst) {
|
||||
failFirst = false;
|
||||
throw new Error('temporary failure');
|
||||
}
|
||||
if (/\[消息 1\]/.test(prompt)) await firstBatch.promise;
|
||||
return `完成:${prompt}`;
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'retry-start', text: { content: '/batch' } }));
|
||||
await bridge.accept(frame({ msgid: 'retry-content', text: { content: '需要重试' } }));
|
||||
await bridge.accept(frame({ msgid: 'retry-send-1', text: { content: '/send' } }));
|
||||
assert.match(transport.streamed.at(-1).content, /消息处理失败.*已保留 1 条消息/s);
|
||||
|
||||
const retry = bridge.accept(frame({ msgid: 'retry-send-2', text: { content: '/send' } }));
|
||||
await eventually(() => prompts.length === 2);
|
||||
const ordinary = bridge.accept(frame({
|
||||
msgid: 'ordinary-after-send',
|
||||
text: { content: '提交期间的普通消息' },
|
||||
}));
|
||||
assert.equal(prompts.length, 2);
|
||||
|
||||
firstBatch.resolve();
|
||||
await Promise.all([retry, ordinary]);
|
||||
assert.equal(prompts.length, 3);
|
||||
assert.equal(prompts[2], '提交期间的普通消息');
|
||||
|
||||
await bridge.accept(frame({ msgid: 'cancel-start', text: { content: '/batch' } }));
|
||||
await bridge.accept(frame({ msgid: 'cancel-content', text: { content: '丢弃' } }));
|
||||
await bridge.accept(frame({ msgid: 'cancel-command', text: { content: '/cancel' } }));
|
||||
assert.match(transport.streamed.at(-1).content, /已取消批量输入.*丢弃 1 条消息/s);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat clears a submitted batch when the Harness turn is stopped', async () => {
|
||||
const transport = testClient();
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'stopped-start', text: { content: '/batch' } }));
|
||||
await bridge.accept(frame({ msgid: 'stopped-content', text: { content: '不要重试' } }));
|
||||
await bridge.accept(frame({ msgid: 'stopped-send', text: { content: '/send' } }));
|
||||
assert.equal(transport.streamed.some(({ content }) => /已保留/.test(content)), false);
|
||||
|
||||
await bridge.accept(frame({ msgid: 'stopped-cancel', text: { content: '/cancel' } }));
|
||||
assert.match(transport.streamed.at(-1).content, /没有正在进行的批量输入/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -778,6 +778,7 @@ test('Weixin lists models and presets without prompting and advertises fast comm
|
|||
for (const command of [
|
||||
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
|
||||
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
|
||||
'/batch', '/send', '/cancel',
|
||||
]) {
|
||||
assert.equal(help.includes(command), true, command);
|
||||
}
|
||||
|
|
@ -1650,3 +1651,154 @@ test('bridge commands are local and internal failures return a generic message',
|
|||
});
|
||||
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private path|secret|token-shaped/);
|
||||
});
|
||||
|
||||
test('Weixin batch input collects up to ten native text messages and submits one ordered turn', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-batch');
|
||||
const sent = [];
|
||||
const prompts = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt) => {
|
||||
prompts.push({ sessionId, prompt });
|
||||
return '批量完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('batch-start', '/batch'));
|
||||
await bridge.accept(message('batch-voice', '', {
|
||||
item_list: [{ type: 3, voice_item: { text: '语音转写不能收录' } }],
|
||||
}));
|
||||
await bridge.accept(message('batch-image', '', {
|
||||
item_list: [{ type: 2, image_item: { media: {} } }],
|
||||
}));
|
||||
for (let index = 1; index <= 10; index += 1) {
|
||||
await bridge.accept(message(`batch-item-${index}`, `内容 ${index}`));
|
||||
}
|
||||
await bridge.accept(message('batch-overflow', '不会收录'));
|
||||
|
||||
assert.equal(prompts.length, 0);
|
||||
assert.equal(sent.some(({ text }) => /目前仅支持文字.*未收录/s.test(text)), true);
|
||||
assert.equal(sent.some(({ text }) => /10\/10.*已满/.test(text)), true);
|
||||
assert.equal(sent.some(({ text }) => /这条消息未收录/.test(text)), true);
|
||||
|
||||
await bridge.accept(message('batch-send', '/send'));
|
||||
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.equal(prompts[0].sessionId, 'session-batch');
|
||||
assert.match(prompts[0].prompt, /\[消息 1\]\n内容 1/);
|
||||
assert.match(prompts[0].prompt, /\[消息 10\]\n内容 10/);
|
||||
assert.doesNotMatch(prompts[0].prompt, /语音转写不能收录|不会收录/);
|
||||
assert.equal(sent.at(-1).text, '批量完成');
|
||||
|
||||
await bridge.accept(message('after-batch', '恢复普通聊天'));
|
||||
assert.equal(prompts.length, 2);
|
||||
assert.equal(prompts[1].prompt, '恢复普通聊天');
|
||||
});
|
||||
|
||||
test('Weixin batch cancellation is local and a failed submission remains retryable', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-batch-retry');
|
||||
const sent = [];
|
||||
let attempts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('temporary failure');
|
||||
return '重试成功';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('retry-start', '/batch'));
|
||||
await bridge.accept(message('retry-content', '需要重试'));
|
||||
await bridge.accept(message('retry-send-1', '/send'));
|
||||
assert.match(sent.at(-1), /消息处理失败.*已保留 1 条消息/s);
|
||||
|
||||
await bridge.accept(message('retry-send-2', '/send'));
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(sent.at(-1), '重试成功');
|
||||
|
||||
await bridge.accept(message('cancel-start', '/batch'));
|
||||
await bridge.accept(message('cancel-content', '丢弃我'));
|
||||
await bridge.accept(message('cancel-command', '/cancel'));
|
||||
assert.match(sent.at(-1), /已取消批量输入.*丢弃 1 条消息/s);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Weixin clears a submitted batch when the Harness turn is stopped', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-batch-stopped');
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('stopped-start', '/batch'));
|
||||
await bridge.accept(message('stopped-content', '不要重试'));
|
||||
await bridge.accept(message('stopped-send', '/send'));
|
||||
assert.doesNotMatch(sent.join('\n'), /已保留/);
|
||||
|
||||
await bridge.accept(message('stopped-cancel', '/cancel'));
|
||||
assert.match(sent.at(-1), /没有正在进行的批量输入/);
|
||||
});
|
||||
|
||||
test('Weixin refuses /batch while the existing conversation queue is running', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-batch-busy');
|
||||
const running = deferred();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async ({ text }) => sent.push(text) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
await running.promise;
|
||||
return '原任务完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const turn = bridge.accept(message('busy-turn', '正在运行'));
|
||||
await eventually(() => asks === 1);
|
||||
await bridge.accept(message('busy-batch', '/batch'));
|
||||
assert.match(sent.at(-1), /正在运行的任务.*\/stop.*\/batch/s);
|
||||
|
||||
running.resolve();
|
||||
await turn;
|
||||
await bridge.accept(message('busy-send', '/send'));
|
||||
assert.match(sent.at(-1), /没有待提交的批量内容/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { WeixinApiError } from '../../../src/channels/weixin/weixin-api.mjs';
|
||||
import { WeixinRuntime } from '../../../src/channels/weixin/weixin-runtime.mjs';
|
||||
import {
|
||||
orderWeixinMessages,
|
||||
WeixinRuntime,
|
||||
} from '../../../src/channels/weixin/weixin-runtime.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
|
|
@ -50,6 +53,90 @@ function abortable(promise, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
test('Weixin orders one update batch by sequence instead of its unrelated message id', () => {
|
||||
const first = { seq: 101, message_id: 900 };
|
||||
const second = { seq: '102', message_id: 100 };
|
||||
const third = { seq: 103, message_id: 500 };
|
||||
assert.deepEqual(orderWeixinMessages([third, first, second]), [first, second, third]);
|
||||
|
||||
const earlier = { create_time_ms: 1_000 };
|
||||
const later = { create_time_ms: 2_000 };
|
||||
assert.deepEqual(orderWeixinMessages([later, earlier]), [earlier, later]);
|
||||
|
||||
const withoutOrder = { client_id: 'local-only' };
|
||||
assert.deepEqual(orderWeixinMessages([third, withoutOrder, first]), [third, withoutOrder, first]);
|
||||
assert.deepEqual(orderWeixinMessages(null), []);
|
||||
});
|
||||
|
||||
test('runtime preserves rapid batch command order when getUpdates returns newest first', async () => {
|
||||
const sends = [];
|
||||
const seen = new Set();
|
||||
let polls = 0;
|
||||
const stopped = deferred();
|
||||
const inbound = (seq, messageId, text) => ({
|
||||
seq,
|
||||
message_id: messageId,
|
||||
message_type: 1,
|
||||
from_user_id: 'owner',
|
||||
context_token: `context-${messageId}`,
|
||||
item_list: [{ type: 1, text_item: { text } }],
|
||||
});
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => {},
|
||||
notifyStop: async () => {},
|
||||
sendText: async ({ text }) => sends.push(text),
|
||||
getUpdates: async ({ signal }) => {
|
||||
polls += 1;
|
||||
if (polls === 1) {
|
||||
return {
|
||||
ret: 0,
|
||||
get_updates_buf: 'ordered-batch',
|
||||
msgs: [
|
||||
inbound(303, 200, '/cancel'),
|
||||
inbound(302, 100, '这条内容必须被取消'),
|
||||
inbound(301, 900, '/batch'),
|
||||
],
|
||||
};
|
||||
}
|
||||
return abortable(stopped.promise, signal);
|
||||
},
|
||||
},
|
||||
config: {
|
||||
botId: 'wx_ordered_batch',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
ownerUserId: 'owner',
|
||||
},
|
||||
token: 'bot-token',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
ask: async () => assert.fail('cancelled batch content must not reach Harness'),
|
||||
},
|
||||
state: {
|
||||
getUpdatesBuf: () => '',
|
||||
setGetUpdatesBuf: async () => {},
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => null,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
try {
|
||||
await eventually(
|
||||
() => sends.some((text) => /丢弃 1 条消息/.test(text)),
|
||||
'the reversed provider batch should be processed in sequence order',
|
||||
);
|
||||
assert.equal(sends.some((text) => /仅支持文字|没有正在进行/.test(text)), false);
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
stopped.resolve({ ret: 0, msgs: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime sends a connection test to the bound Weixin owner without reply context', async () => {
|
||||
const sends = [];
|
||||
const runtime = new WeixinRuntime({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue