feat(qqbot): 优化提醒技能与智能断句

**提醒技能优化**
- 优化 SKILL.md,新增 --system-prompt 参数说明
- 修复 AI 角色混淆问题(避免说"谢谢提醒")
- 完善提醒触发时的 AI 角色指引

**流式消息智能断句**
- 新增语义边界检测(句号/感叹号/问号等)
- 首个分片在自然断句位置发送,避免奇怪的换行
- 支持 emoji 结尾识别

**其他优化**
- 移除 metadata 中的 channels 限制
- 精简 console.md 内容
- 优化 onboarding 引导流程
This commit is contained in:
rianli
2026-02-02 00:51:21 +08:00
parent a3e87f2f37
commit 62d52c8361
8 changed files with 720 additions and 1103 deletions

1442
console.md

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"name": "QQ Bot Channel",
"description": "QQ Bot channel plugin with streaming message support, cron jobs, and proactive messaging",
"channels": ["qqbot"],
"skills": ["qqbot-cron"],
"skills": ["skills/qqbot-cron"],
"capabilities": {
"proactiveMessaging": true,
"cronJobs": true,

View File

@@ -1,7 +1,7 @@
---
name: qqbot-cron
description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。
metadata: {"clawdbot":{"emoji":"⏰","requires":{"channels":["qqbot"]}}}
metadata: {"clawdbot":{"emoji":"⏰"}}
---
# QQ Bot 智能提醒
@@ -56,7 +56,21 @@ openclaw cron add \
--at "{时间}" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","user_name":"{用户名称}","original_message_id":"{message_id}","reminder_content":"{提醒内容}","created_at":"{当前时间ISO格式}"}' \
--message "{消息内容}" \
--system-prompt '
**极端重要角色设定**
你是一个定时提醒机器人,现在是用户之前设置的提醒时间。
**你的唯一任务**:直接输出提醒消息,提醒用户:{提醒内容}
**输出格式**:类似 "🔔 时间到了!该{提醒内容}了~" 的提醒语
**绝对禁止**
- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语
- 你是提醒者,不是被提醒者
- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的
- 不要对 [系统触发] 消息做任何回复,只需输出提醒内容
' \
--message "[系统触发] 定时任务已触发,请执行提醒。" \
--deliver \
--channel qqbot \
--to "{openid}" \
@@ -65,6 +79,8 @@ openclaw cron add \
> 💡 **`--system-event` 说明**:用于存储用户上下文信息,提醒触发时 AI 可以获取这些信息来提供更个性化的提醒。
> ⚠️ **重要**`--message` 参数会被 AI 视为“用户消息”,所以必须在 `--system-prompt` 中**极其强调**角色定位。
> ⚠️ **注意**`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。
### 查询提醒列表
@@ -200,7 +216,7 @@ openclaw message send \
| `--name` | 任务名,含用户标识 | `"喝水提醒-小明"` |
| `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` |
| `--session isolated` | 隔离会话 | 固定值 |
| `--message` | 消息内容,**不能为空** | `"💧 该喝水啦!"` |
| `--message` | **系统触发指令**AI 会视为用户消息) | `"[系统触发] 定时任务已触发,请执行提醒。"` |
| `--deliver` | 启用投递 | 固定值 |
| `--channel qqbot` | QQ 渠道 | 固定值 |
| `--to` | 接收者 openid | 从系统消息获取 |
@@ -210,9 +226,43 @@ openclaw message send \
| 参数 | 说明 | 何时使用 |
|------|------|----------|
| `--system-event` | 用户上下文 JSON | **建议所有任务都使用** |
| `--system-prompt` | AI 角色指引 | **建议所有任务都使用** |
| `--delete-after-run` | 执行后删除 | **一次性任务必须** |
| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** |
### --system-prompt 参数说明
`--system-prompt` 用于在提醒触发时给 AI 一个明确的角色指引,避免 AI 混淆角色。
**为什么需要?**
- 提醒触发时是一个隔离会话(`--session isolated`
- AI 没有原始对话的上下文
- 如果不明确角色AI 可能误以为自己是"被提醒者"而说"谢谢提醒"
**推荐格式****极其重要,必须完整复制**
```
'
**极端重要角色设定**
你是一个定时提醒机器人,现在是用户之前设置的提醒时间。
**你的唯一任务**:直接输出提醒消息,提醒用户:{提醒内容}
**输出格式**:类似 "🔔 时间到了!该{提醒内容}了~" 的提醒语
**绝对禁止**
- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语
- 你是提醒者,不是被提醒者
- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的
- 不要对 [系统触发] 消息做任何回应,只需输出提醒内容
'
```
**关键点**
- `--message` 参数会被 AI 视为“用户消息”,无法改变
- 所以必须在 `--system-prompt` 中**极其强调**角色定位
- 明确告诉 AI`[系统触发]` 消息不是用户发的,不要回复它
- 使用禁止列表明确告知不能说的话
### --system-event 字段说明
`--system-event` 用于存储提醒的上下文信息,格式为 JSON
@@ -257,7 +307,20 @@ openclaw cron add \
--at "5m" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"喝水","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "💧 该喝水啦这是你5分钟前设置的提醒~" \
--system-prompt '
**极端重要角色设定**
你是一个定时提醒机器人现在是用户之前设置的提醒时间。5分钟前用户设置了喝水提醒。
**你的唯一任务**:直接输出提醒消息,提醒用户喝水
**输出格式**:类似 "💧 时间到!该喝水啦~" 的提醒语
**绝对禁止**
- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语
- 你是提醒者,不是被提醒者
- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的
' \
--message "[系统触发] 定时任务已触发,请执行提醒。" \
--deliver \
--channel qqbot \
--to "{openid}" \
@@ -288,7 +351,20 @@ openclaw cron add \
--tz "Asia/Shanghai" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"打卡","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "🌅 早上好!记得打卡签到~" \
--system-prompt '
**极端重要角色设定**
你是一个定时提醒机器人现在是用户设置的每日早上8点打卡提醒时间。
**你的唯一任务**:直接输出提醒消息,提醒用户打卡
**输出格式**:类似 "🌅 早上好!该打卡了~" 的提醒语
**绝对禁止**
- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语
- 你是提醒者,不是被提醒者
- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的
' \
--message "[系统触发] 定时任务已触发,请执行提醒。" \
--deliver \
--channel qqbot \
--to "{openid}"
@@ -320,7 +396,20 @@ openclaw cron add \
--tz "Asia/Shanghai" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"写日报","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "📝 今天的日报别忘了提交!" \
--system-prompt '
**极端重要角色设定**
你是一个定时提醒机器人现在是用户设置的工作日下午6点写日报提醒时间。
**你的唯一任务**:直接输出提醒消息,提醒用户写日报
**输出格式**:类似 "📝 下班啦!别忘了提交今日日报~" 的提醒语
**绝对禁止**
- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语
- 你是提醒者,不是被提醒者
- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的
' \
--message "[系统触发] 定时任务已触发,请执行提醒。" \
--deliver \
--channel qqbot \
--to "{openid}"
@@ -340,7 +429,20 @@ openclaw cron add \
--tz "Asia/Shanghai" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"group:{group_openid}","original_message_id":"{message_id}","reminder_content":"站会","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "📢 各位同事9点站会时间到请准时参加~" \
--system-prompt '
**极端重要角色设定**
你是一个定时提醒机器人现在是工作日早上9点的站会提醒时间。
**你的唯一任务**:直接输出提醒消息,提醒群成员参加站会
**输出格式**:类似 "📢 站会时间到!请各位同事准时参加~" 的提醒语
**绝对禁止**
- 绝对不要说"谢谢提醒"、"我会注意"、"好的收到"等回应语
- 你是提醒者,不是被提醒者
- 下面的 [系统触发] 消息是系统自动发送的,不是用户发的
' \
--message "[系统触发] 定时任务已触发,请执行提醒。" \
--deliver \
--channel qqbot \
--to "group:{group_openid}"
@@ -443,9 +545,24 @@ openclaw cron list
|------|------|
| **message_id 有效期** | 1 小时内有效,超时自动降级 |
| **回复次数限制** | 同一 message_id 最多回复 4 次 |
| **主动消息限** | 只能发给与机器人交互过的用户 |
| **主动消息** | ⚠️ **QQ 机器人需要申请主动消息权限**,否则定时提醒会发送失败 |
| **主动消息限制** | 只能发给与机器人交互过的用户24小时内 |
| **消息内容** | `--message` 不能为空 |
### ⚠️ 主动消息权限说明
定时提醒功能依赖**主动消息能力**,但 QQ 官方默认**不授予**此权限。
**常见错误**
- 错误码 `40034102`"主动消息失败, 无权限"
- 这表示机器人没有主动消息权限
**解决方案**
1. 登录 [QQ 开放平台](https://q.qq.com/)
2. 进入机器人开发-沙箱管理,消息列表配置中添加自己。
> 💡 **临时替代方案**:在没有主动消息权限前,可以让用户使用"回复"方式获得即时提醒,而非定时提醒。
---
## 📝 消息模板

View File

@@ -269,14 +269,21 @@ function buildStreamBody(
msgSeq: number,
stream?: StreamConfig
): Record<string, unknown> {
// 流式 markdown 消息要求每个分片内容必须以换行符结尾
// QQ API 错误码 40034017: "流式消息md分片需要\n结束"
let finalContent = content;
if (stream && currentMarkdownSupport && content && !content.endsWith("\n")) {
finalContent = content + "\n";
}
const body: Record<string, unknown> = currentMarkdownSupport
? {
markdown: { content },
markdown: { content: finalContent },
msg_type: 2,
msg_seq: msgSeq,
}
: {
content,
content: finalContent,
msg_type: 0,
msg_seq: msgSeq,
};

View File

@@ -168,7 +168,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
const account = resolveQQBotAccount(cfg, accountId);
const result = await sendMedia({ to, text, mediaUrl, accountId, replyToId, account });
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
return {
channel: "qqbot",
messageId: result.messageId,
@@ -221,13 +221,13 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
}
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
const entry = accounts[accountId] as Record<string, unknown> | undefined;
if (entry && "clientSecret" in entry) {
delete entry.clientSecret;
cleared = true;
changed = true;
}
if (Object.keys(entry).length === 0) {
if (entry && Object.keys(entry).length === 0) {
delete accounts[accountId];
changed = true;
}
@@ -236,14 +236,16 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
if (changed && nextQQBot) {
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
await getQQBotRuntime().config.writeConfigFile(nextCfg);
const runtime = getQQBotRuntime();
const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
await configApi.writeConfigFile(nextCfg);
}
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
const loggedOut = resolved.secretSource === "none";
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
return { cleared, envToken, loggedOut };
return { ok: true, cleared, envToken, loggedOut };
},
},
status: {
@@ -257,7 +259,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
lastOutboundAt: null,
},
// 新增:构建通道摘要
buildChannelSummary: ({ snapshot }) => ({
buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
@@ -265,7 +267,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
lastConnectedAt: snapshot.lastConnectedAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
name: account?.name,
enabled: account?.enabled ?? false,

View File

@@ -133,7 +133,7 @@ export function applyQQBotAccountConfig(
next.channels = {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
@@ -149,12 +149,12 @@ export function applyQQBotAccountConfig(
next.channels = {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret

View File

@@ -62,6 +62,56 @@ const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫
const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量
const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束
// ============ 智能断句配置 ============
// 首个分片:必须在语义边界处断句,避免奇怪的换行
const FIRST_CHUNK_MIN_LENGTH_SOFT = 20; // 软下限:达到此长度后,遇到语义边界就可以发送
const FIRST_CHUNK_MIN_LENGTH_HARD = 80; // 硬下限:超过此长度必须发送,避免等待太久
const FIRST_CHUNK_MAX_WAIT_TIME = 3000; // 首个分片最长等待时间(毫秒)
// 语义边界检测:判断文本是否在自然断句位置结束
function isAtSemanticBoundary(text: string): boolean {
if (!text) return false;
const trimmed = text.trimEnd();
if (!trimmed) return false;
// 检查最后一个字符是否是断句标点
const lastChar = trimmed[trimmed.length - 1];
const sentenceEnders = ['。', '', '', '~', '…', '.', '!', '?', '\n'];
if (sentenceEnders.includes(lastChar)) return true;
// 检查是否以 emoji 结尾(常见于提醒消息)
const emojiRegex = /[\u{1F300}-\u{1F9FF}]$/u;
if (emojiRegex.test(trimmed)) return true;
// 检查最后几个字符是否是 markdown 列表项结束(如 "- xxx" 后面)
// 不算边界,因为列表通常有多项
return false;
}
// 查找最近的语义边界位置
function findLastSemanticBoundary(text: string, minPos: number = 0): number {
if (!text || text.length <= minPos) return -1;
const sentenceEnders = ['。', '', '', '~', '.', '!', '?'];
let lastBoundary = -1;
for (let i = text.length - 1; i >= minPos; i--) {
const char = text[i];
if (sentenceEnders.includes(char)) {
lastBoundary = i + 1; // 包含这个标点符号
break;
}
// 换行符也是边界
if (char === '\n') {
lastBoundary = i + 1;
break;
}
}
return lastBoundary;
}
// 消息队列配置(异步处理,防止阻塞心跳)
const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度
const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值
@@ -394,8 +444,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
}
// 流式消息开关(默认用,仅 c2c 支持)
const streamEnabled = account.streamEnabled !== false;
// 流式消息开关(默认用,仅 c2c 支持,需要在配置中明确启用
const streamEnabled = account.streamEnabled === true;
log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`);
pluginRuntime.channel.activity.record({
@@ -622,7 +672,7 @@ openclaw cron add \\
log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`);
// 创建流式发送器
const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
let streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落)
@@ -633,6 +683,7 @@ openclaw cron add \\
let streamStartTime = 0; // 流式消息开始时间(用于超时检查)
let sendingLock = false; // 发送锁,防止并发发送
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
let firstChunkWaitStart = 0; // 首个分片开始等待的时间(用于超时判断)
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量
let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持)
@@ -722,15 +773,13 @@ openclaw cron add \\
};
// 流式发送函数 - 用于 onPartialReply 实时发送(增量模式)
// markdown 分片需要以 \n 结尾
// 注意:不要在分片后强制添加换行符,否则会导致消息在奇怪的位置断句
const sendStreamChunk = async (text: string, isEnd: boolean): Promise<boolean> => {
if (!streamSender || streamEnded) return false;
// markdown 分片需要以 \n 结尾(除非是空内容或结束标记)
let contentToSend = text;
if (isEnd && contentToSend && !contentToSend.endsWith("\n") && !isEnd) {
contentToSend = contentToSend + "\n";
}
// 直接发送文本内容,不添加任何额外换行符
// 换行应该由 AI 生成的内容本身决定,而非强制添加
const contentToSend = text;
const result = await streamSender.send(contentToSend, isEnd);
if (result.error) {
@@ -819,9 +868,26 @@ openclaw cron add \\
(fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length))));
if (isNewSegment) {
// 新段落开始,将之前的内容追加到 streamBuffer并重置发送位置
// 新段落开始,结束当前流并创建新流
log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`);
// 保存旧的 sender 用于结束流
const oldStreamSender = streamSender;
const oldStreamStarted = streamStarted;
const oldStreamEnded = streamEnded;
// 1. 先创建新的流式发送器并重置所有状态
// 这样在 await 期间到达的新消息会使用新 sender
streamSender = createStreamSender(account, targetTo, event.messageId);
lastSentLength = 0;
lastSentText = "";
streamStarted = false;
streamEnded = false;
streamStartTime = 0;
keepaliveCountSinceLastChunk = 0;
lastChunkSendTime = 0;
firstChunkWaitStart = 0; // 重置首个分片等待时间
// 记录当前段落在 streamBuffer 中的起始位置
currentSegmentStart = streamBuffer.length;
@@ -831,9 +897,19 @@ openclaw cron add \\
currentSegmentStart = streamBuffer.length;
}
// 重置发送位置,从新段落开始发送
lastSentLength = 0;
lastSentText = "";
// 2. 结束旧流(如果已开始)- 使用旧的 sender
if (oldStreamSender && oldStreamStarted && !oldStreamEnded) {
log?.info(`[qqbot:${account.accountId}] Ending current stream before starting new segment`);
clearKeepalive();
sendingLock = true;
try {
await oldStreamSender.send("", true); // 发送结束标记
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Failed to end stream: ${err}`);
} finally {
sendingLock = false;
}
}
}
// 更新当前段落内容到 streamBuffer
@@ -847,8 +923,46 @@ openclaw cron add \\
if (fullText.length <= lastSentLength) return;
const now = Date.now();
// 初始化首个分片等待开始时间(如果还没有开始)
if (!streamStarted && !firstChunkWaitStart) {
firstChunkWaitStart = now;
}
// 控制发送频率:首次发送或间隔超过阈值
if (!streamStarted || now - lastStreamSendTime >= STREAM_CHUNK_INTERVAL) {
if (!streamStarted) {
// 首个分片:智能断句,在语义边界处发送
const waitTime = firstChunkWaitStart ? now - firstChunkWaitStart : 0;
const atBoundary = isAtSemanticBoundary(fullText);
const reachedSoftLimit = fullText.length >= FIRST_CHUNK_MIN_LENGTH_SOFT;
const reachedHardLimit = fullText.length >= FIRST_CHUNK_MIN_LENGTH_HARD;
const timedOut = waitTime >= FIRST_CHUNK_MAX_WAIT_TIME;
// 发送条件(优先级从高到低):
// 1. 达到硬下限:必须发送,避免等待太久
// 2. 等待超时:必须发送,避免无响应
// 3. 达到软下限 + 在语义边界:可以发送
if (reachedHardLimit || timedOut) {
// 硬性条件:必须发送
if (timedOut && !reachedSoftLimit) {
log?.info(`[qqbot:${account.accountId}] handlePartialReply: first chunk timeout, sending anyway, length=${fullText.length}, wait=${waitTime}ms`);
} else {
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending first chunk (hard limit), length=${fullText.length}`);
}
await doStreamSend(fullText, false);
firstChunkWaitStart = 0; // 重置等待时间
} else if (reachedSoftLimit && atBoundary) {
// 软性条件:在语义边界处发送
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending first chunk (at boundary), length=${fullText.length}`);
await doStreamSend(fullText, false);
firstChunkWaitStart = 0;
} else {
// 还需要等待更多内容
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: waiting for semantic boundary, length=${fullText.length}, atBoundary=${atBoundary}, wait=${waitTime}ms`);
pendingFullText = fullText;
}
} else if (now - lastStreamSendTime >= STREAM_CHUNK_INTERVAL) {
// 后续分片:基于时间间隔发送
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending stream chunk, length=${fullText.length}`);
await doStreamSend(fullText, false);
} else {
@@ -1012,11 +1126,14 @@ openclaw cron add \\
}
}
// 只有频道和群聊消息(不支持流式)在 deliver 中发送文本
// c2c 的文本通过 onPartialReply 流式发送
// 非流式模式下,在 deliver 中发送文本
// 流式模式下,c2c 的文本通过 onPartialReply 流式发送
if (!supportsStream && textWithoutImages.trim()) {
await sendWithTokenRetry(async (token) => {
if (event.type === "group" && event.groupOpenid) {
if (event.type === "c2c") {
// c2c 非流式消息发送
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
} else if (event.type === "group" && event.groupOpenid) {
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
} else if (event.channelId) {
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);

View File

@@ -31,6 +31,14 @@ interface QQBotChannelConfig {
}>;
}
// Prompter 类型定义
interface Prompter {
note: (message: string, title?: string) => Promise<void>;
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
text: (opts: { message: string; placeholder?: string; initialValue?: string; validate?: (value: string) => string | undefined }) => Promise<string>;
select: <T>(opts: { message: string; options: Array<{ value: T; label: string }>; initialValue?: T }) => Promise<T>;
}
/**
* 解析默认账户 ID
*/
@@ -46,7 +54,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channel: "qqbot" as any,
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
const { cfg } = ctx;
const cfg = ctx.cfg as OpenClawConfig;
const configured = listQQBotAccountIds(cfg).some((accountId) => {
const account = resolveQQBotAccount(cfg, accountId);
return Boolean(account.appId && account.clientSecret);
@@ -62,9 +70,12 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
},
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
const cfg = ctx.cfg as OpenClawConfig;
const prompter = ctx.prompter as Prompter;
const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
const qqbotOverride = accountOverrides?.qqbot?.trim();
const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
let accountId = qqbotOverride ?? defaultAccountId;
@@ -83,7 +94,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
let next = cfg;
let next: OpenClawConfig = cfg;
const resolvedAccount = resolveQQBotAccount(next, accountId);
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
@@ -124,7 +135,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
},
},
@@ -136,14 +147,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
@@ -159,14 +170,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
@@ -177,14 +188,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
@@ -197,7 +208,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
appId,
clientSecret,
@@ -210,12 +221,12 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
enabled: true,
appId,
clientSecret,
@@ -227,14 +238,17 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
return { cfg: next as any, accountId };
return { success: true, cfg: next as any, accountId };
},
disable: (cfg) => ({
...cfg,
channels: {
...(cfg as OpenClawConfig).channels,
qqbot: { ...(cfg as OpenClawConfig).channels?.qqbot, enabled: false },
},
}) as any,
disable: (cfg: unknown) => {
const config = cfg as OpenClawConfig;
return {
...config,
channels: {
...config.channels,
qqbot: { ...(config.channels?.qqbot as Record<string, unknown> || {}), enabled: false },
},
} as any;
},
};