@tencent-connect/openclaw-qqbot 1.0.3-alpha.0 → 1.0.4-alpha.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/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +63 -0
- package/src/api.ts +56 -19
- package/src/config.ts +1 -0
- package/src/gateway.ts +271 -398
- package/src/outbound.ts +17 -3
- package/src/stream-handlers/chain.ts +75 -0
- package/src/stream-handlers/index.ts +31 -0
- package/src/stream-handlers/markdown-link-handler.ts +134 -0
- package/src/stream-handlers/media-tag-handler.ts +138 -0
- package/src/stream-handlers/payload-handler.ts +46 -0
- package/src/stream-handlers/types.ts +97 -0
- package/src/types.ts +4 -0
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ PKG_NAME="@tencent-connect/openclaw-qqbot"
|
|
|
13
13
|
INSTALL_SRC=""
|
|
14
14
|
APPID=""
|
|
15
15
|
SECRET=""
|
|
16
|
+
STREAM=""
|
|
16
17
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
17
18
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
18
19
|
|
|
@@ -31,6 +32,7 @@ print_usage() {
|
|
|
31
32
|
echo " upgrade-via-npm.sh # 升级到 latest(默认)"
|
|
32
33
|
echo " upgrade-via-npm.sh --version <版本号> # 升级到指定版本"
|
|
33
34
|
echo " upgrade-via-npm.sh --appid <appid> --secret <secret> # 配置通道并启动"
|
|
35
|
+
echo " upgrade-via-npm.sh --stream <yes|no> # 是否启用流式消息(仅C2C私聊生效)"
|
|
34
36
|
if [ -n "$LOCAL_VERSION" ]; then
|
|
35
37
|
echo " upgrade-via-npm.sh --self-version # 升级到当前仓库版本($LOCAL_VERSION)"
|
|
36
38
|
else
|
|
@@ -40,6 +42,7 @@ print_usage() {
|
|
|
40
42
|
echo "也可以通过环境变量设置:"
|
|
41
43
|
echo " QQBOT_APPID QQ机器人 appid"
|
|
42
44
|
echo " QQBOT_SECRET QQ机器人 secret"
|
|
45
|
+
echo " QQBOT_STREAM 是否启用流式消息(yes/no)"
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
while [[ $# -gt 0 ]]; do
|
|
@@ -69,6 +72,11 @@ while [[ $# -gt 0 ]]; do
|
|
|
69
72
|
SECRET="$2"
|
|
70
73
|
shift 2
|
|
71
74
|
;;
|
|
75
|
+
--stream)
|
|
76
|
+
[ -z "$2" ] && echo "❌ --stream 需要参数" && exit 1
|
|
77
|
+
STREAM="$2"
|
|
78
|
+
shift 2
|
|
79
|
+
;;
|
|
72
80
|
-h|--help)
|
|
73
81
|
print_usage
|
|
74
82
|
exit 0
|
|
@@ -81,6 +89,7 @@ INSTALL_SRC="${INSTALL_SRC:-${PKG_NAME}@latest}"
|
|
|
81
89
|
# 使用命令行参数或环境变量
|
|
82
90
|
APPID="${APPID:-$QQBOT_APPID}"
|
|
83
91
|
SECRET="${SECRET:-$QQBOT_SECRET}"
|
|
92
|
+
STREAM="${STREAM:-$QQBOT_STREAM}"
|
|
84
93
|
|
|
85
94
|
# 检测 CLI
|
|
86
95
|
CMD=""
|
|
@@ -188,6 +197,60 @@ if [ -n "$APPID" ] && [ -n "$SECRET" ]; then
|
|
|
188
197
|
fi
|
|
189
198
|
fi
|
|
190
199
|
|
|
200
|
+
# 配置 stream 选项(仅在明确指定时才配置)
|
|
201
|
+
if [ -n "$STREAM" ]; then
|
|
202
|
+
echo ""
|
|
203
|
+
echo "配置 stream 选项..."
|
|
204
|
+
if [ "$STREAM" = "yes" ] || [ "$STREAM" = "y" ] || [ "$STREAM" = "true" ]; then
|
|
205
|
+
STREAM_VALUE="true"
|
|
206
|
+
echo "启用流式消息(仅C2C私聊生效)..."
|
|
207
|
+
else
|
|
208
|
+
STREAM_VALUE="false"
|
|
209
|
+
echo "禁用流式消息..."
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
CURRENT_STREAM_VALUE=$(node -e "
|
|
213
|
+
const fs = require('fs');
|
|
214
|
+
const path = require('path');
|
|
215
|
+
const home = process.env.HOME;
|
|
216
|
+
for (const app of ['openclaw', 'clawdbot', 'moltbot']) {
|
|
217
|
+
const f = path.join(home, '.' + app, app + '.json');
|
|
218
|
+
if (!fs.existsSync(f)) continue;
|
|
219
|
+
try {
|
|
220
|
+
const cfg = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
221
|
+
const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
|
|
222
|
+
for (const key of keys) {
|
|
223
|
+
const ch = cfg.channels && cfg.channels[key];
|
|
224
|
+
if (!ch) continue;
|
|
225
|
+
if (typeof ch.streamSupport === 'boolean') { process.stdout.write(String(ch.streamSupport)); process.exit(0); }
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
" 2>/dev/null || true)
|
|
230
|
+
|
|
231
|
+
if [ "$CURRENT_STREAM_VALUE" = "$STREAM_VALUE" ]; then
|
|
232
|
+
echo " ✅ stream 配置已是目标值,跳过写入"
|
|
233
|
+
elif $CMD config set channels.qqbot.streamSupport "$STREAM_VALUE" 2>&1; then
|
|
234
|
+
echo " ✅ stream 配置成功"
|
|
235
|
+
else
|
|
236
|
+
echo " ⚠️ $CMD config set 失败,尝试直接编辑配置文件..."
|
|
237
|
+
if [ -f "$APP_CONFIG" ] && node -e "
|
|
238
|
+
const fs = require('fs');
|
|
239
|
+
const cfg = JSON.parse(fs.readFileSync('$APP_CONFIG', 'utf-8'));
|
|
240
|
+
if (!cfg.channels) cfg.channels = {};
|
|
241
|
+
if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
|
|
242
|
+
const target = $STREAM_VALUE;
|
|
243
|
+
if (cfg.channels.qqbot.streamSupport === target) process.exit(0);
|
|
244
|
+
cfg.channels.qqbot.streamSupport = target;
|
|
245
|
+
fs.writeFileSync('$APP_CONFIG', JSON.stringify(cfg, null, 4) + '\n');
|
|
246
|
+
" 2>&1; then
|
|
247
|
+
echo " ✅ stream 配置成功(直接编辑配置文件)"
|
|
248
|
+
else
|
|
249
|
+
echo " ⚠️ stream 配置设置失败,不影响后续运行"
|
|
250
|
+
fi
|
|
251
|
+
fi
|
|
252
|
+
fi
|
|
253
|
+
|
|
191
254
|
# [4/4] 重启网关
|
|
192
255
|
echo ""
|
|
193
256
|
echo "[4/4] 重启网关..."
|
package/src/api.ts
CHANGED
|
@@ -13,12 +13,28 @@ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
|
13
13
|
// 运行时配置
|
|
14
14
|
let currentMarkdownSupport = false;
|
|
15
15
|
|
|
16
|
+
// 模块级 logger:通过 initApiConfig 注入,未注入时 fallback 到 console
|
|
17
|
+
let apiLog: {
|
|
18
|
+
info: (msg: string) => void;
|
|
19
|
+
error: (msg: string) => void;
|
|
20
|
+
} = {
|
|
21
|
+
info: console.log.bind(console),
|
|
22
|
+
error: console.error.bind(console),
|
|
23
|
+
};
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* 初始化 API 配置
|
|
18
27
|
* @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
|
|
28
|
+
* @param options.log - 可选的 logger 接口,注入后 API 日志将通过此 logger 输出(而非 console)
|
|
19
29
|
*/
|
|
20
|
-
export function initApiConfig(options: {
|
|
30
|
+
export function initApiConfig(options: {
|
|
31
|
+
markdownSupport?: boolean;
|
|
32
|
+
log?: { info: (msg: string) => void; error: (msg: string) => void };
|
|
33
|
+
}): void {
|
|
21
34
|
currentMarkdownSupport = options.markdownSupport === true;
|
|
35
|
+
if (options.log) {
|
|
36
|
+
apiLog = options.log;
|
|
37
|
+
}
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
/**
|
|
@@ -54,7 +70,7 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
|
|
54
70
|
// Singleflight: 如果当前 appId 已有进行中的 Token 获取请求,复用它
|
|
55
71
|
let fetchPromise = tokenFetchPromises.get(normalizedAppId);
|
|
56
72
|
if (fetchPromise) {
|
|
57
|
-
|
|
73
|
+
apiLog.info(`[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`);
|
|
58
74
|
return fetchPromise;
|
|
59
75
|
}
|
|
60
76
|
|
|
@@ -80,7 +96,9 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
|
|
|
80
96
|
const requestHeaders = { "Content-Type": "application/json" };
|
|
81
97
|
|
|
82
98
|
// 打印请求信息(隐藏敏感信息)
|
|
83
|
-
|
|
99
|
+
apiLog.info(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`);
|
|
100
|
+
apiLog.info(`[qqbot-api:${appId}] >>> Headers: ${JSON.stringify(requestHeaders, null, 2)}`);
|
|
101
|
+
apiLog.info(`[qqbot-api:${appId}] >>> Body: ${JSON.stringify({ appId, clientSecret: "***" }, null, 2)}`);
|
|
84
102
|
|
|
85
103
|
let response: Response;
|
|
86
104
|
try {
|
|
@@ -90,7 +108,7 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
|
|
|
90
108
|
body: JSON.stringify(requestBody),
|
|
91
109
|
});
|
|
92
110
|
} catch (err) {
|
|
93
|
-
|
|
111
|
+
apiLog.error(`[qqbot-api:${appId}] <<< Network error: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
112
|
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
113
|
}
|
|
96
114
|
|
|
@@ -99,18 +117,25 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
|
|
|
99
117
|
response.headers.forEach((value, key) => {
|
|
100
118
|
responseHeaders[key] = value;
|
|
101
119
|
});
|
|
102
|
-
|
|
120
|
+
apiLog.info(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}`);
|
|
121
|
+
apiLog.info(`[qqbot-api:${appId}] <<< Response Headers: ${JSON.stringify(responseHeaders, null, 2)}`);
|
|
103
122
|
|
|
104
123
|
let data: { access_token?: string; expires_in?: number };
|
|
105
124
|
let rawBody: string;
|
|
106
125
|
try {
|
|
107
126
|
rawBody = await response.text();
|
|
108
|
-
// 隐藏 token
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
// 隐藏 token 值,格式化打印
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(rawBody);
|
|
130
|
+
const logParsed = { ...parsed };
|
|
131
|
+
if (logParsed.access_token) logParsed.access_token = "***";
|
|
132
|
+
apiLog.info(`[qqbot-api:${appId}] <<< Response Body: ${JSON.stringify(logParsed, null, 2)}`);
|
|
133
|
+
} catch {
|
|
134
|
+
apiLog.info(`[qqbot-api:${appId}] <<< Response Body (raw): ${rawBody.slice(0, 2000)}`);
|
|
135
|
+
}
|
|
111
136
|
data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
|
|
112
137
|
} catch (err) {
|
|
113
|
-
|
|
138
|
+
apiLog.error(`[qqbot-api:${appId}] <<< Parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
114
139
|
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
|
|
115
140
|
}
|
|
116
141
|
|
|
@@ -126,7 +151,7 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
|
|
|
126
151
|
appId,
|
|
127
152
|
});
|
|
128
153
|
|
|
129
|
-
|
|
154
|
+
apiLog.info(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`);
|
|
130
155
|
return data.access_token;
|
|
131
156
|
}
|
|
132
157
|
|
|
@@ -138,10 +163,10 @@ export function clearTokenCache(appId?: string): void {
|
|
|
138
163
|
if (appId) {
|
|
139
164
|
const normalizedAppId = String(appId).trim();
|
|
140
165
|
tokenCacheMap.delete(normalizedAppId);
|
|
141
|
-
|
|
166
|
+
apiLog.info(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`);
|
|
142
167
|
} else {
|
|
143
168
|
tokenCacheMap.clear();
|
|
144
|
-
|
|
169
|
+
apiLog.info(`[qqbot-api] All token caches cleared.`);
|
|
145
170
|
}
|
|
146
171
|
}
|
|
147
172
|
|
|
@@ -208,13 +233,16 @@ export async function apiRequest<T = unknown>(
|
|
|
208
233
|
options.body = JSON.stringify(body);
|
|
209
234
|
}
|
|
210
235
|
|
|
211
|
-
//
|
|
212
|
-
|
|
236
|
+
// 打印请求信息:方法、URL、请求头、请求体(JSON 格式化)
|
|
237
|
+
apiLog.info(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
|
|
238
|
+
apiLog.info(`[qqbot-api] >>> Headers: ${JSON.stringify(headers, null, 2)}`);
|
|
213
239
|
if (body) {
|
|
214
240
|
const logBody = { ...body } as Record<string, unknown>;
|
|
241
|
+
// 脱敏:base64 文件数据只显示长度
|
|
215
242
|
if (typeof logBody.file_data === "string") {
|
|
216
243
|
logBody.file_data = `<base64 ${(logBody.file_data as string).length} chars>`;
|
|
217
244
|
}
|
|
245
|
+
apiLog.info(`[qqbot-api] >>> Body: ${JSON.stringify(logBody, null, 2)}`);
|
|
218
246
|
}
|
|
219
247
|
|
|
220
248
|
let res: Response;
|
|
@@ -223,25 +251,34 @@ export async function apiRequest<T = unknown>(
|
|
|
223
251
|
} catch (err) {
|
|
224
252
|
clearTimeout(timeoutId);
|
|
225
253
|
if (err instanceof Error && err.name === "AbortError") {
|
|
226
|
-
|
|
254
|
+
apiLog.error(`[qqbot-api] <<< Request timeout after ${timeout}ms`);
|
|
227
255
|
throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`);
|
|
228
256
|
}
|
|
229
|
-
|
|
257
|
+
apiLog.error(`[qqbot-api] <<< Network error: ${err instanceof Error ? err.message : String(err)}`);
|
|
230
258
|
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
|
231
259
|
} finally {
|
|
232
260
|
clearTimeout(timeoutId);
|
|
233
261
|
}
|
|
234
262
|
|
|
263
|
+
// 打印响应头
|
|
235
264
|
const responseHeaders: Record<string, string> = {};
|
|
236
265
|
res.headers.forEach((value, key) => {
|
|
237
266
|
responseHeaders[key] = value;
|
|
238
267
|
});
|
|
239
|
-
|
|
268
|
+
apiLog.info(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
|
|
269
|
+
apiLog.info(`[qqbot-api] <<< Response Headers: ${JSON.stringify(responseHeaders, null, 2)}`);
|
|
240
270
|
|
|
241
271
|
let data: T;
|
|
242
272
|
let rawBody: string;
|
|
243
273
|
try {
|
|
244
274
|
rawBody = await res.text();
|
|
275
|
+
// 打印响应体(尝试 JSON 格式化)
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(rawBody);
|
|
278
|
+
apiLog.info(`[qqbot-api] <<< Response Body: ${JSON.stringify(parsed, null, 2)}`);
|
|
279
|
+
} catch {
|
|
280
|
+
apiLog.info(`[qqbot-api] <<< Response Body (raw): ${rawBody.slice(0, 2000)}`);
|
|
281
|
+
}
|
|
245
282
|
data = JSON.parse(rawBody) as T;
|
|
246
283
|
} catch (err) {
|
|
247
284
|
throw new Error(`Failed to parse response[${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -285,7 +322,7 @@ async function apiRequestWithRetry<T = unknown>(
|
|
|
285
322
|
|
|
286
323
|
if (attempt < maxRetries) {
|
|
287
324
|
const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
288
|
-
|
|
325
|
+
apiLog.info(`[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`);
|
|
289
326
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
290
327
|
}
|
|
291
328
|
}
|
|
@@ -639,7 +676,7 @@ export function startBackgroundTokenRefresh(
|
|
|
639
676
|
options?: BackgroundTokenRefreshOptions
|
|
640
677
|
): void {
|
|
641
678
|
if (backgroundRefreshControllers.has(appId)) {
|
|
642
|
-
|
|
679
|
+
apiLog.info(`[qqbot-api:${appId}] Background token refresh already running`);
|
|
643
680
|
return;
|
|
644
681
|
}
|
|
645
682
|
|
package/src/config.ts
CHANGED
|
@@ -120,6 +120,7 @@ export function resolveQQBotAccount(
|
|
|
120
120
|
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
|
121
121
|
markdownSupport: accountConfig.markdownSupport !== false,
|
|
122
122
|
streamSupport: accountConfig.streamSupport !== false,
|
|
123
|
+
debug: accountConfig.debug === true,
|
|
123
124
|
config: accountConfig,
|
|
124
125
|
};
|
|
125
126
|
}
|