@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 +154 -0
- package/index.ts +45 -0
- package/openclaw.plugin.json +74 -0
- package/package.json +65 -0
- package/src/channel.ts +242 -0
- package/src/config-schema.ts +15 -0
- package/src/monitor/debounce.ts +68 -0
- package/src/monitor/handler.ts +1070 -0
- package/src/monitor/index.ts +168 -0
- package/src/monitor/send.ts +546 -0
- package/src/outbound.ts +183 -0
- package/src/runtime.ts +14 -0
- package/src/sdk-compat.ts +59 -0
- package/src/tools.ts +602 -0
- package/src/types.ts +50 -0
- package/src/utils.ts +60 -0
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
|
+
}
|