@openclaw/qqbot 2026.5.2-beta.2 → 2026.5.3-beta.1
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/dist/api.js +646 -0
- package/dist/approval-cg0SVahb.js +94 -0
- package/dist/channel-N6Y_Rcjp.js +551 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.setup-ByCRAe5H.js +25 -0
- package/dist/config-D6545NkC.js +94 -0
- package/dist/config-schema-DFcjQw73.js +309 -0
- package/dist/exec-approvals-COUsM6wZ.js +138 -0
- package/dist/gateway-CmSUJKSt.js +6726 -0
- package/dist/handler-runtime-Bqm6N0WG.js +120 -0
- package/dist/index.js +29 -0
- package/dist/narrowing-BoieBTIU.js +25 -0
- package/dist/outbound-BJfhwrPg.js +1357 -0
- package/dist/request-context-DXtpwWui.js +921 -0
- package/dist/resolve-D_06fV6-.js +284 -0
- package/dist/runtime-BJAS3eXW.js +18 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-api.js +56 -0
- package/dist/sender-p-B14eLG.js +1987 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/string-normalize-Ci6NM5DE.js +98 -0
- package/dist/target-parser-Y0prnrXD.js +245 -0
- package/package.json +15 -6
- package/api.ts +0 -56
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -29
- package/runtime-api.ts +0 -9
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/bridge/approval/capability.ts +0 -237
- package/src/bridge/approval/handler-runtime.ts +0 -204
- package/src/bridge/bootstrap.ts +0 -135
- package/src/bridge/channel-entry.ts +0 -18
- package/src/bridge/commands/framework-context-adapter.ts +0 -60
- package/src/bridge/commands/framework-registration.ts +0 -47
- package/src/bridge/commands/from-parser.test.ts +0 -86
- package/src/bridge/commands/from-parser.ts +0 -60
- package/src/bridge/commands/result-dispatcher.ts +0 -76
- package/src/bridge/config-shared.ts +0 -132
- package/src/bridge/config.ts +0 -111
- package/src/bridge/gateway.ts +0 -174
- package/src/bridge/logger.ts +0 -31
- package/src/bridge/narrowing.ts +0 -31
- package/src/bridge/plugin-version.test.ts +0 -146
- package/src/bridge/plugin-version.ts +0 -102
- package/src/bridge/runtime.ts +0 -25
- package/src/bridge/sdk-adapter.ts +0 -131
- package/src/bridge/setup/finalize.ts +0 -144
- package/src/bridge/setup/surface.ts +0 -34
- package/src/bridge/tools/channel.ts +0 -58
- package/src/bridge/tools/index.ts +0 -15
- package/src/bridge/tools/remind.test.ts +0 -124
- package/src/bridge/tools/remind.ts +0 -91
- package/src/channel.setup.ts +0 -33
- package/src/channel.ts +0 -288
- package/src/command-auth.test.ts +0 -62
- package/src/config-schema.ts +0 -84
- package/src/config.test.ts +0 -364
- package/src/engine/access/access-control.test.ts +0 -198
- package/src/engine/access/access-control.ts +0 -226
- package/src/engine/access/index.ts +0 -16
- package/src/engine/access/resolve-policy.test.ts +0 -59
- package/src/engine/access/resolve-policy.ts +0 -57
- package/src/engine/access/sender-match.test.ts +0 -60
- package/src/engine/access/sender-match.ts +0 -55
- package/src/engine/access/types.ts +0 -53
- package/src/engine/adapter/audio.port.ts +0 -27
- package/src/engine/adapter/commands.port.ts +0 -22
- package/src/engine/adapter/history.port.ts +0 -52
- package/src/engine/adapter/index.ts +0 -139
- package/src/engine/adapter/mention-gate.port.ts +0 -50
- package/src/engine/adapter/types.ts +0 -38
- package/src/engine/api/api-client.ts +0 -212
- package/src/engine/api/media-chunked.test.ts +0 -336
- package/src/engine/api/media-chunked.ts +0 -622
- package/src/engine/api/media.ts +0 -218
- package/src/engine/api/messages.ts +0 -293
- package/src/engine/api/retry.ts +0 -217
- package/src/engine/api/routes.ts +0 -95
- package/src/engine/api/token.ts +0 -271
- package/src/engine/approval/index.test.ts +0 -22
- package/src/engine/approval/index.ts +0 -224
- package/src/engine/commands/builtin/log-helpers.ts +0 -319
- package/src/engine/commands/builtin/register-all.ts +0 -17
- package/src/engine/commands/builtin/register-approve.ts +0 -201
- package/src/engine/commands/builtin/register-basic.ts +0 -95
- package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
- package/src/engine/commands/builtin/register-logs.ts +0 -20
- package/src/engine/commands/builtin/register-streaming.ts +0 -137
- package/src/engine/commands/builtin/state.ts +0 -31
- package/src/engine/commands/slash-command-auth.ts +0 -48
- package/src/engine/commands/slash-command-handler.ts +0 -146
- package/src/engine/commands/slash-commands-impl.test.ts +0 -8
- package/src/engine/commands/slash-commands-impl.ts +0 -61
- package/src/engine/commands/slash-commands.ts +0 -199
- package/src/engine/config/credential-backup.test.ts +0 -88
- package/src/engine/config/credential-backup.ts +0 -107
- package/src/engine/config/credentials.ts +0 -76
- package/src/engine/config/group.test.ts +0 -234
- package/src/engine/config/group.ts +0 -299
- package/src/engine/config/resolve.test.ts +0 -152
- package/src/engine/config/resolve.ts +0 -283
- package/src/engine/config/setup-logic.ts +0 -84
- package/src/engine/engine-import-boundary.test.ts +0 -73
- package/src/engine/gateway/codec.ts +0 -47
- package/src/engine/gateway/constants.ts +0 -117
- package/src/engine/gateway/event-dispatcher.ts +0 -177
- package/src/engine/gateway/gateway-connection.ts +0 -371
- package/src/engine/gateway/gateway.ts +0 -291
- package/src/engine/gateway/inbound-attachments.test.ts +0 -126
- package/src/engine/gateway/inbound-attachments.ts +0 -360
- package/src/engine/gateway/inbound-context.ts +0 -195
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
- package/src/engine/gateway/inbound-pipeline.ts +0 -235
- package/src/engine/gateway/interaction-handler.ts +0 -220
- package/src/engine/gateway/message-queue.test.ts +0 -282
- package/src/engine/gateway/message-queue.ts +0 -499
- package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
- package/src/engine/gateway/outbound-dispatch.ts +0 -575
- package/src/engine/gateway/reconnect.ts +0 -199
- package/src/engine/gateway/stages/access-stage.ts +0 -132
- package/src/engine/gateway/stages/assembly-stage.ts +0 -156
- package/src/engine/gateway/stages/content-stage.test.ts +0 -77
- package/src/engine/gateway/stages/content-stage.ts +0 -77
- package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
- package/src/engine/gateway/stages/envelope-stage.ts +0 -144
- package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
- package/src/engine/gateway/stages/index.ts +0 -18
- package/src/engine/gateway/stages/quote-stage.ts +0 -113
- package/src/engine/gateway/stages/refidx-stage.ts +0 -62
- package/src/engine/gateway/stages/stub-contexts.ts +0 -116
- package/src/engine/gateway/types.ts +0 -264
- package/src/engine/gateway/typing-keepalive.ts +0 -79
- package/src/engine/group/activation.test.ts +0 -114
- package/src/engine/group/activation.ts +0 -147
- package/src/engine/group/history.test.ts +0 -314
- package/src/engine/group/history.ts +0 -321
- package/src/engine/group/mention.test.ts +0 -141
- package/src/engine/group/mention.ts +0 -197
- package/src/engine/group/message-gating.test.ts +0 -188
- package/src/engine/group/message-gating.ts +0 -216
- package/src/engine/messaging/decode-media-path.ts +0 -82
- package/src/engine/messaging/media-source.ts +0 -215
- package/src/engine/messaging/media-type-detect.ts +0 -37
- package/src/engine/messaging/outbound-audio-port.ts +0 -38
- package/src/engine/messaging/outbound-deliver.ts +0 -810
- package/src/engine/messaging/outbound-media-send.ts +0 -702
- package/src/engine/messaging/outbound-reply.ts +0 -27
- package/src/engine/messaging/outbound-result-helpers.ts +0 -54
- package/src/engine/messaging/outbound-types.ts +0 -45
- package/src/engine/messaging/outbound.ts +0 -485
- package/src/engine/messaging/reply-dispatcher.ts +0 -597
- package/src/engine/messaging/reply-limiter.ts +0 -164
- package/src/engine/messaging/sender.ts +0 -729
- package/src/engine/messaging/streaming-c2c.ts +0 -1192
- package/src/engine/messaging/streaming-media-send.ts +0 -544
- package/src/engine/messaging/target-parser.ts +0 -104
- package/src/engine/ref/format-message-ref.ts +0 -142
- package/src/engine/ref/format-ref-entry.test.ts +0 -60
- package/src/engine/ref/format-ref-entry.ts +0 -27
- package/src/engine/ref/store.ts +0 -208
- package/src/engine/ref/types.ts +0 -27
- package/src/engine/session/known-users.ts +0 -137
- package/src/engine/session/session-store.ts +0 -204
- package/src/engine/tools/channel-api.ts +0 -244
- package/src/engine/tools/remind-logic.test.ts +0 -280
- package/src/engine/tools/remind-logic.ts +0 -377
- package/src/engine/types.ts +0 -313
- package/src/engine/utils/attachment-tags.test.ts +0 -186
- package/src/engine/utils/attachment-tags.ts +0 -174
- package/src/engine/utils/audio.test.ts +0 -250
- package/src/engine/utils/audio.ts +0 -585
- package/src/engine/utils/data-paths.ts +0 -38
- package/src/engine/utils/diagnostics.ts +0 -109
- package/src/engine/utils/file-utils.test.ts +0 -72
- package/src/engine/utils/file-utils.ts +0 -225
- package/src/engine/utils/format.test.ts +0 -68
- package/src/engine/utils/format.ts +0 -70
- package/src/engine/utils/image-size.test.ts +0 -158
- package/src/engine/utils/image-size.ts +0 -249
- package/src/engine/utils/log.test.ts +0 -28
- package/src/engine/utils/log.ts +0 -61
- package/src/engine/utils/media-tags.test.ts +0 -32
- package/src/engine/utils/media-tags.ts +0 -177
- package/src/engine/utils/payload.test.ts +0 -68
- package/src/engine/utils/payload.ts +0 -145
- package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
- package/src/engine/utils/platform.test.ts +0 -148
- package/src/engine/utils/platform.ts +0 -343
- package/src/engine/utils/request-context.ts +0 -60
- package/src/engine/utils/string-normalize.ts +0 -91
- package/src/engine/utils/stt.test.ts +0 -104
- package/src/engine/utils/stt.ts +0 -100
- package/src/engine/utils/text-parsing.test.ts +0 -29
- package/src/engine/utils/text-parsing.ts +0 -155
- package/src/engine/utils/upload-cache.ts +0 -96
- package/src/engine/utils/voice-text.ts +0 -15
- package/src/exec-approvals.ts +0 -218
- package/src/manifest-schema.test.ts +0 -56
- package/src/qqbot-test-support.ts +0 -29
- package/src/types.ts +0 -210
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,1357 @@
|
|
|
1
|
+
import { i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, s as sanitizeFileName } from "./string-normalize-Ci6NM5DE.js";
|
|
2
|
+
import { A as fileExistsAsync, C as debugLog, D as UPLOAD_PREPARE_FALLBACK_CODE, F as readFileAsync, L as formatErrorMessage, M as getFileTypeName, N as getImageMimeType, O as checkFileSize, P as getMaxUploadSize, S as debugError, T as UploadDailyLimitExceededError, g as sendText$1, h as sendMedia$1, j as formatFileSize, k as downloadFile, t as accountToCreds, u as initApiConfig, w as debugWarn } from "./sender-p-B14eLG.js";
|
|
3
|
+
import { c as getQQBotMediaDir, d as isLocalPath, m as resolveQQBotPayloadLocalFilePath, o as getQQBotDataDir, p as normalizePath, r as parseTarget$1 } from "./target-parser-Y0prnrXD.js";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
//#region \0rolldown/runtime.js
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __exportAll = (all, no_symbols) => {
|
|
9
|
+
let target = {};
|
|
10
|
+
for (var name in all) __defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true
|
|
13
|
+
});
|
|
14
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
15
|
+
return target;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region extensions/qqbot/src/engine/messaging/outbound-audio-port.ts
|
|
19
|
+
let _audioPort = null;
|
|
20
|
+
/**
|
|
21
|
+
* Initialize the outbound audio adapter. Called once by gateway startup
|
|
22
|
+
* via `adapters.outboundAudio`.
|
|
23
|
+
*/
|
|
24
|
+
function setOutboundAudioPort(port) {
|
|
25
|
+
_audioPort = port;
|
|
26
|
+
}
|
|
27
|
+
function getAudio() {
|
|
28
|
+
if (!_audioPort) throw new Error("OutboundAudioPort not initialized — call setOutboundAudioPort first");
|
|
29
|
+
return _audioPort;
|
|
30
|
+
}
|
|
31
|
+
function audioFileToSilkBase64(p, f) {
|
|
32
|
+
return getAudio().audioFileToSilkBase64(p, f);
|
|
33
|
+
}
|
|
34
|
+
function isAudioFile(p, m) {
|
|
35
|
+
try {
|
|
36
|
+
return getAudio().isAudioFile(p, m);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function shouldTranscodeVoice(p) {
|
|
42
|
+
return getAudio().shouldTranscodeVoice(p);
|
|
43
|
+
}
|
|
44
|
+
function waitForFile(p, ms) {
|
|
45
|
+
return getAudio().waitForFile(p, ms);
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region extensions/qqbot/src/engine/messaging/outbound-types.ts
|
|
49
|
+
/**
|
|
50
|
+
* Stable error codes for outbound media send results.
|
|
51
|
+
*/
|
|
52
|
+
const OUTBOUND_ERROR_CODES = {
|
|
53
|
+
FILE_TOO_LARGE: "file_too_large",
|
|
54
|
+
UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded"
|
|
55
|
+
};
|
|
56
|
+
const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。";
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region extensions/qqbot/src/engine/messaging/reply-limiter.ts
|
|
59
|
+
const DEFAULT_LIMIT = 4;
|
|
60
|
+
const DEFAULT_TTL_MS = 3600 * 1e3;
|
|
61
|
+
const DEFAULT_MAX_TRACKED = 1e4;
|
|
62
|
+
/**
|
|
63
|
+
* Per-account reply limiter with automatic eviction.
|
|
64
|
+
*
|
|
65
|
+
* Usage:
|
|
66
|
+
* ```ts
|
|
67
|
+
* const limiter = new ReplyLimiter({ limit: 4, ttlMs: 3600000 });
|
|
68
|
+
* const check = limiter.checkLimit(messageId);
|
|
69
|
+
* if (check.allowed) {
|
|
70
|
+
* await sendPassiveReply(...);
|
|
71
|
+
* limiter.record(messageId);
|
|
72
|
+
* } else if (check.shouldFallbackToProactive) {
|
|
73
|
+
* await sendProactiveMessage(...);
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
var ReplyLimiter = class {
|
|
78
|
+
constructor(config) {
|
|
79
|
+
this.tracker = /* @__PURE__ */ new Map();
|
|
80
|
+
this.limit = config?.limit ?? DEFAULT_LIMIT;
|
|
81
|
+
this.ttlMs = config?.ttlMs ?? DEFAULT_TTL_MS;
|
|
82
|
+
this.maxTracked = config?.maxTrackedMessages ?? DEFAULT_MAX_TRACKED;
|
|
83
|
+
}
|
|
84
|
+
/** Check whether a passive reply is allowed for the given message. */
|
|
85
|
+
checkLimit(messageId) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
this.evictIfNeeded(now);
|
|
88
|
+
const record = this.tracker.get(messageId);
|
|
89
|
+
if (!record) return {
|
|
90
|
+
allowed: true,
|
|
91
|
+
remaining: this.limit,
|
|
92
|
+
shouldFallbackToProactive: false
|
|
93
|
+
};
|
|
94
|
+
if (now - record.firstReplyAt > this.ttlMs) return {
|
|
95
|
+
allowed: false,
|
|
96
|
+
remaining: 0,
|
|
97
|
+
shouldFallbackToProactive: true,
|
|
98
|
+
fallbackReason: "expired",
|
|
99
|
+
message: `Message is older than ${this.ttlMs / (3600 * 1e3)}h; sending as a proactive message instead`
|
|
100
|
+
};
|
|
101
|
+
const remaining = this.limit - record.count;
|
|
102
|
+
if (remaining <= 0) return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
remaining: 0,
|
|
105
|
+
shouldFallbackToProactive: true,
|
|
106
|
+
fallbackReason: "limit_exceeded",
|
|
107
|
+
message: `Passive reply limit reached (${this.limit} per hour); sending proactively instead`
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
allowed: true,
|
|
111
|
+
remaining,
|
|
112
|
+
shouldFallbackToProactive: false
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Record one passive reply against a message. */
|
|
116
|
+
record(messageId) {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const existing = this.tracker.get(messageId);
|
|
119
|
+
if (!existing) this.tracker.set(messageId, {
|
|
120
|
+
count: 1,
|
|
121
|
+
firstReplyAt: now
|
|
122
|
+
});
|
|
123
|
+
else if (now - existing.firstReplyAt > this.ttlMs) this.tracker.set(messageId, {
|
|
124
|
+
count: 1,
|
|
125
|
+
firstReplyAt: now
|
|
126
|
+
});
|
|
127
|
+
else existing.count++;
|
|
128
|
+
}
|
|
129
|
+
/** Return diagnostic stats. */
|
|
130
|
+
getStats() {
|
|
131
|
+
let totalReplies = 0;
|
|
132
|
+
for (const record of this.tracker.values()) totalReplies += record.count;
|
|
133
|
+
return {
|
|
134
|
+
trackedMessages: this.tracker.size,
|
|
135
|
+
totalReplies
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Return limiter configuration. */
|
|
139
|
+
getConfig() {
|
|
140
|
+
return {
|
|
141
|
+
limit: this.limit,
|
|
142
|
+
ttlMs: this.ttlMs,
|
|
143
|
+
ttlHours: this.ttlMs / (3600 * 1e3)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Clear all tracked records. */
|
|
147
|
+
clear() {
|
|
148
|
+
this.tracker.clear();
|
|
149
|
+
}
|
|
150
|
+
/** Opportunistically evict expired records to keep the tracker bounded. */
|
|
151
|
+
evictIfNeeded(now) {
|
|
152
|
+
if (this.tracker.size <= this.maxTracked) return;
|
|
153
|
+
for (const [id, rec] of this.tracker) if (now - rec.firstReplyAt > this.ttlMs) this.tracker.delete(id);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region extensions/qqbot/src/engine/messaging/outbound-reply.ts
|
|
158
|
+
const replyLimiter = new ReplyLimiter();
|
|
159
|
+
const MESSAGE_REPLY_LIMIT = 4;
|
|
160
|
+
function checkMessageReplyLimit(messageId) {
|
|
161
|
+
return replyLimiter.checkLimit(messageId);
|
|
162
|
+
}
|
|
163
|
+
function recordMessageReply(messageId) {
|
|
164
|
+
replyLimiter.record(messageId);
|
|
165
|
+
debugLog(`[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`);
|
|
166
|
+
}
|
|
167
|
+
function getMessageReplyStats() {
|
|
168
|
+
return replyLimiter.getStats();
|
|
169
|
+
}
|
|
170
|
+
function getMessageReplyConfig() {
|
|
171
|
+
return replyLimiter.getConfig();
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region extensions/qqbot/src/engine/messaging/outbound-result-helpers.ts
|
|
175
|
+
/**
|
|
176
|
+
* Convert a media send result into a user-facing message.
|
|
177
|
+
*/
|
|
178
|
+
function resolveUserFacingMediaError(result) {
|
|
179
|
+
if (!result.error) return DEFAULT_MEDIA_SEND_ERROR;
|
|
180
|
+
if (result.qqBizCode === 40093002) return result.error;
|
|
181
|
+
switch (result.errorCode) {
|
|
182
|
+
case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE:
|
|
183
|
+
case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED: return result.error;
|
|
184
|
+
default: return DEFAULT_MEDIA_SEND_ERROR;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function buildDailyLimitExceededResult(err) {
|
|
188
|
+
return {
|
|
189
|
+
channel: "qqbot",
|
|
190
|
+
error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${path.dirname(err.filePath)}/${path.basename(err.filePath)}\`(${formatFileSize(err.fileSize)})`,
|
|
191
|
+
errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
|
|
192
|
+
qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function buildFileTooLargeResult(fileType, fileSize) {
|
|
196
|
+
const typeName = getFileTypeName(fileType);
|
|
197
|
+
const limit = getMaxUploadSize(fileType);
|
|
198
|
+
const limitMB = Math.round(limit / (1024 * 1024));
|
|
199
|
+
return {
|
|
200
|
+
channel: "qqbot",
|
|
201
|
+
error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。`,
|
|
202
|
+
errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region extensions/qqbot/src/engine/messaging/outbound-media-send.ts
|
|
207
|
+
/**
|
|
208
|
+
* Low-level outbound media sends (photo, voice, video, document) and path resolution.
|
|
209
|
+
*/
|
|
210
|
+
/** Parse a qqbot target into a structured delivery target. */
|
|
211
|
+
function parseTarget(to) {
|
|
212
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
213
|
+
debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`);
|
|
214
|
+
const parsed = parseTarget$1(to);
|
|
215
|
+
debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`);
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
/** Build a media target from a normal outbound context. */
|
|
219
|
+
function buildMediaTarget(ctx) {
|
|
220
|
+
const target = parseTarget(ctx.to);
|
|
221
|
+
return {
|
|
222
|
+
targetType: target.type,
|
|
223
|
+
targetId: target.id,
|
|
224
|
+
account: ctx.account,
|
|
225
|
+
replyToId: ctx.replyToId ?? void 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Return true when public URLs should be passed through directly. */
|
|
229
|
+
function shouldDirectUploadUrl(account) {
|
|
230
|
+
return account.config?.urlDirectUpload !== false;
|
|
231
|
+
}
|
|
232
|
+
const qqBotMediaKindLabel = {
|
|
233
|
+
image: "Image",
|
|
234
|
+
voice: "Voice",
|
|
235
|
+
video: "Video",
|
|
236
|
+
file: "File",
|
|
237
|
+
media: "Media"
|
|
238
|
+
};
|
|
239
|
+
function isHttpOrDataSource(pathValue) {
|
|
240
|
+
return pathValue.startsWith("http://") || pathValue.startsWith("https://") || pathValue.startsWith("data:");
|
|
241
|
+
}
|
|
242
|
+
function isPathWithinRoot(candidate, root) {
|
|
243
|
+
const relative = path.relative(root, candidate);
|
|
244
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
245
|
+
}
|
|
246
|
+
function resolveMissingPathWithinMediaRoot(normalizedPath) {
|
|
247
|
+
const resolvedCandidate = path.resolve(normalizedPath);
|
|
248
|
+
if (fs.existsSync(resolvedCandidate)) return null;
|
|
249
|
+
const allowedRoot = path.resolve(getQQBotMediaDir());
|
|
250
|
+
let canonicalAllowedRoot;
|
|
251
|
+
try {
|
|
252
|
+
canonicalAllowedRoot = fs.realpathSync(allowedRoot);
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const missingSegments = [];
|
|
257
|
+
let cursor = resolvedCandidate;
|
|
258
|
+
while (!fs.existsSync(cursor)) {
|
|
259
|
+
const parent = path.dirname(cursor);
|
|
260
|
+
if (parent === cursor) break;
|
|
261
|
+
missingSegments.unshift(path.basename(cursor));
|
|
262
|
+
cursor = parent;
|
|
263
|
+
}
|
|
264
|
+
if (!fs.existsSync(cursor)) return null;
|
|
265
|
+
let canonicalCursor;
|
|
266
|
+
try {
|
|
267
|
+
canonicalCursor = fs.realpathSync(cursor);
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const canonicalCandidate = missingSegments.length > 0 ? path.join(canonicalCursor, ...missingSegments) : canonicalCursor;
|
|
272
|
+
return isPathWithinRoot(canonicalCandidate, canonicalAllowedRoot) ? canonicalCandidate : null;
|
|
273
|
+
}
|
|
274
|
+
function resolveExistingPathWithinRoots(normalizedPath, allowedRoots) {
|
|
275
|
+
const resolvedCandidate = path.resolve(normalizedPath);
|
|
276
|
+
if (!fs.existsSync(resolvedCandidate)) return null;
|
|
277
|
+
let canonicalCandidate;
|
|
278
|
+
try {
|
|
279
|
+
canonicalCandidate = fs.realpathSync(resolvedCandidate);
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
for (const root of allowedRoots) {
|
|
284
|
+
const resolvedRoot = path.resolve(root);
|
|
285
|
+
const canonicalRoot = fs.existsSync(resolvedRoot) ? fs.realpathSync(resolvedRoot) : resolvedRoot;
|
|
286
|
+
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) return canonicalCandidate;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
function resolveOutboundMediaPath(rawPath, mediaKind, options = {}) {
|
|
291
|
+
const normalizedPath = normalizePath(rawPath);
|
|
292
|
+
if (isHttpOrDataSource(normalizedPath)) return {
|
|
293
|
+
ok: true,
|
|
294
|
+
mediaPath: normalizedPath
|
|
295
|
+
};
|
|
296
|
+
const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath);
|
|
297
|
+
if (allowedPath) return {
|
|
298
|
+
ok: true,
|
|
299
|
+
mediaPath: allowedPath
|
|
300
|
+
};
|
|
301
|
+
if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
|
|
302
|
+
const extraAllowedPath = resolveExistingPathWithinRoots(normalizedPath, options.extraLocalRoots);
|
|
303
|
+
if (extraAllowedPath) return {
|
|
304
|
+
ok: true,
|
|
305
|
+
mediaPath: extraAllowedPath
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (options.allowMissingLocalPath) {
|
|
309
|
+
const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
|
|
310
|
+
if (allowedMissingPath) return {
|
|
311
|
+
ok: true,
|
|
312
|
+
mediaPath: allowedMissingPath
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Send a photo from a local file, public URL, or Base64 data URL.
|
|
323
|
+
*/
|
|
324
|
+
async function sendPhoto(ctx, imagePath) {
|
|
325
|
+
const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image");
|
|
326
|
+
if (!resolvedMediaPath.ok) return {
|
|
327
|
+
channel: "qqbot",
|
|
328
|
+
error: resolvedMediaPath.error
|
|
329
|
+
};
|
|
330
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
331
|
+
const isLocal = isLocalPath(mediaPath);
|
|
332
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
333
|
+
const isData = mediaPath.startsWith("data:");
|
|
334
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
335
|
+
debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
|
|
336
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
|
|
337
|
+
if (localFile) return await sendPhotoFromLocal(ctx, localFile);
|
|
338
|
+
return {
|
|
339
|
+
channel: "qqbot",
|
|
340
|
+
error: `Failed to download image: ${mediaPath.slice(0, 80)}`
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (isLocal) return await sendPhotoFromLocal(ctx, mediaPath);
|
|
344
|
+
if (!isHttp && !isData) return {
|
|
345
|
+
channel: "qqbot",
|
|
346
|
+
error: `Unsupported image source: ${mediaPath.slice(0, 50)}`
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
const creds = accountToCreds(ctx.account);
|
|
350
|
+
const target = {
|
|
351
|
+
type: ctx.targetType,
|
|
352
|
+
id: ctx.targetId
|
|
353
|
+
};
|
|
354
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
355
|
+
const r = await sendMedia$1({
|
|
356
|
+
target,
|
|
357
|
+
creds,
|
|
358
|
+
kind: "image",
|
|
359
|
+
source: { url: mediaPath },
|
|
360
|
+
msgId: ctx.replyToId
|
|
361
|
+
});
|
|
362
|
+
return {
|
|
363
|
+
channel: "qqbot",
|
|
364
|
+
messageId: r.id,
|
|
365
|
+
timestamp: r.timestamp
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (isHttp) {
|
|
369
|
+
const r = await sendText$1(target, ``, creds, { msgId: ctx.replyToId });
|
|
370
|
+
return {
|
|
371
|
+
channel: "qqbot",
|
|
372
|
+
messageId: r.id,
|
|
373
|
+
timestamp: r.timestamp
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
debugLog(`sendPhoto: channel does not support local/Base64 images`);
|
|
377
|
+
return {
|
|
378
|
+
channel: "qqbot",
|
|
379
|
+
error: "Channel does not support local/Base64 images"
|
|
380
|
+
};
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const msg = formatErrorMessage(err);
|
|
383
|
+
if (isHttp && !isData) {
|
|
384
|
+
debugWarn(`sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
385
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
|
|
386
|
+
if (localFile) return await sendPhotoFromLocal(ctx, localFile);
|
|
387
|
+
}
|
|
388
|
+
debugError(`sendPhoto failed: ${msg}`);
|
|
389
|
+
return {
|
|
390
|
+
channel: "qqbot",
|
|
391
|
+
error: msg
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Send a photo from a validated local file path. */
|
|
396
|
+
async function sendPhotoFromLocal(ctx, mediaPath) {
|
|
397
|
+
if (!await fileExistsAsync(mediaPath)) return {
|
|
398
|
+
channel: "qqbot",
|
|
399
|
+
error: "Image not found"
|
|
400
|
+
};
|
|
401
|
+
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(1));
|
|
402
|
+
if (!sizeCheck.ok) return buildFileTooLargeResult(1, sizeCheck.size);
|
|
403
|
+
if (!getImageMimeType(mediaPath)) return {
|
|
404
|
+
channel: "qqbot",
|
|
405
|
+
error: `Unsupported image format: ${normalizeLowercaseStringOrEmpty(path.extname(mediaPath))}`
|
|
406
|
+
};
|
|
407
|
+
debugLog(`sendPhoto: local (${formatFileSize(sizeCheck.size)})`);
|
|
408
|
+
try {
|
|
409
|
+
const creds = accountToCreds(ctx.account);
|
|
410
|
+
const target = {
|
|
411
|
+
type: ctx.targetType,
|
|
412
|
+
id: ctx.targetId
|
|
413
|
+
};
|
|
414
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
415
|
+
const r = await sendMedia$1({
|
|
416
|
+
target,
|
|
417
|
+
creds,
|
|
418
|
+
kind: "image",
|
|
419
|
+
source: { localPath: mediaPath },
|
|
420
|
+
msgId: ctx.replyToId,
|
|
421
|
+
localPathForMeta: mediaPath
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
channel: "qqbot",
|
|
425
|
+
messageId: r.id,
|
|
426
|
+
timestamp: r.timestamp
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
debugLog(`sendPhoto: channel does not support local images`);
|
|
430
|
+
return {
|
|
431
|
+
channel: "qqbot",
|
|
432
|
+
error: "Channel does not support local/Base64 images"
|
|
433
|
+
};
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
436
|
+
debugError(`sendPhoto (local): daily upload quota exceeded`);
|
|
437
|
+
return buildDailyLimitExceededResult(err);
|
|
438
|
+
}
|
|
439
|
+
const msg = formatErrorMessage(err);
|
|
440
|
+
debugError(`sendPhoto (local) failed: ${msg}`);
|
|
441
|
+
return {
|
|
442
|
+
channel: "qqbot",
|
|
443
|
+
error: msg
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Send voice from either a local file or a public URL.
|
|
449
|
+
*
|
|
450
|
+
* URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
|
|
451
|
+
*/
|
|
452
|
+
async function sendVoice(ctx, voicePath, directUploadFormats, transcodeEnabled = true) {
|
|
453
|
+
const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", { allowMissingLocalPath: true });
|
|
454
|
+
if (!resolvedMediaPath.ok) return {
|
|
455
|
+
channel: "qqbot",
|
|
456
|
+
error: resolvedMediaPath.error
|
|
457
|
+
};
|
|
458
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
459
|
+
if (mediaPath.startsWith("http://") || mediaPath.startsWith("https://")) {
|
|
460
|
+
if (shouldDirectUploadUrl(ctx.account)) try {
|
|
461
|
+
const creds = accountToCreds(ctx.account);
|
|
462
|
+
const target = {
|
|
463
|
+
type: ctx.targetType,
|
|
464
|
+
id: ctx.targetId
|
|
465
|
+
};
|
|
466
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
467
|
+
const r = await sendMedia$1({
|
|
468
|
+
target,
|
|
469
|
+
creds,
|
|
470
|
+
kind: "voice",
|
|
471
|
+
source: { url: mediaPath },
|
|
472
|
+
msgId: ctx.replyToId
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
channel: "qqbot",
|
|
476
|
+
messageId: r.id,
|
|
477
|
+
timestamp: r.timestamp
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
debugLog(`sendVoice: voice not supported in channel`);
|
|
481
|
+
return {
|
|
482
|
+
channel: "qqbot",
|
|
483
|
+
error: "Voice not supported in channel"
|
|
484
|
+
};
|
|
485
|
+
} catch (err) {
|
|
486
|
+
debugWarn(`sendVoice: URL direct upload failed (${formatErrorMessage(err)}), downloading locally and retrying...`);
|
|
487
|
+
}
|
|
488
|
+
else debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`);
|
|
489
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendVoice");
|
|
490
|
+
if (localFile) return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled);
|
|
491
|
+
return {
|
|
492
|
+
channel: "qqbot",
|
|
493
|
+
error: `Failed to download audio: ${mediaPath.slice(0, 80)}`
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled);
|
|
497
|
+
}
|
|
498
|
+
/** Send voice from a local file. */
|
|
499
|
+
async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled) {
|
|
500
|
+
const fileSize = await waitForFile(mediaPath);
|
|
501
|
+
if (fileSize === 0) return {
|
|
502
|
+
channel: "qqbot",
|
|
503
|
+
error: "Voice generate failed"
|
|
504
|
+
};
|
|
505
|
+
if (fileSize > getMaxUploadSize(3)) return buildFileTooLargeResult(3, fileSize);
|
|
506
|
+
const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
|
|
507
|
+
if (!safeMediaPath) {
|
|
508
|
+
debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
|
|
509
|
+
return {
|
|
510
|
+
channel: "qqbot",
|
|
511
|
+
error: "Voice path must be inside QQ Bot media storage"
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (shouldTranscodeVoice(safeMediaPath) && !transcodeEnabled) {
|
|
515
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath));
|
|
516
|
+
debugLog(`sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`);
|
|
517
|
+
return {
|
|
518
|
+
channel: "qqbot",
|
|
519
|
+
error: `Voice transcoding is disabled and format ${ext} cannot be uploaded directly`
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
let uploadBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats);
|
|
524
|
+
if (!uploadBase64) {
|
|
525
|
+
const buf = await readFileAsync(safeMediaPath);
|
|
526
|
+
uploadBase64 = buf.toString("base64");
|
|
527
|
+
debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
|
|
528
|
+
} else debugLog(`sendVoice: SILK ready (${fileSize} bytes)`);
|
|
529
|
+
const creds = accountToCreds(ctx.account);
|
|
530
|
+
const target = {
|
|
531
|
+
type: ctx.targetType,
|
|
532
|
+
id: ctx.targetId
|
|
533
|
+
};
|
|
534
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
535
|
+
const r = await sendMedia$1({
|
|
536
|
+
target,
|
|
537
|
+
creds,
|
|
538
|
+
kind: "voice",
|
|
539
|
+
source: { base64: uploadBase64 },
|
|
540
|
+
msgId: ctx.replyToId,
|
|
541
|
+
localPathForMeta: safeMediaPath
|
|
542
|
+
});
|
|
543
|
+
return {
|
|
544
|
+
channel: "qqbot",
|
|
545
|
+
messageId: r.id,
|
|
546
|
+
timestamp: r.timestamp
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
debugLog(`sendVoice: voice not supported in channel`);
|
|
550
|
+
return {
|
|
551
|
+
channel: "qqbot",
|
|
552
|
+
error: "Voice not supported in channel"
|
|
553
|
+
};
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
556
|
+
debugError(`sendVoice (local): daily upload quota exceeded`);
|
|
557
|
+
return buildDailyLimitExceededResult(err);
|
|
558
|
+
}
|
|
559
|
+
const msg = formatErrorMessage(err);
|
|
560
|
+
debugError(`sendVoice (local) failed: ${msg}`);
|
|
561
|
+
return {
|
|
562
|
+
channel: "qqbot",
|
|
563
|
+
error: msg
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Send video from either a public URL or a local file. */
|
|
568
|
+
async function sendVideoMsg(ctx, videoPath) {
|
|
569
|
+
const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video");
|
|
570
|
+
if (!resolvedMediaPath.ok) return {
|
|
571
|
+
channel: "qqbot",
|
|
572
|
+
error: resolvedMediaPath.error
|
|
573
|
+
};
|
|
574
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
575
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
576
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
577
|
+
debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
|
|
578
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
|
|
579
|
+
if (localFile) return await sendVideoFromLocal(ctx, localFile);
|
|
580
|
+
return {
|
|
581
|
+
channel: "qqbot",
|
|
582
|
+
error: `Failed to download video: ${mediaPath.slice(0, 80)}`
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
if (isHttp) {
|
|
587
|
+
const creds = accountToCreds(ctx.account);
|
|
588
|
+
const target = {
|
|
589
|
+
type: ctx.targetType,
|
|
590
|
+
id: ctx.targetId
|
|
591
|
+
};
|
|
592
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
593
|
+
const r = await sendMedia$1({
|
|
594
|
+
target,
|
|
595
|
+
creds,
|
|
596
|
+
kind: "video",
|
|
597
|
+
source: { url: mediaPath },
|
|
598
|
+
msgId: ctx.replyToId
|
|
599
|
+
});
|
|
600
|
+
return {
|
|
601
|
+
channel: "qqbot",
|
|
602
|
+
messageId: r.id,
|
|
603
|
+
timestamp: r.timestamp
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
debugLog(`sendVideoMsg: video not supported in channel`);
|
|
607
|
+
return {
|
|
608
|
+
channel: "qqbot",
|
|
609
|
+
error: "Video not supported in channel"
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
return await sendVideoFromLocal(ctx, mediaPath);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
const msg = formatErrorMessage(err);
|
|
615
|
+
if (isHttp) {
|
|
616
|
+
debugWarn(`sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
617
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
|
|
618
|
+
if (localFile) return await sendVideoFromLocal(ctx, localFile);
|
|
619
|
+
}
|
|
620
|
+
debugError(`sendVideoMsg failed: ${msg}`);
|
|
621
|
+
return {
|
|
622
|
+
channel: "qqbot",
|
|
623
|
+
error: msg
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Send video from a local file. */
|
|
628
|
+
async function sendVideoFromLocal(ctx, mediaPath) {
|
|
629
|
+
if (!await fileExistsAsync(mediaPath)) return {
|
|
630
|
+
channel: "qqbot",
|
|
631
|
+
error: "Video not found"
|
|
632
|
+
};
|
|
633
|
+
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(2));
|
|
634
|
+
if (!sizeCheck.ok) return buildFileTooLargeResult(2, sizeCheck.size);
|
|
635
|
+
debugLog(`sendVideoMsg: local video (${formatFileSize(sizeCheck.size)})`);
|
|
636
|
+
try {
|
|
637
|
+
const creds = accountToCreds(ctx.account);
|
|
638
|
+
const target = {
|
|
639
|
+
type: ctx.targetType,
|
|
640
|
+
id: ctx.targetId
|
|
641
|
+
};
|
|
642
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
643
|
+
const r = await sendMedia$1({
|
|
644
|
+
target,
|
|
645
|
+
creds,
|
|
646
|
+
kind: "video",
|
|
647
|
+
source: { localPath: mediaPath },
|
|
648
|
+
msgId: ctx.replyToId,
|
|
649
|
+
localPathForMeta: mediaPath
|
|
650
|
+
});
|
|
651
|
+
return {
|
|
652
|
+
channel: "qqbot",
|
|
653
|
+
messageId: r.id,
|
|
654
|
+
timestamp: r.timestamp
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
debugLog(`sendVideoMsg: video not supported in channel`);
|
|
658
|
+
return {
|
|
659
|
+
channel: "qqbot",
|
|
660
|
+
error: "Video not supported in channel"
|
|
661
|
+
};
|
|
662
|
+
} catch (err) {
|
|
663
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
664
|
+
debugError(`sendVideoMsg (local): daily upload quota exceeded`);
|
|
665
|
+
return buildDailyLimitExceededResult(err);
|
|
666
|
+
}
|
|
667
|
+
const msg = formatErrorMessage(err);
|
|
668
|
+
debugError(`sendVideoMsg (local) failed: ${msg}`);
|
|
669
|
+
return {
|
|
670
|
+
channel: "qqbot",
|
|
671
|
+
error: msg
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Send a file from a local path or public URL. */
|
|
676
|
+
async function sendDocument(ctx, filePath, options = {}) {
|
|
677
|
+
const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", { extraLocalRoots: options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : void 0 });
|
|
678
|
+
if (!resolvedMediaPath.ok) return {
|
|
679
|
+
channel: "qqbot",
|
|
680
|
+
error: resolvedMediaPath.error
|
|
681
|
+
};
|
|
682
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
683
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
684
|
+
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
685
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
686
|
+
debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
|
|
687
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
|
|
688
|
+
if (localFile) return await sendDocumentFromLocal(ctx, localFile);
|
|
689
|
+
return {
|
|
690
|
+
channel: "qqbot",
|
|
691
|
+
error: `Failed to download file: ${mediaPath.slice(0, 80)}`
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
if (isHttp) {
|
|
696
|
+
const creds = accountToCreds(ctx.account);
|
|
697
|
+
const target = {
|
|
698
|
+
type: ctx.targetType,
|
|
699
|
+
id: ctx.targetId
|
|
700
|
+
};
|
|
701
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
702
|
+
const r = await sendMedia$1({
|
|
703
|
+
target,
|
|
704
|
+
creds,
|
|
705
|
+
kind: "file",
|
|
706
|
+
source: { url: mediaPath },
|
|
707
|
+
msgId: ctx.replyToId,
|
|
708
|
+
fileName
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
channel: "qqbot",
|
|
712
|
+
messageId: r.id,
|
|
713
|
+
timestamp: r.timestamp
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
debugLog(`sendDocument: file not supported in channel`);
|
|
717
|
+
return {
|
|
718
|
+
channel: "qqbot",
|
|
719
|
+
error: "File not supported in channel"
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return await sendDocumentFromLocal(ctx, mediaPath);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
const msg = formatErrorMessage(err);
|
|
725
|
+
if (isHttp) {
|
|
726
|
+
debugWarn(`sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
727
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
|
|
728
|
+
if (localFile) return await sendDocumentFromLocal(ctx, localFile);
|
|
729
|
+
}
|
|
730
|
+
debugError(`sendDocument failed: ${msg}`);
|
|
731
|
+
return {
|
|
732
|
+
channel: "qqbot",
|
|
733
|
+
error: msg
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/** Send a file from local storage. */
|
|
738
|
+
async function sendDocumentFromLocal(ctx, mediaPath) {
|
|
739
|
+
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
740
|
+
if (!await fileExistsAsync(mediaPath)) return {
|
|
741
|
+
channel: "qqbot",
|
|
742
|
+
error: "File not found"
|
|
743
|
+
};
|
|
744
|
+
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(4));
|
|
745
|
+
if (!sizeCheck.ok) return buildFileTooLargeResult(4, sizeCheck.size);
|
|
746
|
+
if (sizeCheck.size === 0) return {
|
|
747
|
+
channel: "qqbot",
|
|
748
|
+
error: `File is empty: ${mediaPath}`
|
|
749
|
+
};
|
|
750
|
+
debugLog(`sendDocument: local file (${formatFileSize(sizeCheck.size)})`);
|
|
751
|
+
try {
|
|
752
|
+
const creds = accountToCreds(ctx.account);
|
|
753
|
+
const target = {
|
|
754
|
+
type: ctx.targetType,
|
|
755
|
+
id: ctx.targetId
|
|
756
|
+
};
|
|
757
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
758
|
+
const r = await sendMedia$1({
|
|
759
|
+
target,
|
|
760
|
+
creds,
|
|
761
|
+
kind: "file",
|
|
762
|
+
source: { localPath: mediaPath },
|
|
763
|
+
msgId: ctx.replyToId,
|
|
764
|
+
fileName,
|
|
765
|
+
localPathForMeta: mediaPath
|
|
766
|
+
});
|
|
767
|
+
return {
|
|
768
|
+
channel: "qqbot",
|
|
769
|
+
messageId: r.id,
|
|
770
|
+
timestamp: r.timestamp
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
debugLog(`sendDocument: file not supported in channel`);
|
|
774
|
+
return {
|
|
775
|
+
channel: "qqbot",
|
|
776
|
+
error: "File not supported in channel"
|
|
777
|
+
};
|
|
778
|
+
} catch (err) {
|
|
779
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
780
|
+
debugError(`sendDocument (local): daily upload quota exceeded`);
|
|
781
|
+
return buildDailyLimitExceededResult(err);
|
|
782
|
+
}
|
|
783
|
+
const msg = formatErrorMessage(err);
|
|
784
|
+
debugError(`sendDocument (local) failed: ${msg}`);
|
|
785
|
+
return {
|
|
786
|
+
channel: "qqbot",
|
|
787
|
+
error: msg
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/** Download a remote file into the fallback media directory. */
|
|
792
|
+
async function downloadToFallbackDir(httpUrl, caller) {
|
|
793
|
+
try {
|
|
794
|
+
const localFile = await downloadFile(httpUrl, getQQBotMediaDir("downloads", "url-fallback"));
|
|
795
|
+
if (!localFile) {
|
|
796
|
+
debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
debugLog(`${caller} fallback: downloaded → ${localFile}`);
|
|
800
|
+
return localFile;
|
|
801
|
+
} catch (err) {
|
|
802
|
+
debugError(`${caller} fallback download error:`, err);
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
//#endregion
|
|
807
|
+
//#region extensions/qqbot/src/engine/utils/media-tags.ts
|
|
808
|
+
/**
|
|
809
|
+
* Media tag normalization for QQ Bot messages.
|
|
810
|
+
*
|
|
811
|
+
* Normalizes malformed `<qqimg>`, `<qqvoice>`, etc. tags emitted by
|
|
812
|
+
* smaller models into canonical wrapped-tag format.
|
|
813
|
+
*
|
|
814
|
+
* Zero external dependencies.
|
|
815
|
+
*/
|
|
816
|
+
/** Lowercase and trim a string, returning empty string for falsy input. */
|
|
817
|
+
function lc(s) {
|
|
818
|
+
return (s ?? "").toLowerCase().trim();
|
|
819
|
+
}
|
|
820
|
+
/** Expand `~` prefix to the process home directory. */
|
|
821
|
+
function expandTilde(p) {
|
|
822
|
+
if (!p) return p;
|
|
823
|
+
const home = typeof process !== "undefined" ? process.env.HOME ?? process.env.USERPROFILE : void 0;
|
|
824
|
+
if (!home) return p;
|
|
825
|
+
if (p === "~") return home;
|
|
826
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return `${home}/${p.slice(2)}`;
|
|
827
|
+
return p;
|
|
828
|
+
}
|
|
829
|
+
const VALID_TAGS = [
|
|
830
|
+
"qqimg",
|
|
831
|
+
"qqvoice",
|
|
832
|
+
"qqvideo",
|
|
833
|
+
"qqfile",
|
|
834
|
+
"qqmedia"
|
|
835
|
+
];
|
|
836
|
+
const TAG_ALIASES = {
|
|
837
|
+
qq_img: "qqimg",
|
|
838
|
+
qqimage: "qqimg",
|
|
839
|
+
qq_image: "qqimg",
|
|
840
|
+
qqpic: "qqimg",
|
|
841
|
+
qq_pic: "qqimg",
|
|
842
|
+
qqpicture: "qqimg",
|
|
843
|
+
qq_picture: "qqimg",
|
|
844
|
+
qqphoto: "qqimg",
|
|
845
|
+
qq_photo: "qqimg",
|
|
846
|
+
img: "qqimg",
|
|
847
|
+
image: "qqimg",
|
|
848
|
+
pic: "qqimg",
|
|
849
|
+
picture: "qqimg",
|
|
850
|
+
photo: "qqimg",
|
|
851
|
+
qq_voice: "qqvoice",
|
|
852
|
+
qqaudio: "qqvoice",
|
|
853
|
+
qq_audio: "qqvoice",
|
|
854
|
+
voice: "qqvoice",
|
|
855
|
+
audio: "qqvoice",
|
|
856
|
+
qq_video: "qqvideo",
|
|
857
|
+
video: "qqvideo",
|
|
858
|
+
qq_file: "qqfile",
|
|
859
|
+
qqdoc: "qqfile",
|
|
860
|
+
qq_doc: "qqfile",
|
|
861
|
+
file: "qqfile",
|
|
862
|
+
doc: "qqfile",
|
|
863
|
+
document: "qqfile",
|
|
864
|
+
qq_media: "qqmedia",
|
|
865
|
+
media: "qqmedia",
|
|
866
|
+
attachment: "qqmedia",
|
|
867
|
+
attach: "qqmedia",
|
|
868
|
+
qqattachment: "qqmedia",
|
|
869
|
+
qq_attachment: "qqmedia",
|
|
870
|
+
qqsend: "qqmedia",
|
|
871
|
+
qq_send: "qqmedia",
|
|
872
|
+
send: "qqmedia"
|
|
873
|
+
};
|
|
874
|
+
const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)];
|
|
875
|
+
ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
|
|
876
|
+
const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
|
|
877
|
+
const LEFT_BRACKET = "(?:[<<<]|<)";
|
|
878
|
+
/** Match self-closing media-tag syntax with file/src/path/url attributes. */
|
|
879
|
+
const SELF_CLOSING_TAG_REGEX = new RegExp("`?" + LEFT_BRACKET + "\\s*(" + TAG_NAME_PATTERN + ")(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*\\s+(?:file|src|path|url)\\s*=\\s*[\"']?([^\"'\\s>>]+?)[\"']?(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*\\s*/?\\s*(?:[>>>]|>)`?", "gi");
|
|
880
|
+
/** Match malformed wrapped media tags that should be normalized. */
|
|
881
|
+
const FUZZY_MEDIA_TAG_REGEX = new RegExp("`?" + LEFT_BRACKET + "\\s*(" + TAG_NAME_PATTERN + ")\\s*(?:[>>>]|>)[\"']?\\s*([^<<<>>\"'`]+?)\\s*[\"']?(?:[<<<]|<)\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*(?:[>>>]|>)`?", "gi");
|
|
882
|
+
/** Normalize a raw tag name into the canonical tag set. */
|
|
883
|
+
function resolveTagName(raw) {
|
|
884
|
+
const lower = lc(raw);
|
|
885
|
+
if (VALID_TAGS.includes(lower)) return lower;
|
|
886
|
+
return TAG_ALIASES[lower] ?? "qqimg";
|
|
887
|
+
}
|
|
888
|
+
/** Match wrapped tags whose bodies need newline and tab cleanup. */
|
|
889
|
+
const MULTILINE_TAG_CLEANUP = new RegExp("(" + LEFT_BRACKET + "\\s*(?:" + TAG_NAME_PATTERN + ")\\s*(?:[>>>]|>))([\\s\\S]*?)((?:[<<<]|<)\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*(?:[>>>]|>))", "gi");
|
|
890
|
+
/** Normalize malformed media-tag output into canonical wrapped tags. */
|
|
891
|
+
function normalizeMediaTags(text) {
|
|
892
|
+
const normalizeWrappedTag = (_match, rawTag, content) => {
|
|
893
|
+
const tag = resolveTagName(rawTag);
|
|
894
|
+
const trimmed = content.trim();
|
|
895
|
+
if (!trimmed) return _match;
|
|
896
|
+
return `<${tag}>${expandTilde(trimmed)}</${tag}>`;
|
|
897
|
+
};
|
|
898
|
+
let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, normalizeWrappedTag);
|
|
899
|
+
cleaned = cleaned.replace(MULTILINE_TAG_CLEANUP, (_m, open, body, close) => {
|
|
900
|
+
return open + body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ") + close;
|
|
901
|
+
});
|
|
902
|
+
return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, normalizeWrappedTag);
|
|
903
|
+
}
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region extensions/qqbot/src/engine/utils/payload.ts
|
|
906
|
+
const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:";
|
|
907
|
+
const CRON_PREFIX = "QQBOT_CRON:";
|
|
908
|
+
function formatErr(e) {
|
|
909
|
+
return e instanceof Error ? e.message : String(e);
|
|
910
|
+
}
|
|
911
|
+
/** Parse model output that may start with the QQ Bot structured payload prefix. */
|
|
912
|
+
function parseQQBotPayload(text) {
|
|
913
|
+
const trimmedText = text.trim();
|
|
914
|
+
if (!trimmedText.startsWith(PAYLOAD_PREFIX)) return {
|
|
915
|
+
isPayload: false,
|
|
916
|
+
text
|
|
917
|
+
};
|
|
918
|
+
const jsonContent = trimmedText.slice(14).trim();
|
|
919
|
+
if (!jsonContent) return {
|
|
920
|
+
isPayload: true,
|
|
921
|
+
error: "Payload body is empty"
|
|
922
|
+
};
|
|
923
|
+
try {
|
|
924
|
+
const payload = JSON.parse(jsonContent);
|
|
925
|
+
if (!payload.type) return {
|
|
926
|
+
isPayload: true,
|
|
927
|
+
error: "Payload is missing the type field"
|
|
928
|
+
};
|
|
929
|
+
if (payload.type === "cron_reminder") {
|
|
930
|
+
if (!payload.content || !payload.targetType || !payload.targetAddress) return {
|
|
931
|
+
isPayload: true,
|
|
932
|
+
error: "cron_reminder payload is missing required fields (content, targetType, targetAddress)"
|
|
933
|
+
};
|
|
934
|
+
} else if (payload.type === "media") {
|
|
935
|
+
if (!payload.mediaType || !payload.source || !payload.path) return {
|
|
936
|
+
isPayload: true,
|
|
937
|
+
error: "media payload is missing required fields (mediaType, source, path)"
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
isPayload: true,
|
|
942
|
+
payload
|
|
943
|
+
};
|
|
944
|
+
} catch (e) {
|
|
945
|
+
return {
|
|
946
|
+
isPayload: true,
|
|
947
|
+
error: `Failed to parse JSON: ${formatErr(e)}`
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/** Encode a cron reminder payload into the stored cron-message format. */
|
|
952
|
+
function encodePayloadForCron(payload) {
|
|
953
|
+
const jsonString = JSON.stringify(payload);
|
|
954
|
+
return `${CRON_PREFIX}${Buffer.from(jsonString, "utf-8").toString("base64")}`;
|
|
955
|
+
}
|
|
956
|
+
/** Decode a stored cron payload. */
|
|
957
|
+
function decodeCronPayload(message) {
|
|
958
|
+
const trimmedMessage = message.trim();
|
|
959
|
+
if (!trimmedMessage.startsWith(CRON_PREFIX)) return { isCronPayload: false };
|
|
960
|
+
const base64Content = trimmedMessage.slice(11);
|
|
961
|
+
if (!base64Content) return {
|
|
962
|
+
isCronPayload: true,
|
|
963
|
+
error: "Cron payload body is empty"
|
|
964
|
+
};
|
|
965
|
+
try {
|
|
966
|
+
const jsonString = Buffer.from(base64Content, "base64").toString("utf-8");
|
|
967
|
+
const payload = JSON.parse(jsonString);
|
|
968
|
+
if (payload.type !== "cron_reminder") return {
|
|
969
|
+
isCronPayload: true,
|
|
970
|
+
error: `Expected type cron_reminder but got ${String(payload.type)}`
|
|
971
|
+
};
|
|
972
|
+
if (!payload.content || !payload.targetType || !payload.targetAddress) return {
|
|
973
|
+
isCronPayload: true,
|
|
974
|
+
error: "Cron payload is missing required fields"
|
|
975
|
+
};
|
|
976
|
+
return {
|
|
977
|
+
isCronPayload: true,
|
|
978
|
+
payload
|
|
979
|
+
};
|
|
980
|
+
} catch (e) {
|
|
981
|
+
return {
|
|
982
|
+
isCronPayload: true,
|
|
983
|
+
error: `Failed to decode cron payload: ${formatErr(e)}`
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/** Type guard for cron reminder payloads. */
|
|
988
|
+
function isCronReminderPayload(payload) {
|
|
989
|
+
return payload.type === "cron_reminder";
|
|
990
|
+
}
|
|
991
|
+
/** Type guard for media payloads. */
|
|
992
|
+
function isMediaPayload(payload) {
|
|
993
|
+
return payload.type === "media";
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region extensions/qqbot/src/engine/messaging/media-type-detect.ts
|
|
997
|
+
/**
|
|
998
|
+
* Media type detection — pure functions for classifying files by MIME or extension.
|
|
999
|
+
*
|
|
1000
|
+
* These replace the inline `isImageFile` and `isVideoFile` helpers scattered
|
|
1001
|
+
* across `outbound.ts`. Centralizing them here keeps detection consistent.
|
|
1002
|
+
*/
|
|
1003
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
1004
|
+
".jpg",
|
|
1005
|
+
".jpeg",
|
|
1006
|
+
".png",
|
|
1007
|
+
".gif",
|
|
1008
|
+
".webp",
|
|
1009
|
+
".bmp"
|
|
1010
|
+
]);
|
|
1011
|
+
const VIDEO_EXTENSIONS = new Set([
|
|
1012
|
+
".mp4",
|
|
1013
|
+
".mov",
|
|
1014
|
+
".avi",
|
|
1015
|
+
".mkv",
|
|
1016
|
+
".webm",
|
|
1017
|
+
".flv",
|
|
1018
|
+
".wmv"
|
|
1019
|
+
]);
|
|
1020
|
+
/**
|
|
1021
|
+
* Extract a lowercase file extension from a path or URL, ignoring query and hash.
|
|
1022
|
+
*/
|
|
1023
|
+
function getCleanExtension(filePath) {
|
|
1024
|
+
const cleanPath = filePath.split("?")[0].split("#")[0];
|
|
1025
|
+
const lastDot = cleanPath.lastIndexOf(".");
|
|
1026
|
+
if (lastDot < 0) return "";
|
|
1027
|
+
return cleanPath.slice(lastDot).toLowerCase();
|
|
1028
|
+
}
|
|
1029
|
+
/** Check whether a file is an image using MIME first and extension as fallback. */
|
|
1030
|
+
function isImageFile$1(filePath, mimeType) {
|
|
1031
|
+
if (mimeType?.startsWith("image/")) return true;
|
|
1032
|
+
return IMAGE_EXTENSIONS.has(getCleanExtension(filePath));
|
|
1033
|
+
}
|
|
1034
|
+
/** Check whether a file is a video using MIME first and extension as fallback. */
|
|
1035
|
+
function isVideoFile$1(filePath, mimeType) {
|
|
1036
|
+
if (mimeType?.startsWith("video/")) return true;
|
|
1037
|
+
return VIDEO_EXTENSIONS.has(getCleanExtension(filePath));
|
|
1038
|
+
}
|
|
1039
|
+
//#endregion
|
|
1040
|
+
//#region extensions/qqbot/src/engine/messaging/outbound.ts
|
|
1041
|
+
var outbound_exports = /* @__PURE__ */ __exportAll({
|
|
1042
|
+
DEFAULT_MEDIA_SEND_ERROR: () => DEFAULT_MEDIA_SEND_ERROR,
|
|
1043
|
+
MESSAGE_REPLY_LIMIT: () => 4,
|
|
1044
|
+
sendCronMessage: () => sendCronMessage,
|
|
1045
|
+
sendMedia: () => sendMedia,
|
|
1046
|
+
sendProactiveMessage: () => sendProactiveMessage,
|
|
1047
|
+
sendText: () => sendText
|
|
1048
|
+
});
|
|
1049
|
+
const isImageFile = isImageFile$1;
|
|
1050
|
+
const isVideoFile = isVideoFile$1;
|
|
1051
|
+
/**
|
|
1052
|
+
* Send text, optionally falling back from passive reply mode to proactive mode.
|
|
1053
|
+
*
|
|
1054
|
+
* Also supports inline media tags such as `<qqimg>...</qqimg>`.
|
|
1055
|
+
*/
|
|
1056
|
+
async function sendText(ctx) {
|
|
1057
|
+
const { to, account } = ctx;
|
|
1058
|
+
let { text, replyToId } = ctx;
|
|
1059
|
+
let fallbackToProactive = false;
|
|
1060
|
+
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
|
1061
|
+
debugLog("[qqbot] sendText ctx:", JSON.stringify({
|
|
1062
|
+
to,
|
|
1063
|
+
text: text?.slice(0, 50),
|
|
1064
|
+
replyToId,
|
|
1065
|
+
accountId: account.accountId
|
|
1066
|
+
}, null, 2));
|
|
1067
|
+
if (replyToId) {
|
|
1068
|
+
const limitCheck = checkMessageReplyLimit(replyToId);
|
|
1069
|
+
if (!limitCheck.allowed) if (limitCheck.shouldFallbackToProactive) {
|
|
1070
|
+
debugWarn(`[qqbot] sendText: passive reply unavailable, falling back to proactive send - ${limitCheck.message}`);
|
|
1071
|
+
fallbackToProactive = true;
|
|
1072
|
+
replyToId = null;
|
|
1073
|
+
} else {
|
|
1074
|
+
debugError(`[qqbot] sendText: passive reply was blocked without a fallback path - ${limitCheck.message}`);
|
|
1075
|
+
return {
|
|
1076
|
+
channel: "qqbot",
|
|
1077
|
+
error: limitCheck.message
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
else debugLog(`[qqbot] sendText: remaining passive replies for ${replyToId}: ${limitCheck.remaining}/4`);
|
|
1081
|
+
}
|
|
1082
|
+
text = normalizeMediaTags(text);
|
|
1083
|
+
const mediaTagMatches = text.match(/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi);
|
|
1084
|
+
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
|
1085
|
+
debugLog(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
1086
|
+
const sendQueue = [];
|
|
1087
|
+
let lastIndex = 0;
|
|
1088
|
+
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
1089
|
+
let match;
|
|
1090
|
+
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
1091
|
+
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
1092
|
+
if (textBefore) sendQueue.push({
|
|
1093
|
+
type: "text",
|
|
1094
|
+
content: textBefore
|
|
1095
|
+
});
|
|
1096
|
+
const tagName = normalizeLowercaseStringOrEmpty(match[1]);
|
|
1097
|
+
let mediaPath = normalizeOptionalString(match[2]) ?? "";
|
|
1098
|
+
if (mediaPath.startsWith("MEDIA:")) mediaPath = mediaPath.slice(6);
|
|
1099
|
+
mediaPath = normalizePath(mediaPath);
|
|
1100
|
+
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
1101
|
+
const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
|
|
1102
|
+
try {
|
|
1103
|
+
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
1104
|
+
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
1105
|
+
if (!isWinLocal && (hasOctal || hasNonASCII)) {
|
|
1106
|
+
debugLog(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
|
1107
|
+
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_, octal) => {
|
|
1108
|
+
return String.fromCharCode(Number.parseInt(octal, 8));
|
|
1109
|
+
});
|
|
1110
|
+
const bytes = [];
|
|
1111
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
1112
|
+
const code = decoded.charCodeAt(i);
|
|
1113
|
+
if (code <= 255) bytes.push(code);
|
|
1114
|
+
else {
|
|
1115
|
+
const charBytes = Buffer.from(decoded[i], "utf8");
|
|
1116
|
+
bytes.push(...charBytes);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const utf8Decoded = Buffer.from(bytes).toString("utf8");
|
|
1120
|
+
if (!utf8Decoded.includes("�") || utf8Decoded.length < decoded.length) {
|
|
1121
|
+
mediaPath = utf8Decoded;
|
|
1122
|
+
debugLog(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
} catch (decodeErr) {
|
|
1126
|
+
debugError(`[qqbot] sendText: Path decode error: ${decodeErr instanceof Error ? decodeErr.message : JSON.stringify(decodeErr)}`);
|
|
1127
|
+
}
|
|
1128
|
+
if (mediaPath) if (tagName === "qqmedia") {
|
|
1129
|
+
sendQueue.push({
|
|
1130
|
+
type: "media",
|
|
1131
|
+
content: mediaPath
|
|
1132
|
+
});
|
|
1133
|
+
debugLog(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
1134
|
+
} else if (tagName === "qqvoice") {
|
|
1135
|
+
sendQueue.push({
|
|
1136
|
+
type: "voice",
|
|
1137
|
+
content: mediaPath
|
|
1138
|
+
});
|
|
1139
|
+
debugLog(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
1140
|
+
} else if (tagName === "qqvideo") {
|
|
1141
|
+
sendQueue.push({
|
|
1142
|
+
type: "video",
|
|
1143
|
+
content: mediaPath
|
|
1144
|
+
});
|
|
1145
|
+
debugLog(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
|
1146
|
+
} else if (tagName === "qqfile") {
|
|
1147
|
+
sendQueue.push({
|
|
1148
|
+
type: "file",
|
|
1149
|
+
content: mediaPath
|
|
1150
|
+
});
|
|
1151
|
+
debugLog(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
|
1152
|
+
} else {
|
|
1153
|
+
sendQueue.push({
|
|
1154
|
+
type: "image",
|
|
1155
|
+
content: mediaPath
|
|
1156
|
+
});
|
|
1157
|
+
debugLog(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
|
1158
|
+
}
|
|
1159
|
+
lastIndex = match.index + match[0].length;
|
|
1160
|
+
}
|
|
1161
|
+
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
|
1162
|
+
if (textAfter) sendQueue.push({
|
|
1163
|
+
type: "text",
|
|
1164
|
+
content: textAfter
|
|
1165
|
+
});
|
|
1166
|
+
debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
|
|
1167
|
+
const mediaTarget = buildMediaTarget({
|
|
1168
|
+
to,
|
|
1169
|
+
account,
|
|
1170
|
+
replyToId
|
|
1171
|
+
});
|
|
1172
|
+
let lastResult = { channel: "qqbot" };
|
|
1173
|
+
for (const item of sendQueue) try {
|
|
1174
|
+
if (item.type === "text") {
|
|
1175
|
+
const target = parseTarget(to);
|
|
1176
|
+
const creds = accountToCreds(account);
|
|
1177
|
+
const result = await sendText$1({
|
|
1178
|
+
type: target.type === "channel" ? "channel" : target.type,
|
|
1179
|
+
id: target.id
|
|
1180
|
+
}, item.content, creds, { msgId: replyToId ?? void 0 });
|
|
1181
|
+
if (replyToId) recordMessageReply(replyToId);
|
|
1182
|
+
lastResult = {
|
|
1183
|
+
channel: "qqbot",
|
|
1184
|
+
messageId: result.id,
|
|
1185
|
+
timestamp: result.timestamp,
|
|
1186
|
+
refIdx: result.ext_info?.ref_idx
|
|
1187
|
+
};
|
|
1188
|
+
debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
1189
|
+
} else if (item.type === "image") lastResult = await sendPhoto(mediaTarget, item.content);
|
|
1190
|
+
else if (item.type === "voice") lastResult = await sendVoice(mediaTarget, item.content, void 0, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
|
|
1191
|
+
else if (item.type === "video") lastResult = await sendVideoMsg(mediaTarget, item.content);
|
|
1192
|
+
else if (item.type === "file") lastResult = await sendDocument(mediaTarget, item.content);
|
|
1193
|
+
else if (item.type === "media") lastResult = await sendMedia({
|
|
1194
|
+
to,
|
|
1195
|
+
text: "",
|
|
1196
|
+
mediaUrl: item.content,
|
|
1197
|
+
accountId: account.accountId,
|
|
1198
|
+
replyToId,
|
|
1199
|
+
account
|
|
1200
|
+
});
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
const errMsg = formatErrorMessage(err);
|
|
1203
|
+
debugError(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
|
1204
|
+
lastResult = {
|
|
1205
|
+
channel: "qqbot",
|
|
1206
|
+
error: errMsg
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
return lastResult;
|
|
1210
|
+
}
|
|
1211
|
+
if (!replyToId) {
|
|
1212
|
+
if (!text || text.trim().length === 0) {
|
|
1213
|
+
debugError("[qqbot] sendText error: proactive message content cannot be empty");
|
|
1214
|
+
return {
|
|
1215
|
+
channel: "qqbot",
|
|
1216
|
+
error: "Proactive messages require non-empty content (--message cannot be empty)"
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (fallbackToProactive) debugLog(`[qqbot] sendText: [fallback] sending proactive message to ${to}, length=${text.length}`);
|
|
1220
|
+
else debugLog(`[qqbot] sendText: sending proactive message to ${to}, length=${text.length}`);
|
|
1221
|
+
}
|
|
1222
|
+
if (!account.appId || !account.clientSecret) return {
|
|
1223
|
+
channel: "qqbot",
|
|
1224
|
+
error: "QQBot not configured (missing appId or clientSecret)"
|
|
1225
|
+
};
|
|
1226
|
+
try {
|
|
1227
|
+
const target = parseTarget(to);
|
|
1228
|
+
const creds = accountToCreds(account);
|
|
1229
|
+
const deliveryTarget = {
|
|
1230
|
+
type: target.type === "channel" ? "channel" : target.type,
|
|
1231
|
+
id: target.id
|
|
1232
|
+
};
|
|
1233
|
+
debugLog("[qqbot] sendText target:", JSON.stringify(target));
|
|
1234
|
+
const result = await sendText$1(deliveryTarget, text, creds, { msgId: replyToId ?? void 0 });
|
|
1235
|
+
if (replyToId) recordMessageReply(replyToId);
|
|
1236
|
+
return {
|
|
1237
|
+
channel: "qqbot",
|
|
1238
|
+
messageId: result.id,
|
|
1239
|
+
timestamp: result.timestamp,
|
|
1240
|
+
refIdx: result.ext_info?.ref_idx
|
|
1241
|
+
};
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
return {
|
|
1244
|
+
channel: "qqbot",
|
|
1245
|
+
error: formatErrorMessage(err)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/** Send rich media, auto-routing by media type and source. */
|
|
1250
|
+
async function sendMedia(ctx) {
|
|
1251
|
+
const { to, text, replyToId, account, mimeType } = ctx;
|
|
1252
|
+
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
|
1253
|
+
if (!account.appId || !account.clientSecret) return {
|
|
1254
|
+
channel: "qqbot",
|
|
1255
|
+
error: "QQBot not configured (missing appId or clientSecret)"
|
|
1256
|
+
};
|
|
1257
|
+
if (!ctx.mediaUrl) return {
|
|
1258
|
+
channel: "qqbot",
|
|
1259
|
+
error: "mediaUrl is required for sendMedia"
|
|
1260
|
+
};
|
|
1261
|
+
const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", { allowMissingLocalPath: true });
|
|
1262
|
+
if (!resolvedMediaPath.ok) return {
|
|
1263
|
+
channel: "qqbot",
|
|
1264
|
+
error: resolvedMediaPath.error
|
|
1265
|
+
};
|
|
1266
|
+
const mediaUrl = resolvedMediaPath.mediaPath;
|
|
1267
|
+
const target = buildMediaTarget({
|
|
1268
|
+
to,
|
|
1269
|
+
account,
|
|
1270
|
+
replyToId
|
|
1271
|
+
});
|
|
1272
|
+
if (isAudioFile(mediaUrl, mimeType)) {
|
|
1273
|
+
const result = await sendVoice(target, mediaUrl, account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
|
|
1274
|
+
if (!result.error) {
|
|
1275
|
+
if (text?.trim()) await sendTextAfterMedia(target, text);
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
1278
|
+
const voiceError = result.error;
|
|
1279
|
+
debugWarn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`);
|
|
1280
|
+
const fallback = await sendDocument(target, mediaUrl);
|
|
1281
|
+
if (!fallback.error) {
|
|
1282
|
+
if (text?.trim()) await sendTextAfterMedia(target, text);
|
|
1283
|
+
return fallback;
|
|
1284
|
+
}
|
|
1285
|
+
return {
|
|
1286
|
+
channel: "qqbot",
|
|
1287
|
+
error: `voice: ${voiceError} | fallback file: ${fallback.error}`
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if (isVideoFile(mediaUrl, mimeType)) {
|
|
1291
|
+
const result = await sendVideoMsg(target, mediaUrl);
|
|
1292
|
+
if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
|
|
1293
|
+
return result;
|
|
1294
|
+
}
|
|
1295
|
+
if (!isImageFile(mediaUrl, mimeType) && !isAudioFile(mediaUrl, mimeType) && !isVideoFile(mediaUrl, mimeType)) {
|
|
1296
|
+
const result = await sendDocument(target, mediaUrl);
|
|
1297
|
+
if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
|
|
1298
|
+
return result;
|
|
1299
|
+
}
|
|
1300
|
+
const result = await sendPhoto(target, mediaUrl);
|
|
1301
|
+
if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
|
|
1302
|
+
return result;
|
|
1303
|
+
}
|
|
1304
|
+
async function sendTextAfterMedia(ctx, text) {
|
|
1305
|
+
try {
|
|
1306
|
+
const creds = accountToCreds(ctx.account);
|
|
1307
|
+
await sendText$1({
|
|
1308
|
+
type: ctx.targetType,
|
|
1309
|
+
id: ctx.targetId
|
|
1310
|
+
}, text, creds, { msgId: ctx.replyToId });
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
debugError(`[qqbot] sendTextAfterMedia failed: ${formatErrorMessage(err)}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async function sendProactiveMessage(account, to, content) {
|
|
1316
|
+
return sendText({
|
|
1317
|
+
account,
|
|
1318
|
+
to,
|
|
1319
|
+
text: content
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
async function sendCronMessage(account, to, message) {
|
|
1323
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
|
1325
|
+
const cronResult = decodeCronPayload(message);
|
|
1326
|
+
if (cronResult.isCronPayload) {
|
|
1327
|
+
if (cronResult.error) {
|
|
1328
|
+
debugError(`[${timestamp}] [qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
|
|
1329
|
+
return {
|
|
1330
|
+
channel: "qqbot",
|
|
1331
|
+
error: `Failed to decode cron payload: ${cronResult.error}`
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
if (cronResult.payload) {
|
|
1335
|
+
const payload = cronResult.payload;
|
|
1336
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}, content length=${payload.content.length}`);
|
|
1337
|
+
const targetTo = payload.targetType === "group" ? `group:${payload.targetAddress}` : payload.targetAddress;
|
|
1338
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: sending proactive message to targetTo=${targetTo}`);
|
|
1339
|
+
const result = await sendText({
|
|
1340
|
+
account,
|
|
1341
|
+
to: targetTo,
|
|
1342
|
+
text: payload.content
|
|
1343
|
+
});
|
|
1344
|
+
if (result.error) debugError(`[${timestamp}] [qqbot] sendCronMessage: proactive message failed, error=${result.error}`);
|
|
1345
|
+
else debugLog(`[${timestamp}] [qqbot] sendCronMessage: proactive message sent successfully`);
|
|
1346
|
+
return result;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
|
1350
|
+
return await sendText({
|
|
1351
|
+
account,
|
|
1352
|
+
to,
|
|
1353
|
+
text: message
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
export { recordMessageReply as C, setOutboundAudioPort as E, getMessageReplyStats as S, OUTBOUND_ERROR_CODES as T, sendVoice as _, sendText as a, checkMessageReplyLimit as b, isMediaPayload as c, buildMediaTarget as d, parseTarget as f, sendVideoMsg as g, sendPhoto as h, sendProactiveMessage as i, parseQQBotPayload as l, sendDocument as m, sendCronMessage as n, encodePayloadForCron as o, resolveOutboundMediaPath as p, sendMedia as r, isCronReminderPayload as s, outbound_exports as t, normalizeMediaTags as u, resolveUserFacingMediaError as v, DEFAULT_MEDIA_SEND_ERROR as w, getMessageReplyConfig as x, MESSAGE_REPLY_LIMIT as y };
|