@wzfukui/ani 2026.3.28

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/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @wzfukui/ani
2
+
3
+ OpenClaw channel plugin for [Agent-Native IM (ANI)](https://github.com/wzfukui/agent-native-im), a messaging platform built for human and AI bot collaboration. Version **2026.3.28**.
4
+
5
+ ## Features
6
+
7
+ - **Bidirectional messaging** -- receive messages via WebSocket, send replies immediately via REST API (not buffered)
8
+ - **Tools**: `ani_send_file`, `ani_fetch_chat_history_messages`, `ani_list_conversation_tasks`, `ani_get_task`, `ani_create_task`, `ani_update_task`, `ani_delete_task`
9
+ - **Streaming progress** -- long-running tasks show real-time status in chat via status layers with typing indicators
10
+ - **Artifact rendering** -- `<artifact>` tags in model output sent as structured content (HTML, code, mermaid)
11
+ - **File handling** -- send/receive images, documents, audio, video, archives (up to 32 MB); small text files inlined for AI, protected binary files downloaded with ANI auth and saved to local media paths
12
+ - **Multi-bot collaboration** -- group conversations with multiple bots, @mention routing, conversation context injection
13
+ - **Message revoke listener** -- detects `message.revoked` events and aborts in-flight delivery for that message
14
+ - **Stream cancel abort** -- `stream.cancel` / `task.cancel` events abort the active agent dispatch via AbortController
15
+ - **Reactions** -- ack-reaction on message receipt (configurable via `messages.ackReaction`)
16
+ - **Interactive cards** -- approval/selection UI via ANI's interaction layer
17
+ - **Message chunking** -- long replies split at markdown boundaries (configurable limit)
18
+ - **Auto-reconnecting WebSocket** -- ping/pong keepalive with exponential backoff
19
+ - **Retry with exponential backoff** -- REST calls retry on transient failures (502/503/504) with jitter
20
+ - **Config hot reload** -- changes under `channels.ani` auto-detected; most take effect without restart
21
+ - **Multi-agent routing** -- route specific conversations to dedicated OpenClaw agents with separate workspaces
22
+
23
+ ## Quick Start
24
+
25
+ ### Option A: Install from npm
26
+
27
+ ```bash
28
+ openclaw plugins install @wzfukui/ani
29
+ ```
30
+
31
+ ### Option B: Install from local extension
32
+
33
+ ```bash
34
+ # From the OpenClaw repo with extensions/ani/ present
35
+ openclaw plugins install ./extensions/ani
36
+ ```
37
+
38
+ ### Configure
39
+
40
+ ```bash
41
+ # 1. Set ANI server and API key (create a Bot in ANI Web to get the key)
42
+ openclaw config set channels.ani.serverUrl "https://your-ani-server.example.com"
43
+ openclaw config set channels.ani.apiKey "aim_your_api_key"
44
+
45
+ # 2. Enable the tools
46
+ openclaw config set tools.alsoAllow '["ani_send_file","ani_fetch_chat_history_messages","ani_list_conversation_tasks","ani_get_task","ani_create_task","ani_update_task","ani_delete_task"]' --strict-json
47
+
48
+ # 3. Check the gateway status
49
+ openclaw gateway status
50
+ ```
51
+
52
+ If ANI does not appear online after updating the config, reconnect or restart the OpenClaw gateway.
53
+
54
+ ## Configuration
55
+
56
+ All settings live under `channels.ani` in your OpenClaw config.
57
+
58
+ | Field | Type | Required | Default | Description |
59
+ |---|---|---|---|---|
60
+ | `serverUrl` | string | yes | -- | ANI server base URL (no trailing slash) |
61
+ | `apiKey` | string | yes | -- | Permanent API key (`aim_` prefix). Legacy `aimb_` keys are rejected. |
62
+ | `entityId` | number | no | auto-detected | Legacy numeric override. Usually leave empty. |
63
+ | `enabled` | boolean | no | `true` | Enable/disable the channel |
64
+ | `textChunkLimit` | number | no | `4000` | Max chars per outbound message chunk |
65
+ | `dm.policy` | string | no | `"open"` | DM routing: `"open"` or `"disabled"` |
66
+ | `name` | string | no | -- | Display name for status output |
67
+
68
+ ## How It Works
69
+
70
+ **Inbound (ANI -> OpenClaw):** WebSocket connection to `/api/v1/ws`. On `message.new`, fetches conversation context (title, participants, memories), formats an agent envelope, dispatches through the reply pipeline. Revoked messages and cancelled streams are detected and aborted in-flight.
71
+
72
+ **Outbound (OpenClaw -> ANI):** REST API `POST /api/v1/messages/send`. Parses `<artifact>` tags into structured content. Plain text chunked at markdown boundaries. Files uploaded via multipart then sent as attachments.
73
+
74
+ **Authentication:** On startup, calls `GET /api/v1/me` to verify the API key and auto-discover bot identity. Only permanent keys (`aim_`) are accepted.
75
+
76
+ ## Task Roadmap Tools
77
+
78
+ The ANI plugin can now read and mutate the current conversation task roadmap through dedicated tools:
79
+
80
+ - `ani_list_conversation_tasks`
81
+ - `ani_get_task`
82
+ - `ani_create_task`
83
+ - `ani_update_task`
84
+ - `ani_delete_task`
85
+
86
+ These tools reuse ANI's backend permissions:
87
+
88
+ - the bot must be a member of the conversation
89
+ - create/list/get require conversation participation
90
+ - update/delete still follow ANI's existing creator / assignee / admin rules
91
+
92
+ Planned but not implemented yet:
93
+
94
+ - approval workflow for task mutations in group chats
95
+ - member-submitted task edits entering a pending-review queue for group admins
96
+
97
+ ## Attachment Behavior
98
+
99
+ This plugin now follows ANI's protected attachment model:
100
+
101
+ - conversation files stay as protected ANI resources
102
+ - the plugin downloads them with ANI auth
103
+ - binary/media files are saved locally and passed via `MediaPath` / `MediaPaths`
104
+ - small text files may be inlined into the model prompt
105
+ - the plugin should not expose naked protected `/files/...` URLs to the agent as if they were public downloads
106
+
107
+ Practical implication:
108
+
109
+ - transport can succeed even if the model cannot truly understand the file contents
110
+ - image/audio/video understanding still depends on the selected model/runtime
111
+ - PDF / office docs are transport-supported, but parser experience is still incomplete
112
+
113
+ Current support levels:
114
+
115
+ - text files: most reliable, small files may be inlined for the model
116
+ - images / audio / video: transport works, understanding still depends on the selected model/runtime
117
+ - PDF / Office documents: transport works, parser experience is still incomplete
118
+
119
+ If you need a fuller attachment capability breakdown, document it in your ANI deployment docs rather than relying on private local paths.
120
+
121
+ ## Multi-Agent Routing
122
+
123
+ ```yaml
124
+ agents:
125
+ list:
126
+ - id: main
127
+ workspace: ~/.openclaw/workspace
128
+ - id: ops-agent
129
+ workspace: ~/.openclaw/workspace-ops
130
+
131
+ bindings:
132
+ - agentId: ops-agent
133
+ match:
134
+ channel: ani
135
+ peer:
136
+ kind: channel
137
+ id: "2920436443328762" # ANI conversation ID
138
+ ```
139
+
140
+ Find conversation IDs in: ANI web URL bar, gateway logs (`ani: inbound conv=<id>`), or the bot's system prompt.
141
+
142
+ ## Limitations
143
+
144
+ - **Single account** -- one ANI account per OpenClaw instance
145
+ - **No threads** -- ANI uses a flat conversation model
146
+ - **No polls** -- not supported by ANI
147
+ - **Model-dependent multimodality** -- successful attachment delivery does not guarantee image/audio/video understanding
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ # From OpenClaw repo root
153
+ npx vitest run --config vitest.extensions.config.ts extensions/ani/
154
+ ```
package/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "./src/sdk-compat.js";
2
+
3
+ import { aniPlugin } from "./src/channel.js";
4
+ import { setAniRuntime } from "./src/runtime.js";
5
+ import {
6
+ createCreateTaskTool,
7
+ createDeleteTaskTool,
8
+ createGetHistoryTool,
9
+ createGetTaskTool,
10
+ createListTasksTool,
11
+ createSendFileTool,
12
+ createUpdateTaskTool,
13
+ } from "./src/tools.js";
14
+ // Tool names: ani_send_file, ani_fetch_chat_history_messages, ani_list_conversation_tasks, ani_get_task, ani_create_task, ani_update_task, ani_delete_task
15
+
16
+ const plugin = {
17
+ id: "ani",
18
+ name: "Agent-Native IM",
19
+ description: "ANI Agent-Native IM channel plugin",
20
+ configSchema: emptyPluginConfigSchema(),
21
+ register(api: OpenClawPluginApi) {
22
+ setAniRuntime(api.runtime);
23
+ // Register tool via BOTH paths for maximum compatibility:
24
+ // 1. api.registerTool() — plugin tools path (resolvePluginTools)
25
+ // 2. agentTools on channel — channel tools path (listChannelAgentTools)
26
+ const sendFileTool = createSendFileTool();
27
+ const getHistoryTool = createGetHistoryTool();
28
+ const listTasksTool = createListTasksTool();
29
+ const getTaskTool = createGetTaskTool();
30
+ const createTaskTool = createCreateTaskTool();
31
+ const updateTaskTool = createUpdateTaskTool();
32
+ const deleteTaskTool = createDeleteTaskTool();
33
+ const tools = [sendFileTool, getHistoryTool, listTasksTool, getTaskTool, createTaskTool, updateTaskTool, deleteTaskTool];
34
+ api.registerTool(sendFileTool);
35
+ api.registerTool(getHistoryTool);
36
+ api.registerTool(listTasksTool);
37
+ api.registerTool(getTaskTool);
38
+ api.registerTool(createTaskTool);
39
+ api.registerTool(updateTaskTool);
40
+ api.registerTool(deleteTaskTool);
41
+ api.registerChannel({ plugin: { ...aniPlugin, agentTools: () => tools } });
42
+ },
43
+ };
44
+
45
+ export default plugin;
@@ -0,0 +1,74 @@
1
+ {
2
+ "id": "ani",
3
+ "channels": [
4
+ "ani"
5
+ ],
6
+ "uiHints": {
7
+ "serverUrl": {
8
+ "label": "Server URL",
9
+ "placeholder": "https://your-ani-server.example.com",
10
+ "help": "Base URL of the ANI server (no trailing slash)"
11
+ },
12
+ "apiKey": {
13
+ "label": "API Key",
14
+ "placeholder": "aim_...",
15
+ "sensitive": true,
16
+ "help": "Permanent ANI API key (aim_ prefix). Legacy aimb_ keys are not supported."
17
+ },
18
+ "entityId": {
19
+ "label": "Bot Entity ID",
20
+ "help": "Legacy numeric override. Usually leave empty and let ANI auto-detect.",
21
+ "advanced": true
22
+ },
23
+ "enabled": {
24
+ "label": "Enabled",
25
+ "help": "Enable or disable the ANI channel"
26
+ },
27
+ "textChunkLimit": {
28
+ "label": "Text Chunk Limit",
29
+ "help": "Max characters per outbound message chunk (default: 4000)",
30
+ "advanced": true
31
+ },
32
+ "dm.policy": {
33
+ "label": "DM Policy",
34
+ "help": "Direct message routing policy (open or disabled)",
35
+ "advanced": true
36
+ }
37
+ },
38
+ "configSchema": {
39
+ "type": "object",
40
+ "additionalProperties": true,
41
+ "properties": {
42
+ "enabled": {
43
+ "type": "boolean"
44
+ },
45
+ "name": {
46
+ "type": "string"
47
+ },
48
+ "serverUrl": {
49
+ "type": "string"
50
+ },
51
+ "apiKey": {
52
+ "type": "string"
53
+ },
54
+ "entityId": {
55
+ "type": "number"
56
+ },
57
+ "dm": {
58
+ "type": "object",
59
+ "additionalProperties": false,
60
+ "properties": {
61
+ "policy": {
62
+ "type": "string",
63
+ "enum": ["open", "disabled"]
64
+ }
65
+ }
66
+ },
67
+ "textChunkLimit": {
68
+ "type": "number",
69
+ "minimum": 100
70
+ }
71
+ },
72
+ "required": []
73
+ }
74
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@wzfukui/ani",
3
+ "version": "2026.3.28",
4
+ "type": "module",
5
+ "description": "ANI Agent-Native IM channel plugin",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wzfukui/openclaw.git",
10
+ "directory": "extensions/ani"
11
+ },
12
+ "homepage": "https://github.com/wzfukui/openclaw/tree/main/extensions/ani",
13
+ "bugs": {
14
+ "url": "https://github.com/wzfukui/openclaw/issues"
15
+ },
16
+ "files": [
17
+ "index.ts",
18
+ "openclaw.plugin.json",
19
+ "README.md",
20
+ "src/channel.ts",
21
+ "src/config-schema.ts",
22
+ "src/monitor",
23
+ "src/outbound.ts",
24
+ "src/runtime.ts",
25
+ "src/sdk-compat.ts",
26
+ "src/tools.ts",
27
+ "src/types.ts",
28
+ "src/utils.ts"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "openclaw": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ],
37
+ "channel": {
38
+ "id": "ani",
39
+ "label": "Agent-Native IM",
40
+ "selectionLabel": "Agent-Native IM (ANI)",
41
+ "docsPath": "/channels/ani",
42
+ "docsLabel": "ani",
43
+ "blurb": "Agent-Native IM — messaging built for AI-first Bot collaboration.",
44
+ "order": 80,
45
+ "quickstartAllowFrom": false
46
+ },
47
+ "install": {
48
+ "npmSpec": "@wzfukui/ani",
49
+ "localPath": "extensions/ani",
50
+ "defaultChoice": "npm"
51
+ }
52
+ },
53
+ "dependencies": {
54
+ "@sinclair/typebox": "^0.34.41",
55
+ "ws": "^8.18.0",
56
+ "zod": "^4.3.6"
57
+ },
58
+ "peerDependencies": {
59
+ "openclaw": "*"
60
+ },
61
+ "devDependencies": {
62
+ "openclaw": "workspace:*",
63
+ "@types/ws": "^8.5.13"
64
+ }
65
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,242 @@
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ normalizeAccountId,
4
+ setAccountEnabledInConfigSection,
5
+ deleteAccountFromConfigSection,
6
+ applyAccountNameToChannelSection,
7
+ buildChannelConfigSchema,
8
+ type ChannelPlugin,
9
+ } from "./sdk-compat.js";
10
+
11
+ import { AniConfigSchema } from "./config-schema.js";
12
+ import type { CoreConfig, ResolvedAniAccount } from "./types.js";
13
+ import { aniOutbound } from "./outbound.js";
14
+ import { normalizeAniServerUrl } from "./utils.js";
15
+
16
+ const meta = {
17
+ id: "ani",
18
+ label: "Agent-Native IM",
19
+ selectionLabel: "Agent-Native IM (plugin)",
20
+ docsPath: "/channels/ani",
21
+ docsLabel: "ani",
22
+ blurb: "Agent-Native IM — messaging platform built for AI Bots.",
23
+ order: 80,
24
+ quickstartAllowFrom: false,
25
+ };
26
+
27
+ export function resolveAniAccount(params: {
28
+ cfg: CoreConfig;
29
+ accountId?: string;
30
+ }): ResolvedAniAccount {
31
+ const accountId = normalizeAccountId(params.accountId);
32
+ const aniCfg = params.cfg.channels?.ani ?? {};
33
+ const enabled = aniCfg.enabled !== false;
34
+ const serverUrl = normalizeAniServerUrl(aniCfg.serverUrl);
35
+ const apiKey = aniCfg.apiKey ?? "";
36
+ const configured = Boolean(serverUrl && apiKey && !apiKey.startsWith("aimb_"));
37
+
38
+ return {
39
+ accountId,
40
+ enabled,
41
+ name: aniCfg.name?.trim(),
42
+ configured,
43
+ serverUrl: serverUrl || undefined,
44
+ entityId: aniCfg.entityId,
45
+ config: aniCfg,
46
+ };
47
+ }
48
+
49
+ export const aniPlugin: ChannelPlugin<ResolvedAniAccount> = {
50
+ id: "ani",
51
+ meta,
52
+
53
+ capabilities: {
54
+ chatTypes: ["group", "direct"],
55
+ polls: false,
56
+ reactions: true,
57
+ threads: false,
58
+ media: true,
59
+ },
60
+
61
+ reload: { configPrefixes: ["channels.ani"] },
62
+ configSchema: buildChannelConfigSchema(AniConfigSchema),
63
+
64
+ config: {
65
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
66
+ resolveAccount: (cfg, accountId) =>
67
+ resolveAniAccount({ cfg: cfg as CoreConfig, accountId }),
68
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
69
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
70
+ setAccountEnabledInConfigSection({
71
+ cfg: cfg as CoreConfig,
72
+ sectionKey: "ani",
73
+ accountId,
74
+ enabled,
75
+ allowTopLevel: true,
76
+ }),
77
+ deleteAccount: ({ cfg, accountId }) =>
78
+ deleteAccountFromConfigSection({
79
+ cfg: cfg as CoreConfig,
80
+ sectionKey: "ani",
81
+ accountId,
82
+ clearBaseFields: ["name", "serverUrl", "apiKey", "entityId"],
83
+ }),
84
+ isConfigured: (account) => account.configured,
85
+ describeAccount: (account) => ({
86
+ accountId: account.accountId,
87
+ name: account.name,
88
+ enabled: account.enabled,
89
+ configured: account.configured,
90
+ baseUrl: account.serverUrl,
91
+ }),
92
+ },
93
+
94
+ security: {
95
+ resolveDmPolicy: ({ account }) => ({
96
+ policy: account.config.dm?.policy ?? "open",
97
+ allowFrom: [],
98
+ policyPath: "channels.ani.dm.policy",
99
+ allowFromPath: "channels.ani.dm.allowFrom",
100
+ }),
101
+ },
102
+
103
+ setup: {
104
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
105
+ applyAccountName: ({ cfg, accountId, name }) =>
106
+ applyAccountNameToChannelSection({
107
+ cfg: cfg as CoreConfig,
108
+ channelKey: "ani",
109
+ accountId,
110
+ name,
111
+ }),
112
+ validateInput: ({ input }) => {
113
+ if (input.useEnv) return null;
114
+ if (!input.serverUrl?.trim()) return "ANI requires --server-url";
115
+ if (!input.apiKey?.trim()) return "ANI requires --api-key (permanent aim_ key)";
116
+ const key = input.apiKey?.trim() ?? "";
117
+ if (key.startsWith("aimb_")) {
118
+ return "ANI requires a permanent key (aim_ prefix). Legacy aimb_ keys are no longer supported.";
119
+ }
120
+ return null;
121
+ },
122
+ applyAccountConfig: ({ cfg, input }) => {
123
+ const named = applyAccountNameToChannelSection({
124
+ cfg: cfg as CoreConfig,
125
+ channelKey: "ani",
126
+ accountId: DEFAULT_ACCOUNT_ID,
127
+ name: input.name,
128
+ });
129
+ if (input.useEnv) {
130
+ return {
131
+ ...named,
132
+ channels: {
133
+ ...named.channels,
134
+ ani: { ...named.channels?.ani, enabled: true },
135
+ },
136
+ } as CoreConfig;
137
+ }
138
+ const existing = (named as CoreConfig).channels?.ani ?? {};
139
+ return {
140
+ ...named,
141
+ channels: {
142
+ ...named.channels,
143
+ ani: {
144
+ ...existing,
145
+ enabled: true,
146
+ ...(input.serverUrl ? { serverUrl: input.serverUrl.trim().replace(/\/+$/, "") } : {}),
147
+ ...(input.apiKey ? { apiKey: input.apiKey.trim() } : {}),
148
+ },
149
+ },
150
+ } as CoreConfig;
151
+ },
152
+ },
153
+
154
+ streaming: {
155
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
156
+ },
157
+
158
+ outbound: aniOutbound,
159
+
160
+ status: {
161
+ defaultRuntime: {
162
+ accountId: DEFAULT_ACCOUNT_ID,
163
+ running: false,
164
+ lastStartAt: null,
165
+ lastStopAt: null,
166
+ lastError: null,
167
+ },
168
+ collectStatusIssues: (accounts) =>
169
+ accounts.flatMap((account) => {
170
+ const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
171
+ if (!lastError) return [];
172
+ return [
173
+ {
174
+ channel: "ani",
175
+ accountId: account.accountId,
176
+ kind: "runtime",
177
+ message: `Channel error: ${lastError}`,
178
+ },
179
+ ];
180
+ }),
181
+ buildChannelSummary: ({ snapshot }) => ({
182
+ configured: snapshot.configured ?? false,
183
+ baseUrl: snapshot.baseUrl ?? null,
184
+ running: snapshot.running ?? false,
185
+ lastStartAt: snapshot.lastStartAt ?? null,
186
+ lastStopAt: snapshot.lastStopAt ?? null,
187
+ lastError: snapshot.lastError ?? null,
188
+ }),
189
+ probeAccount: async ({ account }) => {
190
+ if (!account.serverUrl || !account.config.apiKey) {
191
+ return { ok: false, error: "not configured", elapsedMs: 0 };
192
+ }
193
+ const start = Date.now();
194
+ try {
195
+ const { verifyAniConnection } = await import("./monitor/send.js");
196
+ await verifyAniConnection({
197
+ serverUrl: account.serverUrl,
198
+ apiKey: account.config.apiKey,
199
+ });
200
+ return { ok: true, elapsedMs: Date.now() - start };
201
+ } catch (err) {
202
+ return {
203
+ ok: false,
204
+ error: err instanceof Error ? err.message : String(err),
205
+ elapsedMs: Date.now() - start,
206
+ };
207
+ }
208
+ },
209
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
210
+ accountId: account.accountId,
211
+ name: account.name,
212
+ enabled: account.enabled,
213
+ configured: account.configured,
214
+ baseUrl: account.serverUrl,
215
+ running: runtime?.running ?? false,
216
+ lastStartAt: runtime?.lastStartAt ?? null,
217
+ lastStopAt: runtime?.lastStopAt ?? null,
218
+ lastError: runtime?.lastError ?? null,
219
+ probe,
220
+ lastProbeAt: runtime?.lastProbeAt ?? null,
221
+ lastInboundAt: runtime?.lastInboundAt ?? null,
222
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
223
+ }),
224
+ },
225
+
226
+ gateway: {
227
+ startAccount: async (ctx) => {
228
+ const account = ctx.account;
229
+ ctx.setStatus({
230
+ accountId: account.accountId,
231
+ baseUrl: account.serverUrl,
232
+ });
233
+ ctx.log?.info(`[${account.accountId}] starting ANI provider (${account.serverUrl ?? "ani"})`);
234
+ const { monitorAniProvider } = await import("./monitor/index.js");
235
+ return monitorAniProvider({
236
+ runtime: ctx.runtime,
237
+ abortSignal: ctx.abortSignal,
238
+ accountId: account.accountId,
239
+ });
240
+ },
241
+ },
242
+ };
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+
3
+ export const AniConfigSchema = z.object({
4
+ enabled: z.boolean().optional(),
5
+ name: z.string().optional(),
6
+ serverUrl: z.string().optional(),
7
+ apiKey: z.string().optional(),
8
+ entityId: z.number().optional(),
9
+ dm: z
10
+ .object({
11
+ policy: z.enum(["open", "disabled"]).optional(),
12
+ })
13
+ .optional(),
14
+ textChunkLimit: z.number().optional(),
15
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Inbound message debouncer: coalesces rapid messages from the same sender
3
+ * in the same conversation before dispatching to the AI agent.
4
+ *
5
+ * This prevents wasted tokens when a user sends multiple messages in quick
6
+ * succession (e.g., 3 messages in 2 seconds) — each would otherwise trigger
7
+ * a separate AI dispatch.
8
+ */
9
+
10
+ export type PendingMessage = { text: string; messageId: string };
11
+
12
+ export type DebouncerEntry = {
13
+ timer: ReturnType<typeof setTimeout>;
14
+ messages: PendingMessage[];
15
+ };
16
+
17
+ export function createInboundDebouncer(delayMs: number = 1500) {
18
+ const pending = new Map<string, DebouncerEntry>();
19
+
20
+ return {
21
+ /**
22
+ * Queue a message for debounced dispatch.
23
+ * @param key - Unique key, typically `${conversationId}:${senderId}`
24
+ * @param text - Message text
25
+ * @param messageId - Original message ID
26
+ * @param dispatch - Called after the debounce window with combined text and all message IDs
27
+ */
28
+ debounce(
29
+ key: string,
30
+ text: string,
31
+ messageId: string,
32
+ dispatch: (combinedText: string, messageIds: string[]) => void,
33
+ ) {
34
+ const entry = pending.get(key);
35
+ if (entry) {
36
+ clearTimeout(entry.timer);
37
+ entry.messages.push({ text, messageId });
38
+ } else {
39
+ pending.set(key, {
40
+ timer: null as unknown as ReturnType<typeof setTimeout>,
41
+ messages: [{ text, messageId }],
42
+ });
43
+ }
44
+ const e = pending.get(key)!;
45
+ e.timer = setTimeout(() => {
46
+ pending.delete(key);
47
+ dispatch(
48
+ e.messages.map((m) => m.text).join("\n"),
49
+ e.messages.map((m) => m.messageId),
50
+ );
51
+ }, delayMs);
52
+ },
53
+
54
+ /** Cancel and remove a pending debounce entry. */
55
+ clear(key: string) {
56
+ const entry = pending.get(key);
57
+ if (entry) {
58
+ clearTimeout(entry.timer);
59
+ pending.delete(key);
60
+ }
61
+ },
62
+
63
+ /** Number of pending debounce entries (for testing/monitoring). */
64
+ get size() {
65
+ return pending.size;
66
+ },
67
+ };
68
+ }