@openclaw/qqbot 2026.5.2 → 2026.5.3-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/dist/api.js +646 -0
- package/dist/approval-cg0SVahb.js +94 -0
- package/dist/channel-N6Y_Rcjp.js +551 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.setup-ByCRAe5H.js +25 -0
- package/dist/config-D6545NkC.js +94 -0
- package/dist/config-schema-DFcjQw73.js +309 -0
- package/dist/exec-approvals-COUsM6wZ.js +138 -0
- package/dist/gateway-CmSUJKSt.js +6726 -0
- package/dist/handler-runtime-Bqm6N0WG.js +120 -0
- package/dist/index.js +29 -0
- package/dist/narrowing-BoieBTIU.js +25 -0
- package/dist/outbound-BJfhwrPg.js +1357 -0
- package/dist/request-context-DXtpwWui.js +921 -0
- package/dist/resolve-D_06fV6-.js +284 -0
- package/dist/runtime-BJAS3eXW.js +18 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-api.js +56 -0
- package/dist/sender-p-B14eLG.js +1987 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/string-normalize-Ci6NM5DE.js +98 -0
- package/dist/target-parser-Y0prnrXD.js +245 -0
- package/package.json +15 -6
- package/api.ts +0 -56
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -29
- package/runtime-api.ts +0 -9
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/bridge/approval/capability.ts +0 -237
- package/src/bridge/approval/handler-runtime.ts +0 -204
- package/src/bridge/bootstrap.ts +0 -135
- package/src/bridge/channel-entry.ts +0 -18
- package/src/bridge/commands/framework-context-adapter.ts +0 -60
- package/src/bridge/commands/framework-registration.ts +0 -47
- package/src/bridge/commands/from-parser.test.ts +0 -86
- package/src/bridge/commands/from-parser.ts +0 -60
- package/src/bridge/commands/result-dispatcher.ts +0 -76
- package/src/bridge/config-shared.ts +0 -132
- package/src/bridge/config.ts +0 -111
- package/src/bridge/gateway.ts +0 -174
- package/src/bridge/logger.ts +0 -31
- package/src/bridge/narrowing.ts +0 -31
- package/src/bridge/plugin-version.test.ts +0 -146
- package/src/bridge/plugin-version.ts +0 -102
- package/src/bridge/runtime.ts +0 -25
- package/src/bridge/sdk-adapter.ts +0 -131
- package/src/bridge/setup/finalize.ts +0 -144
- package/src/bridge/setup/surface.ts +0 -34
- package/src/bridge/tools/channel.ts +0 -58
- package/src/bridge/tools/index.ts +0 -15
- package/src/bridge/tools/remind.test.ts +0 -124
- package/src/bridge/tools/remind.ts +0 -91
- package/src/channel.setup.ts +0 -33
- package/src/channel.ts +0 -288
- package/src/command-auth.test.ts +0 -62
- package/src/config-schema.ts +0 -84
- package/src/config.test.ts +0 -364
- package/src/engine/access/access-control.test.ts +0 -198
- package/src/engine/access/access-control.ts +0 -226
- package/src/engine/access/index.ts +0 -16
- package/src/engine/access/resolve-policy.test.ts +0 -59
- package/src/engine/access/resolve-policy.ts +0 -57
- package/src/engine/access/sender-match.test.ts +0 -60
- package/src/engine/access/sender-match.ts +0 -55
- package/src/engine/access/types.ts +0 -53
- package/src/engine/adapter/audio.port.ts +0 -27
- package/src/engine/adapter/commands.port.ts +0 -22
- package/src/engine/adapter/history.port.ts +0 -52
- package/src/engine/adapter/index.ts +0 -139
- package/src/engine/adapter/mention-gate.port.ts +0 -50
- package/src/engine/adapter/types.ts +0 -38
- package/src/engine/api/api-client.ts +0 -212
- package/src/engine/api/media-chunked.test.ts +0 -336
- package/src/engine/api/media-chunked.ts +0 -622
- package/src/engine/api/media.ts +0 -218
- package/src/engine/api/messages.ts +0 -293
- package/src/engine/api/retry.ts +0 -217
- package/src/engine/api/routes.ts +0 -95
- package/src/engine/api/token.ts +0 -271
- package/src/engine/approval/index.test.ts +0 -22
- package/src/engine/approval/index.ts +0 -224
- package/src/engine/commands/builtin/log-helpers.ts +0 -319
- package/src/engine/commands/builtin/register-all.ts +0 -17
- package/src/engine/commands/builtin/register-approve.ts +0 -201
- package/src/engine/commands/builtin/register-basic.ts +0 -95
- package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
- package/src/engine/commands/builtin/register-logs.ts +0 -20
- package/src/engine/commands/builtin/register-streaming.ts +0 -137
- package/src/engine/commands/builtin/state.ts +0 -31
- package/src/engine/commands/slash-command-auth.ts +0 -48
- package/src/engine/commands/slash-command-handler.ts +0 -146
- package/src/engine/commands/slash-commands-impl.test.ts +0 -8
- package/src/engine/commands/slash-commands-impl.ts +0 -61
- package/src/engine/commands/slash-commands.ts +0 -199
- package/src/engine/config/credential-backup.test.ts +0 -88
- package/src/engine/config/credential-backup.ts +0 -107
- package/src/engine/config/credentials.ts +0 -76
- package/src/engine/config/group.test.ts +0 -234
- package/src/engine/config/group.ts +0 -299
- package/src/engine/config/resolve.test.ts +0 -152
- package/src/engine/config/resolve.ts +0 -283
- package/src/engine/config/setup-logic.ts +0 -84
- package/src/engine/engine-import-boundary.test.ts +0 -73
- package/src/engine/gateway/codec.ts +0 -47
- package/src/engine/gateway/constants.ts +0 -117
- package/src/engine/gateway/event-dispatcher.ts +0 -177
- package/src/engine/gateway/gateway-connection.ts +0 -371
- package/src/engine/gateway/gateway.ts +0 -291
- package/src/engine/gateway/inbound-attachments.test.ts +0 -126
- package/src/engine/gateway/inbound-attachments.ts +0 -360
- package/src/engine/gateway/inbound-context.ts +0 -195
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
- package/src/engine/gateway/inbound-pipeline.ts +0 -235
- package/src/engine/gateway/interaction-handler.ts +0 -220
- package/src/engine/gateway/message-queue.test.ts +0 -282
- package/src/engine/gateway/message-queue.ts +0 -499
- package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
- package/src/engine/gateway/outbound-dispatch.ts +0 -575
- package/src/engine/gateway/reconnect.ts +0 -199
- package/src/engine/gateway/stages/access-stage.ts +0 -132
- package/src/engine/gateway/stages/assembly-stage.ts +0 -156
- package/src/engine/gateway/stages/content-stage.test.ts +0 -77
- package/src/engine/gateway/stages/content-stage.ts +0 -77
- package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
- package/src/engine/gateway/stages/envelope-stage.ts +0 -144
- package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
- package/src/engine/gateway/stages/index.ts +0 -18
- package/src/engine/gateway/stages/quote-stage.ts +0 -113
- package/src/engine/gateway/stages/refidx-stage.ts +0 -62
- package/src/engine/gateway/stages/stub-contexts.ts +0 -116
- package/src/engine/gateway/types.ts +0 -264
- package/src/engine/gateway/typing-keepalive.ts +0 -79
- package/src/engine/group/activation.test.ts +0 -114
- package/src/engine/group/activation.ts +0 -147
- package/src/engine/group/history.test.ts +0 -314
- package/src/engine/group/history.ts +0 -321
- package/src/engine/group/mention.test.ts +0 -141
- package/src/engine/group/mention.ts +0 -197
- package/src/engine/group/message-gating.test.ts +0 -188
- package/src/engine/group/message-gating.ts +0 -216
- package/src/engine/messaging/decode-media-path.ts +0 -82
- package/src/engine/messaging/media-source.ts +0 -215
- package/src/engine/messaging/media-type-detect.ts +0 -37
- package/src/engine/messaging/outbound-audio-port.ts +0 -38
- package/src/engine/messaging/outbound-deliver.ts +0 -810
- package/src/engine/messaging/outbound-media-send.ts +0 -702
- package/src/engine/messaging/outbound-reply.ts +0 -27
- package/src/engine/messaging/outbound-result-helpers.ts +0 -54
- package/src/engine/messaging/outbound-types.ts +0 -45
- package/src/engine/messaging/outbound.ts +0 -485
- package/src/engine/messaging/reply-dispatcher.ts +0 -597
- package/src/engine/messaging/reply-limiter.ts +0 -164
- package/src/engine/messaging/sender.ts +0 -729
- package/src/engine/messaging/streaming-c2c.ts +0 -1192
- package/src/engine/messaging/streaming-media-send.ts +0 -544
- package/src/engine/messaging/target-parser.ts +0 -104
- package/src/engine/ref/format-message-ref.ts +0 -142
- package/src/engine/ref/format-ref-entry.test.ts +0 -60
- package/src/engine/ref/format-ref-entry.ts +0 -27
- package/src/engine/ref/store.ts +0 -208
- package/src/engine/ref/types.ts +0 -27
- package/src/engine/session/known-users.ts +0 -137
- package/src/engine/session/session-store.ts +0 -204
- package/src/engine/tools/channel-api.ts +0 -244
- package/src/engine/tools/remind-logic.test.ts +0 -280
- package/src/engine/tools/remind-logic.ts +0 -377
- package/src/engine/types.ts +0 -313
- package/src/engine/utils/attachment-tags.test.ts +0 -186
- package/src/engine/utils/attachment-tags.ts +0 -174
- package/src/engine/utils/audio.test.ts +0 -250
- package/src/engine/utils/audio.ts +0 -585
- package/src/engine/utils/data-paths.ts +0 -38
- package/src/engine/utils/diagnostics.ts +0 -109
- package/src/engine/utils/file-utils.test.ts +0 -72
- package/src/engine/utils/file-utils.ts +0 -225
- package/src/engine/utils/format.test.ts +0 -68
- package/src/engine/utils/format.ts +0 -70
- package/src/engine/utils/image-size.test.ts +0 -158
- package/src/engine/utils/image-size.ts +0 -249
- package/src/engine/utils/log.test.ts +0 -28
- package/src/engine/utils/log.ts +0 -61
- package/src/engine/utils/media-tags.test.ts +0 -32
- package/src/engine/utils/media-tags.ts +0 -177
- package/src/engine/utils/payload.test.ts +0 -68
- package/src/engine/utils/payload.ts +0 -145
- package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
- package/src/engine/utils/platform.test.ts +0 -148
- package/src/engine/utils/platform.ts +0 -343
- package/src/engine/utils/request-context.ts +0 -60
- package/src/engine/utils/string-normalize.ts +0 -91
- package/src/engine/utils/stt.test.ts +0 -104
- package/src/engine/utils/stt.ts +0 -100
- package/src/engine/utils/text-parsing.test.ts +0 -29
- package/src/engine/utils/text-parsing.ts +0 -155
- package/src/engine/utils/upload-cache.ts +0 -96
- package/src/engine/utils/voice-text.ts +0 -15
- package/src/exec-approvals.ts +0 -218
- package/src/manifest-schema.test.ts +0 -56
- package/src/qqbot-test-support.ts +0 -29
- package/src/types.ts +0 -210
- package/tsconfig.json +0 -16
|
@@ -1,499 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-user concurrent message queue.
|
|
3
|
-
*
|
|
4
|
-
* Messages are serialized per **peer** (one DM user, one group, one guild
|
|
5
|
-
* channel) and processed in parallel across peers up to
|
|
6
|
-
* {@link DEFAULT_MAX_CONCURRENT_USERS}.
|
|
7
|
-
*
|
|
8
|
-
* Group-specific enhancements (added when merging from the standalone build):
|
|
9
|
-
* - Group peers have a larger queue cap ({@link DEFAULT_GROUP_QUEUE_SIZE})
|
|
10
|
-
* because groups can burst more chatter than a single DM.
|
|
11
|
-
* - When a group's queue overflows, bot-authored messages are evicted
|
|
12
|
-
* preferentially so human messages don't get dropped.
|
|
13
|
-
* - When draining a group peer with more than one queued message, the
|
|
14
|
-
* non-command messages are **merged** into one logical turn (see
|
|
15
|
-
* {@link mergeGroupMessages}). Slash commands are always processed
|
|
16
|
-
* individually to avoid conflating a "/stop" with surrounding chatter.
|
|
17
|
-
*
|
|
18
|
-
* The module is self-contained: the only injected dependency is the
|
|
19
|
-
* logger / abort probe supplied via {@link MessageQueueContext}.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { formatErrorMessage } from "../utils/format.js";
|
|
23
|
-
|
|
24
|
-
// ============ Queue limits ============
|
|
25
|
-
|
|
26
|
-
/** Global cap across all peers. */
|
|
27
|
-
const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
|
|
28
|
-
/** Per-DM / per-channel cap. */
|
|
29
|
-
const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
|
|
30
|
-
/** Per-group cap — larger because groups burst more. */
|
|
31
|
-
const DEFAULT_GROUP_QUEUE_SIZE = 50;
|
|
32
|
-
/** Parallel fanout across peers. */
|
|
33
|
-
const DEFAULT_MAX_CONCURRENT_USERS = 10;
|
|
34
|
-
|
|
35
|
-
// ============ Types ============
|
|
36
|
-
|
|
37
|
-
/** Mention entry carried on group messages (subset of QQ's shape). */
|
|
38
|
-
export interface QueuedMention {
|
|
39
|
-
scope?: "all" | "single";
|
|
40
|
-
id?: string;
|
|
41
|
-
user_openid?: string;
|
|
42
|
-
member_openid?: string;
|
|
43
|
-
username?: string;
|
|
44
|
-
nickname?: string;
|
|
45
|
-
bot?: boolean;
|
|
46
|
-
is_you?: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Metadata attached to a merged group turn.
|
|
51
|
-
*
|
|
52
|
-
* When the drainer folds multiple non-command messages into one
|
|
53
|
-
* representative turn, the merge information lands here instead of
|
|
54
|
-
* being scattered across `_` -prefixed fields on {@link QueuedMessage}.
|
|
55
|
-
*/
|
|
56
|
-
interface QueuedMergeInfo {
|
|
57
|
-
/** Number of original messages folded in. Always >= 2. */
|
|
58
|
-
count: number;
|
|
59
|
-
/** Original messages in insertion order — `messages.at(-1)` is "current". */
|
|
60
|
-
messages: readonly QueuedMessage[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Queue item used for asynchronous message handling without blocking heartbeats.
|
|
65
|
-
*/
|
|
66
|
-
export interface QueuedMessage {
|
|
67
|
-
type: "c2c" | "guild" | "dm" | "group";
|
|
68
|
-
senderId: string;
|
|
69
|
-
senderName?: string;
|
|
70
|
-
/** Whether the sender is another bot. Used by the eviction policy. */
|
|
71
|
-
senderIsBot?: boolean;
|
|
72
|
-
content: string;
|
|
73
|
-
messageId: string;
|
|
74
|
-
timestamp: string;
|
|
75
|
-
channelId?: string;
|
|
76
|
-
guildId?: string;
|
|
77
|
-
groupOpenid?: string;
|
|
78
|
-
attachments?: Array<{
|
|
79
|
-
content_type: string;
|
|
80
|
-
url: string;
|
|
81
|
-
filename?: string;
|
|
82
|
-
voice_wav_url?: string;
|
|
83
|
-
asr_refer_text?: string;
|
|
84
|
-
}>;
|
|
85
|
-
/** refIdx of the quoted message. */
|
|
86
|
-
refMsgIdx?: string;
|
|
87
|
-
/** refIdx assigned to this message for future quoting. */
|
|
88
|
-
msgIdx?: string;
|
|
89
|
-
/** QQ message type (103 = quote). */
|
|
90
|
-
msgType?: number;
|
|
91
|
-
/** Referenced message elements (for quote messages). */
|
|
92
|
-
msgElements?: Array<{
|
|
93
|
-
msg_idx?: string;
|
|
94
|
-
content?: string;
|
|
95
|
-
attachments?: Array<{
|
|
96
|
-
content_type: string;
|
|
97
|
-
url: string;
|
|
98
|
-
filename?: string;
|
|
99
|
-
height?: number;
|
|
100
|
-
width?: number;
|
|
101
|
-
size?: number;
|
|
102
|
-
voice_wav_url?: string;
|
|
103
|
-
asr_refer_text?: string;
|
|
104
|
-
}>;
|
|
105
|
-
}>;
|
|
106
|
-
/**
|
|
107
|
-
* Raw event type (e.g. `GROUP_AT_MESSAGE_CREATE`). Used by the gate to
|
|
108
|
-
* detect explicit @bot without parsing `mentions` ourselves, and by
|
|
109
|
-
* the group merger to decide whether the merged result represents an
|
|
110
|
-
* @bot turn.
|
|
111
|
-
*/
|
|
112
|
-
eventType?: string;
|
|
113
|
-
/** @mentions list from the raw event. */
|
|
114
|
-
mentions?: QueuedMention[];
|
|
115
|
-
/** Scene info (source channel + ext bag). */
|
|
116
|
-
messageScene?: { source?: string; ext?: string[] };
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Set only on merged group turns; absent on single-message turns.
|
|
120
|
-
* See {@link mergeGroupMessages} for merge semantics.
|
|
121
|
-
*/
|
|
122
|
-
merge?: QueuedMergeInfo;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Convenience predicate: is this a merged multi-message turn? */
|
|
126
|
-
export function isMergedTurn(msg: QueuedMessage): msg is QueuedMessage & {
|
|
127
|
-
merge: QueuedMergeInfo;
|
|
128
|
-
} {
|
|
129
|
-
return (msg.merge?.count ?? 0) > 1;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
interface MessageQueueContext {
|
|
133
|
-
accountId: string;
|
|
134
|
-
log?: {
|
|
135
|
-
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
136
|
-
error: (msg: string, meta?: Record<string, unknown>) => void;
|
|
137
|
-
debug?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
138
|
-
};
|
|
139
|
-
/** Abort-state probe supplied by the caller. */
|
|
140
|
-
isAborted: () => boolean;
|
|
141
|
-
/** Per-group queue cap. Defaults to {@link DEFAULT_GROUP_QUEUE_SIZE}. */
|
|
142
|
-
groupQueueSize?: number;
|
|
143
|
-
/** Per-DM / per-channel queue cap. Defaults to {@link DEFAULT_PER_PEER_QUEUE_SIZE}. */
|
|
144
|
-
peerQueueSize?: number;
|
|
145
|
-
/** Global queue cap. Defaults to {@link DEFAULT_GLOBAL_QUEUE_SIZE}. */
|
|
146
|
-
globalQueueSize?: number;
|
|
147
|
-
/** Max concurrent peers. Defaults to {@link DEFAULT_MAX_CONCURRENT_USERS}. */
|
|
148
|
-
maxConcurrentUsers?: number;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Snapshot of the queue state for diagnostics. */
|
|
152
|
-
interface QueueSnapshot {
|
|
153
|
-
totalPending: number;
|
|
154
|
-
activeUsers: number;
|
|
155
|
-
maxConcurrentUsers: number;
|
|
156
|
-
senderPending: number;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
interface MessageQueue {
|
|
160
|
-
enqueue: (msg: QueuedMessage) => void;
|
|
161
|
-
startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
|
|
162
|
-
getSnapshot: (senderPeerId: string) => QueueSnapshot;
|
|
163
|
-
getMessagePeerId: (msg: QueuedMessage) => string;
|
|
164
|
-
/** Clear a user's queued messages and return how many were dropped. */
|
|
165
|
-
clearUserQueue: (peerId: string) => number;
|
|
166
|
-
/** Execute one message immediately, bypassing the queue for urgent commands. */
|
|
167
|
-
executeImmediate: (msg: QueuedMessage) => void;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ============ Group merging ============
|
|
171
|
-
|
|
172
|
-
/** Return true when the peer id refers to a group-like conversation. */
|
|
173
|
-
function isGroupPeer(peerId: string): boolean {
|
|
174
|
-
return peerId.startsWith("group:") || peerId.startsWith("guild:");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Slash-command test used by {@link drainGroupBatch}. */
|
|
178
|
-
function isSlashCommand(msg: QueuedMessage): boolean {
|
|
179
|
-
return (msg.content ?? "").trim().startsWith("/");
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Merge several queued group messages into one representative message.
|
|
184
|
-
*
|
|
185
|
-
* Merge semantics:
|
|
186
|
-
* - `content` is joined with newlines; each line prefixed with `[sender]`
|
|
187
|
-
* so the downstream formatter can attribute speakers.
|
|
188
|
-
* - `attachments` is concatenated.
|
|
189
|
-
* - `mentions` is deduplicated by member/user openid; if *any* source
|
|
190
|
-
* message was a `GROUP_AT_MESSAGE_CREATE`, the merged result inherits
|
|
191
|
-
* that eventType (the merged turn effectively @-s the bot).
|
|
192
|
-
* - `messageId`, `msgIdx`, `timestamp` come from the last message — the
|
|
193
|
-
* most recent identity is what the outbound reply should quote.
|
|
194
|
-
* - `refMsgIdx` (the message that the user quoted) comes from the FIRST
|
|
195
|
-
* message in the batch because the first quote anchors the topic.
|
|
196
|
-
* - `senderIsBot` is true only when every source message was authored
|
|
197
|
-
* by a bot. Any human participation flips the flag.
|
|
198
|
-
*
|
|
199
|
-
* A single-message batch is returned unchanged (no merge overhead).
|
|
200
|
-
*/
|
|
201
|
-
export function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage {
|
|
202
|
-
if (batch.length === 0) {
|
|
203
|
-
throw new Error("mergeGroupMessages: empty batch");
|
|
204
|
-
}
|
|
205
|
-
if (batch.length === 1) {
|
|
206
|
-
return batch[0];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const first = batch[0];
|
|
210
|
-
const last = batch[batch.length - 1];
|
|
211
|
-
|
|
212
|
-
const mergedContent = batch
|
|
213
|
-
.map((m) => `[${m.senderName ?? m.senderId}]: ${m.content}`)
|
|
214
|
-
.join("\n");
|
|
215
|
-
|
|
216
|
-
const mergedAttachments: QueuedMessage["attachments"] = [];
|
|
217
|
-
for (const m of batch) {
|
|
218
|
-
if (m.attachments?.length) {
|
|
219
|
-
mergedAttachments.push(...m.attachments);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const seenMentionIds = new Set<string>();
|
|
224
|
-
const mergedMentions: NonNullable<QueuedMessage["mentions"]> = [];
|
|
225
|
-
let anyAtYouEvent = false;
|
|
226
|
-
for (const m of batch) {
|
|
227
|
-
if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
|
|
228
|
-
anyAtYouEvent = true;
|
|
229
|
-
}
|
|
230
|
-
if (m.mentions) {
|
|
231
|
-
for (const mt of m.mentions) {
|
|
232
|
-
const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
|
|
233
|
-
if (key && seenMentionIds.has(key)) {
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
if (key) {
|
|
237
|
-
seenMentionIds.add(key);
|
|
238
|
-
}
|
|
239
|
-
mergedMentions.push(mt);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const allFromBot = batch.every((m) => m.senderIsBot);
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
type: last.type,
|
|
248
|
-
senderId: last.senderId,
|
|
249
|
-
senderName: last.senderName,
|
|
250
|
-
senderIsBot: allFromBot,
|
|
251
|
-
content: mergedContent,
|
|
252
|
-
messageId: last.messageId,
|
|
253
|
-
timestamp: last.timestamp,
|
|
254
|
-
channelId: last.channelId,
|
|
255
|
-
guildId: last.guildId,
|
|
256
|
-
groupOpenid: last.groupOpenid,
|
|
257
|
-
attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
|
|
258
|
-
refMsgIdx: first.refMsgIdx,
|
|
259
|
-
msgIdx: last.msgIdx,
|
|
260
|
-
eventType: anyAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
|
|
261
|
-
mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
|
|
262
|
-
messageScene: last.messageScene,
|
|
263
|
-
merge: { count: batch.length, messages: batch },
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ============ Queue factory ============
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Create a per-user concurrent queue with built-in group enhancements.
|
|
271
|
-
*/
|
|
272
|
-
export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
|
273
|
-
const { accountId: _accountId, log } = ctx;
|
|
274
|
-
const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
|
|
275
|
-
const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
|
|
276
|
-
const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
|
|
277
|
-
const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
|
|
278
|
-
|
|
279
|
-
const userQueues = new Map<string, QueuedMessage[]>();
|
|
280
|
-
const activeUsers = new Set<string>();
|
|
281
|
-
let handleMessageFnRef: ((msg: QueuedMessage) => Promise<void>) | null = null;
|
|
282
|
-
let totalEnqueued = 0;
|
|
283
|
-
|
|
284
|
-
const getMessagePeerId = (msg: QueuedMessage): string => {
|
|
285
|
-
if (msg.type === "guild") {
|
|
286
|
-
return `guild:${msg.channelId ?? "unknown"}`;
|
|
287
|
-
}
|
|
288
|
-
if (msg.type === "group") {
|
|
289
|
-
return `group:${msg.groupOpenid ?? "unknown"}`;
|
|
290
|
-
}
|
|
291
|
-
return `dm:${msg.senderId}`;
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Evict one message from an over-full queue.
|
|
296
|
-
*
|
|
297
|
-
* For group peers we prefer to drop a bot-authored message so human
|
|
298
|
-
* input never gets lost. Falling back to dropping the oldest keeps the
|
|
299
|
-
* queue bounded when all members are bots.
|
|
300
|
-
*/
|
|
301
|
-
const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => {
|
|
302
|
-
if (isGroup) {
|
|
303
|
-
const botIdx = queue.findIndex((m) => m.senderIsBot);
|
|
304
|
-
if (botIdx >= 0) {
|
|
305
|
-
return queue.splice(botIdx, 1)[0];
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
return queue.shift();
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
/** Run a single message, capturing errors in the log. */
|
|
312
|
-
const processOne = async (msg: QueuedMessage, peerId: string, label: string): Promise<void> => {
|
|
313
|
-
try {
|
|
314
|
-
await handleMessageFnRef!(msg);
|
|
315
|
-
} catch (err) {
|
|
316
|
-
log?.error(`${label} error for ${peerId}: ${formatErrorMessage(err)}`);
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Drain a group's batch:
|
|
322
|
-
* - slash commands are processed one by one (order preserved);
|
|
323
|
-
* - the remaining messages are merged into a single turn.
|
|
324
|
-
*/
|
|
325
|
-
const drainGroupBatch = async (batch: QueuedMessage[], peerId: string): Promise<void> => {
|
|
326
|
-
const commands: QueuedMessage[] = [];
|
|
327
|
-
const normal: QueuedMessage[] = [];
|
|
328
|
-
for (const m of batch) {
|
|
329
|
-
if (isSlashCommand(m)) {
|
|
330
|
-
commands.push(m);
|
|
331
|
-
} else {
|
|
332
|
-
normal.push(m);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
for (const cmd of commands) {
|
|
337
|
-
log?.debug?.(
|
|
338
|
-
`Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`,
|
|
339
|
-
);
|
|
340
|
-
await processOne(cmd, peerId, "Command processor");
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (normal.length > 0) {
|
|
344
|
-
const merged = mergeGroupMessages(normal);
|
|
345
|
-
if (normal.length > 1) {
|
|
346
|
-
log?.debug?.(`Merged ${normal.length} queued group messages for ${peerId} into one`);
|
|
347
|
-
}
|
|
348
|
-
await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
|
|
349
|
-
}
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
/** Process one peer's queue serially. */
|
|
353
|
-
const drainUserQueue = async (peerId: string): Promise<void> => {
|
|
354
|
-
if (activeUsers.has(peerId)) {
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
if (activeUsers.size >= maxConcurrentUsers) {
|
|
358
|
-
log?.debug?.(`Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const queue = userQueues.get(peerId);
|
|
363
|
-
if (!queue || queue.length === 0) {
|
|
364
|
-
userQueues.delete(peerId);
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
activeUsers.add(peerId);
|
|
369
|
-
const isGroup = isGroupPeer(peerId);
|
|
370
|
-
|
|
371
|
-
try {
|
|
372
|
-
while (queue.length > 0 && !ctx.isAborted()) {
|
|
373
|
-
// Group peers with more than one queued message: batch-merge.
|
|
374
|
-
if (isGroup && queue.length > 1 && handleMessageFnRef) {
|
|
375
|
-
const batch = queue.splice(0);
|
|
376
|
-
totalEnqueued = Math.max(0, totalEnqueued - batch.length);
|
|
377
|
-
await drainGroupBatch(batch, peerId);
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Single-message (or non-group) path.
|
|
382
|
-
const msg = queue.shift()!;
|
|
383
|
-
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
384
|
-
if (handleMessageFnRef) {
|
|
385
|
-
await processOne(msg, peerId, "Message processor");
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
} finally {
|
|
389
|
-
activeUsers.delete(peerId);
|
|
390
|
-
userQueues.delete(peerId);
|
|
391
|
-
|
|
392
|
-
// Fill any freed concurrency slots.
|
|
393
|
-
for (const [waitingPeerId, waitingQueue] of userQueues) {
|
|
394
|
-
if (activeUsers.size >= maxConcurrentUsers) {
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
|
|
398
|
-
void drainUserQueue(waitingPeerId);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const enqueue = (msg: QueuedMessage): void => {
|
|
405
|
-
const peerId = getMessagePeerId(msg);
|
|
406
|
-
const isGroup = isGroupPeer(peerId);
|
|
407
|
-
|
|
408
|
-
let queue = userQueues.get(peerId);
|
|
409
|
-
if (!queue) {
|
|
410
|
-
queue = [];
|
|
411
|
-
userQueues.set(peerId, queue);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const maxSize = isGroup ? groupQueueSize : peerQueueSize;
|
|
415
|
-
if (queue.length >= maxSize) {
|
|
416
|
-
const dropped = evictOne(queue, isGroup);
|
|
417
|
-
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
418
|
-
if (isGroup && dropped?.senderIsBot) {
|
|
419
|
-
log?.info(`Queue full for ${peerId}, dropping bot message ${dropped.messageId}`, {
|
|
420
|
-
accountId: ctx.accountId,
|
|
421
|
-
peerId,
|
|
422
|
-
droppedMessageId: dropped.messageId,
|
|
423
|
-
reason: "queue_full_evict_bot",
|
|
424
|
-
});
|
|
425
|
-
} else {
|
|
426
|
-
log?.error(`Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`, {
|
|
427
|
-
accountId: ctx.accountId,
|
|
428
|
-
peerId,
|
|
429
|
-
droppedMessageId: dropped?.messageId,
|
|
430
|
-
reason: "queue_full_evict_oldest",
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
totalEnqueued++;
|
|
436
|
-
if (totalEnqueued > globalQueueSize) {
|
|
437
|
-
log?.error(
|
|
438
|
-
`Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`,
|
|
439
|
-
{ accountId: ctx.accountId, peerId, totalEnqueued, globalQueueSize },
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
queue.push(msg);
|
|
444
|
-
log?.debug?.(
|
|
445
|
-
`Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`,
|
|
446
|
-
);
|
|
447
|
-
|
|
448
|
-
void drainUserQueue(peerId);
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
|
|
452
|
-
handleMessageFnRef = handleMessageFn;
|
|
453
|
-
log?.debug?.(
|
|
454
|
-
`Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`,
|
|
455
|
-
);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const getSnapshot = (senderPeerId: string): QueueSnapshot => {
|
|
459
|
-
let totalPending = 0;
|
|
460
|
-
for (const [, q] of userQueues) {
|
|
461
|
-
totalPending += q.length;
|
|
462
|
-
}
|
|
463
|
-
const senderQueue = userQueues.get(senderPeerId);
|
|
464
|
-
return {
|
|
465
|
-
totalPending,
|
|
466
|
-
activeUsers: activeUsers.size,
|
|
467
|
-
maxConcurrentUsers,
|
|
468
|
-
senderPending: senderQueue ? senderQueue.length : 0,
|
|
469
|
-
};
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
const clearUserQueue = (peerId: string): number => {
|
|
473
|
-
const queue = userQueues.get(peerId);
|
|
474
|
-
if (!queue || queue.length === 0) {
|
|
475
|
-
return 0;
|
|
476
|
-
}
|
|
477
|
-
const droppedCount = queue.length;
|
|
478
|
-
queue.length = 0;
|
|
479
|
-
totalEnqueued = Math.max(0, totalEnqueued - droppedCount);
|
|
480
|
-
return droppedCount;
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
const executeImmediate = (msg: QueuedMessage): void => {
|
|
484
|
-
if (handleMessageFnRef) {
|
|
485
|
-
handleMessageFnRef(msg).catch((err) => {
|
|
486
|
-
log?.error(`Immediate execution error: ${err}`);
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
return {
|
|
492
|
-
enqueue,
|
|
493
|
-
startProcessor,
|
|
494
|
-
getSnapshot,
|
|
495
|
-
getMessagePeerId,
|
|
496
|
-
clearUserQueue,
|
|
497
|
-
executeImmediate,
|
|
498
|
-
};
|
|
499
|
-
}
|