openclaw-nim 0.0.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/README.md +202 -0
- package/index.ts +64 -0
- package/package.json +58 -0
- package/src/accounts.ts +115 -0
- package/src/bot.ts +240 -0
- package/src/channel.ts +191 -0
- package/src/client.ts +425 -0
- package/src/config-schema.ts +50 -0
- package/src/media.ts +315 -0
- package/src/monitor.ts +196 -0
- package/src/outbound.ts +322 -0
- package/src/probe.ts +82 -0
- package/src/reply-dispatcher.ts +111 -0
- package/src/runtime.ts +38 -0
- package/src/send.ts +159 -0
- package/src/targets.ts +94 -0
- package/src/types.ts +203 -0
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# OpenClaw NIM Plugin
|
|
2
|
+
|
|
3
|
+
A [OpenClaw](https://openclaw.ai/) channel plugin for NetEase IM (网易云信).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 📱 Private chat (P2P) message support
|
|
8
|
+
- 📷 Media support (images, files, audio, video)
|
|
9
|
+
- 🔐 AppKey + Token authentication
|
|
10
|
+
- 🔄 Automatic reconnection handling
|
|
11
|
+
- 📝 Message chunking for long responses
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
### Install Node.js
|
|
16
|
+
|
|
17
|
+
#### Option 1: Official Installer (Recommended)
|
|
18
|
+
|
|
19
|
+
1. Visit [nodejs.org](https://nodejs.org/).
|
|
20
|
+
2. Download the **LTS** version (e.g., v20.x.x).
|
|
21
|
+
3. Run the installer and follow the prompts.
|
|
22
|
+
|
|
23
|
+
#### Option 2: NVM (Node Version Manager)
|
|
24
|
+
|
|
25
|
+
NVM allows you to install and manage multiple Node.js versions:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Install nvm (if not already installed)
|
|
29
|
+
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
|
30
|
+
|
|
31
|
+
# Restart terminal or run:
|
|
32
|
+
source ~/.zshrc # or ~/.bashrc for bash
|
|
33
|
+
|
|
34
|
+
# Install Node.js LTS
|
|
35
|
+
nvm install --lts
|
|
36
|
+
|
|
37
|
+
# Use the installed version
|
|
38
|
+
nvm use --lts
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
#### Option 3: Homebrew (macOS)
|
|
42
|
+
|
|
43
|
+
If you have Homebrew installed:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
brew install node
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
#### Verify Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
node --version
|
|
53
|
+
# Should show v20.x.x or higher
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Install OpenClaw
|
|
57
|
+
|
|
58
|
+
Open Terminal
|
|
59
|
+
|
|
60
|
+
Press `Cmd + Space`, type `Terminal`, and hit Enter.
|
|
61
|
+
|
|
62
|
+
Install CLI
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install -g openclaw@latest
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> **Note:** If you see permission errors, use `sudo`:
|
|
69
|
+
>
|
|
70
|
+
> ```bash
|
|
71
|
+
> sudo npm install -g openclaw@latest
|
|
72
|
+
> ```
|
|
73
|
+
|
|
74
|
+
### Installation Plugin
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
openclaw plugins install openclaw-nim
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
openclaw config set channels.nim.appKey "your-app-key"
|
|
84
|
+
openclaw config set channels.nim.account "your-bot-account-id"
|
|
85
|
+
openclaw config set channels.nim.token "your-auth-token"
|
|
86
|
+
openclaw config set channels.nim.enabled true
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or add the following to your OpenClaw configuration (`openclaw.yaml` or `openclaw.json`):
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
channels:
|
|
93
|
+
nim:
|
|
94
|
+
enabled: true
|
|
95
|
+
appKey: "your-app-key"
|
|
96
|
+
account: "your-bot-account-id"
|
|
97
|
+
token: "your-auth-token"
|
|
98
|
+
dmPolicy: "open" # or "allowlist"
|
|
99
|
+
allowFrom: # Required if dmPolicy is "allowlist"
|
|
100
|
+
- "allowed-user-1"
|
|
101
|
+
- "allowed-user-2"
|
|
102
|
+
mediaMaxMb: 30 # Max media file size in MB
|
|
103
|
+
textChunkLimit: 4000 # Max characters per message
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Configuration Options
|
|
107
|
+
|
|
108
|
+
| Option | Type | Default | Description |
|
|
109
|
+
|--------|------|---------|-------------|
|
|
110
|
+
| `enabled` | boolean | `false` | Enable/disable the NIM channel |
|
|
111
|
+
| `appKey` | string | - | NIM application AppKey (required) |
|
|
112
|
+
| `account` | string | - | Bot account ID (required) |
|
|
113
|
+
| `token` | string | - | Authentication token (required) |
|
|
114
|
+
| `dmPolicy` | string | `"open"` | DM access policy: `"open"` or `"allowlist"` |
|
|
115
|
+
| `allowFrom` | array | `[]` | List of allowed sender IDs (when using allowlist) |
|
|
116
|
+
| `mediaMaxMb` | number | `30` | Maximum media file size in MB |
|
|
117
|
+
| `textChunkLimit` | number | `4000` | Maximum characters per message chunk |
|
|
118
|
+
| `lbsUrl` | string | - | Custom LBS server URL (for private deployment) |
|
|
119
|
+
| `linkUrl` | string | - | Custom Link server URL (for private deployment) |
|
|
120
|
+
| `debug` | boolean | `false` | Enable SDK debug logging |
|
|
121
|
+
|
|
122
|
+
## Start the Bot
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
openclaw onboard
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Getting Credentials
|
|
129
|
+
|
|
130
|
+
1. Log in to the [NetEase IM Console](https://app.netease.im/)
|
|
131
|
+
2. Create or select an application
|
|
132
|
+
3. Copy the **AppKey** from the application settings
|
|
133
|
+
4. Create a bot account and obtain its **Account ID** and **Token**
|
|
134
|
+
|
|
135
|
+
## Usage
|
|
136
|
+
|
|
137
|
+
### Sending Messages
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { sendMessageNim, sendImageNim } from "openclaw-nim";
|
|
141
|
+
|
|
142
|
+
// Send text message
|
|
143
|
+
await sendMessageNim({
|
|
144
|
+
cfg: openclawConfig,
|
|
145
|
+
to: "user123",
|
|
146
|
+
text: "Hello from NIM bot!",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Send image
|
|
150
|
+
await sendImageNim({
|
|
151
|
+
cfg: openclawConfig,
|
|
152
|
+
to: "user123",
|
|
153
|
+
imagePath: "/path/to/image.png",
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Target Formats
|
|
158
|
+
|
|
159
|
+
The plugin accepts various target formats:
|
|
160
|
+
|
|
161
|
+
- `user123` - Plain account ID
|
|
162
|
+
- `nim:user123` - Prefixed with `nim:`
|
|
163
|
+
- `user:user123` - Prefixed with `user:`
|
|
164
|
+
|
|
165
|
+
## Supported Message Types
|
|
166
|
+
|
|
167
|
+
| Type | Receive | Send |
|
|
168
|
+
|------|---------|------|
|
|
169
|
+
| Text | ✅ | ✅ |
|
|
170
|
+
| Image | ✅ | ✅ |
|
|
171
|
+
| File | ✅ | ✅ |
|
|
172
|
+
| Audio | ✅ | ✅ |
|
|
173
|
+
| Video | ✅ | ✅ |
|
|
174
|
+
| Location | ✅ | ❌ |
|
|
175
|
+
| Custom | ✅ | ❌ |
|
|
176
|
+
|
|
177
|
+
## Limitations
|
|
178
|
+
|
|
179
|
+
- **Private chat only**: Group chat support is not implemented in this version
|
|
180
|
+
- **No message editing**: NIM does not support editing sent messages
|
|
181
|
+
- **No reactions**: Message reactions are not supported
|
|
182
|
+
- **No threads**: Thread/reply functionality is not supported
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Install dependencies
|
|
188
|
+
npm install
|
|
189
|
+
|
|
190
|
+
# Build
|
|
191
|
+
npm run build
|
|
192
|
+
|
|
193
|
+
# Run tests
|
|
194
|
+
npm test
|
|
195
|
+
|
|
196
|
+
# Watch mode
|
|
197
|
+
npm run dev
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
|
3
|
+
import { nimPlugin } from "./src/channel.js";
|
|
4
|
+
import { setNimRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
// Export monitor functions
|
|
7
|
+
export { monitorNimProvider, stopNimMonitor, isNimMonitorRunning } from "./src/monitor.js";
|
|
8
|
+
|
|
9
|
+
// Export send functions
|
|
10
|
+
export { sendMessageNim, editMessageNim, getMessageNim, sendLongMessageNim } from "./src/send.js";
|
|
11
|
+
|
|
12
|
+
// Export outbound functions
|
|
13
|
+
export {
|
|
14
|
+
nimOutboundConfig,
|
|
15
|
+
sendNimOutboundText,
|
|
16
|
+
sendNimOutboundMedia,
|
|
17
|
+
resolveNimOutboundTarget,
|
|
18
|
+
nimOutbound,
|
|
19
|
+
type NimOutboundResult,
|
|
20
|
+
} from "./src/outbound.js";
|
|
21
|
+
|
|
22
|
+
// Export media functions
|
|
23
|
+
export { sendImageNim, sendFileNim, sendAudioNim, sendVideoNim, downloadNimMedia } from "./src/media.js";
|
|
24
|
+
|
|
25
|
+
// Export probe function
|
|
26
|
+
export { probeNim, probeNimWithConnect } from "./src/probe.js";
|
|
27
|
+
|
|
28
|
+
// Export channel plugin
|
|
29
|
+
export { nimPlugin } from "./src/channel.js";
|
|
30
|
+
|
|
31
|
+
// Export types
|
|
32
|
+
export type {
|
|
33
|
+
NimConfig,
|
|
34
|
+
NimMessageContext,
|
|
35
|
+
NimSendResult,
|
|
36
|
+
NimProbeResult,
|
|
37
|
+
NimMediaInfo,
|
|
38
|
+
NimMessageEvent,
|
|
39
|
+
NimMessageType,
|
|
40
|
+
NimDmPolicy,
|
|
41
|
+
ResolvedNimAccount,
|
|
42
|
+
} from "./src/types.js";
|
|
43
|
+
|
|
44
|
+
// Export utility functions
|
|
45
|
+
export { normalizeNimTarget, looksLikeNimId, formatNimTarget } from "./src/targets.js";
|
|
46
|
+
export { resolveNimCredentials, resolveNimAccount, isNimDmAllowed } from "./src/accounts.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OpenClaw NIM Plugin
|
|
50
|
+
*
|
|
51
|
+
* A Clawdbot channel plugin for NetEase IM (NIM).
|
|
52
|
+
*/
|
|
53
|
+
const plugin = {
|
|
54
|
+
id: "openclaw-nim",
|
|
55
|
+
name: "NIM",
|
|
56
|
+
description: "NetEase IM (网易云信) channel plugin",
|
|
57
|
+
configSchema: emptyPluginConfigSchema(),
|
|
58
|
+
register(api: ClawdbotPluginApi) {
|
|
59
|
+
setNimRuntime(api.runtime);
|
|
60
|
+
api.registerChannel({ plugin: nimPlugin });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-nim",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw NIM (NetEase IM) channel plugin",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"src",
|
|
10
|
+
"openclaw.plugin.json"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"nim",
|
|
15
|
+
"netease",
|
|
16
|
+
"yunxin",
|
|
17
|
+
"im",
|
|
18
|
+
"chatbot",
|
|
19
|
+
"ai"
|
|
20
|
+
],
|
|
21
|
+
"openclaw": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./index.ts"
|
|
24
|
+
],
|
|
25
|
+
"channel": {
|
|
26
|
+
"id": "nim",
|
|
27
|
+
"label": "NIM",
|
|
28
|
+
"selectionLabel": "NetEase IM (网易云信)",
|
|
29
|
+
"docsPath": "/channels/nim",
|
|
30
|
+
"docsLabel": "nim",
|
|
31
|
+
"blurb": "网易云信 IM 即时通讯。",
|
|
32
|
+
"aliases": [
|
|
33
|
+
"netease",
|
|
34
|
+
"yunxin"
|
|
35
|
+
],
|
|
36
|
+
"order": 80
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"dev": "tsc --watch",
|
|
42
|
+
"lint": "eslint src --ext .ts",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"node-nim": "^10.9.72",
|
|
47
|
+
"zod": "^4.3.6"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^25.0.10",
|
|
51
|
+
"openclaw": "2026.1.29",
|
|
52
|
+
"tsx": "^4.21.0",
|
|
53
|
+
"typescript": "^5.7.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"openclaw": ">=2026.1.29"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|
2
|
+
import type { NimConfig, ResolvedNimAccount, NimDmPolicy } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default account ID for NIM (single account mode).
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_NIM_ACCOUNT_ID = "default";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Coerce a value to string.
|
|
11
|
+
* Handles cases where YAML parses numeric values (e.g., account: 123456) as numbers.
|
|
12
|
+
*/
|
|
13
|
+
function coerceToString(value: unknown): string {
|
|
14
|
+
if (typeof value === "number") {
|
|
15
|
+
return String(value);
|
|
16
|
+
}
|
|
17
|
+
return String(value ?? "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve NIM credentials from configuration.
|
|
22
|
+
* Returns null if required credentials are missing.
|
|
23
|
+
* Automatically converts numeric values to strings (YAML may parse them as numbers).
|
|
24
|
+
*/
|
|
25
|
+
export function resolveNimCredentials(
|
|
26
|
+
cfg: NimConfig | undefined,
|
|
27
|
+
): { appKey: string; account: string; token: string } | null {
|
|
28
|
+
if (!cfg?.appKey || !cfg?.account || !cfg?.token) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
appKey: coerceToString(cfg.appKey),
|
|
33
|
+
account: coerceToString(cfg.account),
|
|
34
|
+
token: coerceToString(cfg.token),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve NIM account information from Clawdbot configuration.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveNimAccount(params: {
|
|
42
|
+
cfg: ClawdbotConfig;
|
|
43
|
+
}): ResolvedNimAccount | null {
|
|
44
|
+
const { cfg } = params;
|
|
45
|
+
const nimCfg = cfg.channels?.nim as NimConfig | undefined;
|
|
46
|
+
const creds = resolveNimCredentials(nimCfg);
|
|
47
|
+
|
|
48
|
+
if (!creds) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id: DEFAULT_NIM_ACCOUNT_ID,
|
|
54
|
+
appKey: creds.appKey,
|
|
55
|
+
account: creds.account,
|
|
56
|
+
token: creds.token,
|
|
57
|
+
enabled: nimCfg?.enabled ?? false,
|
|
58
|
+
dmPolicy: (nimCfg?.dmPolicy as NimDmPolicy) ?? "open",
|
|
59
|
+
allowFrom: nimCfg?.allowFrom ?? [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if a sender is in the allowlist.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveNimAllowlistMatch(params: {
|
|
67
|
+
allowFrom: Array<string | number>;
|
|
68
|
+
senderId: string;
|
|
69
|
+
}): { allowed: boolean; matchedEntry?: string | number } {
|
|
70
|
+
const { allowFrom, senderId } = params;
|
|
71
|
+
|
|
72
|
+
if (!allowFrom || allowFrom.length === 0) {
|
|
73
|
+
return { allowed: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const normalizedSenderId = senderId.toLowerCase();
|
|
77
|
+
|
|
78
|
+
for (const entry of allowFrom) {
|
|
79
|
+
const normalizedEntry = String(entry).toLowerCase();
|
|
80
|
+
if (normalizedEntry === normalizedSenderId) {
|
|
81
|
+
return { allowed: true, matchedEntry: entry };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { allowed: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if DM is allowed based on policy and sender.
|
|
90
|
+
*/
|
|
91
|
+
export function isNimDmAllowed(params: {
|
|
92
|
+
dmPolicy: "open" | "pairing" | "allowlist";
|
|
93
|
+
allowFrom: Array<string | number>;
|
|
94
|
+
senderId: string;
|
|
95
|
+
}): boolean {
|
|
96
|
+
const { dmPolicy, allowFrom, senderId } = params;
|
|
97
|
+
|
|
98
|
+
if (dmPolicy === "open") {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (dmPolicy === "allowlist") {
|
|
103
|
+
const match = resolveNimAllowlistMatch({ allowFrom, senderId });
|
|
104
|
+
return match.allowed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// "pairing" mode - could implement pairing logic here
|
|
108
|
+
if (dmPolicy === "pairing") {
|
|
109
|
+
// For now, treat pairing as allowlist
|
|
110
|
+
const match = resolveNimAllowlistMatch({ allowFrom, senderId });
|
|
111
|
+
return match.allowed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
|
2
|
+
import type { NimConfig, NimMessageContext, NimMessageEvent, NimMessageType, NimSessionType } from "./types.js";
|
|
3
|
+
import { isNimDmAllowed } from "./accounts.js";
|
|
4
|
+
import { getNimRuntime } from "./runtime.js";
|
|
5
|
+
import { downloadNimMedia, buildNimMediaPayload, inferMediaPlaceholder } from "./media.js";
|
|
6
|
+
import { createNimReplyDispatcher } from "./reply-dispatcher.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Map node-nim message type number to typed enum.
|
|
10
|
+
* node-nim msg_type: 0=text, 1=image, 2=audio, 3=video, 4=geo, 5=notification, 6=file, 10=tip, 100=custom
|
|
11
|
+
*/
|
|
12
|
+
function mapMessageType(msgType: number): NimMessageType {
|
|
13
|
+
switch (msgType) {
|
|
14
|
+
case 0:
|
|
15
|
+
return "text";
|
|
16
|
+
case 1:
|
|
17
|
+
return "image";
|
|
18
|
+
case 2:
|
|
19
|
+
return "audio";
|
|
20
|
+
case 3:
|
|
21
|
+
return "video";
|
|
22
|
+
case 4:
|
|
23
|
+
return "geo";
|
|
24
|
+
case 5:
|
|
25
|
+
return "notification";
|
|
26
|
+
case 6:
|
|
27
|
+
return "file";
|
|
28
|
+
case 10:
|
|
29
|
+
return "tip";
|
|
30
|
+
case 100:
|
|
31
|
+
return "custom";
|
|
32
|
+
default:
|
|
33
|
+
return "unknown";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get session type name from number.
|
|
39
|
+
* node-nim session_type: 0=p2p, 1=team
|
|
40
|
+
*/
|
|
41
|
+
function getSessionTypeName(sessionType: number): "p2p" | "team" | "unknown" {
|
|
42
|
+
switch (sessionType) {
|
|
43
|
+
case 0:
|
|
44
|
+
return "p2p";
|
|
45
|
+
case 1:
|
|
46
|
+
return "team";
|
|
47
|
+
default:
|
|
48
|
+
return "unknown";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract text content from a NIM message.
|
|
54
|
+
*/
|
|
55
|
+
function extractMessageContent(message: NimMessageEvent): string {
|
|
56
|
+
if (message.type === "text" && message.text) {
|
|
57
|
+
return message.text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (message.type === "geo" && message.attach) {
|
|
61
|
+
const geo = message.attach;
|
|
62
|
+
return `[位置] ${geo.title ?? ""} (${geo.lat}, ${geo.lng})`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (message.type === "custom" && message.ext) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = message.ext;
|
|
68
|
+
return (parsed as any).text || (parsed as any).content || JSON.stringify(parsed);
|
|
69
|
+
} catch {
|
|
70
|
+
return String(message.ext);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// For media messages, return a placeholder
|
|
75
|
+
if (["image", "file", "audio", "video"].includes(message.type)) {
|
|
76
|
+
return inferMediaPlaceholder(message.type);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return message.text || "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse a NIM message event into a message context.
|
|
84
|
+
*/
|
|
85
|
+
export function parseNimMessageEvent(message: NimMessageEvent): NimMessageContext {
|
|
86
|
+
const isDirectMessage = message.sessionType === "p2p";
|
|
87
|
+
const sessionId = isDirectMessage
|
|
88
|
+
? `p2p-${message.from}`
|
|
89
|
+
: `team-${message.to}`;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id: message.clientMsgId,
|
|
93
|
+
sessionId,
|
|
94
|
+
sessionType: message.sessionType,
|
|
95
|
+
senderId: message.from,
|
|
96
|
+
type: message.type,
|
|
97
|
+
text: extractMessageContent(message),
|
|
98
|
+
timestamp: message.time,
|
|
99
|
+
isDm: isDirectMessage,
|
|
100
|
+
rawEvent: message,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Handle an incoming NIM message.
|
|
106
|
+
*/
|
|
107
|
+
export async function handleNimMessage(params: {
|
|
108
|
+
cfg: ClawdbotConfig;
|
|
109
|
+
message: NimMessageEvent;
|
|
110
|
+
runtime?: RuntimeEnv;
|
|
111
|
+
}): Promise<void> {
|
|
112
|
+
const { cfg, message, runtime } = params;
|
|
113
|
+
const nimCfg = cfg.channels?.nim as NimConfig | undefined;
|
|
114
|
+
const log = runtime?.log ?? console.log;
|
|
115
|
+
const error = runtime?.error ?? console.error;
|
|
116
|
+
|
|
117
|
+
// Only process P2P messages (DM only for now)
|
|
118
|
+
if (message.sessionType !== "p2p") {
|
|
119
|
+
log(`nim: ignoring non-P2P message from session type: ${message.sessionType}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ctx = parseNimMessageEvent(message);
|
|
124
|
+
|
|
125
|
+
log(`nim: received message from ${ctx.senderId} (type: ${ctx.type})`);
|
|
126
|
+
|
|
127
|
+
// Check DM policy
|
|
128
|
+
const dmPolicy = nimCfg?.dmPolicy ?? "open";
|
|
129
|
+
const allowFrom = nimCfg?.allowFrom ?? [];
|
|
130
|
+
|
|
131
|
+
const allowed = isNimDmAllowed({
|
|
132
|
+
dmPolicy,
|
|
133
|
+
allowFrom,
|
|
134
|
+
senderId: ctx.senderId,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!allowed) {
|
|
138
|
+
log(`nim: sender ${ctx.senderId} not allowed by DM policy`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const core = getNimRuntime();
|
|
144
|
+
|
|
145
|
+
const nimFrom = `nim:${ctx.senderId}`;
|
|
146
|
+
const nimTo = `user:${ctx.senderId}`;
|
|
147
|
+
|
|
148
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
149
|
+
cfg,
|
|
150
|
+
channel: "nim",
|
|
151
|
+
peer: {
|
|
152
|
+
kind: "dm",
|
|
153
|
+
id: ctx.senderId,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const preview = ctx.text.replace(/\s+/g, " ").slice(0, 160);
|
|
158
|
+
const inboundLabel = `NIM DM from ${ctx.senderId}`;
|
|
159
|
+
|
|
160
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
161
|
+
sessionKey: route.sessionKey,
|
|
162
|
+
contextKey: `nim:message:${ctx.sessionId}:${ctx.id}`,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Handle media if present
|
|
166
|
+
const mediaMaxBytes = (nimCfg?.mediaMaxMb ?? 30) * 1024 * 1024;
|
|
167
|
+
const mediaList = [];
|
|
168
|
+
|
|
169
|
+
if (["image", "file", "audio", "video"].includes(ctx.type)) {
|
|
170
|
+
const attachUrl = message.attach?.url;
|
|
171
|
+
if (attachUrl) {
|
|
172
|
+
const mediaInfo = await downloadNimMedia({
|
|
173
|
+
cfg,
|
|
174
|
+
url: attachUrl,
|
|
175
|
+
filename: message.attach?.name,
|
|
176
|
+
maxBytes: mediaMaxBytes,
|
|
177
|
+
log,
|
|
178
|
+
});
|
|
179
|
+
if (mediaInfo) {
|
|
180
|
+
mediaList.push(mediaInfo);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const mediaPayload = buildNimMediaPayload(mediaList);
|
|
186
|
+
|
|
187
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
188
|
+
|
|
189
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
190
|
+
channel: "NIM",
|
|
191
|
+
from: ctx.senderId,
|
|
192
|
+
timestamp: new Date(ctx.timestamp),
|
|
193
|
+
envelope: envelopeOptions,
|
|
194
|
+
body: ctx.text,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
198
|
+
Body: body,
|
|
199
|
+
RawBody: ctx.text,
|
|
200
|
+
CommandBody: ctx.text,
|
|
201
|
+
From: nimFrom,
|
|
202
|
+
To: nimTo,
|
|
203
|
+
SessionKey: route.sessionKey,
|
|
204
|
+
AccountId: route.accountId,
|
|
205
|
+
ChatType: "direct",
|
|
206
|
+
SenderName: ctx.senderId,
|
|
207
|
+
SenderId: ctx.senderId,
|
|
208
|
+
Provider: "nim" as const,
|
|
209
|
+
Surface: "nim" as const,
|
|
210
|
+
MessageSid: ctx.id,
|
|
211
|
+
Timestamp: ctx.timestamp,
|
|
212
|
+
CommandAuthorized: true,
|
|
213
|
+
OriginatingChannel: "nim" as const,
|
|
214
|
+
OriginatingTo: nimTo,
|
|
215
|
+
...mediaPayload,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createNimReplyDispatcher({
|
|
219
|
+
cfg,
|
|
220
|
+
agentId: route.agentId,
|
|
221
|
+
runtime: runtime as RuntimeEnv,
|
|
222
|
+
senderId: ctx.senderId,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
log(`nim: dispatching to agent (session=${route.sessionKey})`);
|
|
226
|
+
|
|
227
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
228
|
+
ctx: ctxPayload,
|
|
229
|
+
cfg,
|
|
230
|
+
dispatcher,
|
|
231
|
+
replyOptions,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
markDispatchIdle();
|
|
235
|
+
|
|
236
|
+
log(`nim: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
error(`nim: failed to dispatch message: ${String(err)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|