@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,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound dispatcher — manage AI reply delivery, tool fallback, and timeouts.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* 1. Build ctxPayload and call runtime.dispatchReply
|
|
6
|
+
* 2. Tool deliver collection + fallback timeout
|
|
7
|
+
* 3. Block deliver pipeline (consumeQuoteRef → media tags → structured payload → plain text)
|
|
8
|
+
* 4. Timeout / error handling
|
|
9
|
+
*
|
|
10
|
+
* Separated from gateway.ts for testability and to keep handleMessage thin.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FinalizedMsgContext } from "autobot/plugin-sdk/reply-runtime";
|
|
14
|
+
import {
|
|
15
|
+
parseAndSendMediaTags,
|
|
16
|
+
sendPlainReply,
|
|
17
|
+
type DeliverDeps,
|
|
18
|
+
} from "../messaging/outbound-deliver.js";
|
|
19
|
+
import {
|
|
20
|
+
sendDocument,
|
|
21
|
+
sendMedia,
|
|
22
|
+
sendPhoto,
|
|
23
|
+
sendVoice,
|
|
24
|
+
sendVideoMsg,
|
|
25
|
+
} from "../messaging/outbound.js";
|
|
26
|
+
import {
|
|
27
|
+
handleStructuredPayload,
|
|
28
|
+
sendTextAsVoiceReply,
|
|
29
|
+
sendErrorToTarget,
|
|
30
|
+
sendWithTokenRetry,
|
|
31
|
+
type ReplyDispatcherDeps,
|
|
32
|
+
} from "../messaging/reply-dispatcher.js";
|
|
33
|
+
import { StreamingController, shouldUseOfficialC2cStream } from "../messaging/streaming-c2c.js";
|
|
34
|
+
import { audioFileToSilkBase64 } from "../utils/audio.js";
|
|
35
|
+
import type { InboundContext } from "./inbound-context.js";
|
|
36
|
+
import type {
|
|
37
|
+
GatewayAccount,
|
|
38
|
+
EngineLogger,
|
|
39
|
+
GatewayPluginRuntime,
|
|
40
|
+
OutboundResult,
|
|
41
|
+
} from "./types.js";
|
|
42
|
+
|
|
43
|
+
// ============ Config ============
|
|
44
|
+
|
|
45
|
+
const RESPONSE_TIMEOUT = 300_000;
|
|
46
|
+
const TOOL_ONLY_TIMEOUT = 60_000;
|
|
47
|
+
const MAX_TOOL_RENEWALS = 3;
|
|
48
|
+
const TOOL_MEDIA_SEND_TIMEOUT = 45_000;
|
|
49
|
+
|
|
50
|
+
// ============ Dependencies ============
|
|
51
|
+
|
|
52
|
+
interface OutboundDispatchDeps {
|
|
53
|
+
runtime: GatewayPluginRuntime;
|
|
54
|
+
cfg: unknown;
|
|
55
|
+
account: GatewayAccount;
|
|
56
|
+
log?: EngineLogger;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ReplyDeliverPayload = {
|
|
60
|
+
text?: string;
|
|
61
|
+
mediaUrls?: string[];
|
|
62
|
+
mediaUrl?: string;
|
|
63
|
+
audioAsVoice?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ============ dispatchOutbound ============
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Dispatch the AI reply for the given inbound context.
|
|
70
|
+
*
|
|
71
|
+
* Handles tool deliver collection, block deliver pipeline, and timeouts.
|
|
72
|
+
* The caller is responsible for stopping typing.keepAlive in `finally`.
|
|
73
|
+
*/
|
|
74
|
+
export async function dispatchOutbound(
|
|
75
|
+
inbound: InboundContext,
|
|
76
|
+
deps: OutboundDispatchDeps,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const { runtime, cfg, account, log } = deps;
|
|
79
|
+
const { event, qualifiedTarget } = inbound;
|
|
80
|
+
|
|
81
|
+
const replyTarget = {
|
|
82
|
+
type: event.type,
|
|
83
|
+
senderId: event.senderId,
|
|
84
|
+
messageId: event.messageId,
|
|
85
|
+
channelId: event.channelId,
|
|
86
|
+
guildId: event.guildId,
|
|
87
|
+
groupOpenid: event.groupOpenid,
|
|
88
|
+
};
|
|
89
|
+
const replyCtx = { target: replyTarget, account, cfg, log };
|
|
90
|
+
|
|
91
|
+
const sendWithRetry = <T>(sendFn: (token: string) => Promise<T>) =>
|
|
92
|
+
sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
|
|
93
|
+
|
|
94
|
+
const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText);
|
|
95
|
+
|
|
96
|
+
// ---- Build ctxPayload ----
|
|
97
|
+
const ctxPayload = buildCtxPayload(inbound, runtime, cfg);
|
|
98
|
+
|
|
99
|
+
// ---- Deliver state ----
|
|
100
|
+
let hasResponse = false;
|
|
101
|
+
let hasBlockResponse = false;
|
|
102
|
+
let toolDeliverCount = 0;
|
|
103
|
+
const toolTexts: string[] = [];
|
|
104
|
+
const toolMediaUrls: string[] = [];
|
|
105
|
+
let toolFallbackSent = false;
|
|
106
|
+
let toolRenewalCount = 0;
|
|
107
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
108
|
+
let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
|
|
110
|
+
// ---- Tool fallback ----
|
|
111
|
+
const sendToolFallback = async (): Promise<void> => {
|
|
112
|
+
if (toolMediaUrls.length > 0) {
|
|
113
|
+
for (const mediaUrl of toolMediaUrls) {
|
|
114
|
+
const ac = new AbortController();
|
|
115
|
+
try {
|
|
116
|
+
const result = await Promise.race([
|
|
117
|
+
sendMedia({
|
|
118
|
+
to: qualifiedTarget,
|
|
119
|
+
text: "",
|
|
120
|
+
mediaUrl,
|
|
121
|
+
accountId: account.accountId,
|
|
122
|
+
replyToId: event.messageId,
|
|
123
|
+
account,
|
|
124
|
+
}).then((r) => {
|
|
125
|
+
if (ac.signal.aborted) {
|
|
126
|
+
return { channel: "qqbot", error: "suppressed" } as OutboundResult;
|
|
127
|
+
}
|
|
128
|
+
return r;
|
|
129
|
+
}),
|
|
130
|
+
new Promise<OutboundResult>((resolve) =>
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
ac.abort();
|
|
133
|
+
resolve({ channel: "qqbot", error: "timeout" });
|
|
134
|
+
}, TOOL_MEDIA_SEND_TIMEOUT),
|
|
135
|
+
),
|
|
136
|
+
]);
|
|
137
|
+
if (result.error) {
|
|
138
|
+
log?.error(`Tool fallback error: ${result.error}`);
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log?.error(`Tool fallback failed: ${String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (toolTexts.length > 0) {
|
|
147
|
+
await sendErrorMessage(toolTexts.slice(-3).join("\n---\n").slice(0, 2000));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ---- Timeout promise ----
|
|
152
|
+
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
153
|
+
timeoutId = setTimeout(() => {
|
|
154
|
+
if (!hasResponse) {
|
|
155
|
+
reject(new Error("Response timeout"));
|
|
156
|
+
}
|
|
157
|
+
}, RESPONSE_TIMEOUT);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---- Deliver deps ----
|
|
161
|
+
const deliverDeps: DeliverDeps = {
|
|
162
|
+
mediaSender: {
|
|
163
|
+
sendPhoto: (target, imageUrl) => sendPhoto(target, imageUrl),
|
|
164
|
+
sendVoice: (target, voicePath, uploadFormats, transcodeEnabled) =>
|
|
165
|
+
sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
|
|
166
|
+
sendVideoMsg: (target, videoPath) => sendVideoMsg(target, videoPath),
|
|
167
|
+
sendDocument: (target, filePath) => sendDocument(target, filePath),
|
|
168
|
+
sendMedia: (opts) => sendMedia(opts),
|
|
169
|
+
},
|
|
170
|
+
chunkText: (text, limit) => runtime.channel.text.chunkMarkdownText(text, limit),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const replyDeps: ReplyDispatcherDeps = {
|
|
174
|
+
tts: {
|
|
175
|
+
textToSpeech: (params) => runtime.tts.textToSpeech(params),
|
|
176
|
+
audioFileToSilkBase64: async (p) => (await audioFileToSilkBase64(p)) ?? undefined,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const recordOutbound = () =>
|
|
181
|
+
runtime.channel.activity.record({
|
|
182
|
+
channel: "qqbot",
|
|
183
|
+
accountId: account.accountId,
|
|
184
|
+
direction: "outbound",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---- Dispatch ----
|
|
188
|
+
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(
|
|
189
|
+
cfg,
|
|
190
|
+
inbound.route.agentId,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const targetType =
|
|
194
|
+
event.type === "c2c"
|
|
195
|
+
? ("c2c" as const)
|
|
196
|
+
: event.type === "group"
|
|
197
|
+
? ("group" as const)
|
|
198
|
+
: ("channel" as const);
|
|
199
|
+
const useOfficialC2cStream = shouldUseOfficialC2cStream(account, targetType);
|
|
200
|
+
let streamingController: StreamingController | null = null;
|
|
201
|
+
if (useOfficialC2cStream) {
|
|
202
|
+
streamingController = new StreamingController({
|
|
203
|
+
account,
|
|
204
|
+
userId: event.senderId,
|
|
205
|
+
replyToMsgId: event.messageId,
|
|
206
|
+
eventId: event.messageId,
|
|
207
|
+
logPrefix: `[qqbot:${account.accountId}:streaming]`,
|
|
208
|
+
log,
|
|
209
|
+
mediaContext: {
|
|
210
|
+
account,
|
|
211
|
+
event: {
|
|
212
|
+
type: event.type as "c2c" | "group" | "channel",
|
|
213
|
+
senderId: event.senderId,
|
|
214
|
+
messageId: event.messageId,
|
|
215
|
+
groupOpenid: event.groupOpenid,
|
|
216
|
+
channelId: event.channelId,
|
|
217
|
+
},
|
|
218
|
+
log,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const cfgWithSession = cfg as { session?: { store?: unknown } };
|
|
224
|
+
const agentId = inbound.route.agentId ?? "default";
|
|
225
|
+
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
|
|
226
|
+
agentId,
|
|
227
|
+
});
|
|
228
|
+
const dispatchPromise = runtime.channel.turn.run({
|
|
229
|
+
channel: "qqbot",
|
|
230
|
+
accountId: inbound.route.accountId,
|
|
231
|
+
raw: inbound,
|
|
232
|
+
adapter: {
|
|
233
|
+
ingest: () => ({
|
|
234
|
+
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
|
235
|
+
rawText: ctxPayload.RawBody ?? "",
|
|
236
|
+
textForAgent: ctxPayload.BodyForAgent,
|
|
237
|
+
textForCommands: ctxPayload.CommandBody,
|
|
238
|
+
raw: inbound,
|
|
239
|
+
}),
|
|
240
|
+
resolveTurn: () => ({
|
|
241
|
+
channel: "qqbot",
|
|
242
|
+
accountId: inbound.route.accountId,
|
|
243
|
+
routeSessionKey: inbound.route.sessionKey,
|
|
244
|
+
storePath,
|
|
245
|
+
ctxPayload,
|
|
246
|
+
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
247
|
+
record: {
|
|
248
|
+
onRecordError: (err: unknown) => {
|
|
249
|
+
log?.error(
|
|
250
|
+
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
runDispatch: () =>
|
|
255
|
+
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
256
|
+
ctx: ctxPayload,
|
|
257
|
+
cfg,
|
|
258
|
+
dispatcherOptions: {
|
|
259
|
+
responsePrefix: messagesConfig.responsePrefix,
|
|
260
|
+
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
|
|
261
|
+
hasResponse = true;
|
|
262
|
+
|
|
263
|
+
// ---- Tool deliver ----
|
|
264
|
+
if (info.kind === "tool") {
|
|
265
|
+
toolDeliverCount++;
|
|
266
|
+
const toolText = (payload.text ?? "").trim();
|
|
267
|
+
if (toolText) {
|
|
268
|
+
toolTexts.push(toolText);
|
|
269
|
+
}
|
|
270
|
+
if (payload.mediaUrls?.length) {
|
|
271
|
+
toolMediaUrls.push(...payload.mediaUrls);
|
|
272
|
+
}
|
|
273
|
+
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
|
|
274
|
+
toolMediaUrls.push(payload.mediaUrl);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
|
278
|
+
const urlsToSend = [...toolMediaUrls];
|
|
279
|
+
toolMediaUrls.length = 0;
|
|
280
|
+
for (const mediaUrl of urlsToSend) {
|
|
281
|
+
try {
|
|
282
|
+
await sendMedia({
|
|
283
|
+
to: qualifiedTarget,
|
|
284
|
+
text: "",
|
|
285
|
+
mediaUrl,
|
|
286
|
+
accountId: account.accountId,
|
|
287
|
+
replyToId: event.messageId,
|
|
288
|
+
account,
|
|
289
|
+
});
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (toolFallbackSent) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (toolOnlyTimeoutId) {
|
|
298
|
+
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
|
|
299
|
+
clearTimeout(toolOnlyTimeoutId);
|
|
300
|
+
toolRenewalCount++;
|
|
301
|
+
} else {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
toolOnlyTimeoutId = setTimeout(async () => {
|
|
306
|
+
if (!hasBlockResponse && !toolFallbackSent) {
|
|
307
|
+
toolFallbackSent = true;
|
|
308
|
+
try {
|
|
309
|
+
await sendToolFallback();
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
}, TOOL_ONLY_TIMEOUT);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- Block deliver ----
|
|
317
|
+
hasBlockResponse = true;
|
|
318
|
+
inbound.typing.keepAlive?.stop();
|
|
319
|
+
if (timeoutId) {
|
|
320
|
+
clearTimeout(timeoutId);
|
|
321
|
+
timeoutId = null;
|
|
322
|
+
}
|
|
323
|
+
if (toolOnlyTimeoutId) {
|
|
324
|
+
clearTimeout(toolOnlyTimeoutId);
|
|
325
|
+
toolOnlyTimeoutId = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (streamingController && !streamingController.isTerminalPhase) {
|
|
329
|
+
try {
|
|
330
|
+
await streamingController.onDeliver(payload);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log?.error(
|
|
333
|
+
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const replyPreview = (payload.text ?? "").trim();
|
|
338
|
+
if (
|
|
339
|
+
event.type === "group" &&
|
|
340
|
+
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
|
|
341
|
+
) {
|
|
342
|
+
log?.info(
|
|
343
|
+
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
|
|
344
|
+
);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (streamingController.shouldFallbackToStatic) {
|
|
349
|
+
log?.info("Streaming API unavailable, falling back to static for this deliver");
|
|
350
|
+
} else {
|
|
351
|
+
recordOutbound();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const quoteRef = event.msgIdx;
|
|
357
|
+
let quoteRefUsed = false;
|
|
358
|
+
const consumeQuoteRef = (): string | undefined => {
|
|
359
|
+
if (quoteRef && !quoteRefUsed) {
|
|
360
|
+
quoteRefUsed = true;
|
|
361
|
+
return quoteRef;
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
let replyText = payload.text ?? "";
|
|
367
|
+
const deliverEvent = {
|
|
368
|
+
type: event.type,
|
|
369
|
+
senderId: event.senderId,
|
|
370
|
+
messageId: event.messageId,
|
|
371
|
+
channelId: event.channelId,
|
|
372
|
+
groupOpenid: event.groupOpenid,
|
|
373
|
+
msgIdx: event.msgIdx,
|
|
374
|
+
};
|
|
375
|
+
const deliverActx = { account, qualifiedTarget, log };
|
|
376
|
+
|
|
377
|
+
// 1. Media tags
|
|
378
|
+
const mediaResult = await parseAndSendMediaTags(
|
|
379
|
+
replyText,
|
|
380
|
+
deliverEvent,
|
|
381
|
+
deliverActx,
|
|
382
|
+
sendWithRetry,
|
|
383
|
+
consumeQuoteRef,
|
|
384
|
+
deliverDeps,
|
|
385
|
+
);
|
|
386
|
+
if (mediaResult.handled) {
|
|
387
|
+
recordOutbound();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
replyText = mediaResult.normalizedText;
|
|
391
|
+
|
|
392
|
+
// 2. Structured payload (QQBOT_PAYLOAD:)
|
|
393
|
+
const handled = await handleStructuredPayload(
|
|
394
|
+
replyCtx,
|
|
395
|
+
replyText,
|
|
396
|
+
recordOutbound,
|
|
397
|
+
replyDeps,
|
|
398
|
+
);
|
|
399
|
+
if (handled) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 3. Voice-intent plain text
|
|
404
|
+
if (
|
|
405
|
+
payload.audioAsVoice === true &&
|
|
406
|
+
!payload.mediaUrl &&
|
|
407
|
+
!payload.mediaUrls?.length
|
|
408
|
+
) {
|
|
409
|
+
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
|
|
410
|
+
if (sentVoice) {
|
|
411
|
+
recordOutbound();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 4. Plain text + images/media
|
|
417
|
+
await sendPlainReply(
|
|
418
|
+
payload,
|
|
419
|
+
replyText,
|
|
420
|
+
deliverEvent,
|
|
421
|
+
deliverActx,
|
|
422
|
+
sendWithRetry,
|
|
423
|
+
consumeQuoteRef,
|
|
424
|
+
toolMediaUrls,
|
|
425
|
+
deliverDeps,
|
|
426
|
+
);
|
|
427
|
+
recordOutbound();
|
|
428
|
+
},
|
|
429
|
+
onError: async (err: unknown) => {
|
|
430
|
+
if (streamingController && !streamingController.isTerminalPhase) {
|
|
431
|
+
try {
|
|
432
|
+
await streamingController.onError(err);
|
|
433
|
+
} catch (streamErr) {
|
|
434
|
+
const streamErrMsg =
|
|
435
|
+
streamErr instanceof Error ? streamErr.message : String(streamErr);
|
|
436
|
+
log?.error(`Streaming onError failed: ${streamErrMsg}`);
|
|
437
|
+
}
|
|
438
|
+
if (!streamingController.shouldFallbackToStatic) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
443
|
+
log?.error(`Dispatch error: ${errMsg}`);
|
|
444
|
+
hasResponse = true;
|
|
445
|
+
if (timeoutId) {
|
|
446
|
+
clearTimeout(timeoutId);
|
|
447
|
+
timeoutId = null;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
replyOptions: {
|
|
452
|
+
disableBlockStreaming: useOfficialC2cStream
|
|
453
|
+
? true
|
|
454
|
+
: (() => {
|
|
455
|
+
const s = account.config?.streaming;
|
|
456
|
+
if (s === false) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
return typeof s === "object" && s !== null && s.mode === "off";
|
|
460
|
+
})(),
|
|
461
|
+
...(streamingController
|
|
462
|
+
? {
|
|
463
|
+
onPartialReply: async (payload: { text?: string }) => {
|
|
464
|
+
try {
|
|
465
|
+
await streamingController.onPartialReply(payload);
|
|
466
|
+
} catch (partialErr) {
|
|
467
|
+
log?.error(
|
|
468
|
+
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
}
|
|
473
|
+
: {}),
|
|
474
|
+
},
|
|
475
|
+
}),
|
|
476
|
+
}),
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
await Promise.race([dispatchPromise, timeoutPromise]);
|
|
482
|
+
} catch {
|
|
483
|
+
if (timeoutId) {
|
|
484
|
+
clearTimeout(timeoutId);
|
|
485
|
+
}
|
|
486
|
+
} finally {
|
|
487
|
+
if (toolOnlyTimeoutId) {
|
|
488
|
+
clearTimeout(toolOnlyTimeoutId);
|
|
489
|
+
toolOnlyTimeoutId = null;
|
|
490
|
+
}
|
|
491
|
+
if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
|
|
492
|
+
toolFallbackSent = true;
|
|
493
|
+
await sendToolFallback();
|
|
494
|
+
}
|
|
495
|
+
if (streamingController && !streamingController.isTerminalPhase) {
|
|
496
|
+
try {
|
|
497
|
+
streamingController.markFullyComplete();
|
|
498
|
+
await streamingController.onIdle();
|
|
499
|
+
} catch (finalizeErr) {
|
|
500
|
+
log?.error(
|
|
501
|
+
`Streaming finalization error: ${finalizeErr instanceof Error ? finalizeErr.message : String(finalizeErr)}`,
|
|
502
|
+
);
|
|
503
|
+
try {
|
|
504
|
+
await streamingController.abortStreaming();
|
|
505
|
+
} catch {
|
|
506
|
+
/* ignore */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============ ctxPayload builder ============
|
|
514
|
+
|
|
515
|
+
function resolveCommandSource(
|
|
516
|
+
inbound: InboundContext,
|
|
517
|
+
runtime: GatewayPluginRuntime,
|
|
518
|
+
cfg: unknown,
|
|
519
|
+
): "text" | undefined {
|
|
520
|
+
const commandBody = inbound.event.content;
|
|
521
|
+
if (!runtime.channel.commands?.isControlCommandMessage?.(commandBody, cfg)) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
return "text";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function buildCtxPayload(
|
|
528
|
+
inbound: InboundContext,
|
|
529
|
+
runtime: GatewayPluginRuntime,
|
|
530
|
+
cfg: unknown,
|
|
531
|
+
): FinalizedMsgContext {
|
|
532
|
+
const { event } = inbound;
|
|
533
|
+
const commandSource = resolveCommandSource(inbound, runtime, cfg);
|
|
534
|
+
return runtime.channel.reply.finalizeInboundContext({
|
|
535
|
+
Body: inbound.body,
|
|
536
|
+
BodyForAgent: inbound.agentBody,
|
|
537
|
+
RawBody: event.content,
|
|
538
|
+
CommandBody: event.content,
|
|
539
|
+
From: inbound.fromAddress,
|
|
540
|
+
To: inbound.fromAddress,
|
|
541
|
+
SessionKey: inbound.route.sessionKey,
|
|
542
|
+
AccountId: inbound.route.accountId,
|
|
543
|
+
ChatType: inbound.isGroupChat ? "group" : "direct",
|
|
544
|
+
GroupSystemPrompt: inbound.groupSystemPrompt,
|
|
545
|
+
SenderId: event.senderId,
|
|
546
|
+
SenderName: event.senderName,
|
|
547
|
+
Provider: "qqbot",
|
|
548
|
+
Surface: "qqbot",
|
|
549
|
+
MessageSid: event.messageId,
|
|
550
|
+
Timestamp: new Date(event.timestamp).getTime(),
|
|
551
|
+
OriginatingChannel: "qqbot",
|
|
552
|
+
OriginatingTo: inbound.fromAddress,
|
|
553
|
+
QQChannelId: event.channelId,
|
|
554
|
+
QQGuildId: event.guildId,
|
|
555
|
+
QQGroupOpenid: event.groupOpenid,
|
|
556
|
+
QQVoiceAsrReferAvailable: inbound.hasAsrReferFallback,
|
|
557
|
+
QQVoiceTranscriptSources: inbound.voiceTranscriptSources,
|
|
558
|
+
QQVoiceAttachmentPaths: inbound.uniqueVoicePaths,
|
|
559
|
+
QQVoiceAttachmentUrls: inbound.uniqueVoiceUrls,
|
|
560
|
+
QQVoiceAsrReferTexts: inbound.uniqueVoiceAsrReferTexts,
|
|
561
|
+
QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback",
|
|
562
|
+
CommandAuthorized: inbound.commandAuthorized,
|
|
563
|
+
...(commandSource ? { CommandSource: commandSource } : {}),
|
|
564
|
+
...(inbound.voiceMediaTypes.length > 0
|
|
565
|
+
? {
|
|
566
|
+
MediaTypes: inbound.voiceMediaTypes,
|
|
567
|
+
MediaType: inbound.voiceMediaTypes[0],
|
|
568
|
+
}
|
|
569
|
+
: {}),
|
|
570
|
+
...(inbound.localMediaPaths.length > 0
|
|
571
|
+
? {
|
|
572
|
+
MediaPaths: inbound.localMediaPaths,
|
|
573
|
+
MediaPath: inbound.localMediaPaths[0],
|
|
574
|
+
MediaTypes: inbound.localMediaTypes,
|
|
575
|
+
MediaType: inbound.localMediaTypes[0],
|
|
576
|
+
}
|
|
577
|
+
: {}),
|
|
578
|
+
...(inbound.remoteMediaUrls.length > 0
|
|
579
|
+
? { MediaUrls: inbound.remoteMediaUrls, MediaUrl: inbound.remoteMediaUrls[0] }
|
|
580
|
+
: {}),
|
|
581
|
+
...(inbound.replyTo
|
|
582
|
+
? {
|
|
583
|
+
ReplyToId: inbound.replyTo.id,
|
|
584
|
+
ReplyToBody: inbound.replyTo.body,
|
|
585
|
+
ReplyToSender: inbound.replyTo.sender,
|
|
586
|
+
ReplyToIsQuote: inbound.replyTo.isQuote,
|
|
587
|
+
}
|
|
588
|
+
: {}),
|
|
589
|
+
}) as FinalizedMsgContext;
|
|
590
|
+
}
|