@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,218 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { RefIndexEntry } from "../ref/types.js";
|
|
3
|
+
import type { InboundPipelineDeps } from "./inbound-context.js";
|
|
4
|
+
import { buildInboundContext } from "./inbound-pipeline.js";
|
|
5
|
+
import type { QueuedMessage } from "./message-queue.js";
|
|
6
|
+
import type { GatewayAccount, GatewayPluginRuntime, ProcessedAttachments } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const getRefIndexMock = vi.hoisted(() => vi.fn<(refIdx: string) => RefIndexEntry | null>());
|
|
9
|
+
const setRefIndexMock = vi.hoisted(() => vi.fn<(refIdx: string, entry: RefIndexEntry) => void>());
|
|
10
|
+
const formatRefEntryForAgentMock = vi.hoisted(() => vi.fn<(entry: RefIndexEntry) => string>());
|
|
11
|
+
const processAttachmentsMock = vi.hoisted(() =>
|
|
12
|
+
vi.fn<
|
|
13
|
+
(
|
|
14
|
+
attachments: QueuedMessage["attachments"],
|
|
15
|
+
ctx: { accountId: string; cfg: unknown; log?: unknown },
|
|
16
|
+
) => Promise<ProcessedAttachments>
|
|
17
|
+
>(),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
vi.mock("../ref/store.js", () => ({
|
|
21
|
+
getRefIndex: getRefIndexMock,
|
|
22
|
+
setRefIndex: setRefIndexMock,
|
|
23
|
+
formatRefEntryForAgent: formatRefEntryForAgentMock,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./inbound-attachments.js", () => ({
|
|
27
|
+
processAttachments: processAttachmentsMock,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const emptyProcessedAttachments: ProcessedAttachments = {
|
|
31
|
+
attachmentInfo: "",
|
|
32
|
+
imageUrls: [],
|
|
33
|
+
imageMediaTypes: [],
|
|
34
|
+
voiceAttachmentPaths: [],
|
|
35
|
+
voiceAttachmentUrls: [],
|
|
36
|
+
voiceAsrReferTexts: [],
|
|
37
|
+
voiceTranscripts: [],
|
|
38
|
+
voiceTranscriptSources: [],
|
|
39
|
+
attachmentLocalPaths: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const account: GatewayAccount = {
|
|
43
|
+
accountId: "qq-main",
|
|
44
|
+
appId: "app",
|
|
45
|
+
clientSecret: "secret",
|
|
46
|
+
markdownSupport: false,
|
|
47
|
+
config: {},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function makeRuntime(): GatewayPluginRuntime {
|
|
51
|
+
return {
|
|
52
|
+
channel: {
|
|
53
|
+
activity: { record: vi.fn() },
|
|
54
|
+
routing: {
|
|
55
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
56
|
+
sessionKey: "qqbot:c2c:user-openid",
|
|
57
|
+
accountId: "qq-main",
|
|
58
|
+
})),
|
|
59
|
+
},
|
|
60
|
+
reply: {
|
|
61
|
+
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
|
62
|
+
finalizeInboundContext: vi.fn((fields: Record<string, unknown>) => fields),
|
|
63
|
+
formatInboundEnvelope: vi.fn(() => "formatted inbound"),
|
|
64
|
+
resolveEffectiveMessagesConfig: vi.fn(() => ({})),
|
|
65
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
66
|
+
},
|
|
67
|
+
session: {
|
|
68
|
+
resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"),
|
|
69
|
+
recordInboundSession: vi.fn(async () => undefined),
|
|
70
|
+
},
|
|
71
|
+
turn: {
|
|
72
|
+
run: vi.fn(async (rawParams: unknown) => {
|
|
73
|
+
const params = rawParams as {
|
|
74
|
+
raw: unknown;
|
|
75
|
+
adapter: {
|
|
76
|
+
ingest: (raw: unknown) => unknown;
|
|
77
|
+
resolveTurn: (...args: unknown[]) => unknown;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const input = await params.adapter.ingest(params.raw);
|
|
81
|
+
const turn = (await params.adapter.resolveTurn(
|
|
82
|
+
input,
|
|
83
|
+
{
|
|
84
|
+
kind: "message",
|
|
85
|
+
canStartAgentTurn: true,
|
|
86
|
+
},
|
|
87
|
+
{},
|
|
88
|
+
)) as { runDispatch: () => Promise<unknown> };
|
|
89
|
+
return { dispatchResult: await turn.runDispatch() };
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
text: {
|
|
93
|
+
chunkMarkdownText: (text: string) => [text],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
tts: {
|
|
97
|
+
textToSpeech: vi.fn(),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function makeEvent(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
|
|
103
|
+
return {
|
|
104
|
+
type: "c2c",
|
|
105
|
+
senderId: "user-openid",
|
|
106
|
+
messageId: "msg-1",
|
|
107
|
+
content: "hello",
|
|
108
|
+
timestamp: "2026-04-25T00:00:00.000Z",
|
|
109
|
+
...overrides,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function makeDeps(overrides: Partial<InboundPipelineDeps> = {}): InboundPipelineDeps {
|
|
114
|
+
return {
|
|
115
|
+
account,
|
|
116
|
+
cfg: {},
|
|
117
|
+
log: { info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
118
|
+
runtime: makeRuntime(),
|
|
119
|
+
startTyping: vi.fn(async () => ({ keepAlive: null })),
|
|
120
|
+
adapters: {
|
|
121
|
+
history: {
|
|
122
|
+
recordPendingHistoryEntry: vi.fn(() => []),
|
|
123
|
+
buildPendingHistoryContext: vi.fn(() => ""),
|
|
124
|
+
clearPendingHistory: vi.fn(),
|
|
125
|
+
},
|
|
126
|
+
mentionGate: {
|
|
127
|
+
resolveInboundMentionDecision: vi.fn(() => ({
|
|
128
|
+
effectiveWasMentioned: false,
|
|
129
|
+
shouldSkip: false,
|
|
130
|
+
shouldBypassMention: false,
|
|
131
|
+
implicitMention: false,
|
|
132
|
+
})),
|
|
133
|
+
},
|
|
134
|
+
audioConvert: {
|
|
135
|
+
convertSilkToWav: vi.fn(async () => null),
|
|
136
|
+
isVoiceAttachment: vi.fn(() => false),
|
|
137
|
+
formatDuration: vi.fn(() => "0s"),
|
|
138
|
+
},
|
|
139
|
+
outboundAudio: {
|
|
140
|
+
audioFileToSilkBase64: vi.fn(async () => undefined),
|
|
141
|
+
isAudioFile: vi.fn(() => false),
|
|
142
|
+
shouldTranscodeVoice: vi.fn(() => false),
|
|
143
|
+
waitForFile: vi.fn(async () => 0),
|
|
144
|
+
},
|
|
145
|
+
commands: {
|
|
146
|
+
pluginVersion: "0.0.0-test",
|
|
147
|
+
resolveVersion: vi.fn(() => "0.0.0"),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
...overrides,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe("buildInboundContext bot self-echo suppression", () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
vi.clearAllMocks();
|
|
157
|
+
getRefIndexMock.mockReturnValue(null);
|
|
158
|
+
formatRefEntryForAgentMock.mockReturnValue("bot reply");
|
|
159
|
+
processAttachmentsMock.mockResolvedValue(emptyProcessedAttachments);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("does not block inbound events whose current msgIdx matches this bot's outbound ref (self-echo handled upstream)", async () => {
|
|
163
|
+
getRefIndexMock.mockReturnValue({
|
|
164
|
+
content: "mirrored reply",
|
|
165
|
+
senderId: "qq-main",
|
|
166
|
+
timestamp: 1,
|
|
167
|
+
isBot: true,
|
|
168
|
+
});
|
|
169
|
+
const deps = makeDeps();
|
|
170
|
+
|
|
171
|
+
const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps);
|
|
172
|
+
|
|
173
|
+
// Self-echo suppression is handled by the gateway layer upstream;
|
|
174
|
+
// buildInboundContext no longer short-circuits on msgIdx match.
|
|
175
|
+
expect(inbound.blocked).toBe(false);
|
|
176
|
+
expect(deps.startTyping).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(processAttachmentsMock).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not block a user message that quotes a bot-authored ref", async () => {
|
|
181
|
+
getRefIndexMock.mockReturnValue({
|
|
182
|
+
content: "previous bot reply",
|
|
183
|
+
senderId: "qq-main",
|
|
184
|
+
timestamp: 1,
|
|
185
|
+
isBot: true,
|
|
186
|
+
});
|
|
187
|
+
const deps = makeDeps();
|
|
188
|
+
|
|
189
|
+
const inbound = await buildInboundContext(makeEvent({ refMsgIdx: "REF_BOT" }), deps);
|
|
190
|
+
|
|
191
|
+
expect(getRefIndexMock).toHaveBeenCalledWith("REF_BOT");
|
|
192
|
+
expect(formatRefEntryForAgentMock).toHaveBeenCalled();
|
|
193
|
+
expect(inbound.blocked).toBe(false);
|
|
194
|
+
expect(inbound.replyTo).toMatchObject({
|
|
195
|
+
id: "REF_BOT",
|
|
196
|
+
body: "bot reply",
|
|
197
|
+
isQuote: true,
|
|
198
|
+
});
|
|
199
|
+
expect(deps.startTyping).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(processAttachmentsMock).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("does not block matching refs from another QQ Bot account", async () => {
|
|
204
|
+
getRefIndexMock.mockReturnValue({
|
|
205
|
+
content: "other bot reply",
|
|
206
|
+
senderId: "qq-other",
|
|
207
|
+
timestamp: 1,
|
|
208
|
+
isBot: true,
|
|
209
|
+
});
|
|
210
|
+
const deps = makeDeps();
|
|
211
|
+
|
|
212
|
+
const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps);
|
|
213
|
+
|
|
214
|
+
expect(inbound.blocked).toBe(false);
|
|
215
|
+
expect(deps.startTyping).toHaveBeenCalledTimes(1);
|
|
216
|
+
expect(processAttachmentsMock).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound pipeline — compose stages into a single
|
|
3
|
+
* {@link buildInboundContext} call.
|
|
4
|
+
*
|
|
5
|
+
* The pipeline stays intentionally thin: all real logic lives in
|
|
6
|
+
* `./stages/*`. Reading this file top-to-bottom should be enough to
|
|
7
|
+
* understand the full inbound path.
|
|
8
|
+
*
|
|
9
|
+
* Stage order:
|
|
10
|
+
* 1. access — route + access control (early return on block)
|
|
11
|
+
* 2. attachments — download + STT + image metadata
|
|
12
|
+
* 3. typing — start the typing indicator (awaited before refIdx write)
|
|
13
|
+
* 4. content — parseFaceTags + voice text + attachment info + mention cleanup
|
|
14
|
+
* 5. quote — resolve `refMsgIdx` three ways
|
|
15
|
+
* 6. refIdx — cache the current message so future quotes work
|
|
16
|
+
* 7. group gate — @mention / ignoreOther / activation / command bypass
|
|
17
|
+
* (early return on skip, history already recorded)
|
|
18
|
+
* 8. envelope — body / quotePart / dynamicCtx
|
|
19
|
+
* 9. assembly — userMessage + agentBody (with pending-history prefix)
|
|
20
|
+
* 10. system — final group system prompt composition
|
|
21
|
+
* 11. classify — media classification (local vs remote; dedup voice)
|
|
22
|
+
*
|
|
23
|
+
* Returns a fully populated {@link InboundContext}. The gateway handler
|
|
24
|
+
* then branches on `blocked` / `skipped` to decide whether to dispatch
|
|
25
|
+
* outbound.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { HistoryPort } from "../adapter/history.port.js";
|
|
29
|
+
import type { HistoryEntry } from "../group/history.js";
|
|
30
|
+
import { processAttachments } from "./inbound-attachments.js";
|
|
31
|
+
import type { InboundContext, InboundPipelineDeps } from "./inbound-context.js";
|
|
32
|
+
import type { QueuedMessage } from "./message-queue.js";
|
|
33
|
+
import {
|
|
34
|
+
buildAgentBody,
|
|
35
|
+
buildBody,
|
|
36
|
+
buildDynamicCtx,
|
|
37
|
+
buildGroupSystemPrompt,
|
|
38
|
+
buildQuotePart,
|
|
39
|
+
buildSkippedInboundContext,
|
|
40
|
+
buildUserContent,
|
|
41
|
+
buildUserMessage,
|
|
42
|
+
classifyMedia,
|
|
43
|
+
resolveCommandAuthorized,
|
|
44
|
+
resolveQuote,
|
|
45
|
+
runAccessStage,
|
|
46
|
+
runGroupGateStage,
|
|
47
|
+
writeRefIndex,
|
|
48
|
+
} from "./stages/index.js";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Process a raw queued message through the full inbound pipeline.
|
|
52
|
+
*
|
|
53
|
+
* Returns an {@link InboundContext} with `blocked` / `skipped` set when
|
|
54
|
+
* the message should not reach the AI dispatcher.
|
|
55
|
+
*/
|
|
56
|
+
export async function buildInboundContext(
|
|
57
|
+
event: QueuedMessage,
|
|
58
|
+
deps: InboundPipelineDeps,
|
|
59
|
+
): Promise<InboundContext> {
|
|
60
|
+
const { account, log } = deps;
|
|
61
|
+
|
|
62
|
+
// ---- 1. Access ----
|
|
63
|
+
const accessResult = runAccessStage(event, deps);
|
|
64
|
+
if (accessResult.kind === "block") {
|
|
65
|
+
return accessResult.context;
|
|
66
|
+
}
|
|
67
|
+
const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult;
|
|
68
|
+
|
|
69
|
+
// ---- 2. Typing indicator (async; awaited before refIdx write) ----
|
|
70
|
+
const typingPromise = deps.startTyping(event);
|
|
71
|
+
|
|
72
|
+
// ---- 3. Attachments ----
|
|
73
|
+
const processed = await processAttachments(event.attachments, {
|
|
74
|
+
accountId: account.accountId,
|
|
75
|
+
cfg: deps.cfg,
|
|
76
|
+
audioConvert: deps.adapters.audioConvert,
|
|
77
|
+
log,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---- 4. Content ----
|
|
81
|
+
const { parsedContent, userContent } = buildUserContent({
|
|
82
|
+
event,
|
|
83
|
+
attachmentInfo: processed.attachmentInfo,
|
|
84
|
+
voiceTranscripts: processed.voiceTranscripts,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---- 5. Quote ----
|
|
88
|
+
const replyTo = await resolveQuote(event, deps);
|
|
89
|
+
|
|
90
|
+
// ---- 6. RefIdx ----
|
|
91
|
+
const typingResult = await typingPromise;
|
|
92
|
+
writeRefIndex({
|
|
93
|
+
event,
|
|
94
|
+
parsedContent,
|
|
95
|
+
processed,
|
|
96
|
+
inputNotifyRefIdx: typingResult.refIdx,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---- 7. Group gate ----
|
|
100
|
+
let groupInfo: InboundContext["group"];
|
|
101
|
+
if (event.type === "group" && event.groupOpenid) {
|
|
102
|
+
const gateOutcome = runGroupGateStage({
|
|
103
|
+
event,
|
|
104
|
+
deps,
|
|
105
|
+
accountId: account.accountId,
|
|
106
|
+
agentId: route.agentId,
|
|
107
|
+
sessionKey: route.sessionKey,
|
|
108
|
+
userContent,
|
|
109
|
+
processedAttachments: processed,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (gateOutcome.kind === "skip") {
|
|
113
|
+
typingResult.keepAlive?.stop();
|
|
114
|
+
return buildSkippedInboundContext({
|
|
115
|
+
event,
|
|
116
|
+
route,
|
|
117
|
+
isGroupChat: true,
|
|
118
|
+
peerId,
|
|
119
|
+
qualifiedTarget,
|
|
120
|
+
fromAddress,
|
|
121
|
+
group: gateOutcome.groupInfo,
|
|
122
|
+
skipReason: gateOutcome.skipReason,
|
|
123
|
+
access,
|
|
124
|
+
typing: { keepAlive: typingResult.keepAlive },
|
|
125
|
+
inputNotifyRefIdx: typingResult.refIdx,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
groupInfo = gateOutcome.groupInfo;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- 8. Envelope ----
|
|
132
|
+
const body = buildBody({
|
|
133
|
+
event,
|
|
134
|
+
deps,
|
|
135
|
+
userContent,
|
|
136
|
+
isGroupChat,
|
|
137
|
+
imageUrls: processed.imageUrls,
|
|
138
|
+
});
|
|
139
|
+
const quotePart = buildQuotePart(replyTo);
|
|
140
|
+
const media = classifyMedia(processed);
|
|
141
|
+
const dynamicCtx = buildDynamicCtx({
|
|
142
|
+
imageUrls: processed.imageUrls,
|
|
143
|
+
uniqueVoicePaths: media.uniqueVoicePaths,
|
|
144
|
+
uniqueVoiceUrls: media.uniqueVoiceUrls,
|
|
145
|
+
uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- 9. Assembly ----
|
|
149
|
+
const userMessage = buildUserMessage({
|
|
150
|
+
event,
|
|
151
|
+
userContent,
|
|
152
|
+
quotePart,
|
|
153
|
+
isGroupChat,
|
|
154
|
+
groupInfo,
|
|
155
|
+
});
|
|
156
|
+
const agentBody = buildAgentBody({
|
|
157
|
+
event,
|
|
158
|
+
userContent,
|
|
159
|
+
userMessage,
|
|
160
|
+
dynamicCtx,
|
|
161
|
+
isGroupChat,
|
|
162
|
+
groupInfo,
|
|
163
|
+
deps,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---- 10. System prompt ----
|
|
167
|
+
const systemPrompts: string[] = [];
|
|
168
|
+
if (account.systemPrompt) {
|
|
169
|
+
systemPrompts.push(account.systemPrompt);
|
|
170
|
+
}
|
|
171
|
+
const accountSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
|
|
172
|
+
const groupSystemPrompt = buildGroupSystemPrompt(accountSystemInstruction, groupInfo);
|
|
173
|
+
|
|
174
|
+
// ---- 11. Authorization ----
|
|
175
|
+
const commandAuthorized = resolveCommandAuthorized(access);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
event,
|
|
179
|
+
route,
|
|
180
|
+
isGroupChat,
|
|
181
|
+
peerId,
|
|
182
|
+
qualifiedTarget,
|
|
183
|
+
fromAddress,
|
|
184
|
+
parsedContent,
|
|
185
|
+
userContent,
|
|
186
|
+
quotePart,
|
|
187
|
+
dynamicCtx,
|
|
188
|
+
userMessage,
|
|
189
|
+
agentBody,
|
|
190
|
+
body,
|
|
191
|
+
systemPrompts,
|
|
192
|
+
groupSystemPrompt,
|
|
193
|
+
attachments: processed,
|
|
194
|
+
localMediaPaths: media.localMediaPaths,
|
|
195
|
+
localMediaTypes: media.localMediaTypes,
|
|
196
|
+
remoteMediaUrls: media.remoteMediaUrls,
|
|
197
|
+
remoteMediaTypes: media.remoteMediaTypes,
|
|
198
|
+
uniqueVoicePaths: media.uniqueVoicePaths,
|
|
199
|
+
uniqueVoiceUrls: media.uniqueVoiceUrls,
|
|
200
|
+
uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
|
|
201
|
+
voiceMediaTypes: media.voiceMediaTypes,
|
|
202
|
+
hasAsrReferFallback: media.hasAsrReferFallback,
|
|
203
|
+
voiceTranscriptSources: media.voiceTranscriptSources,
|
|
204
|
+
replyTo,
|
|
205
|
+
commandAuthorized,
|
|
206
|
+
group: groupInfo,
|
|
207
|
+
blocked: false,
|
|
208
|
+
skipped: false,
|
|
209
|
+
accessDecision: access.decision,
|
|
210
|
+
typing: { keepAlive: typingResult.keepAlive },
|
|
211
|
+
inputNotifyRefIdx: typingResult.refIdx,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============ Public history-clear helper ============
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Clear a group's pending history buffer. Exposed so the gateway can
|
|
219
|
+
* call it in its `finally` block after a reply attempt.
|
|
220
|
+
*/
|
|
221
|
+
export function clearGroupPendingHistory(params: {
|
|
222
|
+
historyMap: Map<string, HistoryEntry[]> | undefined;
|
|
223
|
+
groupOpenid: string | undefined;
|
|
224
|
+
historyLimit: number;
|
|
225
|
+
historyPort: HistoryPort;
|
|
226
|
+
}): void {
|
|
227
|
+
if (!params.historyMap || !params.groupOpenid) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
params.historyPort.clearPendingHistory({
|
|
231
|
+
historyMap: params.historyMap,
|
|
232
|
+
historyKey: params.groupOpenid,
|
|
233
|
+
limit: params.historyLimit,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INTERACTION_CREATE event handler.
|
|
3
|
+
*
|
|
4
|
+
* Handles three interaction branches:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Config query** (type=2001) — reads config, ACKs with `claw_cfg`.
|
|
7
|
+
* 2. **Config update** (type=2002) — writes config, ACKs with updated snapshot.
|
|
8
|
+
* 3. **Approval button** (other) — ACKs, resolves approval via PlatformAdapter.
|
|
9
|
+
*
|
|
10
|
+
* Config query/update require `runtime.config`. When unavailable, those
|
|
11
|
+
* branches fall through to a bare ACK (backward-compatible).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolveQQBotEffectivePolicies } from "../access/resolve-policy.js";
|
|
15
|
+
import { getPlatformAdapter } from "../adapter/index.js";
|
|
16
|
+
import { parseApprovalButtonData } from "../approval/index.js";
|
|
17
|
+
import { getPluginVersion, getFrameworkVersion } from "../commands/slash-commands-impl.js";
|
|
18
|
+
import { resolveGroupConfig, resolveMentionPatterns } from "../config/group.js";
|
|
19
|
+
import { resolveAccountBase } from "../config/resolve.js";
|
|
20
|
+
import type { GroupActivationMode } from "../group/activation.js";
|
|
21
|
+
import { accountToCreds, acknowledgeInteraction } from "../messaging/sender.js";
|
|
22
|
+
import type { InteractionEvent, QQBotAccountConfigView } from "../types.js";
|
|
23
|
+
import { InteractionType } from "./constants.js";
|
|
24
|
+
import type { GatewayAccount, GatewayPluginRuntime, EngineLogger } from "./types.js";
|
|
25
|
+
|
|
26
|
+
// ============ claw_cfg snapshot ============
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the canonical `claw_cfg` snapshot returned in interaction ACKs.
|
|
30
|
+
*
|
|
31
|
+
* Pure function — all resolution helpers live in engine/config/.
|
|
32
|
+
*/
|
|
33
|
+
function buildClawCfgSnapshot(
|
|
34
|
+
cfg: Record<string, unknown>,
|
|
35
|
+
accountId: string,
|
|
36
|
+
groupOpenid: string,
|
|
37
|
+
runtime: GatewayPluginRuntime,
|
|
38
|
+
): Record<string, unknown> {
|
|
39
|
+
const groupCfg = groupOpenid ? resolveGroupConfig(cfg, groupOpenid, accountId) : null;
|
|
40
|
+
const accountBase = resolveAccountBase(cfg, accountId);
|
|
41
|
+
const acctCfg = accountBase.config as QQBotAccountConfigView;
|
|
42
|
+
const policies = resolveQQBotEffectivePolicies({
|
|
43
|
+
allowFrom: acctCfg.allowFrom,
|
|
44
|
+
groupAllowFrom: acctCfg.groupAllowFrom,
|
|
45
|
+
dmPolicy: acctCfg.dmPolicy,
|
|
46
|
+
groupPolicy: acctCfg.groupPolicy,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const requireMentionMode: GroupActivationMode =
|
|
50
|
+
(groupCfg?.requireMention ?? true) ? "mention" : "always";
|
|
51
|
+
|
|
52
|
+
const interactionAgentId = groupOpenid
|
|
53
|
+
? (
|
|
54
|
+
runtime.channel.routing.resolveAgentRoute({
|
|
55
|
+
cfg,
|
|
56
|
+
channel: "qqbot",
|
|
57
|
+
accountId,
|
|
58
|
+
peer: { kind: "group", id: groupOpenid },
|
|
59
|
+
}) as { agentId?: string } | undefined
|
|
60
|
+
)?.agentId
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
channel_type: "qqbot",
|
|
65
|
+
channel_ver: getPluginVersion(),
|
|
66
|
+
claw_type: "openclaw",
|
|
67
|
+
claw_ver: getFrameworkVersion(),
|
|
68
|
+
require_mention: requireMentionMode,
|
|
69
|
+
group_policy: policies.groupPolicy,
|
|
70
|
+
mention_patterns: resolveMentionPatterns(cfg, interactionAgentId).join(","),
|
|
71
|
+
online_state: "online",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============ Config update ============
|
|
76
|
+
|
|
77
|
+
/** Apply a config-update interaction and return the updated claw_cfg. */
|
|
78
|
+
async function applyConfigUpdate(
|
|
79
|
+
event: InteractionEvent,
|
|
80
|
+
accountId: string,
|
|
81
|
+
runtime: GatewayPluginRuntime,
|
|
82
|
+
log?: EngineLogger,
|
|
83
|
+
): Promise<Record<string, unknown>> {
|
|
84
|
+
const configApi = runtime.config;
|
|
85
|
+
if (!configApi) {
|
|
86
|
+
throw new Error("runtime.config not available");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolved = event.data?.resolved as Record<string, unknown> | undefined;
|
|
90
|
+
const clawCfgUpdate = resolved?.claw_cfg as Record<string, unknown> | undefined;
|
|
91
|
+
const groupOpenid = event.group_openid ?? "";
|
|
92
|
+
|
|
93
|
+
const currentCfg = structuredClone(configApi.current());
|
|
94
|
+
let changed = false;
|
|
95
|
+
|
|
96
|
+
if (clawCfgUpdate?.require_mention !== undefined && groupOpenid) {
|
|
97
|
+
applyRequireMentionUpdate(currentCfg, accountId, groupOpenid, clawCfgUpdate);
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (changed) {
|
|
102
|
+
await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } });
|
|
103
|
+
log?.info(
|
|
104
|
+
`Config updated via interaction ${event.id}: require_mention=${String(clawCfgUpdate?.require_mention)}, group=${groupOpenid}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const latestCfg = changed ? configApi.current() : currentCfg;
|
|
109
|
+
return buildClawCfgSnapshot(latestCfg, accountId, groupOpenid, runtime);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Mutate `cfg` in place to apply a require_mention update for a group. */
|
|
113
|
+
function applyRequireMentionUpdate(
|
|
114
|
+
cfg: Record<string, unknown>,
|
|
115
|
+
accountId: string,
|
|
116
|
+
groupOpenid: string,
|
|
117
|
+
update: Record<string, unknown>,
|
|
118
|
+
): void {
|
|
119
|
+
const requireMentionBool = update.require_mention === "mention";
|
|
120
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
121
|
+
const qqbot = (channels.qqbot ?? {}) as Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
const isNamedAccount =
|
|
124
|
+
accountId !== "default" &&
|
|
125
|
+
Boolean((qqbot.accounts as Record<string, Record<string, unknown>> | undefined)?.[accountId]);
|
|
126
|
+
|
|
127
|
+
if (isNamedAccount) {
|
|
128
|
+
const accounts = (qqbot.accounts ?? {}) as Record<string, Record<string, unknown>>;
|
|
129
|
+
const acct = accounts[accountId] ?? {};
|
|
130
|
+
const groups = (acct.groups ?? {}) as Record<string, Record<string, unknown>>;
|
|
131
|
+
groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
|
|
132
|
+
acct.groups = groups;
|
|
133
|
+
accounts[accountId] = acct;
|
|
134
|
+
qqbot.accounts = accounts;
|
|
135
|
+
} else {
|
|
136
|
+
const groups = (qqbot.groups ?? {}) as Record<string, Record<string, unknown>>;
|
|
137
|
+
groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
|
|
138
|
+
qqbot.groups = groups;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============ Public factory ============
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create the INTERACTION_CREATE event handler.
|
|
146
|
+
*
|
|
147
|
+
* Returns a fire-and-forget callback that `GatewayConnection` calls
|
|
148
|
+
* on every `action: "interaction"` dispatch result.
|
|
149
|
+
*/
|
|
150
|
+
export function createInteractionHandler(
|
|
151
|
+
account: GatewayAccount,
|
|
152
|
+
runtime: GatewayPluginRuntime,
|
|
153
|
+
log?: EngineLogger,
|
|
154
|
+
): (event: InteractionEvent) => void {
|
|
155
|
+
return (event) => {
|
|
156
|
+
const creds = accountToCreds(account);
|
|
157
|
+
const type = event.data?.type;
|
|
158
|
+
|
|
159
|
+
// ---- Config query (type=2001) ----
|
|
160
|
+
if (type === InteractionType.CONFIG_QUERY && runtime.config) {
|
|
161
|
+
void handleWithAck(creds, event, log, "CONFIG_QUERY", () => {
|
|
162
|
+
const cfg = runtime.config!.current();
|
|
163
|
+
return buildClawCfgSnapshot(cfg, account.accountId, event.group_openid ?? "", runtime);
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- Config update (type=2002) ----
|
|
169
|
+
if (type === InteractionType.CONFIG_UPDATE && runtime.config) {
|
|
170
|
+
void handleWithAck(creds, event, log, "CONFIG_UPDATE", () =>
|
|
171
|
+
applyConfigUpdate(event, account.accountId, runtime, log),
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Approval button / other ----
|
|
177
|
+
void acknowledgeInteraction(creds, event.id).catch((err) => {
|
|
178
|
+
log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const parsed = parseApprovalButtonData(event.data?.resolved?.button_data ?? "");
|
|
182
|
+
if (!parsed) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const adapter = getPlatformAdapter();
|
|
187
|
+
if (!adapter.resolveApproval) {
|
|
188
|
+
log?.error("resolveApproval not available on PlatformAdapter");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
void adapter.resolveApproval(parsed.approvalId, parsed.decision).then((ok) => {
|
|
193
|
+
if (ok) {
|
|
194
|
+
log?.info(`Approval resolved: id=${parsed.approvalId}, decision=${parsed.decision}`);
|
|
195
|
+
} else {
|
|
196
|
+
log?.error(`Approval resolve failed: id=${parsed.approvalId}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============ Helpers ============
|
|
203
|
+
|
|
204
|
+
/** Execute an async handler, ACK with the result, and handle errors. */
|
|
205
|
+
async function handleWithAck(
|
|
206
|
+
creds: { appId: string; clientSecret: string },
|
|
207
|
+
event: InteractionEvent,
|
|
208
|
+
log: EngineLogger | undefined,
|
|
209
|
+
label: string,
|
|
210
|
+
handler: () => Record<string, unknown> | Promise<Record<string, unknown>>,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
try {
|
|
213
|
+
const clawCfg = await handler();
|
|
214
|
+
await acknowledgeInteraction(creds, event.id, 0, { claw_cfg: clawCfg });
|
|
215
|
+
log?.info(`Interaction ACK (${label}) sent: ${event.id}`);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
log?.error(`${label} interaction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
218
|
+
void acknowledgeInteraction(creds, event.id).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
}
|