@openclaw/qqbot 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +56 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +814 -0
- package/package.json +66 -0
- package/runtime-api.ts +9 -0
- package/setup-entry.ts +9 -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 +237 -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 +47 -0
- package/src/bridge/commands/from-parser.test.ts +86 -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 +111 -0
- package/src/bridge/gateway.ts +174 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.test.ts +146 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +131 -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.test.ts +124 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +288 -0
- package/src/command-auth.test.ts +62 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +364 -0
- package/src/engine/access/access-control.test.ts +198 -0
- package/src/engine/access/access-control.ts +226 -0
- package/src/engine/access/index.ts +16 -0
- package/src/engine/access/resolve-policy.test.ts +59 -0
- package/src/engine/access/resolve-policy.ts +57 -0
- package/src/engine/access/sender-match.test.ts +60 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +53 -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 +139 -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.test.ts +336 -0
- package/src/engine/api/media-chunked.ts +622 -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 +271 -0
- package/src/engine/approval/index.test.ts +22 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +319 -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 +137 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +48 -0
- package/src/engine/commands/slash-command-handler.ts +146 -0
- package/src/engine/commands/slash-commands-impl.test.ts +8 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +199 -0
- package/src/engine/config/credential-backup.test.ts +88 -0
- package/src/engine/config/credential-backup.ts +107 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.test.ts +234 -0
- package/src/engine/config/group.ts +299 -0
- package/src/engine/config/resolve.test.ts +152 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/engine-import-boundary.test.ts +73 -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 +371 -0
- package/src/engine/gateway/gateway.ts +291 -0
- package/src/engine/gateway/inbound-attachments.test.ts +126 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +195 -0
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
- package/src/engine/gateway/inbound-pipeline.ts +235 -0
- package/src/engine/gateway/interaction-handler.ts +220 -0
- package/src/engine/gateway/message-queue.test.ts +282 -0
- package/src/engine/gateway/message-queue.ts +499 -0
- package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
- package/src/engine/gateway/outbound-dispatch.ts +575 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +132 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.test.ts +77 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +292 -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 +116 -0
- package/src/engine/gateway/types.ts +264 -0
- package/src/engine/gateway/typing-keepalive.ts +79 -0
- package/src/engine/group/activation.test.ts +114 -0
- package/src/engine/group/activation.ts +147 -0
- package/src/engine/group/history.test.ts +314 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.test.ts +141 -0
- package/src/engine/group/mention.ts +197 -0
- package/src/engine/group/message-gating.test.ts +188 -0
- package/src/engine/group/message-gating.ts +216 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +215 -0
- package/src/engine/messaging/media-type-detect.ts +37 -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 +702 -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 +45 -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 +729 -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.test.ts +60 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +224 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +254 -0
- package/src/engine/session/session-store.ts +284 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.test.ts +280 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.test.ts +186 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.test.ts +250 -0
- package/src/engine/utils/audio.ts +585 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +109 -0
- package/src/engine/utils/file-utils.test.ts +72 -0
- package/src/engine/utils/file-utils.ts +225 -0
- package/src/engine/utils/format.test.ts +68 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.test.ts +158 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.test.ts +28 -0
- package/src/engine/utils/log.ts +61 -0
- package/src/engine/utils/media-tags.test.ts +32 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.test.ts +68 -0
- package/src/engine/utils/payload.ts +145 -0
- package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
- package/src/engine/utils/platform.test.ts +148 -0
- package/src/engine/utils/platform.ts +343 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.test.ts +104 -0
- package/src/engine/utils/stt.ts +100 -0
- package/src/engine/utils/text-parsing.test.ts +29 -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 +218 -0
- package/src/manifest-schema.test.ts +56 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket reconnection state machine and close-code handler.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the reconnect delay scheduling, quick-disconnect detection,
|
|
5
|
+
* and close-code interpretation that both plugin versions share.
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies — uses only the constants from `./constants.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { EngineLogger } from "../types.js";
|
|
11
|
+
import {
|
|
12
|
+
RECONNECT_DELAYS,
|
|
13
|
+
RATE_LIMIT_DELAY,
|
|
14
|
+
MAX_RECONNECT_ATTEMPTS,
|
|
15
|
+
MAX_QUICK_DISCONNECT_COUNT,
|
|
16
|
+
QUICK_DISCONNECT_THRESHOLD,
|
|
17
|
+
GatewayCloseCode,
|
|
18
|
+
} from "./constants.js";
|
|
19
|
+
|
|
20
|
+
/** Actions the caller should take after processing a close event. */
|
|
21
|
+
interface CloseAction {
|
|
22
|
+
/** Whether to schedule a reconnect. */
|
|
23
|
+
shouldReconnect: boolean;
|
|
24
|
+
/** Custom delay override (ms), or undefined to use the default backoff. */
|
|
25
|
+
reconnectDelay?: number;
|
|
26
|
+
/** Whether the session is invalidated and should be cleared. */
|
|
27
|
+
clearSession: boolean;
|
|
28
|
+
/** Whether the token should be refreshed before reconnecting. */
|
|
29
|
+
refreshToken: boolean;
|
|
30
|
+
/** Whether the bot is fatally blocked (offline/banned) and should stop. */
|
|
31
|
+
fatal: boolean;
|
|
32
|
+
/** Human-readable description of the close reason. */
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reconnection state machine.
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
* ```ts
|
|
41
|
+
* const rs = new ReconnectState('account-1', log);
|
|
42
|
+
* // On successful connect:
|
|
43
|
+
* rs.onConnected();
|
|
44
|
+
* // On close:
|
|
45
|
+
* const action = rs.handleClose(code);
|
|
46
|
+
* if (action.shouldReconnect) {
|
|
47
|
+
* const delay = rs.getNextDelay(action.reconnectDelay);
|
|
48
|
+
* setTimeout(connect, delay);
|
|
49
|
+
* }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export class ReconnectState {
|
|
53
|
+
private attempts = 0;
|
|
54
|
+
private lastConnectTime = 0;
|
|
55
|
+
private quickDisconnectCount = 0;
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly accountId: string,
|
|
59
|
+
private readonly log?: EngineLogger,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
/** Call when a WebSocket connection is successfully established. */
|
|
63
|
+
onConnected(): void {
|
|
64
|
+
this.attempts = 0;
|
|
65
|
+
this.lastConnectTime = Date.now();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether reconnection attempts are exhausted. */
|
|
69
|
+
isExhausted(): boolean {
|
|
70
|
+
return this.attempts >= MAX_RECONNECT_ATTEMPTS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compute the next reconnect delay and increment the attempt counter.
|
|
75
|
+
*
|
|
76
|
+
* @param customDelay Override from `CloseAction.reconnectDelay`.
|
|
77
|
+
* @returns Delay in milliseconds.
|
|
78
|
+
*/
|
|
79
|
+
getNextDelay(customDelay?: number): number {
|
|
80
|
+
const delay =
|
|
81
|
+
customDelay ?? RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)];
|
|
82
|
+
this.attempts++;
|
|
83
|
+
this.log?.debug?.(`Reconnecting in ${delay}ms (attempt ${this.attempts})`);
|
|
84
|
+
return delay;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Interpret a WebSocket close code and return the appropriate action.
|
|
89
|
+
*/
|
|
90
|
+
handleClose(code: number, isAborted: boolean): CloseAction {
|
|
91
|
+
// Fatal: bot offline or banned.
|
|
92
|
+
if (
|
|
93
|
+
code === GatewayCloseCode.INSUFFICIENT_INTENTS ||
|
|
94
|
+
code === GatewayCloseCode.DISALLOWED_INTENTS
|
|
95
|
+
) {
|
|
96
|
+
const reason =
|
|
97
|
+
code === GatewayCloseCode.INSUFFICIENT_INTENTS ? "offline/sandbox-only" : "banned";
|
|
98
|
+
this.log?.error(`Bot is ${reason}. Please contact QQ platform.`);
|
|
99
|
+
return {
|
|
100
|
+
shouldReconnect: false,
|
|
101
|
+
clearSession: false,
|
|
102
|
+
refreshToken: false,
|
|
103
|
+
fatal: true,
|
|
104
|
+
reason,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Invalid token.
|
|
109
|
+
if (code === GatewayCloseCode.AUTH_FAILED) {
|
|
110
|
+
this.log?.info(`Invalid token (4004), will refresh token and reconnect`);
|
|
111
|
+
return {
|
|
112
|
+
shouldReconnect: !isAborted,
|
|
113
|
+
clearSession: false,
|
|
114
|
+
refreshToken: true,
|
|
115
|
+
fatal: false,
|
|
116
|
+
reason: "invalid token (4004)",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Rate limited.
|
|
121
|
+
if (code === GatewayCloseCode.RATE_LIMITED) {
|
|
122
|
+
this.log?.info(`Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms`);
|
|
123
|
+
return {
|
|
124
|
+
shouldReconnect: !isAborted,
|
|
125
|
+
reconnectDelay: RATE_LIMIT_DELAY,
|
|
126
|
+
clearSession: false,
|
|
127
|
+
refreshToken: false,
|
|
128
|
+
fatal: false,
|
|
129
|
+
reason: "rate limited (4008)",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Session invalid / seq invalid / session timeout.
|
|
134
|
+
if (
|
|
135
|
+
code === GatewayCloseCode.INVALID_SESSION ||
|
|
136
|
+
code === GatewayCloseCode.SEQ_OUT_OF_RANGE ||
|
|
137
|
+
code === GatewayCloseCode.SESSION_TIMEOUT
|
|
138
|
+
) {
|
|
139
|
+
const codeDesc: Record<number, string> = {
|
|
140
|
+
[GatewayCloseCode.INVALID_SESSION]: "session no longer valid",
|
|
141
|
+
[GatewayCloseCode.SEQ_OUT_OF_RANGE]: "invalid seq on resume",
|
|
142
|
+
[GatewayCloseCode.SESSION_TIMEOUT]: "session timed out",
|
|
143
|
+
};
|
|
144
|
+
this.log?.info(`Error ${code} (${codeDesc[code]}), will re-identify`);
|
|
145
|
+
return {
|
|
146
|
+
shouldReconnect: !isAborted,
|
|
147
|
+
clearSession: true,
|
|
148
|
+
refreshToken: true,
|
|
149
|
+
fatal: false,
|
|
150
|
+
reason: codeDesc[code],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Internal server errors.
|
|
155
|
+
if (code >= GatewayCloseCode.SERVER_ERROR_START && code <= GatewayCloseCode.SERVER_ERROR_END) {
|
|
156
|
+
this.log?.info(`Internal error (${code}), will re-identify`);
|
|
157
|
+
return {
|
|
158
|
+
shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL,
|
|
159
|
+
clearSession: true,
|
|
160
|
+
refreshToken: true,
|
|
161
|
+
fatal: false,
|
|
162
|
+
reason: `internal error (${code})`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Quick disconnect detection.
|
|
167
|
+
const connectionDuration = Date.now() - this.lastConnectTime;
|
|
168
|
+
if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && this.lastConnectTime > 0) {
|
|
169
|
+
this.quickDisconnectCount++;
|
|
170
|
+
this.log?.debug?.(
|
|
171
|
+
`Quick disconnect detected (${connectionDuration}ms), count: ${this.quickDisconnectCount}`,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (this.quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
|
|
175
|
+
this.log?.error(`Too many quick disconnects. This may indicate a permission issue.`);
|
|
176
|
+
this.quickDisconnectCount = 0;
|
|
177
|
+
return {
|
|
178
|
+
shouldReconnect: !isAborted && code !== 1000,
|
|
179
|
+
reconnectDelay: RATE_LIMIT_DELAY,
|
|
180
|
+
clearSession: false,
|
|
181
|
+
refreshToken: false,
|
|
182
|
+
fatal: false,
|
|
183
|
+
reason: "too many quick disconnects",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
this.quickDisconnectCount = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Default: reconnect with backoff.
|
|
191
|
+
return {
|
|
192
|
+
shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL,
|
|
193
|
+
clearSession: false,
|
|
194
|
+
refreshToken: false,
|
|
195
|
+
fatal: false,
|
|
196
|
+
reason: `close code ${code}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access stage — resolves routing target + runs access control.
|
|
3
|
+
*
|
|
4
|
+
* Split from the pipeline so it is trivially unit-testable: given a raw
|
|
5
|
+
* event and the runtime's routing info, the stage returns either:
|
|
6
|
+
* - `{ kind: "allow", ... }` — proceed through the rest of the pipeline
|
|
7
|
+
* - `{ kind: "block", context }` — short-circuit; the caller returns
|
|
8
|
+
* `context` directly to its own caller.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolveQQBotAccess, type QQBotAccessResult } from "../../access/index.js";
|
|
12
|
+
import type { InboundContext, InboundPipelineDeps } from "../inbound-context.js";
|
|
13
|
+
import type { QueuedMessage } from "../message-queue.js";
|
|
14
|
+
import { buildBlockedInboundContext } from "./stub-contexts.js";
|
|
15
|
+
|
|
16
|
+
// ─────────────────────────── Types ───────────────────────────
|
|
17
|
+
|
|
18
|
+
interface AccessStageAllow {
|
|
19
|
+
kind: "allow";
|
|
20
|
+
isGroupChat: boolean;
|
|
21
|
+
peerId: string;
|
|
22
|
+
qualifiedTarget: string;
|
|
23
|
+
fromAddress: string;
|
|
24
|
+
route: { sessionKey: string; accountId: string; agentId?: string };
|
|
25
|
+
access: QQBotAccessResult;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AccessStageBlock {
|
|
29
|
+
kind: "block";
|
|
30
|
+
context: InboundContext;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AccessStageResult = AccessStageAllow | AccessStageBlock;
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────── Stage ───────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the routing target, walk the access policy, and decide whether
|
|
39
|
+
* the inbound message should proceed to the rest of the pipeline.
|
|
40
|
+
*/
|
|
41
|
+
export function runAccessStage(event: QueuedMessage, deps: InboundPipelineDeps): AccessStageResult {
|
|
42
|
+
const { account, cfg, runtime, log } = deps;
|
|
43
|
+
|
|
44
|
+
const isGroupChat = event.type === "guild" || event.type === "group";
|
|
45
|
+
const peerId = resolvePeerId(event, isGroupChat);
|
|
46
|
+
const qualifiedTarget = buildQualifiedTarget(event, isGroupChat);
|
|
47
|
+
|
|
48
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
49
|
+
cfg,
|
|
50
|
+
channel: "qqbot",
|
|
51
|
+
accountId: account.accountId,
|
|
52
|
+
peer: { kind: isGroupChat ? "group" : "direct", id: peerId },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const access = resolveQQBotAccess({
|
|
56
|
+
isGroup: isGroupChat,
|
|
57
|
+
senderId: event.senderId,
|
|
58
|
+
allowFrom: account.config?.allowFrom,
|
|
59
|
+
groupAllowFrom: account.config?.groupAllowFrom,
|
|
60
|
+
dmPolicy: account.config?.dmPolicy,
|
|
61
|
+
groupPolicy: account.config?.groupPolicy,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (access.decision !== "allow") {
|
|
65
|
+
log?.info(
|
|
66
|
+
`Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` +
|
|
67
|
+
`reason=${access.reason} senderId=${event.senderId} ` +
|
|
68
|
+
`accountId=${account.accountId} isGroup=${isGroupChat}`,
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
kind: "block",
|
|
72
|
+
context: buildBlockedInboundContext({
|
|
73
|
+
event,
|
|
74
|
+
route,
|
|
75
|
+
isGroupChat,
|
|
76
|
+
peerId,
|
|
77
|
+
qualifiedTarget,
|
|
78
|
+
fromAddress: qualifiedTarget,
|
|
79
|
+
access,
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
kind: "allow",
|
|
86
|
+
isGroupChat,
|
|
87
|
+
peerId,
|
|
88
|
+
qualifiedTarget,
|
|
89
|
+
fromAddress: qualifiedTarget,
|
|
90
|
+
route,
|
|
91
|
+
access,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────── Internal helpers ───────────────────────────
|
|
96
|
+
|
|
97
|
+
function resolvePeerId(event: QueuedMessage, isGroupChat: boolean): string {
|
|
98
|
+
if (event.type === "guild") {
|
|
99
|
+
return event.channelId ?? "unknown";
|
|
100
|
+
}
|
|
101
|
+
if (event.type === "group") {
|
|
102
|
+
return event.groupOpenid ?? "unknown";
|
|
103
|
+
}
|
|
104
|
+
if (isGroupChat) {
|
|
105
|
+
return "unknown";
|
|
106
|
+
} // defensive, should never hit
|
|
107
|
+
return event.senderId;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildQualifiedTarget(event: QueuedMessage, isGroupChat: boolean): string {
|
|
111
|
+
if (isGroupChat) {
|
|
112
|
+
return event.type === "guild"
|
|
113
|
+
? `qqbot:channel:${event.channelId}`
|
|
114
|
+
: `qqbot:group:${event.groupOpenid}`;
|
|
115
|
+
}
|
|
116
|
+
return event.type === "dm" ? `qqbot:dm:${event.guildId}` : `qqbot:c2c:${event.senderId}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Decide whether the access decision permits running text-based control
|
|
121
|
+
* commands. Placed in the access stage because the rule is an
|
|
122
|
+
* access-policy derivative, not a gate derivative.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveCommandAuthorized(access: QQBotAccessResult): boolean {
|
|
125
|
+
return (
|
|
126
|
+
access.reasonCode === "dm_policy_open" ||
|
|
127
|
+
access.reasonCode === "dm_policy_allowlisted" ||
|
|
128
|
+
(access.reasonCode === "group_policy_allowed" &&
|
|
129
|
+
access.effectiveGroupAllowFrom.length > 0 &&
|
|
130
|
+
access.groupPolicy === "allowlist")
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembly stage — build the user-turn string the AI sees.
|
|
3
|
+
*
|
|
4
|
+
* Responsible for:
|
|
5
|
+
* - Rendering merged turns (preceding messages in a begin/end block
|
|
6
|
+
* + a "current" message).
|
|
7
|
+
* - Attaching the sender label + (@you) suffix for group chat.
|
|
8
|
+
* - Prepending the group's buffered history via
|
|
9
|
+
* {@link buildPendingHistoryContext} when the current turn is
|
|
10
|
+
* `@`-activated.
|
|
11
|
+
* - Handing out the plain `agentBody` for DM-style turns.
|
|
12
|
+
*
|
|
13
|
+
* The envelope rendering (Web UI body + dynamic ctx block) lives in
|
|
14
|
+
* `envelope-stage.ts`; this stage only produces text that the model
|
|
15
|
+
* sees directly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
buildMergedMessageContext,
|
|
20
|
+
formatAttachmentTags,
|
|
21
|
+
formatMessageContent,
|
|
22
|
+
type HistoryEntry,
|
|
23
|
+
} from "../../group/history.js";
|
|
24
|
+
import type { InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js";
|
|
25
|
+
import type { QueuedMessage } from "../message-queue.js";
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────── buildUserMessage ───────────────────────────
|
|
28
|
+
|
|
29
|
+
interface BuildUserMessageInput {
|
|
30
|
+
event: QueuedMessage;
|
|
31
|
+
userContent: string;
|
|
32
|
+
quotePart: string;
|
|
33
|
+
isGroupChat: boolean;
|
|
34
|
+
groupInfo?: InboundGroupInfo;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compose the user-turn string. For merged group turns, renders a
|
|
39
|
+
* preceding block and a current-message suffix; for single turns,
|
|
40
|
+
* prefixes the sender label and (@you) suffix as appropriate.
|
|
41
|
+
*/
|
|
42
|
+
export function buildUserMessage(input: BuildUserMessageInput): string {
|
|
43
|
+
const { event, userContent, quotePart, isGroupChat, groupInfo } = input;
|
|
44
|
+
|
|
45
|
+
// ---- Merged group turn ----
|
|
46
|
+
if (groupInfo?.isMerged && groupInfo.mergedMessages?.length) {
|
|
47
|
+
const preceding = groupInfo.mergedMessages.slice(0, -1);
|
|
48
|
+
const lastMsg = groupInfo.mergedMessages[groupInfo.mergedMessages.length - 1];
|
|
49
|
+
const atYouTag = groupInfo.gate.effectiveWasMentioned ? " (@you)" : "";
|
|
50
|
+
|
|
51
|
+
const envelopeParts = preceding.map((m) => `[${formatSenderLabel(m)}] ${formatSub(m)}`);
|
|
52
|
+
const lastPart = `[${formatSenderLabel(lastMsg)}] ${formatSub(lastMsg)}${atYouTag}`;
|
|
53
|
+
|
|
54
|
+
return buildMergedMessageContext({
|
|
55
|
+
precedingParts: envelopeParts,
|
|
56
|
+
currentMessage: lastPart,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- Single-message turn ----
|
|
61
|
+
const isAtYouTag = isGroupChat ? (groupInfo?.gate.effectiveWasMentioned ? " (@you)" : "") : "";
|
|
62
|
+
const senderPrefix =
|
|
63
|
+
event.type === "group" ? `[${formatSenderLabelFrom(event.senderName, event.senderId)}] ` : "";
|
|
64
|
+
|
|
65
|
+
return senderPrefix
|
|
66
|
+
? `${senderPrefix}${quotePart}${userContent}${isAtYouTag}`
|
|
67
|
+
: `${quotePart}${userContent}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─────────────────────────── buildAgentBody ───────────────────────────
|
|
71
|
+
|
|
72
|
+
interface BuildAgentBodyInput {
|
|
73
|
+
event: QueuedMessage;
|
|
74
|
+
userContent: string;
|
|
75
|
+
userMessage: string;
|
|
76
|
+
dynamicCtx: string;
|
|
77
|
+
isGroupChat: boolean;
|
|
78
|
+
groupInfo?: InboundGroupInfo;
|
|
79
|
+
deps: InboundPipelineDeps;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compose the final `agentBody` the AI receives.
|
|
84
|
+
*
|
|
85
|
+
* Prepends buffered non-@ chatter via
|
|
86
|
+
* {@link buildPendingHistoryContext} when the current turn is
|
|
87
|
+
* `@`-activated in a group. Slash-commands bypass all decoration so
|
|
88
|
+
* the command parser sees verbatim input.
|
|
89
|
+
*/
|
|
90
|
+
export function buildAgentBody(input: BuildAgentBodyInput): string {
|
|
91
|
+
const { event, userContent, userMessage, dynamicCtx, groupInfo, deps } = input;
|
|
92
|
+
|
|
93
|
+
// Slash commands: strip all decoration so the command parser sees raw input.
|
|
94
|
+
if (userContent.startsWith("/")) {
|
|
95
|
+
return userContent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const base = `${dynamicCtx}${userMessage}`;
|
|
99
|
+
|
|
100
|
+
// Non-group or group-without-history: no mixing in.
|
|
101
|
+
if (event.type !== "group" || !event.groupOpenid || !deps.groupHistories || !groupInfo) {
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
|
|
106
|
+
return deps.adapters.history.buildPendingHistoryContext({
|
|
107
|
+
historyMap: deps.groupHistories,
|
|
108
|
+
historyKey: event.groupOpenid,
|
|
109
|
+
limit: groupInfo.historyLimit,
|
|
110
|
+
currentMessage: base,
|
|
111
|
+
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, deps, envelopeOpts),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─────────────────────────── Internal ───────────────────────────
|
|
116
|
+
|
|
117
|
+
function formatSub(m: QueuedMessage): string {
|
|
118
|
+
return formatMessageContent({
|
|
119
|
+
content: m.content ?? "",
|
|
120
|
+
chatType: m.type,
|
|
121
|
+
mentions: m.mentions as never,
|
|
122
|
+
attachments: m.attachments,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatSenderLabel(m: QueuedMessage): string {
|
|
127
|
+
return formatSenderLabelFrom(m.senderName, m.senderId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Render a "Nick (openid)" label. When `name` already includes `id`
|
|
132
|
+
* (e.g. the label was pre-formatted upstream), avoid double-wrapping.
|
|
133
|
+
*/
|
|
134
|
+
function formatSenderLabelFrom(name: string | undefined, id: string): string {
|
|
135
|
+
if (!name) {
|
|
136
|
+
return id;
|
|
137
|
+
}
|
|
138
|
+
return name.includes(id) ? name : `${name} (${id})`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatHistoryEntry(
|
|
142
|
+
entry: HistoryEntry,
|
|
143
|
+
deps: InboundPipelineDeps,
|
|
144
|
+
envelopeOpts: unknown,
|
|
145
|
+
): string {
|
|
146
|
+
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
|
147
|
+
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
|
|
148
|
+
return deps.runtime.channel.reply.formatInboundEnvelope({
|
|
149
|
+
channel: "qqbot",
|
|
150
|
+
from: entry.sender,
|
|
151
|
+
timestamp: entry.timestamp,
|
|
152
|
+
body: bodyWithAttachments,
|
|
153
|
+
chatType: "group",
|
|
154
|
+
envelope: envelopeOpts,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { QueuedMessage } from "../message-queue.js";
|
|
3
|
+
import { buildUserContent } from "./content-stage.js";
|
|
4
|
+
|
|
5
|
+
function makeEvent(partial: Partial<QueuedMessage> = {}): QueuedMessage {
|
|
6
|
+
return {
|
|
7
|
+
type: "group",
|
|
8
|
+
senderId: "U1",
|
|
9
|
+
content: "hello",
|
|
10
|
+
messageId: "M1",
|
|
11
|
+
timestamp: "2025-01-01T00:00:00.000Z",
|
|
12
|
+
groupOpenid: "G1",
|
|
13
|
+
...partial,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("content-stage", () => {
|
|
18
|
+
describe("buildUserContent", () => {
|
|
19
|
+
it("returns plain content when no voice / no mentions", () => {
|
|
20
|
+
const out = buildUserContent({
|
|
21
|
+
event: makeEvent({ content: "plain" }),
|
|
22
|
+
attachmentInfo: "",
|
|
23
|
+
voiceTranscripts: [],
|
|
24
|
+
});
|
|
25
|
+
expect(out.parsedContent).toBe("plain");
|
|
26
|
+
expect(out.userContent).toBe("plain");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("appends attachmentInfo after content", () => {
|
|
30
|
+
const out = buildUserContent({
|
|
31
|
+
event: makeEvent({ content: "see" }),
|
|
32
|
+
attachmentInfo: " [img]",
|
|
33
|
+
voiceTranscripts: [],
|
|
34
|
+
});
|
|
35
|
+
expect(out.userContent).toBe("see [img]");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("interleaves voice transcripts on their own line", () => {
|
|
39
|
+
const out = buildUserContent({
|
|
40
|
+
event: makeEvent({ content: "hi" }),
|
|
41
|
+
attachmentInfo: "",
|
|
42
|
+
voiceTranscripts: ["hello world"],
|
|
43
|
+
});
|
|
44
|
+
// formatVoiceText renders "[Voice message] …" or "[Voice N] …" — the
|
|
45
|
+
// important assertion is that voice text ends up in userContent.
|
|
46
|
+
expect(out.userContent).toContain("hi");
|
|
47
|
+
expect(out.userContent).toContain("hello world");
|
|
48
|
+
expect(out.userContent.length).toBeGreaterThan(out.parsedContent.length);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("strips <@bot> mention tags in group chats", () => {
|
|
52
|
+
const out = buildUserContent({
|
|
53
|
+
event: makeEvent({
|
|
54
|
+
type: "group",
|
|
55
|
+
content: "<@BOT> help",
|
|
56
|
+
mentions: [{ member_openid: "BOT", is_you: true }],
|
|
57
|
+
}),
|
|
58
|
+
attachmentInfo: "",
|
|
59
|
+
voiceTranscripts: [],
|
|
60
|
+
});
|
|
61
|
+
expect(out.userContent.trim()).toBe("help");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("replaces <@user> with @nickname in DMs", () => {
|
|
65
|
+
const out = buildUserContent({
|
|
66
|
+
event: makeEvent({
|
|
67
|
+
type: "c2c",
|
|
68
|
+
content: "hi <@U2> there",
|
|
69
|
+
mentions: [{ member_openid: "U2", username: "Alice" }],
|
|
70
|
+
}),
|
|
71
|
+
attachmentInfo: "",
|
|
72
|
+
voiceTranscripts: [],
|
|
73
|
+
});
|
|
74
|
+
expect(out.userContent).toBe("hi @Alice there");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content stage — build the user-visible message body.
|
|
3
|
+
*
|
|
4
|
+
* Responsible for:
|
|
5
|
+
* 1. Parsing QQ emoji tags (`<faceType=...>` → `[Emoji: name]`)
|
|
6
|
+
* 2. Appending attachment info + voice transcripts
|
|
7
|
+
* 3. Stripping `<@openid>` mention tags in group messages
|
|
8
|
+
* 4. Replacing `<@openid>` → `@nickname` in DMs (best-effort)
|
|
9
|
+
*
|
|
10
|
+
* Pure function: same input → same output, no I/O.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { stripMentionText } from "../../group/mention.js";
|
|
14
|
+
import { parseFaceTags } from "../../utils/text-parsing.js";
|
|
15
|
+
import { formatVoiceText } from "../../utils/voice-text.js";
|
|
16
|
+
import type { QueuedMention, QueuedMessage } from "../message-queue.js";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────── Types ───────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Input for {@link buildUserContent}. */
|
|
21
|
+
interface ContentStageInput {
|
|
22
|
+
event: QueuedMessage;
|
|
23
|
+
/** `attachmentInfo` from the attachment stage — appended verbatim. */
|
|
24
|
+
attachmentInfo: string;
|
|
25
|
+
/** Voice transcripts collected from the attachment stage. */
|
|
26
|
+
voiceTranscripts: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Output of {@link buildUserContent}. */
|
|
30
|
+
interface ContentStageOutput {
|
|
31
|
+
/** `parseFaceTags(event.content)`. */
|
|
32
|
+
parsedContent: string;
|
|
33
|
+
/** Full user-visible content (parsed + voice + attachments + mention cleanup). */
|
|
34
|
+
userContent: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────── Stage ───────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build both the raw-parsed content and the fully composed user-visible
|
|
41
|
+
* body that downstream stages feed to the AI and to the envelope.
|
|
42
|
+
*/
|
|
43
|
+
export function buildUserContent(input: ContentStageInput): ContentStageOutput {
|
|
44
|
+
const { event, attachmentInfo, voiceTranscripts } = input;
|
|
45
|
+
|
|
46
|
+
const parsedContent = parseFaceTags(event.content);
|
|
47
|
+
const voiceText = formatVoiceText(voiceTranscripts);
|
|
48
|
+
|
|
49
|
+
let userContent = voiceText
|
|
50
|
+
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
|
|
51
|
+
: parsedContent + attachmentInfo;
|
|
52
|
+
|
|
53
|
+
// Mention cleanup — only for events with mentions attached.
|
|
54
|
+
if (event.type === "group" && event.mentions?.length) {
|
|
55
|
+
userContent = stripMentionText(userContent, event.mentions as never) ?? userContent;
|
|
56
|
+
} else if (event.mentions?.length) {
|
|
57
|
+
userContent = replaceMentionsWithNicknames(userContent, event.mentions);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { parsedContent, userContent };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────── Internal ───────────────────────────
|
|
64
|
+
|
|
65
|
+
function replaceMentionsWithNicknames(text: string, mentions: QueuedMention[]): string {
|
|
66
|
+
let out = text;
|
|
67
|
+
for (const m of mentions) {
|
|
68
|
+
if (m.member_openid && m.username) {
|
|
69
|
+
out = out.replace(new RegExp(`<@${escapeRegex(m.member_openid)}>`, "g"), `@${m.username}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function escapeRegex(str: string): string {
|
|
76
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
77
|
+
}
|