@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 已知用户存储
|
|
3
|
+
* 记录与机器人交互过的所有用户
|
|
4
|
+
* 支持主动消息和批量通知功能
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
// 存储文件路径
|
|
9
|
+
const KNOWN_USERS_DIR = path.join(process.env.HOME || "/tmp", "clawd", "qqbot-data");
|
|
10
|
+
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
|
|
11
|
+
// 内存缓存
|
|
12
|
+
let usersCache = null;
|
|
13
|
+
// 写入节流配置
|
|
14
|
+
const SAVE_THROTTLE_MS = 5000; // 5秒写入一次
|
|
15
|
+
let saveTimer = null;
|
|
16
|
+
let isDirty = false;
|
|
17
|
+
/**
|
|
18
|
+
* 确保目录存在
|
|
19
|
+
*/
|
|
20
|
+
function ensureDir() {
|
|
21
|
+
if (!fs.existsSync(KNOWN_USERS_DIR)) {
|
|
22
|
+
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 从文件加载用户数据到缓存
|
|
27
|
+
*/
|
|
28
|
+
function loadUsersFromFile() {
|
|
29
|
+
if (usersCache !== null) {
|
|
30
|
+
return usersCache;
|
|
31
|
+
}
|
|
32
|
+
usersCache = new Map();
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
|
35
|
+
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
|
36
|
+
const users = JSON.parse(data);
|
|
37
|
+
for (const user of users) {
|
|
38
|
+
// 使用复合键:accountId + type + openid(群组还要加 groupOpenid)
|
|
39
|
+
const key = makeUserKey(user);
|
|
40
|
+
usersCache.set(key, user);
|
|
41
|
+
}
|
|
42
|
+
console.log(`[known-users] Loaded ${usersCache.size} users from file`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
console.error(`[known-users] Failed to load users: ${err}`);
|
|
47
|
+
usersCache = new Map();
|
|
48
|
+
}
|
|
49
|
+
return usersCache;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 保存用户数据到文件(节流版本)
|
|
53
|
+
*/
|
|
54
|
+
function saveUsersToFile() {
|
|
55
|
+
if (!isDirty)
|
|
56
|
+
return;
|
|
57
|
+
if (saveTimer) {
|
|
58
|
+
return; // 已有定时器在等待
|
|
59
|
+
}
|
|
60
|
+
saveTimer = setTimeout(() => {
|
|
61
|
+
saveTimer = null;
|
|
62
|
+
doSaveUsersToFile();
|
|
63
|
+
}, SAVE_THROTTLE_MS);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 实际执行保存
|
|
67
|
+
*/
|
|
68
|
+
function doSaveUsersToFile() {
|
|
69
|
+
if (!usersCache || !isDirty)
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
ensureDir();
|
|
73
|
+
const users = Array.from(usersCache.values());
|
|
74
|
+
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
|
|
75
|
+
isDirty = false;
|
|
76
|
+
console.log(`[known-users] Saved ${users.length} users to file`);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error(`[known-users] Failed to save users: ${err}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 强制立即保存(用于进程退出前)
|
|
84
|
+
*/
|
|
85
|
+
export function flushKnownUsers() {
|
|
86
|
+
if (saveTimer) {
|
|
87
|
+
clearTimeout(saveTimer);
|
|
88
|
+
saveTimer = null;
|
|
89
|
+
}
|
|
90
|
+
doSaveUsersToFile();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 生成用户唯一键
|
|
94
|
+
*/
|
|
95
|
+
function makeUserKey(user) {
|
|
96
|
+
const base = `${user.accountId}:${user.type}:${user.openid}`;
|
|
97
|
+
if (user.type === "group" && user.groupOpenid) {
|
|
98
|
+
return `${base}:${user.groupOpenid}`;
|
|
99
|
+
}
|
|
100
|
+
return base;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 记录已知用户(收到消息时调用)
|
|
104
|
+
* @param user 用户信息(部分字段)
|
|
105
|
+
*/
|
|
106
|
+
export function recordKnownUser(user) {
|
|
107
|
+
const cache = loadUsersFromFile();
|
|
108
|
+
const key = makeUserKey(user);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const existing = cache.get(key);
|
|
111
|
+
if (existing) {
|
|
112
|
+
// 更新已存在的用户
|
|
113
|
+
existing.lastSeenAt = now;
|
|
114
|
+
existing.interactionCount++;
|
|
115
|
+
if (user.nickname && user.nickname !== existing.nickname) {
|
|
116
|
+
existing.nickname = user.nickname;
|
|
117
|
+
}
|
|
118
|
+
console.log(`[known-users] Updated user ${user.openid}, interactions: ${existing.interactionCount}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// 新用户
|
|
122
|
+
const newUser = {
|
|
123
|
+
openid: user.openid,
|
|
124
|
+
type: user.type,
|
|
125
|
+
nickname: user.nickname,
|
|
126
|
+
groupOpenid: user.groupOpenid,
|
|
127
|
+
accountId: user.accountId,
|
|
128
|
+
firstSeenAt: now,
|
|
129
|
+
lastSeenAt: now,
|
|
130
|
+
interactionCount: 1,
|
|
131
|
+
};
|
|
132
|
+
cache.set(key, newUser);
|
|
133
|
+
console.log(`[known-users] New user recorded: ${user.openid} (${user.type})`);
|
|
134
|
+
}
|
|
135
|
+
isDirty = true;
|
|
136
|
+
saveUsersToFile();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 获取单个用户信息
|
|
140
|
+
* @param accountId 机器人账户 ID
|
|
141
|
+
* @param openid 用户 openid
|
|
142
|
+
* @param type 消息类型
|
|
143
|
+
* @param groupOpenid 群组 openid(可选)
|
|
144
|
+
*/
|
|
145
|
+
export function getKnownUser(accountId, openid, type = "c2c", groupOpenid) {
|
|
146
|
+
const cache = loadUsersFromFile();
|
|
147
|
+
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
|
148
|
+
return cache.get(key);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 列出所有已知用户
|
|
152
|
+
* @param options 筛选选项
|
|
153
|
+
*/
|
|
154
|
+
export function listKnownUsers(options) {
|
|
155
|
+
const cache = loadUsersFromFile();
|
|
156
|
+
let users = Array.from(cache.values());
|
|
157
|
+
// 筛选
|
|
158
|
+
if (options?.accountId) {
|
|
159
|
+
users = users.filter(u => u.accountId === options.accountId);
|
|
160
|
+
}
|
|
161
|
+
if (options?.type) {
|
|
162
|
+
users = users.filter(u => u.type === options.type);
|
|
163
|
+
}
|
|
164
|
+
if (options?.activeWithin) {
|
|
165
|
+
const cutoff = Date.now() - options.activeWithin;
|
|
166
|
+
users = users.filter(u => u.lastSeenAt >= cutoff);
|
|
167
|
+
}
|
|
168
|
+
// 排序
|
|
169
|
+
const sortBy = options?.sortBy ?? "lastSeenAt";
|
|
170
|
+
const sortOrder = options?.sortOrder ?? "desc";
|
|
171
|
+
users.sort((a, b) => {
|
|
172
|
+
const aVal = a[sortBy] ?? 0;
|
|
173
|
+
const bVal = b[sortBy] ?? 0;
|
|
174
|
+
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
|
175
|
+
});
|
|
176
|
+
// 限制数量
|
|
177
|
+
if (options?.limit && options.limit > 0) {
|
|
178
|
+
users = users.slice(0, options.limit);
|
|
179
|
+
}
|
|
180
|
+
return users;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 获取用户统计信息
|
|
184
|
+
* @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计)
|
|
185
|
+
*/
|
|
186
|
+
export function getKnownUsersStats(accountId) {
|
|
187
|
+
let users = listKnownUsers({ accountId });
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const day = 24 * 60 * 60 * 1000;
|
|
190
|
+
return {
|
|
191
|
+
totalUsers: users.length,
|
|
192
|
+
c2cUsers: users.filter(u => u.type === "c2c").length,
|
|
193
|
+
groupUsers: users.filter(u => u.type === "group").length,
|
|
194
|
+
activeIn24h: users.filter(u => now - u.lastSeenAt < day).length,
|
|
195
|
+
activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 删除用户记录
|
|
200
|
+
* @param accountId 机器人账户 ID
|
|
201
|
+
* @param openid 用户 openid
|
|
202
|
+
* @param type 消息类型
|
|
203
|
+
* @param groupOpenid 群组 openid(可选)
|
|
204
|
+
*/
|
|
205
|
+
export function removeKnownUser(accountId, openid, type = "c2c", groupOpenid) {
|
|
206
|
+
const cache = loadUsersFromFile();
|
|
207
|
+
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
|
208
|
+
if (cache.has(key)) {
|
|
209
|
+
cache.delete(key);
|
|
210
|
+
isDirty = true;
|
|
211
|
+
saveUsersToFile();
|
|
212
|
+
console.log(`[known-users] Removed user ${openid}`);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 清除所有用户记录
|
|
219
|
+
* @param accountId 机器人账户 ID(可选,不传则清除所有)
|
|
220
|
+
*/
|
|
221
|
+
export function clearKnownUsers(accountId) {
|
|
222
|
+
const cache = loadUsersFromFile();
|
|
223
|
+
let count = 0;
|
|
224
|
+
if (accountId) {
|
|
225
|
+
// 只清除指定账户的用户
|
|
226
|
+
for (const [key, user] of cache.entries()) {
|
|
227
|
+
if (user.accountId === accountId) {
|
|
228
|
+
cache.delete(key);
|
|
229
|
+
count++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// 清除所有
|
|
235
|
+
count = cache.size;
|
|
236
|
+
cache.clear();
|
|
237
|
+
}
|
|
238
|
+
if (count > 0) {
|
|
239
|
+
isDirty = true;
|
|
240
|
+
doSaveUsersToFile(); // 立即保存
|
|
241
|
+
console.log(`[known-users] Cleared ${count} users`);
|
|
242
|
+
}
|
|
243
|
+
return count;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 获取用户的所有群组(某用户在哪些群里交互过)
|
|
247
|
+
* @param accountId 机器人账户 ID
|
|
248
|
+
* @param openid 用户 openid
|
|
249
|
+
*/
|
|
250
|
+
export function getUserGroups(accountId, openid) {
|
|
251
|
+
const users = listKnownUsers({ accountId, type: "group" });
|
|
252
|
+
return users
|
|
253
|
+
.filter(u => u.openid === openid && u.groupOpenid)
|
|
254
|
+
.map(u => u.groupOpenid);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* 获取群组的所有成员
|
|
258
|
+
* @param accountId 机器人账户 ID
|
|
259
|
+
* @param groupOpenid 群组 openid
|
|
260
|
+
*/
|
|
261
|
+
export function getGroupMembers(accountId, groupOpenid) {
|
|
262
|
+
return listKnownUsers({ accountId, type: "group" })
|
|
263
|
+
.filter(u => u.groupOpenid === groupOpenid);
|
|
264
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot CLI Onboarding Adapter
|
|
3
|
+
*
|
|
4
|
+
* 提供 openclaw onboard 命令的交互式配置支持
|
|
5
|
+
*/
|
|
6
|
+
import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
|
|
7
|
+
/**
|
|
8
|
+
* QQBot Onboarding Adapter
|
|
9
|
+
*/
|
|
10
|
+
export declare const qqbotOnboardingAdapter: ChannelOnboardingAdapter;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* 解析默认账户 ID
|
|
4
|
+
*/
|
|
5
|
+
function resolveDefaultQQBotAccountId(cfg) {
|
|
6
|
+
const ids = listQQBotAccountIds(cfg);
|
|
7
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* QQBot Onboarding Adapter
|
|
11
|
+
*/
|
|
12
|
+
export const qqbotOnboardingAdapter = {
|
|
13
|
+
channel: "qqbot",
|
|
14
|
+
getStatus: async (ctx) => {
|
|
15
|
+
const cfg = ctx.cfg;
|
|
16
|
+
const configured = listQQBotAccountIds(cfg).some((accountId) => {
|
|
17
|
+
const account = resolveQQBotAccount(cfg, accountId);
|
|
18
|
+
return Boolean(account.appId && account.clientSecret);
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
channel: "qqbot",
|
|
22
|
+
configured,
|
|
23
|
+
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
|
24
|
+
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
|
|
25
|
+
quickstartScore: configured ? 1 : 20,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
configure: async (ctx) => {
|
|
29
|
+
const cfg = ctx.cfg;
|
|
30
|
+
const prompter = ctx.prompter;
|
|
31
|
+
const accountOverrides = ctx.accountOverrides;
|
|
32
|
+
const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
|
|
33
|
+
const qqbotOverride = accountOverrides?.qqbot?.trim();
|
|
34
|
+
const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
|
|
35
|
+
let accountId = qqbotOverride ?? defaultAccountId;
|
|
36
|
+
// 是否需要提示选择账户
|
|
37
|
+
if (shouldPromptAccountIds && !qqbotOverride) {
|
|
38
|
+
const existingIds = listQQBotAccountIds(cfg);
|
|
39
|
+
if (existingIds.length > 1) {
|
|
40
|
+
accountId = await prompter.select({
|
|
41
|
+
message: "选择 QQBot 账户",
|
|
42
|
+
options: existingIds.map((id) => ({
|
|
43
|
+
value: id,
|
|
44
|
+
label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id,
|
|
45
|
+
})),
|
|
46
|
+
initialValue: accountId,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let next = cfg;
|
|
51
|
+
const resolvedAccount = resolveQQBotAccount(next, accountId);
|
|
52
|
+
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
|
|
53
|
+
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
|
54
|
+
const envAppId = typeof process !== "undefined" ? process.env?.QQBOT_APP_ID?.trim() : undefined;
|
|
55
|
+
const envSecret = typeof process !== "undefined" ? process.env?.QQBOT_CLIENT_SECRET?.trim() : undefined;
|
|
56
|
+
const canUseEnv = allowEnv && Boolean(envAppId && envSecret);
|
|
57
|
+
const hasConfigCredentials = Boolean(resolvedAccount.config.appId && resolvedAccount.config.clientSecret);
|
|
58
|
+
let appId = null;
|
|
59
|
+
let clientSecret = null;
|
|
60
|
+
// 显示帮助
|
|
61
|
+
if (!accountConfigured) {
|
|
62
|
+
await prompter.note([
|
|
63
|
+
"1) 打开 QQ 开放平台: https://q.qq.com/",
|
|
64
|
+
"2) 创建机器人应用,获取 AppID 和 ClientSecret",
|
|
65
|
+
"3) 在「开发设置」中添加沙箱成员(测试阶段)",
|
|
66
|
+
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
|
|
67
|
+
"",
|
|
68
|
+
"文档: https://bot.q.qq.com/wiki/",
|
|
69
|
+
"",
|
|
70
|
+
"此版本支持流式消息发送!",
|
|
71
|
+
].join("\n"), "QQ Bot 配置");
|
|
72
|
+
}
|
|
73
|
+
// 检测环境变量
|
|
74
|
+
if (canUseEnv && !hasConfigCredentials) {
|
|
75
|
+
const keepEnv = await prompter.confirm({
|
|
76
|
+
message: "检测到环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET,是否使用?",
|
|
77
|
+
initialValue: true,
|
|
78
|
+
});
|
|
79
|
+
if (keepEnv) {
|
|
80
|
+
next = {
|
|
81
|
+
...next,
|
|
82
|
+
channels: {
|
|
83
|
+
...next.channels,
|
|
84
|
+
qqbot: {
|
|
85
|
+
...(next.channels?.qqbot || {}),
|
|
86
|
+
enabled: true,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// 手动输入
|
|
93
|
+
appId = String(await prompter.text({
|
|
94
|
+
message: "请输入 QQ Bot AppID",
|
|
95
|
+
placeholder: "例如: 102146862",
|
|
96
|
+
initialValue: resolvedAccount.appId || undefined,
|
|
97
|
+
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
|
98
|
+
})).trim();
|
|
99
|
+
clientSecret = String(await prompter.text({
|
|
100
|
+
message: "请输入 QQ Bot ClientSecret",
|
|
101
|
+
placeholder: "你的 ClientSecret",
|
|
102
|
+
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
|
103
|
+
})).trim();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (hasConfigCredentials) {
|
|
107
|
+
// 已有配置
|
|
108
|
+
const keep = await prompter.confirm({
|
|
109
|
+
message: "QQ Bot 已配置,是否保留当前配置?",
|
|
110
|
+
initialValue: true,
|
|
111
|
+
});
|
|
112
|
+
if (!keep) {
|
|
113
|
+
appId = String(await prompter.text({
|
|
114
|
+
message: "请输入 QQ Bot AppID",
|
|
115
|
+
placeholder: "例如: 102146862",
|
|
116
|
+
initialValue: resolvedAccount.appId || undefined,
|
|
117
|
+
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
|
118
|
+
})).trim();
|
|
119
|
+
clientSecret = String(await prompter.text({
|
|
120
|
+
message: "请输入 QQ Bot ClientSecret",
|
|
121
|
+
placeholder: "你的 ClientSecret",
|
|
122
|
+
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
|
123
|
+
})).trim();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// 没有配置,需要输入
|
|
128
|
+
appId = String(await prompter.text({
|
|
129
|
+
message: "请输入 QQ Bot AppID",
|
|
130
|
+
placeholder: "例如: 102146862",
|
|
131
|
+
initialValue: resolvedAccount.appId || undefined,
|
|
132
|
+
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
|
133
|
+
})).trim();
|
|
134
|
+
clientSecret = String(await prompter.text({
|
|
135
|
+
message: "请输入 QQ Bot ClientSecret",
|
|
136
|
+
placeholder: "你的 ClientSecret",
|
|
137
|
+
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
|
138
|
+
})).trim();
|
|
139
|
+
}
|
|
140
|
+
// 应用配置
|
|
141
|
+
if (appId && clientSecret) {
|
|
142
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
143
|
+
next = {
|
|
144
|
+
...next,
|
|
145
|
+
channels: {
|
|
146
|
+
...next.channels,
|
|
147
|
+
qqbot: {
|
|
148
|
+
...(next.channels?.qqbot || {}),
|
|
149
|
+
enabled: true,
|
|
150
|
+
appId,
|
|
151
|
+
clientSecret,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
next = {
|
|
158
|
+
...next,
|
|
159
|
+
channels: {
|
|
160
|
+
...next.channels,
|
|
161
|
+
qqbot: {
|
|
162
|
+
...(next.channels?.qqbot || {}),
|
|
163
|
+
enabled: true,
|
|
164
|
+
accounts: {
|
|
165
|
+
...(next.channels?.qqbot?.accounts || {}),
|
|
166
|
+
[accountId]: {
|
|
167
|
+
...(next.channels?.qqbot?.accounts?.[accountId] || {}),
|
|
168
|
+
enabled: true,
|
|
169
|
+
appId,
|
|
170
|
+
clientSecret,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { success: true, cfg: next, accountId };
|
|
179
|
+
},
|
|
180
|
+
disable: (cfg) => {
|
|
181
|
+
const config = cfg;
|
|
182
|
+
return {
|
|
183
|
+
...config,
|
|
184
|
+
channels: {
|
|
185
|
+
...config.channels,
|
|
186
|
+
qqbot: { ...(config.channels?.qqbot || {}), enabled: false },
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 消息发送模块
|
|
3
|
+
*/
|
|
4
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
5
|
+
/** 限流检查结果 */
|
|
6
|
+
export interface ReplyLimitResult {
|
|
7
|
+
/** 是否允许被动回复 */
|
|
8
|
+
allowed: boolean;
|
|
9
|
+
/** 剩余被动回复次数 */
|
|
10
|
+
remaining: number;
|
|
11
|
+
/** 是否需要降级为主动消息(超期或超过次数) */
|
|
12
|
+
shouldFallbackToProactive: boolean;
|
|
13
|
+
/** 降级原因 */
|
|
14
|
+
fallbackReason?: "expired" | "limit_exceeded";
|
|
15
|
+
/** 提示消息 */
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 检查是否可以回复该消息(限流检查)
|
|
20
|
+
* @param messageId 消息ID
|
|
21
|
+
* @returns ReplyLimitResult 限流检查结果
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkMessageReplyLimit(messageId: string): ReplyLimitResult;
|
|
24
|
+
/**
|
|
25
|
+
* 记录一次消息回复
|
|
26
|
+
* @param messageId 消息ID
|
|
27
|
+
*/
|
|
28
|
+
export declare function recordMessageReply(messageId: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* 获取消息回复统计信息
|
|
31
|
+
*/
|
|
32
|
+
export declare function getMessageReplyStats(): {
|
|
33
|
+
trackedMessages: number;
|
|
34
|
+
totalReplies: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* 获取消息回复限制配置(供外部查询)
|
|
38
|
+
*/
|
|
39
|
+
export declare function getMessageReplyConfig(): {
|
|
40
|
+
limit: number;
|
|
41
|
+
ttlMs: number;
|
|
42
|
+
ttlHours: number;
|
|
43
|
+
};
|
|
44
|
+
export interface OutboundContext {
|
|
45
|
+
to: string;
|
|
46
|
+
text: string;
|
|
47
|
+
accountId?: string | null;
|
|
48
|
+
replyToId?: string | null;
|
|
49
|
+
account: ResolvedQQBotAccount;
|
|
50
|
+
}
|
|
51
|
+
export interface MediaOutboundContext extends OutboundContext {
|
|
52
|
+
mediaUrl: string;
|
|
53
|
+
}
|
|
54
|
+
export interface OutboundResult {
|
|
55
|
+
channel: string;
|
|
56
|
+
messageId?: string;
|
|
57
|
+
timestamp?: string | number;
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 发送文本消息
|
|
62
|
+
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
|
63
|
+
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
|
64
|
+
*
|
|
65
|
+
* 注意:
|
|
66
|
+
* 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送
|
|
67
|
+
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
|
|
68
|
+
*/
|
|
69
|
+
export declare function sendText(ctx: OutboundContext): Promise<OutboundResult>;
|
|
70
|
+
/**
|
|
71
|
+
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
|
72
|
+
*
|
|
73
|
+
* @param account - 账户配置
|
|
74
|
+
* @param to - 目标地址,格式:openid(单聊)或 group:xxx(群聊)
|
|
75
|
+
* @param text - 消息内容
|
|
76
|
+
*/
|
|
77
|
+
export declare function sendProactiveMessage(account: ResolvedQQBotAccount, to: string, text: string): Promise<OutboundResult>;
|
|
78
|
+
/**
|
|
79
|
+
* 发送富媒体消息(图片)
|
|
80
|
+
*
|
|
81
|
+
* 支持以下 mediaUrl 格式:
|
|
82
|
+
* - 公网 URL: https://example.com/image.png
|
|
83
|
+
* - Base64 Data URL: data:image/png;base64,xxxxx
|
|
84
|
+
* - 本地文件路径: /path/to/image.png(自动读取并转换为 Base64)
|
|
85
|
+
*
|
|
86
|
+
* @param ctx - 发送上下文,包含 mediaUrl
|
|
87
|
+
* @returns 发送结果
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* // 发送网络图片
|
|
92
|
+
* const result = await sendMedia({
|
|
93
|
+
* to: "group:xxx",
|
|
94
|
+
* text: "这是图片说明",
|
|
95
|
+
* mediaUrl: "https://example.com/image.png",
|
|
96
|
+
* account,
|
|
97
|
+
* replyToId: msgId,
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* // 发送 Base64 图片
|
|
101
|
+
* const result = await sendMedia({
|
|
102
|
+
* to: "group:xxx",
|
|
103
|
+
* text: "这是图片说明",
|
|
104
|
+
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
|
|
105
|
+
* account,
|
|
106
|
+
* replyToId: msgId,
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* // 发送本地文件(自动读取并转换为 Base64)
|
|
110
|
+
* const result = await sendMedia({
|
|
111
|
+
* to: "group:xxx",
|
|
112
|
+
* text: "这是图片说明",
|
|
113
|
+
* mediaUrl: "/tmp/generated-chart.png",
|
|
114
|
+
* account,
|
|
115
|
+
* replyToId: msgId,
|
|
116
|
+
* });
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export declare function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult>;
|
|
120
|
+
/**
|
|
121
|
+
* 发送 Cron 触发的消息
|
|
122
|
+
*
|
|
123
|
+
* 当 OpenClaw cron 任务触发时,消息内容可能是:
|
|
124
|
+
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
|
|
125
|
+
* 2. 普通文本 - 直接发送到指定目标
|
|
126
|
+
*
|
|
127
|
+
* @param account - 账户配置
|
|
128
|
+
* @param to - 目标地址(作为后备,如果载荷中没有指定)
|
|
129
|
+
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
|
|
130
|
+
* @returns 发送结果
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* // 处理结构化载荷
|
|
135
|
+
* const result = await sendCronMessage(
|
|
136
|
+
* account,
|
|
137
|
+
* "user_openid", // 后备地址
|
|
138
|
+
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
|
|
139
|
+
* );
|
|
140
|
+
*
|
|
141
|
+
* // 处理普通文本
|
|
142
|
+
* const result = await sendCronMessage(
|
|
143
|
+
* account,
|
|
144
|
+
* "user_openid",
|
|
145
|
+
* "这是一条普通的提醒消息"
|
|
146
|
+
* );
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export declare function sendCronMessage(account: ResolvedQQBotAccount, to: string, message: string): Promise<OutboundResult>;
|