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 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
+ }
@@ -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
+ }