@sliverp/qqbot 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -0
- package/clawdbot.plugin.json +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/src/api.d.ts +194 -0
- package/dist/src/api.js +555 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +146 -0
- package/dist/src/config.d.ts +25 -0
- package/dist/src/config.js +148 -0
- package/dist/src/gateway.d.ts +17 -0
- package/dist/src/gateway.js +722 -0
- package/dist/src/image-server.d.ts +62 -0
- package/dist/src/image-server.js +401 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +264 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +190 -0
- package/dist/src/outbound.d.ts +149 -0
- package/dist/src/outbound.js +476 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +398 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/session-store.d.ts +49 -0
- package/dist/src/session-store.js +242 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils/image-size.d.ts +51 -0
- package/dist/src/utils/image-size.js +234 -0
- package/dist/src/utils/payload.d.ts +112 -0
- package/dist/src/utils/payload.js +186 -0
- package/index.ts +27 -0
- package/moltbot.plugin.json +16 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +13 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +69 -0
- package/node_modules/ws/wrapper.mjs +8 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +38 -0
- package/qqbot-1.3.0.tgz +0 -0
- package/scripts/proactive-api-server.ts +346 -0
- package/scripts/send-proactive.ts +273 -0
- package/scripts/upgrade.sh +106 -0
- package/skills/qqbot-cron/SKILL.md +490 -0
- package/skills/qqbot-media/SKILL.md +138 -0
- package/src/api.ts +752 -0
- package/src/channel.ts +303 -0
- package/src/config.ts +172 -0
- package/src/gateway.ts +1588 -0
- package/src/image-server.ts +474 -0
- package/src/known-users.ts +358 -0
- package/src/onboarding.ts +254 -0
- package/src/openclaw-plugin-sdk.d.ts +483 -0
- package/src/outbound.ts +571 -0
- package/src/proactive.ts +528 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +292 -0
- package/src/types.ts +123 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/payload.ts +265 -0
- package/tsconfig.json +16 -0
- package/upgrade-and-run.sh +89 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 消息发送模块
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { decodeCronPayload } from "./utils/payload.js";
|
|
7
|
+
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CImageMessage, sendGroupImageMessage, } from "./api.js";
|
|
8
|
+
// ============ 消息回复限流器 ============
|
|
9
|
+
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
|
10
|
+
const MESSAGE_REPLY_LIMIT = 4;
|
|
11
|
+
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
|
12
|
+
const messageReplyTracker = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* 检查是否可以回复该消息(限流检查)
|
|
15
|
+
* @param messageId 消息ID
|
|
16
|
+
* @returns ReplyLimitResult 限流检查结果
|
|
17
|
+
*/
|
|
18
|
+
export function checkMessageReplyLimit(messageId) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const record = messageReplyTracker.get(messageId);
|
|
21
|
+
// 清理过期记录(定期清理,避免内存泄漏)
|
|
22
|
+
if (messageReplyTracker.size > 10000) {
|
|
23
|
+
for (const [id, rec] of messageReplyTracker) {
|
|
24
|
+
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
25
|
+
messageReplyTracker.delete(id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 新消息,首次回复
|
|
30
|
+
if (!record) {
|
|
31
|
+
return {
|
|
32
|
+
allowed: true,
|
|
33
|
+
remaining: MESSAGE_REPLY_LIMIT,
|
|
34
|
+
shouldFallbackToProactive: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// 检查是否超过1小时(message_id 过期)
|
|
38
|
+
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
39
|
+
// 超过1小时,被动回复不可用,需要降级为主动消息
|
|
40
|
+
return {
|
|
41
|
+
allowed: false,
|
|
42
|
+
remaining: 0,
|
|
43
|
+
shouldFallbackToProactive: true,
|
|
44
|
+
fallbackReason: "expired",
|
|
45
|
+
message: `消息已超过1小时有效期,将使用主动消息发送`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// 检查是否超过回复次数限制
|
|
49
|
+
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
|
50
|
+
if (remaining <= 0) {
|
|
51
|
+
return {
|
|
52
|
+
allowed: false,
|
|
53
|
+
remaining: 0,
|
|
54
|
+
shouldFallbackToProactive: true,
|
|
55
|
+
fallbackReason: "limit_exceeded",
|
|
56
|
+
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
allowed: true,
|
|
61
|
+
remaining,
|
|
62
|
+
shouldFallbackToProactive: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 记录一次消息回复
|
|
67
|
+
* @param messageId 消息ID
|
|
68
|
+
*/
|
|
69
|
+
export function recordMessageReply(messageId) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const record = messageReplyTracker.get(messageId);
|
|
72
|
+
if (!record) {
|
|
73
|
+
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// 检查是否过期,过期则重新计数
|
|
77
|
+
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
78
|
+
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
record.count++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 获取消息回复统计信息
|
|
88
|
+
*/
|
|
89
|
+
export function getMessageReplyStats() {
|
|
90
|
+
let totalReplies = 0;
|
|
91
|
+
for (const record of messageReplyTracker.values()) {
|
|
92
|
+
totalReplies += record.count;
|
|
93
|
+
}
|
|
94
|
+
return { trackedMessages: messageReplyTracker.size, totalReplies };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 获取消息回复限制配置(供外部查询)
|
|
98
|
+
*/
|
|
99
|
+
export function getMessageReplyConfig() {
|
|
100
|
+
return {
|
|
101
|
+
limit: MESSAGE_REPLY_LIMIT,
|
|
102
|
+
ttlMs: MESSAGE_REPLY_TTL,
|
|
103
|
+
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 解析目标地址
|
|
108
|
+
* 格式:
|
|
109
|
+
* - openid (32位十六进制) -> C2C 单聊
|
|
110
|
+
* - group:xxx -> 群聊
|
|
111
|
+
* - channel:xxx -> 频道
|
|
112
|
+
* - 纯数字 -> 频道
|
|
113
|
+
*/
|
|
114
|
+
function parseTarget(to) {
|
|
115
|
+
// 去掉 qqbot: 前缀
|
|
116
|
+
let id = to.replace(/^qqbot:/i, "");
|
|
117
|
+
if (id.startsWith("c2c:")) {
|
|
118
|
+
return { type: "c2c", id: id.slice(4) };
|
|
119
|
+
}
|
|
120
|
+
if (id.startsWith("group:")) {
|
|
121
|
+
return { type: "group", id: id.slice(6) };
|
|
122
|
+
}
|
|
123
|
+
if (id.startsWith("channel:")) {
|
|
124
|
+
return { type: "channel", id: id.slice(8) };
|
|
125
|
+
}
|
|
126
|
+
// 默认当作 c2c(私聊)
|
|
127
|
+
return { type: "c2c", id };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 发送文本消息
|
|
131
|
+
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
|
132
|
+
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
|
133
|
+
*
|
|
134
|
+
* 注意:
|
|
135
|
+
* 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送
|
|
136
|
+
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
|
|
137
|
+
*/
|
|
138
|
+
export async function sendText(ctx) {
|
|
139
|
+
const { to, text, account } = ctx;
|
|
140
|
+
let { replyToId } = ctx;
|
|
141
|
+
let fallbackToProactive = false;
|
|
142
|
+
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
|
|
143
|
+
// ============ 消息回复限流检查 ============
|
|
144
|
+
// 如果有 replyToId,检查是否可以被动回复
|
|
145
|
+
if (replyToId) {
|
|
146
|
+
const limitCheck = checkMessageReplyLimit(replyToId);
|
|
147
|
+
if (!limitCheck.allowed) {
|
|
148
|
+
// 检查是否需要降级为主动消息
|
|
149
|
+
if (limitCheck.shouldFallbackToProactive) {
|
|
150
|
+
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
|
|
151
|
+
fallbackToProactive = true;
|
|
152
|
+
replyToId = null; // 清除 replyToId,改为主动消息
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// 不应该发生,但作为保底
|
|
156
|
+
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
|
157
|
+
return {
|
|
158
|
+
channel: "qqbot",
|
|
159
|
+
error: limitCheck.message
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ============ 主动消息校验(参考 Telegram 机制) ============
|
|
168
|
+
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
|
169
|
+
if (!replyToId) {
|
|
170
|
+
if (!text || text.trim().length === 0) {
|
|
171
|
+
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
|
|
172
|
+
return {
|
|
173
|
+
channel: "qqbot",
|
|
174
|
+
error: "主动消息必须有内容 (--message 参数不能为空)"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (fallbackToProactive) {
|
|
178
|
+
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!account.appId || !account.clientSecret) {
|
|
185
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
189
|
+
const target = parseTarget(to);
|
|
190
|
+
console.log("[qqbot] sendText target:", JSON.stringify(target));
|
|
191
|
+
// 如果没有 replyToId,使用主动发送接口
|
|
192
|
+
if (!replyToId) {
|
|
193
|
+
if (target.type === "c2c") {
|
|
194
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
|
|
195
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
196
|
+
}
|
|
197
|
+
else if (target.type === "group") {
|
|
198
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
|
|
199
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// 频道暂不支持主动消息
|
|
203
|
+
const result = await sendChannelMessage(accessToken, target.id, text);
|
|
204
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 有 replyToId,使用被动回复接口
|
|
208
|
+
if (target.type === "c2c") {
|
|
209
|
+
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
|
210
|
+
// 记录回复次数
|
|
211
|
+
recordMessageReply(replyToId);
|
|
212
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
213
|
+
}
|
|
214
|
+
else if (target.type === "group") {
|
|
215
|
+
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
|
216
|
+
// 记录回复次数
|
|
217
|
+
recordMessageReply(replyToId);
|
|
218
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
|
222
|
+
// 记录回复次数
|
|
223
|
+
recordMessageReply(replyToId);
|
|
224
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
return { channel: "qqbot", error: message };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
|
234
|
+
*
|
|
235
|
+
* @param account - 账户配置
|
|
236
|
+
* @param to - 目标地址,格式:openid(单聊)或 group:xxx(群聊)
|
|
237
|
+
* @param text - 消息内容
|
|
238
|
+
*/
|
|
239
|
+
export async function sendProactiveMessage(account, to, text) {
|
|
240
|
+
if (!account.appId || !account.clientSecret) {
|
|
241
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
245
|
+
const target = parseTarget(to);
|
|
246
|
+
if (target.type === "c2c") {
|
|
247
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
|
|
248
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
249
|
+
}
|
|
250
|
+
else if (target.type === "group") {
|
|
251
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
|
|
252
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
// 频道暂不支持主动消息,使用普通发送
|
|
256
|
+
const result = await sendChannelMessage(accessToken, target.id, text);
|
|
257
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
262
|
+
return { channel: "qqbot", error: message };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 发送富媒体消息(图片)
|
|
267
|
+
*
|
|
268
|
+
* 支持以下 mediaUrl 格式:
|
|
269
|
+
* - 公网 URL: https://example.com/image.png
|
|
270
|
+
* - Base64 Data URL: data:image/png;base64,xxxxx
|
|
271
|
+
* - 本地文件路径: /path/to/image.png(自动读取并转换为 Base64)
|
|
272
|
+
*
|
|
273
|
+
* @param ctx - 发送上下文,包含 mediaUrl
|
|
274
|
+
* @returns 发送结果
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```typescript
|
|
278
|
+
* // 发送网络图片
|
|
279
|
+
* const result = await sendMedia({
|
|
280
|
+
* to: "group:xxx",
|
|
281
|
+
* text: "这是图片说明",
|
|
282
|
+
* mediaUrl: "https://example.com/image.png",
|
|
283
|
+
* account,
|
|
284
|
+
* replyToId: msgId,
|
|
285
|
+
* });
|
|
286
|
+
*
|
|
287
|
+
* // 发送 Base64 图片
|
|
288
|
+
* const result = await sendMedia({
|
|
289
|
+
* to: "group:xxx",
|
|
290
|
+
* text: "这是图片说明",
|
|
291
|
+
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
|
|
292
|
+
* account,
|
|
293
|
+
* replyToId: msgId,
|
|
294
|
+
* });
|
|
295
|
+
*
|
|
296
|
+
* // 发送本地文件(自动读取并转换为 Base64)
|
|
297
|
+
* const result = await sendMedia({
|
|
298
|
+
* to: "group:xxx",
|
|
299
|
+
* text: "这是图片说明",
|
|
300
|
+
* mediaUrl: "/tmp/generated-chart.png",
|
|
301
|
+
* account,
|
|
302
|
+
* replyToId: msgId,
|
|
303
|
+
* });
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
export async function sendMedia(ctx) {
|
|
307
|
+
const { to, text, replyToId, account } = ctx;
|
|
308
|
+
const { mediaUrl } = ctx;
|
|
309
|
+
if (!account.appId || !account.clientSecret) {
|
|
310
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
311
|
+
}
|
|
312
|
+
if (!mediaUrl) {
|
|
313
|
+
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
|
314
|
+
}
|
|
315
|
+
// 验证 mediaUrl 格式:支持公网 URL、Base64 Data URL 或本地文件路径
|
|
316
|
+
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
|
317
|
+
const isDataUrl = mediaUrl.startsWith("data:");
|
|
318
|
+
const isLocalPath = mediaUrl.startsWith("/") ||
|
|
319
|
+
/^[a-zA-Z]:[\\/]/.test(mediaUrl) ||
|
|
320
|
+
mediaUrl.startsWith("./") ||
|
|
321
|
+
mediaUrl.startsWith("../");
|
|
322
|
+
// 处理本地文件路径:读取文件并转换为 Base64 Data URL
|
|
323
|
+
let processedMediaUrl = mediaUrl;
|
|
324
|
+
if (isLocalPath) {
|
|
325
|
+
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
|
|
326
|
+
try {
|
|
327
|
+
// 检查文件是否存在
|
|
328
|
+
if (!fs.existsSync(mediaUrl)) {
|
|
329
|
+
return {
|
|
330
|
+
channel: "qqbot",
|
|
331
|
+
error: `本地文件不存在: ${mediaUrl}`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// 读取文件内容
|
|
335
|
+
const fileBuffer = fs.readFileSync(mediaUrl);
|
|
336
|
+
const base64Data = fileBuffer.toString("base64");
|
|
337
|
+
// 根据文件扩展名确定 MIME 类型
|
|
338
|
+
const ext = path.extname(mediaUrl).toLowerCase();
|
|
339
|
+
const mimeTypes = {
|
|
340
|
+
".jpg": "image/jpeg",
|
|
341
|
+
".jpeg": "image/jpeg",
|
|
342
|
+
".png": "image/png",
|
|
343
|
+
".gif": "image/gif",
|
|
344
|
+
".webp": "image/webp",
|
|
345
|
+
".bmp": "image/bmp",
|
|
346
|
+
};
|
|
347
|
+
const mimeType = mimeTypes[ext];
|
|
348
|
+
if (!mimeType) {
|
|
349
|
+
return {
|
|
350
|
+
channel: "qqbot",
|
|
351
|
+
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// 构造 Data URL
|
|
355
|
+
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
|
|
356
|
+
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
|
|
357
|
+
}
|
|
358
|
+
catch (readErr) {
|
|
359
|
+
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
|
360
|
+
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
|
|
361
|
+
return {
|
|
362
|
+
channel: "qqbot",
|
|
363
|
+
error: `读取本地文件失败: ${errMsg}`
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (!isHttpUrl && !isDataUrl) {
|
|
368
|
+
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
|
|
369
|
+
return {
|
|
370
|
+
channel: "qqbot",
|
|
371
|
+
error: `不支持的图片格式: ${mediaUrl.slice(0, 50)}...。支持的格式: 公网 URL (http/https)、Base64 Data URL (data:image/...) 或本地文件路径。`
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
else if (isDataUrl) {
|
|
375
|
+
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
382
|
+
const target = parseTarget(to);
|
|
383
|
+
// 先发送图片(使用处理后的 URL,可能是 Base64 Data URL)
|
|
384
|
+
let imageResult;
|
|
385
|
+
if (target.type === "c2c") {
|
|
386
|
+
imageResult = await sendC2CImageMessage(accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined // content 参数,图片消息不支持同时带文本
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
else if (target.type === "group") {
|
|
390
|
+
imageResult = await sendGroupImageMessage(accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
// 频道暂不支持富媒体消息,只发送文本 + URL(本地文件路径无法在频道展示)
|
|
394
|
+
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
|
|
395
|
+
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
|
|
396
|
+
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
|
|
397
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
398
|
+
}
|
|
399
|
+
// 如果有文本说明,再发送一条文本消息
|
|
400
|
+
if (text?.trim()) {
|
|
401
|
+
try {
|
|
402
|
+
if (target.type === "c2c") {
|
|
403
|
+
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
404
|
+
}
|
|
405
|
+
else if (target.type === "group") {
|
|
406
|
+
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (textErr) {
|
|
410
|
+
// 文本发送失败不影响整体结果,图片已发送成功
|
|
411
|
+
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
418
|
+
return { channel: "qqbot", error: message };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* 发送 Cron 触发的消息
|
|
423
|
+
*
|
|
424
|
+
* 当 OpenClaw cron 任务触发时,消息内容可能是:
|
|
425
|
+
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
|
|
426
|
+
* 2. 普通文本 - 直接发送到指定目标
|
|
427
|
+
*
|
|
428
|
+
* @param account - 账户配置
|
|
429
|
+
* @param to - 目标地址(作为后备,如果载荷中没有指定)
|
|
430
|
+
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
|
|
431
|
+
* @returns 发送结果
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* ```typescript
|
|
435
|
+
* // 处理结构化载荷
|
|
436
|
+
* const result = await sendCronMessage(
|
|
437
|
+
* account,
|
|
438
|
+
* "user_openid", // 后备地址
|
|
439
|
+
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
|
|
440
|
+
* );
|
|
441
|
+
*
|
|
442
|
+
* // 处理普通文本
|
|
443
|
+
* const result = await sendCronMessage(
|
|
444
|
+
* account,
|
|
445
|
+
* "user_openid",
|
|
446
|
+
* "这是一条普通的提醒消息"
|
|
447
|
+
* );
|
|
448
|
+
* ```
|
|
449
|
+
*/
|
|
450
|
+
export async function sendCronMessage(account, to, message) {
|
|
451
|
+
console.log(`[qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
|
452
|
+
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
|
|
453
|
+
const cronResult = decodeCronPayload(message);
|
|
454
|
+
if (cronResult.isCronPayload) {
|
|
455
|
+
if (cronResult.error) {
|
|
456
|
+
console.error(`[qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
|
|
457
|
+
return {
|
|
458
|
+
channel: "qqbot",
|
|
459
|
+
error: `Cron 载荷解码失败: ${cronResult.error}`
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (cronResult.payload) {
|
|
463
|
+
const payload = cronResult.payload;
|
|
464
|
+
console.log(`[qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}`);
|
|
465
|
+
// 使用载荷中的目标地址和类型发送消息
|
|
466
|
+
const targetTo = payload.targetType === "group"
|
|
467
|
+
? `group:${payload.targetAddress}`
|
|
468
|
+
: payload.targetAddress;
|
|
469
|
+
// 发送提醒内容
|
|
470
|
+
return await sendProactiveMessage(account, targetTo, payload.content);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// 非结构化载荷,作为普通文本处理
|
|
474
|
+
console.log(`[qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
|
475
|
+
return await sendProactiveMessage(account, to, message);
|
|
476
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 主动发送消息模块
|
|
3
|
+
*
|
|
4
|
+
* 该模块提供以下能力:
|
|
5
|
+
* 1. 记录已知用户(曾与机器人交互过的用户)
|
|
6
|
+
* 2. 主动发送消息给用户或群组
|
|
7
|
+
* 3. 查询已知用户列表
|
|
8
|
+
*/
|
|
9
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* 已知用户信息
|
|
12
|
+
*/
|
|
13
|
+
export interface KnownUser {
|
|
14
|
+
type: "c2c" | "group" | "channel";
|
|
15
|
+
openid: string;
|
|
16
|
+
accountId: string;
|
|
17
|
+
nickname?: string;
|
|
18
|
+
firstInteractionAt: number;
|
|
19
|
+
lastInteractionAt: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 主动发送消息选项
|
|
23
|
+
*/
|
|
24
|
+
export interface ProactiveSendOptions {
|
|
25
|
+
to: string;
|
|
26
|
+
text: string;
|
|
27
|
+
type?: "c2c" | "group" | "channel";
|
|
28
|
+
imageUrl?: string;
|
|
29
|
+
accountId?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 主动发送消息结果
|
|
33
|
+
*/
|
|
34
|
+
export interface ProactiveSendResult {
|
|
35
|
+
success: boolean;
|
|
36
|
+
messageId?: string;
|
|
37
|
+
timestamp?: number | string;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 列出已知用户选项
|
|
42
|
+
*/
|
|
43
|
+
export interface ListKnownUsersOptions {
|
|
44
|
+
type?: "c2c" | "group" | "channel";
|
|
45
|
+
accountId?: string;
|
|
46
|
+
sortByLastInteraction?: boolean;
|
|
47
|
+
limit?: number;
|
|
48
|
+
}
|
|
49
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
50
|
+
/**
|
|
51
|
+
* 记录一个已知用户(当收到用户消息时调用)
|
|
52
|
+
*
|
|
53
|
+
* @param user - 用户信息
|
|
54
|
+
*/
|
|
55
|
+
export declare function recordKnownUser(user: Omit<KnownUser, "firstInteractionAt">): void;
|
|
56
|
+
/**
|
|
57
|
+
* 获取一个已知用户
|
|
58
|
+
*
|
|
59
|
+
* @param type - 用户类型
|
|
60
|
+
* @param openid - 用户 openid
|
|
61
|
+
* @param accountId - 账户 ID
|
|
62
|
+
*/
|
|
63
|
+
export declare function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* 列出已知用户
|
|
66
|
+
*
|
|
67
|
+
* @param options - 过滤选项
|
|
68
|
+
*/
|
|
69
|
+
export declare function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[];
|
|
70
|
+
/**
|
|
71
|
+
* 删除一个已知用户
|
|
72
|
+
*
|
|
73
|
+
* @param type - 用户类型
|
|
74
|
+
* @param openid - 用户 openid
|
|
75
|
+
* @param accountId - 账户 ID
|
|
76
|
+
*/
|
|
77
|
+
export declare function removeKnownUser(type: string, openid: string, accountId: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* 清除所有已知用户
|
|
80
|
+
*
|
|
81
|
+
* @param accountId - 可选,只清除指定账户的用户
|
|
82
|
+
*/
|
|
83
|
+
export declare function clearKnownUsers(accountId?: string): number;
|
|
84
|
+
/**
|
|
85
|
+
* 主动发送消息(带配置解析)
|
|
86
|
+
* 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
|
|
87
|
+
*
|
|
88
|
+
* @param options - 发送选项
|
|
89
|
+
* @param cfg - OpenClaw 配置
|
|
90
|
+
* @returns 发送结果
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* // 发送私聊消息
|
|
95
|
+
* const result = await sendProactive({
|
|
96
|
+
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid
|
|
97
|
+
* text: "你好!这是一条主动消息",
|
|
98
|
+
* type: "c2c",
|
|
99
|
+
* }, cfg);
|
|
100
|
+
*
|
|
101
|
+
* // 发送群聊消息
|
|
102
|
+
* const result = await sendProactive({
|
|
103
|
+
* to: "A1B2C3D4E5F6A7B8", // 群组 openid
|
|
104
|
+
* text: "群公告:今天有活动",
|
|
105
|
+
* type: "group",
|
|
106
|
+
* }, cfg);
|
|
107
|
+
*
|
|
108
|
+
* // 发送带图片的消息
|
|
109
|
+
* const result = await sendProactive({
|
|
110
|
+
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
|
|
111
|
+
* text: "看看这张图片",
|
|
112
|
+
* imageUrl: "https://example.com/image.png",
|
|
113
|
+
* type: "c2c",
|
|
114
|
+
* }, cfg);
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export declare function sendProactive(options: ProactiveSendOptions, cfg: OpenClawConfig): Promise<ProactiveSendResult>;
|
|
118
|
+
/**
|
|
119
|
+
* 批量发送主动消息
|
|
120
|
+
*
|
|
121
|
+
* @param recipients - 接收者列表(openid 数组)
|
|
122
|
+
* @param text - 消息内容
|
|
123
|
+
* @param type - 消息类型
|
|
124
|
+
* @param cfg - OpenClaw 配置
|
|
125
|
+
* @param accountId - 账户 ID
|
|
126
|
+
* @returns 发送结果列表
|
|
127
|
+
*/
|
|
128
|
+
export declare function sendBulkProactiveMessage(recipients: string[], text: string, type: "c2c" | "group", cfg: OpenClawConfig, accountId?: string): Promise<Array<{
|
|
129
|
+
to: string;
|
|
130
|
+
result: ProactiveSendResult;
|
|
131
|
+
}>>;
|
|
132
|
+
/**
|
|
133
|
+
* 发送消息给所有已知用户
|
|
134
|
+
*
|
|
135
|
+
* @param text - 消息内容
|
|
136
|
+
* @param cfg - OpenClaw 配置
|
|
137
|
+
* @param options - 过滤选项
|
|
138
|
+
* @returns 发送结果统计
|
|
139
|
+
*/
|
|
140
|
+
export declare function broadcastMessage(text: string, cfg: OpenClawConfig, options?: {
|
|
141
|
+
type?: "c2c" | "group";
|
|
142
|
+
accountId?: string;
|
|
143
|
+
limit?: number;
|
|
144
|
+
}): Promise<{
|
|
145
|
+
total: number;
|
|
146
|
+
success: number;
|
|
147
|
+
failed: number;
|
|
148
|
+
results: Array<{
|
|
149
|
+
to: string;
|
|
150
|
+
result: ProactiveSendResult;
|
|
151
|
+
}>;
|
|
152
|
+
}>;
|
|
153
|
+
/**
|
|
154
|
+
* 根据账户配置直接发送主动消息(不需要 cfg)
|
|
155
|
+
*
|
|
156
|
+
* @param account - 已解析的账户配置
|
|
157
|
+
* @param to - 目标 openid
|
|
158
|
+
* @param text - 消息内容
|
|
159
|
+
* @param type - 消息类型
|
|
160
|
+
*/
|
|
161
|
+
export declare function sendProactiveMessageDirect(account: ResolvedQQBotAccount, to: string, text: string, type?: "c2c" | "group"): Promise<ProactiveSendResult>;
|
|
162
|
+
/**
|
|
163
|
+
* 获取已知用户统计
|
|
164
|
+
*/
|
|
165
|
+
export declare function getKnownUsersStats(accountId?: string): {
|
|
166
|
+
total: number;
|
|
167
|
+
c2c: number;
|
|
168
|
+
group: number;
|
|
169
|
+
channel: number;
|
|
170
|
+
};
|