@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,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply dispatcher — structured payload handling and text routing.
|
|
3
|
+
*
|
|
4
|
+
* Uses the unified `sender.ts` business function layer for all message
|
|
5
|
+
* sending. TTS is injected via `ReplyDispatcherDeps`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { MediaFileType, type GatewayAccount } from "../types.js";
|
|
11
|
+
import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js";
|
|
12
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
13
|
+
import {
|
|
14
|
+
parseQQBotPayload,
|
|
15
|
+
encodePayloadForCron,
|
|
16
|
+
isCronReminderPayload,
|
|
17
|
+
isMediaPayload,
|
|
18
|
+
type MediaPayload,
|
|
19
|
+
} from "../utils/payload.js";
|
|
20
|
+
import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js";
|
|
21
|
+
import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js";
|
|
22
|
+
import { sanitizeFileName } from "../utils/string-normalize.js";
|
|
23
|
+
import { openLocalFile } from "./media-source.js";
|
|
24
|
+
import {
|
|
25
|
+
sendText as senderSendText,
|
|
26
|
+
sendMedia as senderSendMedia,
|
|
27
|
+
withTokenRetry,
|
|
28
|
+
buildDeliveryTarget,
|
|
29
|
+
accountToCreds,
|
|
30
|
+
} from "./sender.js";
|
|
31
|
+
|
|
32
|
+
// ---- Injected dependencies ----
|
|
33
|
+
|
|
34
|
+
/** TTS provider interface — injected from the outer layer. */
|
|
35
|
+
interface TTSProvider {
|
|
36
|
+
/** Framework TTS: text → audio file path. */
|
|
37
|
+
textToSpeech(params: {
|
|
38
|
+
text: string;
|
|
39
|
+
cfg: unknown;
|
|
40
|
+
channel: string;
|
|
41
|
+
accountId?: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
success: boolean;
|
|
44
|
+
audioPath?: string;
|
|
45
|
+
provider?: string;
|
|
46
|
+
outputFormat?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}>;
|
|
49
|
+
/** Convert any audio file to SILK base64. */
|
|
50
|
+
audioFileToSilkBase64(audioPath: string): Promise<string | undefined>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Dependencies injected into reply-dispatcher functions. */
|
|
54
|
+
export interface ReplyDispatcherDeps {
|
|
55
|
+
tts: TTSProvider;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- Exported types ----
|
|
59
|
+
|
|
60
|
+
interface MessageTarget {
|
|
61
|
+
type: "c2c" | "guild" | "dm" | "group";
|
|
62
|
+
senderId: string;
|
|
63
|
+
messageId: string;
|
|
64
|
+
channelId?: string;
|
|
65
|
+
guildId?: string;
|
|
66
|
+
groupOpenid?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ReplyContext {
|
|
70
|
+
target: MessageTarget;
|
|
71
|
+
account: GatewayAccount;
|
|
72
|
+
cfg: unknown;
|
|
73
|
+
log?: {
|
|
74
|
+
info: (msg: string) => void;
|
|
75
|
+
error: (msg: string) => void;
|
|
76
|
+
debug?: (msg: string) => void;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- Token retry (delegated to sender.ts) ----
|
|
81
|
+
|
|
82
|
+
/** Send a message and retry once if the token appears to have expired. */
|
|
83
|
+
export async function sendWithTokenRetry<T>(
|
|
84
|
+
appId: string,
|
|
85
|
+
clientSecret: string,
|
|
86
|
+
sendFn: (token: string) => Promise<T>,
|
|
87
|
+
log?: ReplyContext["log"],
|
|
88
|
+
accountId?: string,
|
|
89
|
+
): Promise<T> {
|
|
90
|
+
return withTokenRetry({ appId, clientSecret }, sendFn, log, accountId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Text routing ----
|
|
94
|
+
|
|
95
|
+
/** Route a text message to the correct QQ target type. */
|
|
96
|
+
async function sendTextToTarget(ctx: ReplyContext, text: string, refIdx?: string): Promise<void> {
|
|
97
|
+
const { target, account } = ctx;
|
|
98
|
+
const deliveryTarget = buildDeliveryTarget(target);
|
|
99
|
+
const creds = accountToCreds(account);
|
|
100
|
+
await withTokenRetry(
|
|
101
|
+
creds,
|
|
102
|
+
async () => {
|
|
103
|
+
await senderSendText(deliveryTarget, text, creds, {
|
|
104
|
+
msgId: target.messageId,
|
|
105
|
+
messageReference: refIdx,
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
ctx.log,
|
|
109
|
+
account.accountId,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Best-effort delivery for error text back to the user. */
|
|
114
|
+
export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise<void> {
|
|
115
|
+
try {
|
|
116
|
+
await sendTextToTarget(ctx, errorText);
|
|
117
|
+
} catch (sendErr) {
|
|
118
|
+
ctx.log?.error(`Failed to send error message: ${String(sendErr)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- Structured payload handling ----
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle a structured payload prefixed with `QQBOT_PAYLOAD:`.
|
|
126
|
+
* Returns true when the reply was handled here, otherwise false.
|
|
127
|
+
*/
|
|
128
|
+
export async function handleStructuredPayload(
|
|
129
|
+
ctx: ReplyContext,
|
|
130
|
+
replyText: string,
|
|
131
|
+
recordActivity: () => void,
|
|
132
|
+
deps?: ReplyDispatcherDeps,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const { account: _account, log } = ctx;
|
|
135
|
+
const payloadResult = parseQQBotPayload(replyText);
|
|
136
|
+
|
|
137
|
+
if (!payloadResult.isPayload) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (payloadResult.error) {
|
|
142
|
+
log?.error(`Payload parse error: ${payloadResult.error}`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!payloadResult.payload) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const parsedPayload = payloadResult.payload;
|
|
151
|
+
const unknownPayload = payloadResult.payload as unknown;
|
|
152
|
+
log?.info(`Detected structured payload, type: ${parsedPayload.type}`);
|
|
153
|
+
|
|
154
|
+
if (isCronReminderPayload(parsedPayload)) {
|
|
155
|
+
log?.debug?.(`Processing cron_reminder payload`);
|
|
156
|
+
const cronMessage = encodePayloadForCron(parsedPayload);
|
|
157
|
+
const confirmText = `⏰ Reminder scheduled. It will be sent at the configured time: "${parsedPayload.content}"`;
|
|
158
|
+
try {
|
|
159
|
+
await sendTextToTarget(ctx, confirmText);
|
|
160
|
+
log?.debug?.(`Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
log?.error(`Failed to send cron confirmation: ${formatErrorMessage(err)}`);
|
|
163
|
+
}
|
|
164
|
+
recordActivity();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isMediaPayload(parsedPayload)) {
|
|
169
|
+
log?.debug?.(`Processing media payload, mediaType: ${parsedPayload.mediaType}`);
|
|
170
|
+
|
|
171
|
+
if (parsedPayload.mediaType === "image") {
|
|
172
|
+
await handleImagePayload(ctx, parsedPayload);
|
|
173
|
+
} else if (parsedPayload.mediaType === "audio") {
|
|
174
|
+
await handleAudioPayload(ctx, parsedPayload, deps);
|
|
175
|
+
} else if (parsedPayload.mediaType === "video") {
|
|
176
|
+
await handleVideoPayload(ctx, parsedPayload);
|
|
177
|
+
} else if (parsedPayload.mediaType === "file") {
|
|
178
|
+
await handleFilePayload(ctx, parsedPayload);
|
|
179
|
+
} else {
|
|
180
|
+
log?.error(`Unknown media type: ${JSON.stringify(parsedPayload.mediaType)}`);
|
|
181
|
+
}
|
|
182
|
+
recordActivity();
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const payloadType =
|
|
187
|
+
typeof unknownPayload === "object" &&
|
|
188
|
+
unknownPayload !== null &&
|
|
189
|
+
"type" in unknownPayload &&
|
|
190
|
+
typeof unknownPayload.type === "string"
|
|
191
|
+
? unknownPayload.type
|
|
192
|
+
: "unknown";
|
|
193
|
+
log?.error(`Unknown payload type: ${payloadType}`);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- Media payload handlers ----
|
|
198
|
+
|
|
199
|
+
type StructuredPayloadMediaType = "image" | "video" | "file";
|
|
200
|
+
|
|
201
|
+
function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string {
|
|
202
|
+
return mediaType[0].toUpperCase() + mediaType.slice(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateStructuredPayloadLocalPath(
|
|
206
|
+
ctx: ReplyContext,
|
|
207
|
+
payloadPath: string,
|
|
208
|
+
mediaType: StructuredPayloadMediaType,
|
|
209
|
+
): string | null {
|
|
210
|
+
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
|
|
211
|
+
if (allowedPath) {
|
|
212
|
+
return allowedPath;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
ctx.log?.error(`Blocked ${mediaType} payload local path outside QQ Bot media storage`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isRemoteHttpUrl(p: string): boolean {
|
|
220
|
+
return p.startsWith("http://") || p.startsWith("https://");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isInlineImageDataUrl(p: string): boolean {
|
|
224
|
+
return /^data:image\/[^;]+;base64,/i.test(p);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resolveStructuredPayloadPath(
|
|
228
|
+
ctx: ReplyContext,
|
|
229
|
+
payload: MediaPayload,
|
|
230
|
+
mediaType: StructuredPayloadMediaType,
|
|
231
|
+
): { path: string; isHttpUrl: boolean } | null {
|
|
232
|
+
const originalPath = payload.path ?? "";
|
|
233
|
+
const normalizedPath = normalizePath(originalPath);
|
|
234
|
+
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
|
235
|
+
const resolvedPath = isHttpUrl
|
|
236
|
+
? normalizedPath
|
|
237
|
+
: validateStructuredPayloadLocalPath(ctx, originalPath, mediaType);
|
|
238
|
+
if (!resolvedPath) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (!resolvedPath.trim()) {
|
|
242
|
+
ctx.log?.error(
|
|
243
|
+
`[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`,
|
|
244
|
+
);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return { path: resolvedPath, isHttpUrl };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sanitizeForLog(value: string, maxLen = 200): string {
|
|
251
|
+
return value
|
|
252
|
+
.replace(/[\r\n\t]/g, " ")
|
|
253
|
+
.replaceAll("\0", " ")
|
|
254
|
+
.slice(0, maxLen);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
|
|
258
|
+
if (!isHttpUrl) {
|
|
259
|
+
return "<local-file>";
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const url = new URL(pathValue);
|
|
263
|
+
url.username = "";
|
|
264
|
+
url.password = "";
|
|
265
|
+
const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
|
|
266
|
+
return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
|
|
267
|
+
} catch {
|
|
268
|
+
return "<invalid-url>";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Read a local file into memory for image base64 inlining.
|
|
274
|
+
*
|
|
275
|
+
* Non-image media (video / file) should pass `source: { localPath }` to
|
|
276
|
+
* `sender.sendMedia` directly — the sender pipeline handles chunked
|
|
277
|
+
* routing once this function validates the per-type ceiling.
|
|
278
|
+
*/
|
|
279
|
+
async function readLocalFileForInlineBase64(
|
|
280
|
+
filePath: string,
|
|
281
|
+
fileType: MediaFileType,
|
|
282
|
+
): Promise<Buffer> {
|
|
283
|
+
const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
|
|
284
|
+
try {
|
|
285
|
+
return await opened.handle.readFile();
|
|
286
|
+
} finally {
|
|
287
|
+
await opened.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Enforce the per-{@link MediaFileType} upload ceiling before handing a
|
|
293
|
+
* local path to `sender.sendMedia`. The sender's internal `normalizeSource`
|
|
294
|
+
* uses an unlimited cap so it can accept whatever size the policy layer
|
|
295
|
+
* (outbound / reply-dispatcher) approves; the policy gate lives here.
|
|
296
|
+
*
|
|
297
|
+
* Returns the validated byte size. Throws via {@link openLocalFile} with a
|
|
298
|
+
* human-readable "File is too large" message when exceeding the ceiling.
|
|
299
|
+
*/
|
|
300
|
+
async function assertLocalFileWithinTypeLimit(
|
|
301
|
+
filePath: string,
|
|
302
|
+
fileType: MediaFileType,
|
|
303
|
+
): Promise<number> {
|
|
304
|
+
const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
|
|
305
|
+
try {
|
|
306
|
+
return opened.size;
|
|
307
|
+
} finally {
|
|
308
|
+
await opened.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
313
|
+
const { target, account, log } = ctx;
|
|
314
|
+
const normalizedPath = normalizePath(payload.path);
|
|
315
|
+
let imageUrl: string | null;
|
|
316
|
+
if (payload.source === "file") {
|
|
317
|
+
imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
|
|
318
|
+
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
|
|
319
|
+
imageUrl = normalizedPath;
|
|
320
|
+
} else {
|
|
321
|
+
log?.error(
|
|
322
|
+
`Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
|
|
323
|
+
);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!imageUrl) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const originalImagePath = payload.source === "file" ? imageUrl : undefined;
|
|
330
|
+
|
|
331
|
+
if (payload.source === "file") {
|
|
332
|
+
try {
|
|
333
|
+
const fileBuffer = await readLocalFileForInlineBase64(imageUrl, MediaFileType.IMAGE);
|
|
334
|
+
const base64Data = fileBuffer.toString("base64");
|
|
335
|
+
const mimeType = getImageMimeType(imageUrl);
|
|
336
|
+
if (!mimeType) {
|
|
337
|
+
const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl));
|
|
338
|
+
log?.error(`Unsupported image format: ${ext}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
|
342
|
+
log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
343
|
+
} catch (readErr) {
|
|
344
|
+
log?.error(
|
|
345
|
+
`Failed to read local image: ${
|
|
346
|
+
readErr instanceof Error ? readErr.message : JSON.stringify(readErr)
|
|
347
|
+
}`,
|
|
348
|
+
);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const deliveryTarget = buildDeliveryTarget(target);
|
|
355
|
+
const creds = accountToCreds(account);
|
|
356
|
+
|
|
357
|
+
await withTokenRetry(
|
|
358
|
+
creds,
|
|
359
|
+
async () => {
|
|
360
|
+
if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
|
|
361
|
+
await senderSendMedia({
|
|
362
|
+
target: deliveryTarget,
|
|
363
|
+
creds,
|
|
364
|
+
kind: "image",
|
|
365
|
+
source: { url: imageUrl },
|
|
366
|
+
msgId: target.messageId,
|
|
367
|
+
localPathForMeta: originalImagePath,
|
|
368
|
+
});
|
|
369
|
+
} else if (deliveryTarget.type === "dm") {
|
|
370
|
+
await senderSendText(deliveryTarget, ``, creds, {
|
|
371
|
+
msgId: target.messageId,
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
await senderSendText(deliveryTarget, ``, creds, {
|
|
375
|
+
msgId: target.messageId,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
log,
|
|
380
|
+
account.accountId,
|
|
381
|
+
);
|
|
382
|
+
log?.debug?.(`Sent image via media payload`);
|
|
383
|
+
|
|
384
|
+
if (payload.caption) {
|
|
385
|
+
await sendTextToTarget(ctx, payload.caption);
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
log?.error(`Failed to send image: ${formatErrorMessage(err)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function handleAudioPayload(
|
|
393
|
+
ctx: ReplyContext,
|
|
394
|
+
payload: MediaPayload,
|
|
395
|
+
deps?: ReplyDispatcherDeps,
|
|
396
|
+
): Promise<void> {
|
|
397
|
+
const ttsText = payload.caption || payload.path;
|
|
398
|
+
await sendTextAsVoiceReply(ctx, ttsText, deps);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function sendTextAsVoiceReply(
|
|
402
|
+
ctx: ReplyContext,
|
|
403
|
+
text: string | undefined,
|
|
404
|
+
deps?: ReplyDispatcherDeps,
|
|
405
|
+
): Promise<boolean> {
|
|
406
|
+
const { target, account, cfg, log } = ctx;
|
|
407
|
+
if (!deps) {
|
|
408
|
+
log?.error(`TTS deps not provided, cannot handle audio payload`);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const ttsText = text;
|
|
413
|
+
if (!ttsText?.trim()) {
|
|
414
|
+
log?.error(`Voice missing text`);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
log?.debug?.(`TTS: "${ttsText.slice(0, 50)}..."`);
|
|
419
|
+
const ttsResult = await deps.tts.textToSpeech({
|
|
420
|
+
text: ttsText,
|
|
421
|
+
cfg,
|
|
422
|
+
channel: "qqbot",
|
|
423
|
+
accountId: account.accountId,
|
|
424
|
+
});
|
|
425
|
+
if (!ttsResult.success || !ttsResult.audioPath) {
|
|
426
|
+
log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`);
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const providerLabel = ttsResult.provider ?? "unknown";
|
|
431
|
+
log?.debug?.(
|
|
432
|
+
`TTS returned: provider=${providerLabel}, format=${ttsResult.outputFormat}, path=${ttsResult.audioPath}`,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const silkBase64 = await deps.tts.audioFileToSilkBase64(ttsResult.audioPath);
|
|
436
|
+
if (!silkBase64) {
|
|
437
|
+
log?.error(`Failed to convert TTS audio to SILK`);
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
const silkPath = ttsResult.audioPath;
|
|
441
|
+
|
|
442
|
+
log?.debug?.(`TTS done (${providerLabel}), file: ${silkPath}`);
|
|
443
|
+
|
|
444
|
+
const deliveryTarget = buildDeliveryTarget(target);
|
|
445
|
+
const creds = accountToCreds(account);
|
|
446
|
+
|
|
447
|
+
await withTokenRetry(
|
|
448
|
+
creds,
|
|
449
|
+
async () => {
|
|
450
|
+
if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
|
|
451
|
+
await senderSendMedia({
|
|
452
|
+
target: deliveryTarget,
|
|
453
|
+
creds,
|
|
454
|
+
kind: "voice",
|
|
455
|
+
source: { base64: silkBase64 },
|
|
456
|
+
msgId: target.messageId,
|
|
457
|
+
ttsText,
|
|
458
|
+
localPathForMeta: silkPath,
|
|
459
|
+
});
|
|
460
|
+
} else {
|
|
461
|
+
log?.error(`Voice not supported in ${deliveryTarget.type}, sending text fallback`);
|
|
462
|
+
await senderSendText(deliveryTarget, ttsText, creds, { msgId: target.messageId });
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
log,
|
|
466
|
+
account.accountId,
|
|
467
|
+
);
|
|
468
|
+
log?.debug?.(`Voice message sent`);
|
|
469
|
+
return true;
|
|
470
|
+
} catch (err) {
|
|
471
|
+
log?.error(`TTS/voice send failed: ${formatErrorMessage(err)}`);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
477
|
+
const { target, account, log } = ctx;
|
|
478
|
+
try {
|
|
479
|
+
const resolved = resolveStructuredPayloadPath(ctx, payload, "video");
|
|
480
|
+
if (!resolved) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const videoPath = resolved.path;
|
|
484
|
+
const isHttpUrl = resolved.isHttpUrl;
|
|
485
|
+
|
|
486
|
+
log?.debug?.(`Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`);
|
|
487
|
+
|
|
488
|
+
const deliveryTarget = buildDeliveryTarget(target);
|
|
489
|
+
const creds = accountToCreds(account);
|
|
490
|
+
|
|
491
|
+
if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") {
|
|
492
|
+
log?.error(`Video not supported in ${deliveryTarget.type}`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
await withTokenRetry(
|
|
497
|
+
creds,
|
|
498
|
+
async () => {
|
|
499
|
+
if (isHttpUrl) {
|
|
500
|
+
await senderSendMedia({
|
|
501
|
+
target: deliveryTarget,
|
|
502
|
+
creds,
|
|
503
|
+
kind: "video",
|
|
504
|
+
source: { url: videoPath },
|
|
505
|
+
msgId: target.messageId,
|
|
506
|
+
});
|
|
507
|
+
} else {
|
|
508
|
+
const size = await assertLocalFileWithinTypeLimit(videoPath, MediaFileType.VIDEO);
|
|
509
|
+
log?.debug?.(
|
|
510
|
+
`Video local (${formatFileSize(size)}): ${describeMediaTargetForLog(videoPath, false)}`,
|
|
511
|
+
);
|
|
512
|
+
// Hand the local path straight to the sender — `dispatchUpload`
|
|
513
|
+
// routes one-shot vs chunked based on size.
|
|
514
|
+
await senderSendMedia({
|
|
515
|
+
target: deliveryTarget,
|
|
516
|
+
creds,
|
|
517
|
+
kind: "video",
|
|
518
|
+
source: { localPath: videoPath },
|
|
519
|
+
msgId: target.messageId,
|
|
520
|
+
localPathForMeta: videoPath,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
log,
|
|
525
|
+
account.accountId,
|
|
526
|
+
);
|
|
527
|
+
log?.debug?.(`Video message sent`);
|
|
528
|
+
|
|
529
|
+
if (payload.caption) {
|
|
530
|
+
await sendTextToTarget(ctx, payload.caption);
|
|
531
|
+
}
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log?.error(`Video send failed: ${formatErrorMessage(err)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
|
538
|
+
const { target, account, log } = ctx;
|
|
539
|
+
try {
|
|
540
|
+
const resolved = resolveStructuredPayloadPath(ctx, payload, "file");
|
|
541
|
+
if (!resolved) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const filePath = resolved.path;
|
|
545
|
+
const isHttpUrl = resolved.isHttpUrl;
|
|
546
|
+
|
|
547
|
+
const fileName = sanitizeFileName(path.basename(filePath));
|
|
548
|
+
log?.debug?.(
|
|
549
|
+
`File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const deliveryTarget = buildDeliveryTarget(target);
|
|
553
|
+
const creds = accountToCreds(account);
|
|
554
|
+
|
|
555
|
+
if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") {
|
|
556
|
+
log?.error(`File not supported in ${deliveryTarget.type}`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
await withTokenRetry(
|
|
561
|
+
creds,
|
|
562
|
+
async () => {
|
|
563
|
+
if (isHttpUrl) {
|
|
564
|
+
await senderSendMedia({
|
|
565
|
+
target: deliveryTarget,
|
|
566
|
+
creds,
|
|
567
|
+
kind: "file",
|
|
568
|
+
source: { url: filePath },
|
|
569
|
+
msgId: target.messageId,
|
|
570
|
+
fileName,
|
|
571
|
+
});
|
|
572
|
+
} else {
|
|
573
|
+
const size = await assertLocalFileWithinTypeLimit(filePath, MediaFileType.FILE);
|
|
574
|
+
log?.debug?.(
|
|
575
|
+
`File local (${formatFileSize(size)}): ${describeMediaTargetForLog(filePath, false)}`,
|
|
576
|
+
);
|
|
577
|
+
// Hand the local path straight to the sender — `dispatchUpload`
|
|
578
|
+
// routes one-shot vs chunked based on size.
|
|
579
|
+
await senderSendMedia({
|
|
580
|
+
target: deliveryTarget,
|
|
581
|
+
creds,
|
|
582
|
+
kind: "file",
|
|
583
|
+
source: { localPath: filePath },
|
|
584
|
+
msgId: target.messageId,
|
|
585
|
+
fileName,
|
|
586
|
+
localPathForMeta: filePath,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
log,
|
|
591
|
+
account.accountId,
|
|
592
|
+
);
|
|
593
|
+
log?.debug?.(`File message sent`);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
log?.error(`File send failed: ${formatErrorMessage(err)}`);
|
|
596
|
+
}
|
|
597
|
+
}
|