mercury-agent 0.5.15 → 0.5.17
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/package.json +1 -1
- package/src/cli/mercury.ts +9 -0
- package/src/core/conversation.ts +1 -0
- package/src/core/debounce.ts +135 -0
- package/src/core/handler.ts +129 -88
- package/src/core/routes/chat.ts +43 -1
- package/src/core/routes/config-builtin.ts +9 -0
package/package.json
CHANGED
package/src/cli/mercury.ts
CHANGED
|
@@ -1940,6 +1940,10 @@ program
|
|
|
1940
1940
|
.option("-s, --space <spaceId>", "Space to route the message to", "main")
|
|
1941
1941
|
.option("-f, --file <paths...>", "Attach files to the message")
|
|
1942
1942
|
.option("--caller <callerId>", "Caller ID", "cli:user")
|
|
1943
|
+
.option(
|
|
1944
|
+
"--platform <platform>",
|
|
1945
|
+
"Simulate platform for DM auto-space resolution (e.g. whatsapp)",
|
|
1946
|
+
)
|
|
1943
1947
|
.option("--json", "Output raw JSON response")
|
|
1944
1948
|
.action(
|
|
1945
1949
|
async (
|
|
@@ -1949,6 +1953,7 @@ program
|
|
|
1949
1953
|
space: string;
|
|
1950
1954
|
file?: string[];
|
|
1951
1955
|
caller: string;
|
|
1956
|
+
platform?: string;
|
|
1952
1957
|
json?: boolean;
|
|
1953
1958
|
},
|
|
1954
1959
|
) => {
|
|
@@ -1975,6 +1980,10 @@ program
|
|
|
1975
1980
|
spaceId: options.space,
|
|
1976
1981
|
};
|
|
1977
1982
|
|
|
1983
|
+
if (options.platform) {
|
|
1984
|
+
body.platform = options.platform;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1978
1987
|
if (options.file && options.file.length > 0) {
|
|
1979
1988
|
const files: Array<{ name: string; data: string }> = [];
|
|
1980
1989
|
for (const filePath of options.file) {
|
package/src/core/conversation.ts
CHANGED
|
@@ -86,6 +86,7 @@ export function resolveConversation(
|
|
|
86
86
|
|
|
87
87
|
seedSpaceConfigIfAbsent(db, spaceId, "trigger.match", "always");
|
|
88
88
|
seedSpaceConfigIfAbsent(db, spaceId, "context.mode", "context");
|
|
89
|
+
seedSpaceConfigIfAbsent(db, spaceId, "debounce.idle_timeout_ms", "2000");
|
|
89
90
|
if (autoSpace.defaultMemberPermissions) {
|
|
90
91
|
seedSpaceConfigIfAbsent(
|
|
91
92
|
db,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { logger } from "../logger.js";
|
|
2
|
+
import type { IngressMessage } from "../types.js";
|
|
3
|
+
|
|
4
|
+
interface DebounceEntry {
|
|
5
|
+
ingresses: IngressMessage[];
|
|
6
|
+
timer: ReturnType<typeof setTimeout>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ProcessingEntry {
|
|
10
|
+
pending: IngressMessage[] | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type FlushCallback = (merged: IngressMessage) => Promise<void>;
|
|
14
|
+
|
|
15
|
+
const DEBOUNCE_PLATFORMS: Record<string, number> = {
|
|
16
|
+
whatsapp: 2000,
|
|
17
|
+
telegram: 2000,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getDefaultDebounceMs(platform: string): number {
|
|
21
|
+
return DEBOUNCE_PLATFORMS[platform] ?? 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mergeIngresses(batch: IngressMessage[]): IngressMessage {
|
|
25
|
+
if (batch.length === 1) return batch[0];
|
|
26
|
+
|
|
27
|
+
const first = batch[0];
|
|
28
|
+
const last = batch[batch.length - 1];
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
platform: first.platform,
|
|
32
|
+
spaceId: first.spaceId,
|
|
33
|
+
conversationExternalId: first.conversationExternalId,
|
|
34
|
+
callerId: first.callerId,
|
|
35
|
+
authorName: first.authorName,
|
|
36
|
+
text: batch.map((m) => m.text).join("\n"),
|
|
37
|
+
isDM: first.isDM,
|
|
38
|
+
isReplyToBot: first.isReplyToBot,
|
|
39
|
+
attachments: batch.flatMap((m) => m.attachments),
|
|
40
|
+
hadIncomingAttachments: batch.some((m) => m.hadIncomingAttachments),
|
|
41
|
+
replyToPlatformMessageId: first.replyToPlatformMessageId,
|
|
42
|
+
platformMessageId: last.platformMessageId,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class MessageDebouncer {
|
|
47
|
+
private batches = new Map<string, DebounceEntry>();
|
|
48
|
+
private processing = new Map<string, ProcessingEntry>();
|
|
49
|
+
|
|
50
|
+
submit(
|
|
51
|
+
key: string,
|
|
52
|
+
ingress: IngressMessage,
|
|
53
|
+
timeoutMs: number,
|
|
54
|
+
onFlush: FlushCallback,
|
|
55
|
+
): void {
|
|
56
|
+
const proc = this.processing.get(key);
|
|
57
|
+
if (proc) {
|
|
58
|
+
if (!proc.pending) {
|
|
59
|
+
proc.pending = [ingress];
|
|
60
|
+
} else {
|
|
61
|
+
proc.pending.push(ingress);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existing = this.batches.get(key);
|
|
67
|
+
if (existing) {
|
|
68
|
+
clearTimeout(existing.timer);
|
|
69
|
+
existing.ingresses.push(ingress);
|
|
70
|
+
existing.timer = setTimeout(() => {
|
|
71
|
+
this.flush(key, onFlush);
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
} else {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
this.flush(key, onFlush);
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
this.batches.set(key, { ingresses: [ingress], timer });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
flushKey(key: string, onFlush: FlushCallback): void {
|
|
82
|
+
const entry = this.batches.get(key);
|
|
83
|
+
if (!entry) return;
|
|
84
|
+
clearTimeout(entry.timer);
|
|
85
|
+
this.batches.delete(key);
|
|
86
|
+
this.executeFlush(key, entry.ingresses, onFlush);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
isProcessing(key: string): boolean {
|
|
90
|
+
return this.processing.has(key);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
flushAll(onFlush: FlushCallback): void {
|
|
94
|
+
for (const [key, entry] of this.batches) {
|
|
95
|
+
clearTimeout(entry.timer);
|
|
96
|
+
this.executeFlush(key, entry.ingresses, onFlush);
|
|
97
|
+
}
|
|
98
|
+
this.batches.clear();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private flush(key: string, onFlush: FlushCallback): void {
|
|
102
|
+
const entry = this.batches.get(key);
|
|
103
|
+
if (!entry) return;
|
|
104
|
+
this.batches.delete(key);
|
|
105
|
+
this.executeFlush(key, entry.ingresses, onFlush);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private executeFlush(
|
|
109
|
+
key: string,
|
|
110
|
+
ingresses: IngressMessage[],
|
|
111
|
+
onFlush: FlushCallback,
|
|
112
|
+
): void {
|
|
113
|
+
const merged = mergeIngresses(ingresses);
|
|
114
|
+
this.processing.set(key, { pending: null });
|
|
115
|
+
|
|
116
|
+
onFlush(merged)
|
|
117
|
+
.catch((err) => {
|
|
118
|
+
logger.error("Debounce flush error", {
|
|
119
|
+
key,
|
|
120
|
+
error: err instanceof Error ? err.message : String(err),
|
|
121
|
+
});
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
const proc = this.processing.get(key);
|
|
125
|
+
if (proc?.pending) {
|
|
126
|
+
const batch = proc.pending;
|
|
127
|
+
proc.pending = null;
|
|
128
|
+
this.processing.delete(key);
|
|
129
|
+
this.executeFlush(key, batch, onFlush);
|
|
130
|
+
} else {
|
|
131
|
+
this.processing.delete(key);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/core/handler.ts
CHANGED
|
@@ -2,12 +2,17 @@ import type { Adapter, Message } from "chat";
|
|
|
2
2
|
import { telegramInboundLooksLikeMedia } from "../bridges/telegram.js";
|
|
3
3
|
import type { AppConfig } from "../config.js";
|
|
4
4
|
import { logger } from "../logger.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
IngressMessage,
|
|
7
|
+
NormalizeContext,
|
|
8
|
+
PlatformBridge,
|
|
9
|
+
} from "../types.js";
|
|
6
10
|
import {
|
|
7
11
|
type AutoSpaceConfig,
|
|
8
12
|
inferConversationKind,
|
|
9
13
|
resolveConversation,
|
|
10
14
|
} from "./conversation.js";
|
|
15
|
+
import { getDefaultDebounceMs, MessageDebouncer } from "./debounce.js";
|
|
11
16
|
import type { MercuryCoreRuntime } from "./runtime.js";
|
|
12
17
|
import { loadTriggerConfig, matchTrigger } from "./trigger.js";
|
|
13
18
|
|
|
@@ -38,6 +43,105 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
|
|
|
38
43
|
}
|
|
39
44
|
: undefined;
|
|
40
45
|
|
|
46
|
+
const debouncer = new MessageDebouncer();
|
|
47
|
+
|
|
48
|
+
async function processIngress(
|
|
49
|
+
ingress: IngressMessage,
|
|
50
|
+
threadId: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
let lastStatusMessageId: string | undefined;
|
|
54
|
+
let hadStatusMessage = false;
|
|
55
|
+
|
|
56
|
+
const heartbeat = setInterval(() => {
|
|
57
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
58
|
+
const statusText = `⏳ Processing… (${elapsed}s)`;
|
|
59
|
+
const currentId = lastStatusMessageId;
|
|
60
|
+
|
|
61
|
+
if (currentId && bridge.editMessage) {
|
|
62
|
+
bridge
|
|
63
|
+
.editMessage(threadId, currentId, statusText)
|
|
64
|
+
.then((ok) => {
|
|
65
|
+
if (!ok) {
|
|
66
|
+
bridge
|
|
67
|
+
.sendReply(threadId, statusText)
|
|
68
|
+
.then(async (id) => {
|
|
69
|
+
await bridge
|
|
70
|
+
.deleteMessages?.(threadId, [currentId])
|
|
71
|
+
.catch(() => {});
|
|
72
|
+
if (id) lastStatusMessageId = id;
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
} else {
|
|
79
|
+
const prevId = lastStatusMessageId;
|
|
80
|
+
bridge
|
|
81
|
+
.sendReply(threadId, statusText)
|
|
82
|
+
.then(async (id) => {
|
|
83
|
+
if (prevId) {
|
|
84
|
+
await bridge.deleteMessages?.(threadId, [prevId]).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
if (id) {
|
|
87
|
+
lastStatusMessageId = id;
|
|
88
|
+
hadStatusMessage = true;
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
}, 30_000);
|
|
94
|
+
|
|
95
|
+
let result: Awaited<ReturnType<typeof core.handleRawInput>>;
|
|
96
|
+
try {
|
|
97
|
+
result = await core.handleRawInput(ingress, "chat-sdk");
|
|
98
|
+
} finally {
|
|
99
|
+
clearInterval(heartbeat);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (lastStatusMessageId) {
|
|
103
|
+
await bridge
|
|
104
|
+
.deleteMessages?.(threadId, [lastStatusMessageId])
|
|
105
|
+
.catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (result.type === "ignore") return;
|
|
109
|
+
|
|
110
|
+
if (result.type === "denied") {
|
|
111
|
+
await bridge.sendReply(threadId, result.reason);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (result.result) {
|
|
116
|
+
const { reply, files, assistantMessageId } = result.result;
|
|
117
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
118
|
+
const finalReply =
|
|
119
|
+
hadStatusMessage && reply
|
|
120
|
+
? `${reply}\n\n_(responded in ${elapsed}s)_`
|
|
121
|
+
: reply;
|
|
122
|
+
if (finalReply || files.length > 0) {
|
|
123
|
+
const sentPlatformId = await bridge.sendReply(
|
|
124
|
+
threadId,
|
|
125
|
+
finalReply,
|
|
126
|
+
files.length > 0 ? files : undefined,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
sentPlatformId &&
|
|
131
|
+
assistantMessageId &&
|
|
132
|
+
ingress.conversationExternalId
|
|
133
|
+
) {
|
|
134
|
+
core.recordOutboundPlatformId(
|
|
135
|
+
assistantMessageId,
|
|
136
|
+
bridge.platform,
|
|
137
|
+
ingress.conversationExternalId,
|
|
138
|
+
sentPlatformId,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
41
145
|
return async (
|
|
42
146
|
adapter: Adapter,
|
|
43
147
|
threadId: string,
|
|
@@ -120,101 +224,38 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
|
|
|
120
224
|
}
|
|
121
225
|
}
|
|
122
226
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const heartbeat = setInterval(() => {
|
|
128
|
-
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
129
|
-
const statusText = `⏳ Processing… (${elapsed}s)`;
|
|
130
|
-
const currentId = lastStatusMessageId;
|
|
131
|
-
|
|
132
|
-
if (currentId && bridge.editMessage) {
|
|
133
|
-
bridge
|
|
134
|
-
.editMessage(threadId, currentId, statusText)
|
|
135
|
-
.then((ok) => {
|
|
136
|
-
if (!ok) {
|
|
137
|
-
// Edit failed — fall back to delete+send
|
|
138
|
-
bridge
|
|
139
|
-
.sendReply(threadId, statusText)
|
|
140
|
-
.then(async (id) => {
|
|
141
|
-
await bridge
|
|
142
|
-
.deleteMessages?.(threadId, [currentId])
|
|
143
|
-
.catch(() => {});
|
|
144
|
-
if (id) lastStatusMessageId = id;
|
|
145
|
-
})
|
|
146
|
-
.catch(() => {});
|
|
147
|
-
}
|
|
148
|
-
})
|
|
149
|
-
.catch(() => {});
|
|
150
|
-
} else {
|
|
151
|
-
const prevId = lastStatusMessageId;
|
|
152
|
-
bridge
|
|
153
|
-
.sendReply(threadId, statusText)
|
|
154
|
-
.then(async (id) => {
|
|
155
|
-
if (prevId) {
|
|
156
|
-
await bridge
|
|
157
|
-
.deleteMessages?.(threadId, [prevId])
|
|
158
|
-
.catch(() => {});
|
|
159
|
-
}
|
|
160
|
-
if (id) {
|
|
161
|
-
lastStatusMessageId = id;
|
|
162
|
-
hadStatusMessage = true;
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
.catch(() => {});
|
|
166
|
-
}
|
|
167
|
-
}, 30_000);
|
|
168
|
-
|
|
169
|
-
let result: Awaited<ReturnType<typeof core.handleRawInput>>;
|
|
170
|
-
try {
|
|
171
|
-
result = await core.handleRawInput(ingress, "chat-sdk");
|
|
172
|
-
} finally {
|
|
173
|
-
clearInterval(heartbeat);
|
|
174
|
-
}
|
|
227
|
+
const isCommand = text.startsWith("/");
|
|
228
|
+
const shouldProcess =
|
|
229
|
+
triggerResult.matched || (ingress.isReplyToBot && !isDM);
|
|
175
230
|
|
|
176
|
-
if (
|
|
177
|
-
await bridge
|
|
178
|
-
.deleteMessages?.(threadId, [lastStatusMessageId])
|
|
179
|
-
.catch(() => {});
|
|
180
|
-
}
|
|
231
|
+
if (!shouldProcess && !isCommand) return;
|
|
181
232
|
|
|
182
|
-
|
|
233
|
+
const debounceKey = `${spaceId}:${ingress.callerId}`;
|
|
234
|
+
const onFlush = (merged: IngressMessage) =>
|
|
235
|
+
processIngress(merged, threadId);
|
|
183
236
|
|
|
184
|
-
if (
|
|
185
|
-
|
|
237
|
+
if (isCommand) {
|
|
238
|
+
debouncer.flushKey(debounceKey, onFlush);
|
|
239
|
+
await processIngress(ingress, threadId);
|
|
186
240
|
return;
|
|
187
241
|
}
|
|
188
242
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
threadId,
|
|
199
|
-
finalReply,
|
|
200
|
-
files.length > 0 ? files : undefined,
|
|
201
|
-
);
|
|
243
|
+
const configuredMs = core.db.getSpaceConfig(
|
|
244
|
+
spaceId,
|
|
245
|
+
"debounce.idle_timeout_ms",
|
|
246
|
+
);
|
|
247
|
+
const parsed =
|
|
248
|
+
configuredMs !== null ? Number.parseInt(configuredMs, 10) : Number.NaN;
|
|
249
|
+
const timeoutMs = Number.isNaN(parsed)
|
|
250
|
+
? getDefaultDebounceMs(bridge.platform)
|
|
251
|
+
: parsed;
|
|
202
252
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
assistantMessageId &&
|
|
207
|
-
ingress.conversationExternalId
|
|
208
|
-
) {
|
|
209
|
-
core.recordOutboundPlatformId(
|
|
210
|
-
assistantMessageId,
|
|
211
|
-
bridge.platform,
|
|
212
|
-
ingress.conversationExternalId,
|
|
213
|
-
sentPlatformId,
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
253
|
+
if (timeoutMs <= 0) {
|
|
254
|
+
await processIngress(ingress, threadId);
|
|
255
|
+
return;
|
|
217
256
|
}
|
|
257
|
+
|
|
258
|
+
debouncer.submit(debounceKey, ingress, timeoutMs, onFlush);
|
|
218
259
|
} catch (err) {
|
|
219
260
|
logger.error("Message handler error", {
|
|
220
261
|
platform: bridge.platform,
|
package/src/core/routes/chat.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Hono } from "hono";
|
|
|
5
5
|
import { logger } from "../../logger.js";
|
|
6
6
|
import { ensureSpaceWorkspace } from "../../storage/memory.js";
|
|
7
7
|
import type { IngressMessage, MessageAttachment } from "../../types.js";
|
|
8
|
+
import { type AutoSpaceConfig, resolveConversation } from "../conversation.js";
|
|
8
9
|
import { extToMime, mimeToMediaType } from "../media.js";
|
|
9
10
|
import type { MercuryCoreRuntime } from "../runtime.js";
|
|
10
11
|
import { isOverQuota } from "../storage-guard.js";
|
|
@@ -54,7 +55,7 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
|
54
55
|
? body.callerId.trim()
|
|
55
56
|
: "api:anonymous";
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
let spaceId =
|
|
58
59
|
typeof body.spaceId === "string" && body.spaceId.trim()
|
|
59
60
|
? body.spaceId.trim()
|
|
60
61
|
: "main";
|
|
@@ -62,6 +63,44 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
|
62
63
|
const authorName =
|
|
63
64
|
typeof body.authorName === "string" ? body.authorName.trim() : undefined;
|
|
64
65
|
|
|
66
|
+
const platform =
|
|
67
|
+
typeof body.platform === "string" && body.platform.trim()
|
|
68
|
+
? body.platform.trim()
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
if (platform && platform !== "api") {
|
|
72
|
+
const autoSpaceConfig: AutoSpaceConfig | undefined = core.config
|
|
73
|
+
.dmAutoSpaceEnabled
|
|
74
|
+
? {
|
|
75
|
+
enabled: true,
|
|
76
|
+
adminIds: core.config.dmAutoSpaceAdminIds
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((s) => s.trim())
|
|
79
|
+
.filter(Boolean),
|
|
80
|
+
defaultSystemPrompt: core.config.dmAutoSpaceDefaultSystemPrompt,
|
|
81
|
+
defaultMemberPermissions:
|
|
82
|
+
core.config.dmAutoSpaceDefaultMemberPermissions,
|
|
83
|
+
rateLimitDailyMember: core.config.rateLimitDailyMember,
|
|
84
|
+
}
|
|
85
|
+
: undefined;
|
|
86
|
+
|
|
87
|
+
const resolution = resolveConversation(
|
|
88
|
+
core.db,
|
|
89
|
+
platform,
|
|
90
|
+
callerId,
|
|
91
|
+
"dm",
|
|
92
|
+
undefined,
|
|
93
|
+
autoSpaceConfig,
|
|
94
|
+
authorName,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (!resolution) {
|
|
98
|
+
return c.json({ error: "Conversation not linked to any space" }, 404);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
spaceId = resolution.spaceId;
|
|
102
|
+
}
|
|
103
|
+
|
|
65
104
|
if (!core.db.getSpace(spaceId)) {
|
|
66
105
|
return c.json({ error: "Space not found" }, 404);
|
|
67
106
|
}
|
|
@@ -126,6 +165,9 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
|
126
165
|
logger.info("API chat inbound", {
|
|
127
166
|
callerId,
|
|
128
167
|
spaceId,
|
|
168
|
+
...(platform && platform !== "api"
|
|
169
|
+
? { simulatedPlatform: platform }
|
|
170
|
+
: {}),
|
|
129
171
|
preview: ingress.text.slice(0, 80),
|
|
130
172
|
fileCount: attachments.length,
|
|
131
173
|
});
|
|
@@ -13,6 +13,7 @@ export const BUILTIN_CONFIG_KEYS = new Set([
|
|
|
13
13
|
"rate_limit",
|
|
14
14
|
"rate_limit.member",
|
|
15
15
|
"rate_limit.admin",
|
|
16
|
+
"debounce.idle_timeout_ms",
|
|
16
17
|
]);
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -46,6 +47,8 @@ export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
|
|
|
46
47
|
"Daily message cap for members in this space. Overrides global rate_limit_daily_member. Integer ≥ 1, or 0 for unlimited.",
|
|
47
48
|
"rate_limit.admin":
|
|
48
49
|
"Daily message cap for admins in this space. Overrides global rate_limit_daily_admin. Integer ≥ 1, or 0 for unlimited.",
|
|
50
|
+
"debounce.idle_timeout_ms":
|
|
51
|
+
"Milliseconds to wait for additional messages before processing a batch. 0 disables debounce. Platform default: 2000 for WhatsApp/Telegram, 0 for others.",
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
@@ -103,6 +106,12 @@ const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
|
103
106
|
? null
|
|
104
107
|
: "Invalid rate_limit.admin value. Must be an integer between 0 and 10000";
|
|
105
108
|
},
|
|
109
|
+
"debounce.idle_timeout_ms": (v) => {
|
|
110
|
+
const n = Number.parseInt(v, 10);
|
|
111
|
+
return Number.isInteger(n) && n >= 0 && n <= 10000
|
|
112
|
+
? null
|
|
113
|
+
: "Invalid debounce.idle_timeout_ms value. Must be an integer between 0 and 10000";
|
|
114
|
+
},
|
|
106
115
|
};
|
|
107
116
|
|
|
108
117
|
export function isBuiltinConfigKey(key: string): boolean {
|