@ryantest/openclaw-qqbot 1.6.6-alpha.4 → 1.6.7-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -15
- package/README.zh.md +24 -15
- package/dist/src/api.d.ts +32 -5
- package/dist/src/api.js +111 -12
- package/dist/src/channel.d.ts +18 -0
- package/dist/src/channel.js +85 -2
- package/dist/src/config.d.ts +33 -2
- package/dist/src/config.js +125 -1
- package/dist/src/gateway.js +566 -24
- package/dist/src/group-history.d.ts +136 -0
- package/dist/src/group-history.js +226 -0
- package/dist/src/message-gating.d.ts +53 -0
- package/dist/src/message-gating.js +107 -0
- package/dist/src/message-queue.d.ts +36 -0
- package/dist/src/message-queue.js +164 -22
- package/dist/src/outbound.d.ts +4 -4
- package/dist/src/outbound.js +18 -6
- package/dist/src/ref-index-store.js +5 -28
- package/dist/src/request-context.d.ts +7 -0
- package/dist/src/request-context.js +7 -0
- package/dist/src/slash-commands.d.ts +6 -0
- package/dist/src/slash-commands.js +3 -3
- package/dist/src/tools/remind.js +17 -9
- package/dist/src/types.d.ts +90 -2
- package/dist/src/utils/audio-convert.d.ts +1 -1
- package/dist/src/utils/audio-convert.js +1 -1
- package/dist/src/utils/chunked-upload.d.ts +11 -2
- package/dist/src/utils/chunked-upload.js +63 -11
- package/dist/src/utils/media-send.js +1 -1
- package/dist/src/utils/text-parsing.js +7 -18
- package/package.json +1 -1
- package/scripts/postinstall-link-sdk.js +22 -9
- package/scripts/upgrade-via-npm.sh +11 -3
- package/scripts/upgrade-via-source.sh +63 -15
- package/skills/qqbot-remind/SKILL.md +21 -11
- package/src/api.ts +135 -7
- package/src/channel.ts +85 -2
- package/src/config.ts +170 -3
- package/src/gateway.ts +662 -29
- package/src/group-history.ts +328 -0
- package/src/message-gating.ts +190 -0
- package/src/message-queue.ts +201 -21
- package/src/openclaw-plugin-sdk.d.ts +65 -0
- package/src/outbound.ts +18 -6
- package/src/ref-index-store.ts +5 -27
- package/src/request-context.ts +10 -0
- package/src/slash-commands.ts +3 -3
- package/src/tools/remind.ts +17 -9
- package/src/types.ts +94 -2
- package/src/utils/audio-convert.ts +1 -1
- package/src/utils/chunked-upload.ts +76 -12
- package/src/utils/media-send.ts +1 -2
- package/src/utils/text-parsing.ts +7 -14
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群历史消息缓存
|
|
3
|
+
*
|
|
4
|
+
* 非@消息写入内存 Map,被@时一次性注入上下文后清空。
|
|
5
|
+
* 自包含实现,不依赖 openclaw/plugin-sdk。
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* 附件摘要(统一格式)
|
|
9
|
+
*
|
|
10
|
+
* 兼容 ref-index-store 的 RefAttachmentSummary、群历史缓存、以及 gateway 当前消息动态上下文。
|
|
11
|
+
* 所有场景的附件描述都通过 formatAttachmentTags() 统一格式化,确保标签风格一致。
|
|
12
|
+
*/
|
|
13
|
+
export interface AttachmentSummary {
|
|
14
|
+
/** 附件类型 */
|
|
15
|
+
type: "image" | "voice" | "video" | "file" | "unknown";
|
|
16
|
+
/** 文件名(如有) */
|
|
17
|
+
filename?: string;
|
|
18
|
+
/** 语音转录文本(入站:STT/ASR识别结果;出站:TTS原文本) */
|
|
19
|
+
transcript?: string;
|
|
20
|
+
/** 语音转录来源:stt=本地STT、asr=平台ASR、tts=TTS原文本、fallback=兜底文案 */
|
|
21
|
+
transcriptSource?: "stt" | "asr" | "tts" | "fallback";
|
|
22
|
+
/** 已下载到本地的文件路径 */
|
|
23
|
+
localPath?: string;
|
|
24
|
+
/** 在线来源 URL(公网图片/文件等) */
|
|
25
|
+
url?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* QQ 事件原始附件(来自 gateway 事件的通用字段子集)
|
|
29
|
+
*
|
|
30
|
+
* 多处需要将原始附件转换为 AttachmentSummary,统一此类型避免内联重复定义。
|
|
31
|
+
*/
|
|
32
|
+
export interface RawAttachment {
|
|
33
|
+
content_type: string;
|
|
34
|
+
filename?: string;
|
|
35
|
+
/** 语音 ASR 识别文本(QQ 事件内置) */
|
|
36
|
+
asr_refer_text?: string;
|
|
37
|
+
/** 附件 URL */
|
|
38
|
+
url?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 根据 content_type 推断附件类型(统一判断逻辑,避免多处重复)
|
|
42
|
+
*/
|
|
43
|
+
export declare function inferAttachmentType(contentType?: string): AttachmentSummary["type"];
|
|
44
|
+
/**
|
|
45
|
+
* 将原始附件数组转换为 AttachmentSummary 数组。
|
|
46
|
+
*
|
|
47
|
+
* 统一"原始附件 → 摘要"的映射逻辑,供历史记录缓存、合并消息格式化等场景复用。
|
|
48
|
+
* 无附件时返回 undefined(而非空数组),与 HistoryEntry.attachments 的可选语义一致。
|
|
49
|
+
*/
|
|
50
|
+
export declare function toAttachmentSummaries(attachments?: RawAttachment[]): AttachmentSummary[] | undefined;
|
|
51
|
+
/** @deprecated 使用 AttachmentSummary 代替 */
|
|
52
|
+
export type HistoryAttachment = AttachmentSummary;
|
|
53
|
+
export interface HistoryEntry {
|
|
54
|
+
sender: string;
|
|
55
|
+
body: string;
|
|
56
|
+
timestamp?: number;
|
|
57
|
+
messageId?: string;
|
|
58
|
+
/** 富媒体附件摘要(图片/语音/视频/文件) */
|
|
59
|
+
attachments?: AttachmentSummary[];
|
|
60
|
+
}
|
|
61
|
+
/** formatMessageContent 入参 */
|
|
62
|
+
export interface FormatMessageContentParams {
|
|
63
|
+
content: string;
|
|
64
|
+
/** 消息类型(group 时才做 mention 清理) */
|
|
65
|
+
chatType?: string;
|
|
66
|
+
mentions?: unknown[];
|
|
67
|
+
attachments?: RawAttachment[];
|
|
68
|
+
/** QQ 表情标签解析(<faceType=...> → 【表情: 中文名】) */
|
|
69
|
+
parseFaceTags: (text: string) => string;
|
|
70
|
+
/** mention @ 清理(移除 <@member_openid> 标记) */
|
|
71
|
+
stripMentionText?: (text: string, mentions: unknown[]) => string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 格式化单条消息内容:表情标签解析 → mention 清理 → 附件标签拼接。
|
|
75
|
+
*
|
|
76
|
+
* 用于合并消息的逐条子消息格式化,将外部依赖(parseFaceTags / stripMentionText)
|
|
77
|
+
* 通过参数注入,保持本模块自包含。
|
|
78
|
+
*/
|
|
79
|
+
export declare function formatMessageContent(params: FormatMessageContentParams): string;
|
|
80
|
+
/**
|
|
81
|
+
* 将附件摘要格式化为统一的人类可读标签描述(供 AI 上下文注入)。
|
|
82
|
+
*
|
|
83
|
+
* 标签风格沿用框架的 MEDIA: 标签格式:
|
|
84
|
+
* 有路径的附件 → MEDIA:path
|
|
85
|
+
* 语音+转录 → MEDIA:path (内容: "transcript")
|
|
86
|
+
* 无路径的语音 → [语音消息(内容: "transcript")]
|
|
87
|
+
* 无路径无转录 → [图片] / [语音消息] / [视频] / [文件]
|
|
88
|
+
*
|
|
89
|
+
* 此函数是所有附件描述的 **唯一格式化入口**,确保引用消息、群历史缓存、
|
|
90
|
+
* 当前消息动态上下文三处标签风格完全一致。
|
|
91
|
+
*/
|
|
92
|
+
export declare function formatAttachmentTags(attachments?: AttachmentSummary[]): string;
|
|
93
|
+
/** @deprecated 使用 formatAttachmentTags 代替 */
|
|
94
|
+
export declare const formatHistoryAttachments: typeof formatAttachmentTags;
|
|
95
|
+
/**
|
|
96
|
+
* 记录一条待注入的历史消息(非@消息调用此函数)。
|
|
97
|
+
* limit <= 0 或 entry 为空时不记录。
|
|
98
|
+
*/
|
|
99
|
+
export declare function recordPendingHistoryEntry(params: {
|
|
100
|
+
historyMap: Map<string, HistoryEntry[]>;
|
|
101
|
+
historyKey: string;
|
|
102
|
+
entry?: HistoryEntry | null;
|
|
103
|
+
limit: number;
|
|
104
|
+
}): HistoryEntry[];
|
|
105
|
+
/**
|
|
106
|
+
* 构建包含历史上下文的完整消息体(被@时调用)。
|
|
107
|
+
* 如果没有累积的历史消息,直接返回 currentMessage 原文。
|
|
108
|
+
*/
|
|
109
|
+
export declare function buildPendingHistoryContext(params: {
|
|
110
|
+
historyMap: Map<string, HistoryEntry[]>;
|
|
111
|
+
historyKey: string;
|
|
112
|
+
limit: number;
|
|
113
|
+
currentMessage: string;
|
|
114
|
+
formatEntry: (entry: HistoryEntry) => string;
|
|
115
|
+
lineBreak?: string;
|
|
116
|
+
}): string;
|
|
117
|
+
/**
|
|
118
|
+
* 构建合并消息上下文(多条排队消息被合并时调用)。
|
|
119
|
+
* 前置消息用 [合并消息开始]...[合并消息结束] 段落标签包裹,
|
|
120
|
+
* 最后一条作为当前消息紧跟其后。
|
|
121
|
+
* 如果只有一条消息,直接返回 currentMessage 原文。
|
|
122
|
+
*/
|
|
123
|
+
export declare function buildMergedMessageContext(params: {
|
|
124
|
+
precedingParts: string[];
|
|
125
|
+
currentMessage: string;
|
|
126
|
+
lineBreak?: string;
|
|
127
|
+
}): string;
|
|
128
|
+
/**
|
|
129
|
+
* 清空指定群的历史缓存(回复完成后调用)。
|
|
130
|
+
* limit <= 0 表示功能已禁用,不做操作。
|
|
131
|
+
*/
|
|
132
|
+
export declare function clearPendingHistory(params: {
|
|
133
|
+
historyMap: Map<string, HistoryEntry[]>;
|
|
134
|
+
historyKey: string;
|
|
135
|
+
limit: number;
|
|
136
|
+
}): void;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群历史消息缓存
|
|
3
|
+
*
|
|
4
|
+
* 非@消息写入内存 Map,被@时一次性注入上下文后清空。
|
|
5
|
+
* 自包含实现,不依赖 openclaw/plugin-sdk。
|
|
6
|
+
*/
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// 常量
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/** 历史上下文段落标签 */
|
|
11
|
+
const HISTORY_CTX_START = "[上次回复后的聊天消息 - 作为上下文]";
|
|
12
|
+
const HISTORY_CTX_END = "[当前消息 - 请回复此条]";
|
|
13
|
+
/** 合并消息段落标签 */
|
|
14
|
+
const MERGED_CTX_START = "[以下是合并消息 - 作为上下文]";
|
|
15
|
+
const MERGED_CTX_END = "[当前消息 - 结合上下文回复]";
|
|
16
|
+
/** 历史 Map 最大 key 数量(LRU 淘汰,防止无限增长) */
|
|
17
|
+
const MAX_HISTORY_KEYS = 1000;
|
|
18
|
+
/**
|
|
19
|
+
* 根据 content_type 推断附件类型(统一判断逻辑,避免多处重复)
|
|
20
|
+
*/
|
|
21
|
+
export function inferAttachmentType(contentType) {
|
|
22
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
23
|
+
if (ct.startsWith("image/"))
|
|
24
|
+
return "image";
|
|
25
|
+
if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr"))
|
|
26
|
+
return "voice";
|
|
27
|
+
if (ct.startsWith("video/"))
|
|
28
|
+
return "video";
|
|
29
|
+
if (ct.startsWith("application/") || ct.startsWith("text/"))
|
|
30
|
+
return "file";
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 将原始附件数组转换为 AttachmentSummary 数组。
|
|
35
|
+
*
|
|
36
|
+
* 统一"原始附件 → 摘要"的映射逻辑,供历史记录缓存、合并消息格式化等场景复用。
|
|
37
|
+
* 无附件时返回 undefined(而非空数组),与 HistoryEntry.attachments 的可选语义一致。
|
|
38
|
+
*/
|
|
39
|
+
export function toAttachmentSummaries(attachments) {
|
|
40
|
+
if (!attachments?.length)
|
|
41
|
+
return undefined;
|
|
42
|
+
return attachments.map((att) => ({
|
|
43
|
+
type: inferAttachmentType(att.content_type),
|
|
44
|
+
filename: att.filename,
|
|
45
|
+
transcript: att.asr_refer_text || undefined,
|
|
46
|
+
url: att.url || undefined,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 格式化单条消息内容:表情标签解析 → mention 清理 → 附件标签拼接。
|
|
51
|
+
*
|
|
52
|
+
* 用于合并消息的逐条子消息格式化,将外部依赖(parseFaceTags / stripMentionText)
|
|
53
|
+
* 通过参数注入,保持本模块自包含。
|
|
54
|
+
*/
|
|
55
|
+
export function formatMessageContent(params) {
|
|
56
|
+
let msgContent = params.parseFaceTags(params.content);
|
|
57
|
+
if (params.chatType === "group" && params.mentions?.length && params.stripMentionText) {
|
|
58
|
+
msgContent = params.stripMentionText(msgContent, params.mentions);
|
|
59
|
+
}
|
|
60
|
+
if (params.attachments?.length) {
|
|
61
|
+
const attachmentDesc = formatAttachmentTags(toAttachmentSummaries(params.attachments));
|
|
62
|
+
if (attachmentDesc) {
|
|
63
|
+
msgContent = `${msgContent} ${attachmentDesc}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return msgContent;
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// 附件标签格式化(全局统一)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
/**
|
|
72
|
+
* 将附件摘要格式化为统一的人类可读标签描述(供 AI 上下文注入)。
|
|
73
|
+
*
|
|
74
|
+
* 标签风格沿用框架的 MEDIA: 标签格式:
|
|
75
|
+
* 有路径的附件 → MEDIA:path
|
|
76
|
+
* 语音+转录 → MEDIA:path (内容: "transcript")
|
|
77
|
+
* 无路径的语音 → [语音消息(内容: "transcript")]
|
|
78
|
+
* 无路径无转录 → [图片] / [语音消息] / [视频] / [文件]
|
|
79
|
+
*
|
|
80
|
+
* 此函数是所有附件描述的 **唯一格式化入口**,确保引用消息、群历史缓存、
|
|
81
|
+
* 当前消息动态上下文三处标签风格完全一致。
|
|
82
|
+
*/
|
|
83
|
+
export function formatAttachmentTags(attachments) {
|
|
84
|
+
if (!attachments?.length)
|
|
85
|
+
return "";
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const att of attachments) {
|
|
88
|
+
const source = att.localPath || att.url;
|
|
89
|
+
if (source) {
|
|
90
|
+
// 有路径:使用 MEDIA: 标签
|
|
91
|
+
if (att.type === "voice" && att.transcript) {
|
|
92
|
+
parts.push(`MEDIA:${source}(内容: "${att.transcript}")`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
parts.push(`MEDIA:${source}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// 无路径:使用描述性标签
|
|
100
|
+
switch (att.type) {
|
|
101
|
+
case "image":
|
|
102
|
+
parts.push(`[图片${att.filename ? `: ${att.filename}` : ""}]`);
|
|
103
|
+
break;
|
|
104
|
+
case "voice":
|
|
105
|
+
parts.push(att.transcript
|
|
106
|
+
? `[语音消息(内容: "${att.transcript}")]`
|
|
107
|
+
: "[语音消息]");
|
|
108
|
+
break;
|
|
109
|
+
case "video":
|
|
110
|
+
parts.push(`[视频${att.filename ? `: ${att.filename}` : ""}]`);
|
|
111
|
+
break;
|
|
112
|
+
case "file":
|
|
113
|
+
parts.push(`[文件${att.filename ? `: ${att.filename}` : ""}]`);
|
|
114
|
+
break;
|
|
115
|
+
default:
|
|
116
|
+
parts.push(`[附件${att.filename ? `: ${att.filename}` : ""}]`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return parts.join("\n");
|
|
121
|
+
}
|
|
122
|
+
/** @deprecated 使用 formatAttachmentTags 代替 */
|
|
123
|
+
export const formatHistoryAttachments = formatAttachmentTags;
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// 内部工具
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* LRU 淘汰:当 historyMap 的 key 数量超过阈值时,删除最早插入的 key。
|
|
129
|
+
*/
|
|
130
|
+
function evictOldHistoryKeys(historyMap, maxKeys = MAX_HISTORY_KEYS) {
|
|
131
|
+
if (historyMap.size <= maxKeys)
|
|
132
|
+
return;
|
|
133
|
+
const keysToDelete = historyMap.size - maxKeys;
|
|
134
|
+
const iterator = historyMap.keys();
|
|
135
|
+
for (let i = 0; i < keysToDelete; i++) {
|
|
136
|
+
const key = iterator.next().value;
|
|
137
|
+
if (key !== undefined) {
|
|
138
|
+
historyMap.delete(key);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 向指定 key 的历史列表追加一条记录,超出 limit 时从头部淘汰。
|
|
144
|
+
* 同时刷新 key 的插入顺序(Map 迭代顺序 = 插入顺序),实现 LRU 语义。
|
|
145
|
+
*/
|
|
146
|
+
function appendHistoryEntry(params) {
|
|
147
|
+
const { historyMap, historyKey, entry } = params;
|
|
148
|
+
if (params.limit <= 0)
|
|
149
|
+
return [];
|
|
150
|
+
const history = historyMap.get(historyKey) ?? [];
|
|
151
|
+
history.push(entry);
|
|
152
|
+
while (history.length > params.limit) {
|
|
153
|
+
history.shift();
|
|
154
|
+
}
|
|
155
|
+
// 刷新插入顺序
|
|
156
|
+
if (historyMap.has(historyKey)) {
|
|
157
|
+
historyMap.delete(historyKey);
|
|
158
|
+
}
|
|
159
|
+
historyMap.set(historyKey, history);
|
|
160
|
+
evictOldHistoryKeys(historyMap);
|
|
161
|
+
return history;
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// 公开 API
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* 记录一条待注入的历史消息(非@消息调用此函数)。
|
|
168
|
+
* limit <= 0 或 entry 为空时不记录。
|
|
169
|
+
*/
|
|
170
|
+
export function recordPendingHistoryEntry(params) {
|
|
171
|
+
if (!params.entry || params.limit <= 0)
|
|
172
|
+
return [];
|
|
173
|
+
return appendHistoryEntry({
|
|
174
|
+
historyMap: params.historyMap,
|
|
175
|
+
historyKey: params.historyKey,
|
|
176
|
+
entry: params.entry,
|
|
177
|
+
limit: params.limit,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 构建包含历史上下文的完整消息体(被@时调用)。
|
|
182
|
+
* 如果没有累积的历史消息,直接返回 currentMessage 原文。
|
|
183
|
+
*/
|
|
184
|
+
export function buildPendingHistoryContext(params) {
|
|
185
|
+
if (params.limit <= 0)
|
|
186
|
+
return params.currentMessage;
|
|
187
|
+
const entries = params.historyMap.get(params.historyKey) ?? [];
|
|
188
|
+
if (entries.length === 0)
|
|
189
|
+
return params.currentMessage;
|
|
190
|
+
const lineBreak = params.lineBreak ?? "\n";
|
|
191
|
+
const historyText = entries.map(params.formatEntry).join(lineBreak);
|
|
192
|
+
return [
|
|
193
|
+
HISTORY_CTX_START,
|
|
194
|
+
historyText,
|
|
195
|
+
"",
|
|
196
|
+
HISTORY_CTX_END,
|
|
197
|
+
params.currentMessage,
|
|
198
|
+
].join(lineBreak);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 构建合并消息上下文(多条排队消息被合并时调用)。
|
|
202
|
+
* 前置消息用 [合并消息开始]...[合并消息结束] 段落标签包裹,
|
|
203
|
+
* 最后一条作为当前消息紧跟其后。
|
|
204
|
+
* 如果只有一条消息,直接返回 currentMessage 原文。
|
|
205
|
+
*/
|
|
206
|
+
export function buildMergedMessageContext(params) {
|
|
207
|
+
const { precedingParts, currentMessage } = params;
|
|
208
|
+
if (precedingParts.length === 0)
|
|
209
|
+
return currentMessage;
|
|
210
|
+
const lineBreak = params.lineBreak ?? "\n";
|
|
211
|
+
return [
|
|
212
|
+
MERGED_CTX_START,
|
|
213
|
+
precedingParts.join(lineBreak),
|
|
214
|
+
MERGED_CTX_END,
|
|
215
|
+
currentMessage,
|
|
216
|
+
].join(lineBreak);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 清空指定群的历史缓存(回复完成后调用)。
|
|
220
|
+
* limit <= 0 表示功能已禁用,不做操作。
|
|
221
|
+
*/
|
|
222
|
+
export function clearPendingHistory(params) {
|
|
223
|
+
if (params.limit <= 0)
|
|
224
|
+
return;
|
|
225
|
+
params.historyMap.set(params.historyKey, []);
|
|
226
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群消息门控 — 统一入口。
|
|
3
|
+
*
|
|
4
|
+
* 将 ignoreOtherMentions / shouldBlock / mentionGating 三层判断收敛到
|
|
5
|
+
* 一个纯函数 resolveGroupMessageGate() 中,让 gateway 主流程只关心一个结果。
|
|
6
|
+
*
|
|
7
|
+
* 按优先级串行检查:
|
|
8
|
+
* 1. ignoreOtherMentions — @了其他人但未 @bot → 丢弃(记历史)
|
|
9
|
+
* 2. shouldBlock — 未授权控制命令静默拦截
|
|
10
|
+
* 3. mentionGating — requireMention 门控 + 命令旁路
|
|
11
|
+
*/
|
|
12
|
+
export type MentionGateResult = {
|
|
13
|
+
effectiveWasMentioned: boolean;
|
|
14
|
+
shouldSkip: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type MentionGateWithBypassResult = MentionGateResult & {
|
|
17
|
+
shouldBypassMention: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type GroupMessageGateAction =
|
|
20
|
+
/** @了其他人但未 @bot,丢弃并记录历史 */
|
|
21
|
+
"drop_other_mention"
|
|
22
|
+
/** 未授权控制命令,静默拦截 */
|
|
23
|
+
| "block_unauthorized_command"
|
|
24
|
+
/** 非 @bot 消息,记录历史后跳过 AI */
|
|
25
|
+
| "skip_no_mention"
|
|
26
|
+
/** 正常放行,交给 AI */
|
|
27
|
+
| "pass";
|
|
28
|
+
export type GroupMessageGateResult = {
|
|
29
|
+
action: GroupMessageGateAction;
|
|
30
|
+
/** 仅 action=pass|skip_no_mention 时有值 */
|
|
31
|
+
effectiveWasMentioned: boolean;
|
|
32
|
+
shouldBypassMention: boolean;
|
|
33
|
+
};
|
|
34
|
+
export type GroupMessageGateParams = {
|
|
35
|
+
ignoreOtherMentions: boolean;
|
|
36
|
+
hasAnyMention: boolean;
|
|
37
|
+
wasMentioned: boolean;
|
|
38
|
+
implicitMention: boolean;
|
|
39
|
+
allowTextCommands: boolean;
|
|
40
|
+
isControlCommand: boolean;
|
|
41
|
+
commandAuthorized: boolean;
|
|
42
|
+
requireMention: boolean;
|
|
43
|
+
canDetectMention: boolean;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* 群消息统一门控,按优先级串行判定:
|
|
47
|
+
*
|
|
48
|
+
* 1. ignoreOtherMentions — @了其他人但未 @bot → drop_other_mention
|
|
49
|
+
* 2. shouldBlock — 未授权控制命令 → block_unauthorized_command
|
|
50
|
+
* 3. mentionGating — 未满足 @bot 条件 → skip_no_mention
|
|
51
|
+
* 4. 通过所有检查 → pass
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveGroupMessageGate(params: GroupMessageGateParams): GroupMessageGateResult;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群消息门控 — 统一入口。
|
|
3
|
+
*
|
|
4
|
+
* 将 ignoreOtherMentions / shouldBlock / mentionGating 三层判断收敛到
|
|
5
|
+
* 一个纯函数 resolveGroupMessageGate() 中,让 gateway 主流程只关心一个结果。
|
|
6
|
+
*
|
|
7
|
+
* 按优先级串行检查:
|
|
8
|
+
* 1. ignoreOtherMentions — @了其他人但未 @bot → 丢弃(记历史)
|
|
9
|
+
* 2. shouldBlock — 未授权控制命令静默拦截
|
|
10
|
+
* 3. mentionGating — requireMention 门控 + 命令旁路
|
|
11
|
+
*/
|
|
12
|
+
// ────────────────────── Core Logic ──────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* 基础 mention 门控纯函数。
|
|
15
|
+
* effectiveWasMentioned = wasMentioned || implicitMention || shouldBypassMention
|
|
16
|
+
* shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned
|
|
17
|
+
*/
|
|
18
|
+
function resolveMentionGating(params) {
|
|
19
|
+
const implicit = params.implicitMention === true;
|
|
20
|
+
const bypass = params.shouldBypassMention === true;
|
|
21
|
+
const effectiveWasMentioned = params.wasMentioned || implicit || bypass;
|
|
22
|
+
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
|
|
23
|
+
return { effectiveWasMentioned, shouldSkip };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 带命令旁路的 mention 门控。
|
|
27
|
+
*
|
|
28
|
+
* shouldBypassMention 条件(全部满足时才旁路):
|
|
29
|
+
* 1. requireMention — 开启了 mention 要求
|
|
30
|
+
* 2. !wasMentioned — 没有被直接 mention
|
|
31
|
+
* 3. !hasAnyMention — 消息中没有任何 @(防止 @ 其他人的消息误 bypass)
|
|
32
|
+
* 4. allowTextCommands — 文本命令已启用
|
|
33
|
+
* 5. commandAuthorized — 发送者有命令权限
|
|
34
|
+
* 6. hasControlCommand — 消息是合法控制命令
|
|
35
|
+
*/
|
|
36
|
+
function resolveMentionGatingWithBypass(params) {
|
|
37
|
+
const shouldBypassMention = params.requireMention &&
|
|
38
|
+
!params.wasMentioned &&
|
|
39
|
+
!(params.hasAnyMention ?? false) &&
|
|
40
|
+
params.allowTextCommands &&
|
|
41
|
+
params.commandAuthorized &&
|
|
42
|
+
params.hasControlCommand;
|
|
43
|
+
return {
|
|
44
|
+
...resolveMentionGating({
|
|
45
|
+
requireMention: params.requireMention,
|
|
46
|
+
canDetectMention: params.canDetectMention,
|
|
47
|
+
wasMentioned: params.wasMentioned,
|
|
48
|
+
implicitMention: params.implicitMention,
|
|
49
|
+
shouldBypassMention,
|
|
50
|
+
}),
|
|
51
|
+
shouldBypassMention,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// ────────────────────── Unified Gate ──────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* 群消息统一门控,按优先级串行判定:
|
|
57
|
+
*
|
|
58
|
+
* 1. ignoreOtherMentions — @了其他人但未 @bot → drop_other_mention
|
|
59
|
+
* 2. shouldBlock — 未授权控制命令 → block_unauthorized_command
|
|
60
|
+
* 3. mentionGating — 未满足 @bot 条件 → skip_no_mention
|
|
61
|
+
* 4. 通过所有检查 → pass
|
|
62
|
+
*/
|
|
63
|
+
export function resolveGroupMessageGate(params) {
|
|
64
|
+
const { ignoreOtherMentions, hasAnyMention, wasMentioned, implicitMention, allowTextCommands, isControlCommand, commandAuthorized, requireMention, canDetectMention, } = params;
|
|
65
|
+
// ── Layer 1: ignoreOtherMentions ──
|
|
66
|
+
if (ignoreOtherMentions &&
|
|
67
|
+
hasAnyMention &&
|
|
68
|
+
!wasMentioned &&
|
|
69
|
+
!implicitMention) {
|
|
70
|
+
return {
|
|
71
|
+
action: "drop_other_mention",
|
|
72
|
+
effectiveWasMentioned: false,
|
|
73
|
+
shouldBypassMention: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// ── Layer 2: shouldBlock 未授权控制命令 ──
|
|
77
|
+
if (allowTextCommands && isControlCommand && !commandAuthorized) {
|
|
78
|
+
return {
|
|
79
|
+
action: "block_unauthorized_command",
|
|
80
|
+
effectiveWasMentioned: false,
|
|
81
|
+
shouldBypassMention: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// ── Layer 3: mention 门控 + 命令旁路 ──
|
|
85
|
+
const mentionGate = resolveMentionGatingWithBypass({
|
|
86
|
+
requireMention,
|
|
87
|
+
canDetectMention,
|
|
88
|
+
wasMentioned,
|
|
89
|
+
implicitMention,
|
|
90
|
+
hasAnyMention,
|
|
91
|
+
allowTextCommands,
|
|
92
|
+
hasControlCommand: isControlCommand,
|
|
93
|
+
commandAuthorized,
|
|
94
|
+
});
|
|
95
|
+
if (mentionGate.shouldSkip) {
|
|
96
|
+
return {
|
|
97
|
+
action: "skip_no_mention",
|
|
98
|
+
effectiveWasMentioned: mentionGate.effectiveWasMentioned,
|
|
99
|
+
shouldBypassMention: mentionGate.shouldBypassMention,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
action: "pass",
|
|
104
|
+
effectiveWasMentioned: mentionGate.effectiveWasMentioned,
|
|
105
|
+
shouldBypassMention: mentionGate.shouldBypassMention,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -23,6 +23,29 @@ export interface QueuedMessage {
|
|
|
23
23
|
refMsgIdx?: string;
|
|
24
24
|
/** 当前消息自身的 refIdx(供将来被引用) */
|
|
25
25
|
msgIdx?: string;
|
|
26
|
+
/** 事件类型(如 GROUP_AT_MESSAGE_CREATE),用于群消息合并时判断是否有 @ */
|
|
27
|
+
eventType?: string;
|
|
28
|
+
/** 发送者是否为机器人 */
|
|
29
|
+
senderIsBot?: boolean;
|
|
30
|
+
/** @ 提及列表(群消息合并时需要去重合并) */
|
|
31
|
+
mentions?: Array<{
|
|
32
|
+
scope?: "all" | "single";
|
|
33
|
+
id?: string;
|
|
34
|
+
user_openid?: string;
|
|
35
|
+
member_openid?: string;
|
|
36
|
+
username?: string;
|
|
37
|
+
bot?: boolean;
|
|
38
|
+
is_you?: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
/** 消息场景(来源、扩展字段) */
|
|
41
|
+
messageScene?: {
|
|
42
|
+
source?: string;
|
|
43
|
+
ext?: string[];
|
|
44
|
+
};
|
|
45
|
+
/** 群消息合并标记:记录合并了多少条原始消息 */
|
|
46
|
+
_mergedCount?: number;
|
|
47
|
+
/** 合并前的原始消息列表(用于 gateway 侧逐条格式化信封) */
|
|
48
|
+
_mergedMessages?: QueuedMessage[];
|
|
26
49
|
}
|
|
27
50
|
export interface MessageQueueContext {
|
|
28
51
|
accountId: string;
|
|
@@ -33,6 +56,14 @@ export interface MessageQueueContext {
|
|
|
33
56
|
};
|
|
34
57
|
/** 外部提供的 abort 状态检查 */
|
|
35
58
|
isAborted: () => boolean;
|
|
59
|
+
/** 群聊队列上限(默认 50) */
|
|
60
|
+
groupQueueSize?: number;
|
|
61
|
+
/** 私聊/DM 队列上限(默认 20) */
|
|
62
|
+
peerQueueSize?: number;
|
|
63
|
+
/** 全局队列总量上限(默认 1000) */
|
|
64
|
+
globalQueueSize?: number;
|
|
65
|
+
/** 最大并发处理用户数(默认 10) */
|
|
66
|
+
maxConcurrentUsers?: number;
|
|
36
67
|
}
|
|
37
68
|
export interface MessageQueue {
|
|
38
69
|
enqueue: (msg: QueuedMessage) => void;
|
|
@@ -46,5 +77,10 @@ export interface MessageQueue {
|
|
|
46
77
|
}
|
|
47
78
|
/**
|
|
48
79
|
* 创建按用户并发的消息队列(同用户串行,跨用户并行)
|
|
80
|
+
*
|
|
81
|
+
* 内置群消息增强:
|
|
82
|
+
* - 群聊 / 私聊使用不同队列上限
|
|
83
|
+
* - 群聊溢出时优先丢弃 bot 消息
|
|
84
|
+
* - drain 时自动合并群聊排队消息(斜杠命令单独处理)
|
|
49
85
|
*/
|
|
50
86
|
export declare function createMessageQueue(ctx: MessageQueueContext): MessageQueue;
|