@gakr-gakr/qqbot 0.1.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/api.ts +56 -0
- package/autobot.plugin.json +167 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +33 -0
- package/package.json +64 -0
- package/runtime-api.ts +9 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +225 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +66 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +176 -0
- package/src/bridge/gateway.ts +178 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +164 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +399 -0
- package/src/config-schema.ts +84 -0
- package/src/engine/access/index.ts +2 -0
- package/src/engine/access/resolve-policy.ts +30 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +2 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +76 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.ts +644 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +277 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +341 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +138 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +88 -0
- package/src/engine/commands/slash-command-handler.ts +168 -0
- package/src/engine/commands/slash-command-test-support.ts +39 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +202 -0
- package/src/engine/config/credential-backup.ts +108 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.ts +227 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/gateway/active-cfg.ts +52 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +356 -0
- package/src/engine/gateway/gateway.ts +267 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +82 -0
- package/src/engine/gateway/inbound-pipeline.ts +171 -0
- package/src/engine/gateway/interaction-handler.ts +345 -0
- package/src/engine/gateway/message-queue.ts +404 -0
- package/src/engine/gateway/outbound-dispatch.ts +590 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +99 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +77 -0
- package/src/engine/gateway/types.ts +230 -0
- package/src/engine/gateway/typing-keepalive.ts +102 -0
- package/src/engine/gateway/ws-client.ts +16 -0
- package/src/engine/group/activation.ts +88 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.ts +114 -0
- package/src/engine/group/message-gating.ts +108 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +210 -0
- package/src/engine/messaging/media-type-detect.ts +27 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +658 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +47 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +741 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +211 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +138 -0
- package/src/engine/session/session-store.ts +207 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.ts +525 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +93 -0
- package/src/engine/utils/file-utils.ts +215 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.ts +77 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.ts +157 -0
- package/src/engine/utils/platform.ts +265 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.ts +103 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +237 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/secret-contract.ts +82 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level outbound media sends (photo, voice, video, document) and path resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
pathExistsSync,
|
|
8
|
+
resolveLocalPathFromRootsSync,
|
|
9
|
+
} from "autobot/plugin-sdk/security-runtime";
|
|
10
|
+
import type { GatewayAccount } from "../types.js";
|
|
11
|
+
import { MediaFileType } from "../types.js";
|
|
12
|
+
import {
|
|
13
|
+
checkFileSize,
|
|
14
|
+
downloadFile,
|
|
15
|
+
fileExistsAsync,
|
|
16
|
+
formatFileSize,
|
|
17
|
+
getImageMimeType,
|
|
18
|
+
getMaxUploadSize,
|
|
19
|
+
readFileAsync,
|
|
20
|
+
} from "../utils/file-utils.js";
|
|
21
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
22
|
+
import { debugError, debugLog, debugWarn } from "../utils/log.js";
|
|
23
|
+
import {
|
|
24
|
+
getQQBotDataDir,
|
|
25
|
+
getQQBotMediaDir,
|
|
26
|
+
isLocalPath as isLocalFilePath,
|
|
27
|
+
normalizePath,
|
|
28
|
+
resolveQQBotPayloadLocalFilePath,
|
|
29
|
+
} from "../utils/platform.js";
|
|
30
|
+
import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js";
|
|
31
|
+
import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js";
|
|
32
|
+
import {
|
|
33
|
+
buildDailyLimitExceededResult,
|
|
34
|
+
buildFileTooLargeResult,
|
|
35
|
+
} from "./outbound-result-helpers.js";
|
|
36
|
+
import type { MediaTargetContext, OutboundResult } from "./outbound-types.js";
|
|
37
|
+
import {
|
|
38
|
+
accountToCreds,
|
|
39
|
+
sendMedia as senderSendMedia,
|
|
40
|
+
sendText as senderSendText,
|
|
41
|
+
UploadDailyLimitExceededError,
|
|
42
|
+
type DeliveryTarget,
|
|
43
|
+
} from "./sender.js";
|
|
44
|
+
import { parseTarget as coreParseTarget } from "./target-parser.js";
|
|
45
|
+
|
|
46
|
+
/** Parse a qqbot target into a structured delivery target. */
|
|
47
|
+
export function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
|
48
|
+
const timestamp = new Date().toISOString();
|
|
49
|
+
debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`);
|
|
50
|
+
const parsed = coreParseTarget(to);
|
|
51
|
+
debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`);
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Structured media send helpers shared by gateway delivery and sendText.
|
|
56
|
+
|
|
57
|
+
/** Build a media target from a normal outbound context. */
|
|
58
|
+
export function buildMediaTarget(ctx: {
|
|
59
|
+
to: string;
|
|
60
|
+
account: GatewayAccount;
|
|
61
|
+
replyToId?: string | null;
|
|
62
|
+
}): MediaTargetContext {
|
|
63
|
+
const target = parseTarget(ctx.to);
|
|
64
|
+
return {
|
|
65
|
+
targetType: target.type,
|
|
66
|
+
targetId: target.id,
|
|
67
|
+
account: ctx.account,
|
|
68
|
+
replyToId: ctx.replyToId ?? undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Return true when public URLs should be passed through directly. */
|
|
73
|
+
function shouldDirectUploadUrl(account: GatewayAccount): boolean {
|
|
74
|
+
return account.config?.urlDirectUpload !== false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type QQBotMediaKind = "image" | "voice" | "video" | "file" | "media";
|
|
78
|
+
|
|
79
|
+
const qqBotMediaKindLabel: Record<QQBotMediaKind, string> = {
|
|
80
|
+
image: "Image",
|
|
81
|
+
voice: "Voice",
|
|
82
|
+
video: "Video",
|
|
83
|
+
file: "File",
|
|
84
|
+
media: "Media",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type ResolvedOutboundMediaPath = { ok: true; mediaPath: string } | { ok: false; error: string };
|
|
88
|
+
type ResolveOutboundMediaPathOptions = {
|
|
89
|
+
allowMissingLocalPath?: boolean;
|
|
90
|
+
extraLocalRoots?: string[];
|
|
91
|
+
};
|
|
92
|
+
type SendDocumentOptions = {
|
|
93
|
+
allowQQBotDataDownloads?: boolean;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function isHttpOrDataSource(pathValue: string): boolean {
|
|
97
|
+
return (
|
|
98
|
+
pathValue.startsWith("http://") ||
|
|
99
|
+
pathValue.startsWith("https://") ||
|
|
100
|
+
pathValue.startsWith("data:")
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveMissingPathWithinMediaRoot(normalizedPath: string): string | null {
|
|
105
|
+
const resolvedCandidate = path.resolve(normalizedPath);
|
|
106
|
+
if (pathExistsSync(resolvedCandidate)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return (
|
|
110
|
+
resolveLocalPathFromRootsSync({
|
|
111
|
+
filePath: resolvedCandidate,
|
|
112
|
+
roots: [getQQBotMediaDir()],
|
|
113
|
+
label: "QQ Bot media storage",
|
|
114
|
+
allowMissing: true,
|
|
115
|
+
})?.path ?? null
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveExistingPathWithinRoots(
|
|
120
|
+
normalizedPath: string,
|
|
121
|
+
allowedRoots: readonly string[],
|
|
122
|
+
): string | null {
|
|
123
|
+
return (
|
|
124
|
+
resolveLocalPathFromRootsSync({
|
|
125
|
+
filePath: normalizedPath,
|
|
126
|
+
roots: allowedRoots,
|
|
127
|
+
label: "QQ Bot local roots",
|
|
128
|
+
})?.path ?? null
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolveOutboundMediaPath(
|
|
133
|
+
rawPath: string,
|
|
134
|
+
mediaKind: QQBotMediaKind,
|
|
135
|
+
options: ResolveOutboundMediaPathOptions = {},
|
|
136
|
+
): ResolvedOutboundMediaPath {
|
|
137
|
+
const normalizedPath = normalizePath(rawPath);
|
|
138
|
+
if (isHttpOrDataSource(normalizedPath)) {
|
|
139
|
+
return { ok: true, mediaPath: normalizedPath };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath);
|
|
143
|
+
if (allowedPath) {
|
|
144
|
+
return { ok: true, mediaPath: allowedPath };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
|
|
148
|
+
const extraAllowedPath = resolveExistingPathWithinRoots(
|
|
149
|
+
normalizedPath,
|
|
150
|
+
options.extraLocalRoots,
|
|
151
|
+
);
|
|
152
|
+
if (extraAllowedPath) {
|
|
153
|
+
return { ok: true, mediaPath: extraAllowedPath };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.allowMissingLocalPath) {
|
|
158
|
+
const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
|
|
159
|
+
if (allowedMissingPath) {
|
|
160
|
+
return { ok: true, mediaPath: allowedMissingPath };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Send a photo from a local file, public URL, or Base64 data URL.
|
|
173
|
+
*/
|
|
174
|
+
export async function sendPhoto(
|
|
175
|
+
ctx: MediaTargetContext,
|
|
176
|
+
imagePath: string,
|
|
177
|
+
): Promise<OutboundResult> {
|
|
178
|
+
const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image");
|
|
179
|
+
if (!resolvedMediaPath.ok) {
|
|
180
|
+
return { channel: "qqbot", error: resolvedMediaPath.error };
|
|
181
|
+
}
|
|
182
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
183
|
+
const isLocal = isLocalFilePath(mediaPath);
|
|
184
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
185
|
+
const isData = mediaPath.startsWith("data:");
|
|
186
|
+
|
|
187
|
+
// Force a local download before upload when direct URL upload is disabled.
|
|
188
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
189
|
+
debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
|
|
190
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
|
|
191
|
+
if (localFile) {
|
|
192
|
+
return await sendPhotoFromLocal(ctx, localFile);
|
|
193
|
+
}
|
|
194
|
+
return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isLocal) {
|
|
198
|
+
return await sendPhotoFromLocal(ctx, mediaPath);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!isHttp && !isData) {
|
|
202
|
+
return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Remote URL or data: URL — try direct upload first, fall back to
|
|
206
|
+
// download-then-local on failure.
|
|
207
|
+
try {
|
|
208
|
+
const creds = accountToCreds(ctx.account);
|
|
209
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
210
|
+
|
|
211
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
212
|
+
const r = await senderSendMedia({
|
|
213
|
+
target,
|
|
214
|
+
creds,
|
|
215
|
+
kind: "image",
|
|
216
|
+
source: { url: mediaPath },
|
|
217
|
+
msgId: ctx.replyToId,
|
|
218
|
+
});
|
|
219
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (isHttp) {
|
|
223
|
+
const r = await senderSendText(target, ``, creds, {
|
|
224
|
+
msgId: ctx.replyToId,
|
|
225
|
+
});
|
|
226
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
227
|
+
}
|
|
228
|
+
debugLog(`sendPhoto: channel does not support local/Base64 images`);
|
|
229
|
+
return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const msg = formatErrorMessage(err);
|
|
232
|
+
|
|
233
|
+
// Fall back to plugin-managed download + local upload when QQ fails to
|
|
234
|
+
// fetch the URL directly. One-shot, non-recursive.
|
|
235
|
+
if (isHttp && !isData) {
|
|
236
|
+
debugWarn(
|
|
237
|
+
`sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
|
|
238
|
+
);
|
|
239
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
|
|
240
|
+
if (localFile) {
|
|
241
|
+
return await sendPhotoFromLocal(ctx, localFile);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
debugError(`sendPhoto failed: ${msg}`);
|
|
246
|
+
return { channel: "qqbot", error: msg };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Send a photo from a validated local file path. */
|
|
251
|
+
async function sendPhotoFromLocal(
|
|
252
|
+
ctx: MediaTargetContext,
|
|
253
|
+
mediaPath: string,
|
|
254
|
+
): Promise<OutboundResult> {
|
|
255
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
256
|
+
return { channel: "qqbot", error: "Image not found" };
|
|
257
|
+
}
|
|
258
|
+
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.IMAGE));
|
|
259
|
+
if (!sizeCheck.ok) {
|
|
260
|
+
return buildFileTooLargeResult(MediaFileType.IMAGE, sizeCheck.size);
|
|
261
|
+
}
|
|
262
|
+
const mimeType = getImageMimeType(mediaPath);
|
|
263
|
+
if (!mimeType) {
|
|
264
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath));
|
|
265
|
+
return { channel: "qqbot", error: `Unsupported image format: ${ext}` };
|
|
266
|
+
}
|
|
267
|
+
debugLog(`sendPhoto: local (${formatFileSize(sizeCheck.size)})`);
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const creds = accountToCreds(ctx.account);
|
|
271
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
272
|
+
|
|
273
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
274
|
+
const r = await senderSendMedia({
|
|
275
|
+
target,
|
|
276
|
+
creds,
|
|
277
|
+
kind: "image",
|
|
278
|
+
source: { localPath: mediaPath },
|
|
279
|
+
msgId: ctx.replyToId,
|
|
280
|
+
localPathForMeta: mediaPath,
|
|
281
|
+
});
|
|
282
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
283
|
+
}
|
|
284
|
+
debugLog(`sendPhoto: channel does not support local images`);
|
|
285
|
+
return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
288
|
+
debugError(`sendPhoto (local): daily upload quota exceeded`);
|
|
289
|
+
return buildDailyLimitExceededResult(err);
|
|
290
|
+
}
|
|
291
|
+
const msg = formatErrorMessage(err);
|
|
292
|
+
debugError(`sendPhoto (local) failed: ${msg}`);
|
|
293
|
+
return { channel: "qqbot", error: msg };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Send voice from either a local file or a public URL.
|
|
299
|
+
*
|
|
300
|
+
* URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
|
|
301
|
+
*/
|
|
302
|
+
export async function sendVoice(
|
|
303
|
+
ctx: MediaTargetContext,
|
|
304
|
+
voicePath: string,
|
|
305
|
+
directUploadFormats?: string[],
|
|
306
|
+
transcodeEnabled: boolean = true,
|
|
307
|
+
): Promise<OutboundResult> {
|
|
308
|
+
const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", {
|
|
309
|
+
allowMissingLocalPath: true,
|
|
310
|
+
});
|
|
311
|
+
if (!resolvedMediaPath.ok) {
|
|
312
|
+
return { channel: "qqbot", error: resolvedMediaPath.error };
|
|
313
|
+
}
|
|
314
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
315
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
316
|
+
|
|
317
|
+
if (isHttp) {
|
|
318
|
+
if (shouldDirectUploadUrl(ctx.account)) {
|
|
319
|
+
try {
|
|
320
|
+
const creds = accountToCreds(ctx.account);
|
|
321
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
322
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
323
|
+
const r = await senderSendMedia({
|
|
324
|
+
target,
|
|
325
|
+
creds,
|
|
326
|
+
kind: "voice",
|
|
327
|
+
source: { url: mediaPath },
|
|
328
|
+
msgId: ctx.replyToId,
|
|
329
|
+
});
|
|
330
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
331
|
+
}
|
|
332
|
+
debugLog(`sendVoice: voice not supported in channel`);
|
|
333
|
+
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const msg = formatErrorMessage(err);
|
|
336
|
+
debugWarn(
|
|
337
|
+
`sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendVoice");
|
|
345
|
+
if (localFile) {
|
|
346
|
+
return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled);
|
|
347
|
+
}
|
|
348
|
+
return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Send voice from a local file. */
|
|
355
|
+
async function sendVoiceFromLocal(
|
|
356
|
+
ctx: MediaTargetContext,
|
|
357
|
+
mediaPath: string,
|
|
358
|
+
directUploadFormats: string[] | undefined,
|
|
359
|
+
transcodeEnabled: boolean,
|
|
360
|
+
): Promise<OutboundResult> {
|
|
361
|
+
// TTS can still be flushing the file to disk, so wait for a stable file first.
|
|
362
|
+
const fileSize = await waitForFile(mediaPath);
|
|
363
|
+
if (fileSize === 0) {
|
|
364
|
+
return { channel: "qqbot", error: "Voice generate failed" };
|
|
365
|
+
}
|
|
366
|
+
if (fileSize > getMaxUploadSize(MediaFileType.VOICE)) {
|
|
367
|
+
return buildFileTooLargeResult(MediaFileType.VOICE, fileSize);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Re-check containment after the file appears to prevent symlink-race escapes.
|
|
371
|
+
const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
|
|
372
|
+
if (!safeMediaPath) {
|
|
373
|
+
debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
|
|
374
|
+
return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const needsTranscode = shouldTranscodeVoice(safeMediaPath);
|
|
378
|
+
|
|
379
|
+
if (needsTranscode && !transcodeEnabled) {
|
|
380
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath));
|
|
381
|
+
debugLog(
|
|
382
|
+
`sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`,
|
|
383
|
+
);
|
|
384
|
+
return {
|
|
385
|
+
channel: "qqbot",
|
|
386
|
+
error: `Voice transcoding is disabled and format ${ext} cannot be uploaded directly`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const silkBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats);
|
|
392
|
+
let uploadBase64 = silkBase64;
|
|
393
|
+
|
|
394
|
+
if (!uploadBase64) {
|
|
395
|
+
const buf = await readFileAsync(safeMediaPath);
|
|
396
|
+
uploadBase64 = buf.toString("base64");
|
|
397
|
+
debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
|
|
398
|
+
} else {
|
|
399
|
+
debugLog(`sendVoice: SILK ready (${fileSize} bytes)`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const creds = accountToCreds(ctx.account);
|
|
403
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
404
|
+
|
|
405
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
406
|
+
const r = await senderSendMedia({
|
|
407
|
+
target,
|
|
408
|
+
creds,
|
|
409
|
+
kind: "voice",
|
|
410
|
+
source: { base64: uploadBase64 },
|
|
411
|
+
msgId: ctx.replyToId,
|
|
412
|
+
localPathForMeta: safeMediaPath,
|
|
413
|
+
});
|
|
414
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
415
|
+
}
|
|
416
|
+
debugLog(`sendVoice: voice not supported in channel`);
|
|
417
|
+
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
420
|
+
debugError(`sendVoice (local): daily upload quota exceeded`);
|
|
421
|
+
return buildDailyLimitExceededResult(err);
|
|
422
|
+
}
|
|
423
|
+
const msg = formatErrorMessage(err);
|
|
424
|
+
debugError(`sendVoice (local) failed: ${msg}`);
|
|
425
|
+
return { channel: "qqbot", error: msg };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Send video from either a public URL or a local file. */
|
|
430
|
+
export async function sendVideoMsg(
|
|
431
|
+
ctx: MediaTargetContext,
|
|
432
|
+
videoPath: string,
|
|
433
|
+
): Promise<OutboundResult> {
|
|
434
|
+
const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video");
|
|
435
|
+
if (!resolvedMediaPath.ok) {
|
|
436
|
+
return { channel: "qqbot", error: resolvedMediaPath.error };
|
|
437
|
+
}
|
|
438
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
439
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
440
|
+
|
|
441
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
442
|
+
debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
|
|
443
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
|
|
444
|
+
if (localFile) {
|
|
445
|
+
return await sendVideoFromLocal(ctx, localFile);
|
|
446
|
+
}
|
|
447
|
+
return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
if (isHttp) {
|
|
452
|
+
const creds = accountToCreds(ctx.account);
|
|
453
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
454
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
455
|
+
const r = await senderSendMedia({
|
|
456
|
+
target,
|
|
457
|
+
creds,
|
|
458
|
+
kind: "video",
|
|
459
|
+
source: { url: mediaPath },
|
|
460
|
+
msgId: ctx.replyToId,
|
|
461
|
+
});
|
|
462
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
463
|
+
}
|
|
464
|
+
debugLog(`sendVideoMsg: video not supported in channel`);
|
|
465
|
+
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return await sendVideoFromLocal(ctx, mediaPath);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
const msg = formatErrorMessage(err);
|
|
471
|
+
|
|
472
|
+
// If direct URL upload fails, retry through a local download path.
|
|
473
|
+
if (isHttp) {
|
|
474
|
+
debugWarn(
|
|
475
|
+
`sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
|
|
476
|
+
);
|
|
477
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
|
|
478
|
+
if (localFile) {
|
|
479
|
+
return await sendVideoFromLocal(ctx, localFile);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
debugError(`sendVideoMsg failed: ${msg}`);
|
|
484
|
+
return { channel: "qqbot", error: msg };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Send video from a local file. */
|
|
489
|
+
async function sendVideoFromLocal(
|
|
490
|
+
ctx: MediaTargetContext,
|
|
491
|
+
mediaPath: string,
|
|
492
|
+
): Promise<OutboundResult> {
|
|
493
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
494
|
+
return { channel: "qqbot", error: "Video not found" };
|
|
495
|
+
}
|
|
496
|
+
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.VIDEO));
|
|
497
|
+
if (!sizeCheck.ok) {
|
|
498
|
+
return buildFileTooLargeResult(MediaFileType.VIDEO, sizeCheck.size);
|
|
499
|
+
}
|
|
500
|
+
debugLog(`sendVideoMsg: local video (${formatFileSize(sizeCheck.size)})`);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const creds = accountToCreds(ctx.account);
|
|
504
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
505
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
506
|
+
const r = await senderSendMedia({
|
|
507
|
+
target,
|
|
508
|
+
creds,
|
|
509
|
+
kind: "video",
|
|
510
|
+
source: { localPath: mediaPath },
|
|
511
|
+
msgId: ctx.replyToId,
|
|
512
|
+
localPathForMeta: mediaPath,
|
|
513
|
+
});
|
|
514
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
515
|
+
}
|
|
516
|
+
debugLog(`sendVideoMsg: video not supported in channel`);
|
|
517
|
+
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
518
|
+
} catch (err) {
|
|
519
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
520
|
+
debugError(`sendVideoMsg (local): daily upload quota exceeded`);
|
|
521
|
+
return buildDailyLimitExceededResult(err);
|
|
522
|
+
}
|
|
523
|
+
const msg = formatErrorMessage(err);
|
|
524
|
+
debugError(`sendVideoMsg (local) failed: ${msg}`);
|
|
525
|
+
return { channel: "qqbot", error: msg };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Send a file from a local path or public URL. */
|
|
530
|
+
export async function sendDocument(
|
|
531
|
+
ctx: MediaTargetContext,
|
|
532
|
+
filePath: string,
|
|
533
|
+
options: SendDocumentOptions = {},
|
|
534
|
+
): Promise<OutboundResult> {
|
|
535
|
+
const extraLocalRoots = options.allowQQBotDataDownloads
|
|
536
|
+
? [getQQBotDataDir("downloads")]
|
|
537
|
+
: undefined;
|
|
538
|
+
const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", {
|
|
539
|
+
extraLocalRoots,
|
|
540
|
+
});
|
|
541
|
+
if (!resolvedMediaPath.ok) {
|
|
542
|
+
return { channel: "qqbot", error: resolvedMediaPath.error };
|
|
543
|
+
}
|
|
544
|
+
const mediaPath = resolvedMediaPath.mediaPath;
|
|
545
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
546
|
+
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
547
|
+
|
|
548
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
549
|
+
debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
|
|
550
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
|
|
551
|
+
if (localFile) {
|
|
552
|
+
return await sendDocumentFromLocal(ctx, localFile);
|
|
553
|
+
}
|
|
554
|
+
return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
if (isHttp) {
|
|
559
|
+
const creds = accountToCreds(ctx.account);
|
|
560
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
561
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
562
|
+
const r = await senderSendMedia({
|
|
563
|
+
target,
|
|
564
|
+
creds,
|
|
565
|
+
kind: "file",
|
|
566
|
+
source: { url: mediaPath },
|
|
567
|
+
msgId: ctx.replyToId,
|
|
568
|
+
fileName,
|
|
569
|
+
});
|
|
570
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
571
|
+
}
|
|
572
|
+
debugLog(`sendDocument: file not supported in channel`);
|
|
573
|
+
return { channel: "qqbot", error: "File not supported in channel" };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return await sendDocumentFromLocal(ctx, mediaPath);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
const msg = formatErrorMessage(err);
|
|
579
|
+
|
|
580
|
+
// If direct URL upload fails, retry through a local download path.
|
|
581
|
+
if (isHttp) {
|
|
582
|
+
debugWarn(
|
|
583
|
+
`sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
|
|
584
|
+
);
|
|
585
|
+
const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
|
|
586
|
+
if (localFile) {
|
|
587
|
+
return await sendDocumentFromLocal(ctx, localFile);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
debugError(`sendDocument failed: ${msg}`);
|
|
592
|
+
return { channel: "qqbot", error: msg };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Send a file from local storage. */
|
|
597
|
+
async function sendDocumentFromLocal(
|
|
598
|
+
ctx: MediaTargetContext,
|
|
599
|
+
mediaPath: string,
|
|
600
|
+
): Promise<OutboundResult> {
|
|
601
|
+
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
602
|
+
|
|
603
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
604
|
+
return { channel: "qqbot", error: "File not found" };
|
|
605
|
+
}
|
|
606
|
+
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.FILE));
|
|
607
|
+
if (!sizeCheck.ok) {
|
|
608
|
+
return buildFileTooLargeResult(MediaFileType.FILE, sizeCheck.size);
|
|
609
|
+
}
|
|
610
|
+
if (sizeCheck.size === 0) {
|
|
611
|
+
return { channel: "qqbot", error: `File is empty: ${mediaPath}` };
|
|
612
|
+
}
|
|
613
|
+
debugLog(`sendDocument: local file (${formatFileSize(sizeCheck.size)})`);
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
const creds = accountToCreds(ctx.account);
|
|
617
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
618
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
619
|
+
const r = await senderSendMedia({
|
|
620
|
+
target,
|
|
621
|
+
creds,
|
|
622
|
+
kind: "file",
|
|
623
|
+
source: { localPath: mediaPath },
|
|
624
|
+
msgId: ctx.replyToId,
|
|
625
|
+
fileName,
|
|
626
|
+
localPathForMeta: mediaPath,
|
|
627
|
+
});
|
|
628
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
629
|
+
}
|
|
630
|
+
debugLog(`sendDocument: file not supported in channel`);
|
|
631
|
+
return { channel: "qqbot", error: "File not supported in channel" };
|
|
632
|
+
} catch (err) {
|
|
633
|
+
if (err instanceof UploadDailyLimitExceededError) {
|
|
634
|
+
debugError(`sendDocument (local): daily upload quota exceeded`);
|
|
635
|
+
return buildDailyLimitExceededResult(err);
|
|
636
|
+
}
|
|
637
|
+
const msg = formatErrorMessage(err);
|
|
638
|
+
debugError(`sendDocument (local) failed: ${msg}`);
|
|
639
|
+
return { channel: "qqbot", error: msg };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Download a remote file into the fallback media directory. */
|
|
644
|
+
async function downloadToFallbackDir(httpUrl: string, caller: string): Promise<string | null> {
|
|
645
|
+
try {
|
|
646
|
+
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
|
647
|
+
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
648
|
+
if (!localFile) {
|
|
649
|
+
debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
debugLog(`${caller} fallback: downloaded → ${localFile}`);
|
|
653
|
+
return localFile;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
debugError(`${caller} fallback download error:`, err);
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { debugLog } from "../utils/log.js";
|
|
2
|
+
import { ReplyLimiter, type ReplyLimitResult } from "./reply-limiter.js";
|
|
3
|
+
|
|
4
|
+
const replyLimiter = new ReplyLimiter();
|
|
5
|
+
|
|
6
|
+
export type { ReplyLimitResult };
|
|
7
|
+
|
|
8
|
+
export const MESSAGE_REPLY_LIMIT = 4;
|
|
9
|
+
|
|
10
|
+
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
|
|
11
|
+
return replyLimiter.checkLimit(messageId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function recordMessageReply(messageId: string): void {
|
|
15
|
+
replyLimiter.record(messageId);
|
|
16
|
+
debugLog(
|
|
17
|
+
`[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
|
|
22
|
+
return replyLimiter.getStats();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
|
|
26
|
+
return replyLimiter.getConfig();
|
|
27
|
+
}
|