multi-openim-channel 0.1.0

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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Public type surface for the multi-openim-channel plugin.
3
+ */
4
+ import type { ApiService, CallbackEvent, MessageItem } from "@openim/client-sdk";
5
+ export declare const CHANNEL_ID: "multi-openim";
6
+ export type ChannelId = typeof CHANNEL_ID;
7
+ export type TokenRefreshMode = "hook" | "off" | "http";
8
+ /**
9
+ * Config-driven HTTP refresher. Every backend-specific string (URL, body
10
+ * shape, response field names, optional sidecar state file) is operator
11
+ * input. Placeholder substitution is applied to string templates (endpoint,
12
+ * header values, body string leaves, clearOnSuccess paths):
13
+ *
14
+ * - `{accountId}` — the account being refreshed
15
+ * - `{state.<field>}` — `stateFile[accountId][field]`, coerced to string
16
+ * (empty string when missing)
17
+ *
18
+ * Response extraction (`responseTokenPath` / `responseUserIdPath` /
19
+ * `stateWriteBack` values) uses dot-paths into the parsed JSON response;
20
+ * when an array is given, the first non-empty path wins.
21
+ */
22
+ export interface HttpTokenRefreshConfig {
23
+ stateFile?: string;
24
+ endpoint: string;
25
+ method?: string;
26
+ headers?: Record<string, string>;
27
+ body?: unknown;
28
+ timeoutMs?: number;
29
+ responseTokenPath: string | string[];
30
+ responseUserIdPath?: string | string[];
31
+ stateWriteBack?: Record<string, string | string[]>;
32
+ clearOnSuccess?: string[];
33
+ }
34
+ export interface TokenRefreshConfig {
35
+ mode: TokenRefreshMode;
36
+ manualLoginMarkerPath?: string;
37
+ http?: HttpTokenRefreshConfig;
38
+ }
39
+ export interface AccountConfigRaw {
40
+ enabled?: boolean;
41
+ token?: string;
42
+ wsAddr?: string;
43
+ apiAddr?: string;
44
+ userID?: string;
45
+ platformID?: number;
46
+ requireMention?: boolean;
47
+ inboundWhitelist?: string[];
48
+ disableFriendSdk?: boolean;
49
+ }
50
+ export interface AccountConfig {
51
+ accountId: string;
52
+ enabled: boolean;
53
+ userID: string;
54
+ token: string;
55
+ wsAddr: string;
56
+ apiAddr: string;
57
+ platformID: number;
58
+ requireMention: boolean;
59
+ inboundWhitelist: string[];
60
+ disableFriendSdk: boolean;
61
+ }
62
+ export interface ChannelConfigRaw {
63
+ enabled?: boolean;
64
+ healthCheckIntervalMinutes?: number;
65
+ tokenRefresh?: Partial<TokenRefreshConfig>;
66
+ disableFriendSdk?: boolean;
67
+ accounts?: Record<string, AccountConfigRaw>;
68
+ }
69
+ export interface ChannelConfig {
70
+ enabled: boolean;
71
+ healthCheckIntervalMinutes: number;
72
+ tokenRefresh: TokenRefreshConfig;
73
+ disableFriendSdk: boolean;
74
+ accounts: Record<string, AccountConfig>;
75
+ }
76
+ export interface ClientHandlers {
77
+ onRecvNewMessage: (event: CallbackEvent<MessageItem>) => void;
78
+ onRecvNewMessages: (event: CallbackEvent<MessageItem[]>) => void;
79
+ onRecvOfflineNewMessages: (event: CallbackEvent<MessageItem[]>) => void;
80
+ onConnectFailed: (event: unknown) => void;
81
+ onUserTokenExpired: () => void;
82
+ onUserTokenInvalid: () => void;
83
+ onKickedOffline: () => void;
84
+ }
85
+ export interface ClientState {
86
+ sdk: ApiService;
87
+ config: AccountConfig;
88
+ handlers: ClientHandlers;
89
+ connected: boolean;
90
+ lastConnectedAt?: number;
91
+ lastError?: string;
92
+ }
93
+ export type TargetKind = "user" | "group";
94
+ export interface ParsedTarget {
95
+ kind: TargetKind;
96
+ id: string;
97
+ }
98
+ export interface InboundMediaItem {
99
+ kind: "image" | "video" | "file";
100
+ url?: string;
101
+ mimeType?: string;
102
+ fileName?: string;
103
+ size?: number;
104
+ snapshotUrl?: string;
105
+ }
106
+ export type InboundKind = "text" | "image" | "video" | "file" | "mixed" | "unknown";
107
+ export interface InboundBodyResult {
108
+ body: string;
109
+ kind: InboundKind;
110
+ media?: InboundMediaItem[];
111
+ }
112
+ /**
113
+ * Optional in-process refresher invoked when `tokenRefresh.mode === "hook"`.
114
+ * Returning null (or throwing) means recovery failed and the channel should
115
+ * fall back to writing a manual-login marker.
116
+ */
117
+ export type TokenRefresherFn = (accountId: string, reason: string) => Promise<{
118
+ token: string;
119
+ userID?: string;
120
+ } | null>;
121
+ declare global {
122
+ var __multiOpenimTokenRefresher: TokenRefresherFn | undefined;
123
+ }
124
+ export interface PluginLogger {
125
+ info?(msg: string): void;
126
+ warn?(msg: string): void;
127
+ error?(msg: string): void;
128
+ }
129
+ export interface RegisterToolParams {
130
+ name: string;
131
+ description: string;
132
+ parameters: unknown;
133
+ execute(id: string, params: Record<string, unknown>): Promise<unknown> | unknown;
134
+ }
135
+ export interface RegisterServiceParams {
136
+ id: string;
137
+ start(): Promise<void> | void;
138
+ stop(): Promise<void> | void;
139
+ }
140
+ export interface PluginApi {
141
+ config?: unknown;
142
+ runtime?: unknown;
143
+ logger?: PluginLogger;
144
+ registerChannel?(spec: {
145
+ plugin: unknown;
146
+ }): void;
147
+ registerTool?(spec: RegisterToolParams): void;
148
+ registerService?(spec: RegisterServiceParams): void;
149
+ registerCli?(cb: (ctx: {
150
+ program: {
151
+ command(name: string): {
152
+ description(desc: string): {
153
+ command(sub: string): {
154
+ description(d: string): {
155
+ action(handler: () => Promise<void> | void): void;
156
+ };
157
+ };
158
+ };
159
+ };
160
+ };
161
+ }) => void, opts?: {
162
+ commands?: string[];
163
+ }): void;
164
+ }
165
+ export interface GatewayLifecycleStatus {
166
+ accountId: string;
167
+ enabled?: boolean;
168
+ configured?: boolean;
169
+ running?: boolean;
170
+ connected?: boolean;
171
+ lastConnectedAt?: number;
172
+ lastError?: string | null;
173
+ }
174
+ export interface GatewayStartAccountCtx {
175
+ cfg: unknown;
176
+ accountId: string;
177
+ abortSignal?: AbortSignal;
178
+ setStatus?(status: GatewayLifecycleStatus): void;
179
+ log?: PluginLogger;
180
+ }
181
+ export interface GatewayStopAccountCtx {
182
+ accountId: string;
183
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Public type surface for the multi-openim-channel plugin.
3
+ */
4
+ export const CHANNEL_ID = "multi-openim";
@@ -0,0 +1,6 @@
1
+ export declare function safeStringify(value: unknown): string;
2
+ export declare function toFiniteNumber(value: unknown, fallback: number): number;
3
+ export declare function formatSdkError(error: unknown): string;
4
+ export declare function truncate(text: string, max: number): string;
5
+ export declare function logTag(sub?: string): string;
6
+ export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
package/dist/utils.js ADDED
@@ -0,0 +1,68 @@
1
+ import { CHANNEL_ID } from "./types.js";
2
+ export function safeStringify(value) {
3
+ try {
4
+ return JSON.stringify(value);
5
+ }
6
+ catch {
7
+ return String(value);
8
+ }
9
+ }
10
+ export function toFiniteNumber(value, fallback) {
11
+ if (typeof value === "number") {
12
+ return Number.isFinite(value) ? value : fallback;
13
+ }
14
+ if (value === null || value === undefined)
15
+ return fallback;
16
+ const n = parseInt(String(value), 10);
17
+ return Number.isFinite(n) ? n : fallback;
18
+ }
19
+ export function formatSdkError(error) {
20
+ if (error && typeof error === "object") {
21
+ const e = error;
22
+ const parts = [];
23
+ if ("event" in e && e.event !== undefined)
24
+ parts.push(`event=${String(e.event)}`);
25
+ if ("errCode" in e && e.errCode !== undefined)
26
+ parts.push(`errCode=${String(e.errCode)}`);
27
+ if ("errMsg" in e && e.errMsg !== undefined)
28
+ parts.push(`errMsg=${String(e.errMsg)}`);
29
+ if ("operationID" in e && e.operationID !== undefined)
30
+ parts.push(`operationID=${String(e.operationID)}`);
31
+ if ("data" in e && e.data !== undefined && e.data !== null)
32
+ parts.push(`data=${safeStringify(e.data)}`);
33
+ if (parts.length > 0)
34
+ return parts.join(", ");
35
+ if (error instanceof Error)
36
+ return error.message;
37
+ }
38
+ return safeStringify(error);
39
+ }
40
+ export function truncate(text, max) {
41
+ if (text.length <= max)
42
+ return text;
43
+ return `${text.slice(0, Math.max(0, max - 1))}…`;
44
+ }
45
+ export function logTag(sub) {
46
+ return sub ? `[${CHANNEL_ID}][${sub}]` : `[${CHANNEL_ID}]`;
47
+ }
48
+ export function decodeJwtPayload(token) {
49
+ if (typeof token !== "string")
50
+ return null;
51
+ const parts = token.split(".");
52
+ if (parts.length < 2)
53
+ return null;
54
+ const payload = parts[1];
55
+ if (!payload)
56
+ return null;
57
+ try {
58
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
59
+ const padding = (4 - (normalized.length % 4)) % 4;
60
+ const padded = normalized + "=".repeat(padding);
61
+ const json = Buffer.from(padded, "base64").toString("utf8");
62
+ const parsed = JSON.parse(json);
63
+ return parsed && typeof parsed === "object" ? parsed : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
@@ -0,0 +1,258 @@
1
+ {
2
+ "id": "multi-openim-channel",
3
+ "name": "Multi-OpenIM Channel",
4
+ "version": "0.1.0",
5
+ "description": "Multi-account / multi-server OpenIM channel plugin for OpenClaw, with config-driven HTTP token refresh and an optional friend-API guard",
6
+ "author": "Multi-OpenIM",
7
+ "channels": ["multi-openim"],
8
+ "contracts": {
9
+ "tools": [
10
+ "multi_openim_send_text",
11
+ "multi_openim_send_image",
12
+ "multi_openim_send_video",
13
+ "multi_openim_send_file"
14
+ ]
15
+ },
16
+ "channelConfigs": {
17
+ "multi-openim": {
18
+ "label": "Multi-OpenIM",
19
+ "description": "OpenIM channel supporting multiple accounts and multiple servers in one gateway process. Token refresh is fully configuration-driven (HTTP request templates, no subprocess); friend SDK methods are stubbed by default so external systems can own friend relationships.",
20
+ "schema": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "enabled": { "type": "boolean", "default": true },
25
+ "healthCheckIntervalMinutes": { "type": "number", "default": 30, "minimum": 1 },
26
+ "disableFriendSdk": { "type": "boolean", "default": true },
27
+ "tokenRefresh": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "mode": { "type": "string", "enum": ["hook", "off", "http"], "default": "hook" },
32
+ "manualLoginMarkerPath": { "type": "string" },
33
+ "http": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "stateFile": { "type": "string" },
38
+ "endpoint": { "type": "string" },
39
+ "method": { "type": "string", "default": "POST" },
40
+ "headers": {
41
+ "type": "object",
42
+ "additionalProperties": { "type": "string" }
43
+ },
44
+ "body": {},
45
+ "timeoutMs": { "type": "number", "default": 15000, "minimum": 1 },
46
+ "responseTokenPath": {
47
+ "oneOf": [
48
+ { "type": "string" },
49
+ { "type": "array", "items": { "type": "string" } }
50
+ ]
51
+ },
52
+ "responseUserIdPath": {
53
+ "oneOf": [
54
+ { "type": "string" },
55
+ { "type": "array", "items": { "type": "string" } }
56
+ ]
57
+ },
58
+ "stateWriteBack": {
59
+ "type": "object",
60
+ "additionalProperties": {
61
+ "oneOf": [
62
+ { "type": "string" },
63
+ { "type": "array", "items": { "type": "string" } }
64
+ ]
65
+ }
66
+ },
67
+ "clearOnSuccess": {
68
+ "type": "array",
69
+ "items": { "type": "string" }
70
+ }
71
+ },
72
+ "required": ["endpoint", "responseTokenPath"]
73
+ }
74
+ }
75
+ },
76
+ "accounts": {
77
+ "type": "object",
78
+ "additionalProperties": {
79
+ "type": "object",
80
+ "additionalProperties": false,
81
+ "properties": {
82
+ "enabled": { "type": "boolean", "default": true },
83
+ "token": { "type": "string", "description": "OpenIM JWT token" },
84
+ "wsAddr": { "type": "string", "description": "OpenIM WebSocket endpoint, e.g. ws://127.0.0.1:10001" },
85
+ "apiAddr": { "type": "string", "description": "OpenIM REST endpoint, e.g. http://127.0.0.1:10002" },
86
+ "userID": { "type": "string", "description": "Optional, auto-derived from JWT claim" },
87
+ "platformID": { "type": "number", "description": "Optional, auto-derived from JWT claim; default 5" },
88
+ "requireMention": { "type": "boolean", "default": true },
89
+ "inboundWhitelist": { "type": "array", "items": { "type": "string" }, "default": [] },
90
+ "disableFriendSdk": { "type": "boolean" }
91
+ },
92
+ "required": ["token", "wsAddr", "apiAddr"]
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ },
99
+ "configSchema": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "enabled": {
104
+ "type": "boolean",
105
+ "default": true,
106
+ "description": "Whether the channel is enabled"
107
+ },
108
+ "healthCheckIntervalMinutes": {
109
+ "type": "number",
110
+ "default": 30,
111
+ "minimum": 1,
112
+ "description": "Per-channel health-monitor interval override; takes precedence over the gateway global default"
113
+ },
114
+ "tokenRefresh": {
115
+ "type": "object",
116
+ "additionalProperties": false,
117
+ "description": "How the plugin recovers from OnUserTokenExpired / OnUserTokenInvalid / OnConnectFailed / OnKickedOffline. The plugin never spawns subprocesses; recovery is either a config-driven HTTP request (mode='http') or an in-process hook (mode='hook').",
118
+ "properties": {
119
+ "mode": {
120
+ "type": "string",
121
+ "enum": ["hook", "off", "http"],
122
+ "default": "hook",
123
+ "description": "http = call the configured tokenRefresh.http endpoint and parse the response (recommended); hook = call globalThis.__multiOpenimTokenRefresher(accountId, reason); off = skip recovery, write the manual-login marker immediately"
124
+ },
125
+ "manualLoginMarkerPath": {
126
+ "type": "string",
127
+ "description": "Template for the manual-login marker path. <accountId> is inserted before the extension so N accounts produce N independent files. Defaults to ~/.openclaw/multi-openim/manual-login.json (i.e. manual-login.<accountId>.json)."
128
+ },
129
+ "http": {
130
+ "type": "object",
131
+ "additionalProperties": false,
132
+ "description": "Fully config-driven HTTP refresh. Templates support {accountId} and {state.<field>} placeholders, where state.<field> reads stateFile[accountId][field]. The channel never embeds backend-specific strings — every backend detail is declared here. On success the new token is written back to ~/.openclaw/openclaw.json (channels.multi-openim.accounts.<id>.token) so it survives gateway restarts.",
133
+ "properties": {
134
+ "stateFile": {
135
+ "type": "string",
136
+ "description": "Optional JSON file keyed by accountId. Read before the request to resolve {state.X} placeholders; written-back after when stateWriteBack is set."
137
+ },
138
+ "endpoint": {
139
+ "type": "string",
140
+ "description": "URL template, e.g. \"{state.server_url}/refresh\""
141
+ },
142
+ "method": {
143
+ "type": "string",
144
+ "default": "POST",
145
+ "description": "HTTP method"
146
+ },
147
+ "headers": {
148
+ "type": "object",
149
+ "additionalProperties": { "type": "string" },
150
+ "description": "Headers; values may contain placeholders"
151
+ },
152
+ "body": {
153
+ "description": "Request body. Object → JSON-encoded after recursive placeholder substitution on string leaves; string → sent verbatim after substitution; omitted → no body"
154
+ },
155
+ "timeoutMs": {
156
+ "type": "number",
157
+ "default": 15000,
158
+ "minimum": 1,
159
+ "description": "Request timeout in milliseconds"
160
+ },
161
+ "responseTokenPath": {
162
+ "oneOf": [
163
+ { "type": "string" },
164
+ { "type": "array", "items": { "type": "string" } }
165
+ ],
166
+ "description": "Dot-path(s) into the response JSON to extract the new IM token. First non-empty wins."
167
+ },
168
+ "responseUserIdPath": {
169
+ "oneOf": [
170
+ { "type": "string" },
171
+ { "type": "array", "items": { "type": "string" } }
172
+ ],
173
+ "description": "Optional dot-path(s) for a rotated userID. First non-empty wins."
174
+ },
175
+ "stateWriteBack": {
176
+ "type": "object",
177
+ "additionalProperties": {
178
+ "oneOf": [
179
+ { "type": "string" },
180
+ { "type": "array", "items": { "type": "string" } }
181
+ ]
182
+ },
183
+ "description": "Persist response fields back to stateFile under the accountId key. Maps <state-field> → response dot-path(s). Ignored when stateFile is unset."
184
+ },
185
+ "clearOnSuccess": {
186
+ "type": "array",
187
+ "items": { "type": "string" },
188
+ "description": "Extra paths to delete on a successful refresh. Supports {accountId} placeholder."
189
+ }
190
+ },
191
+ "required": ["endpoint", "responseTokenPath"]
192
+ }
193
+ }
194
+ },
195
+ "disableFriendSdk": {
196
+ "type": "boolean",
197
+ "default": true,
198
+ "description": "When true (default), all OpenIM SDK friend-related APIs (addFriend / acceptFriendApplication / ...) are replaced with throwing stubs. Recommended when an external system owns friend relationships; set false to expose the raw SDK behavior."
199
+ },
200
+ "accounts": {
201
+ "type": "object",
202
+ "description": "Multi-account configuration keyed by accountId. Top-level single-account fallback is NOT supported; use accounts.<id>.",
203
+ "additionalProperties": {
204
+ "type": "object",
205
+ "additionalProperties": false,
206
+ "properties": {
207
+ "enabled": { "type": "boolean", "default": true },
208
+ "token": {
209
+ "type": "string",
210
+ "description": "OpenIM JWT token. userID / platformID are auto-derived from JWT claims when omitted."
211
+ },
212
+ "wsAddr": {
213
+ "type": "string",
214
+ "description": "OpenIM WebSocket endpoint, e.g. ws://127.0.0.1:10001"
215
+ },
216
+ "apiAddr": {
217
+ "type": "string",
218
+ "description": "OpenIM REST endpoint, e.g. http://127.0.0.1:10002"
219
+ },
220
+ "userID": {
221
+ "type": "string",
222
+ "description": "OpenIM userID. Optional; auto-derived from JWT 'UserID' claim if omitted."
223
+ },
224
+ "platformID": {
225
+ "type": "number",
226
+ "description": "OpenIM platformID. Optional; auto-derived from JWT 'PlatformID' claim or defaults to 5."
227
+ },
228
+ "requireMention": {
229
+ "type": "boolean",
230
+ "default": true,
231
+ "description": "Whether group messages must @-mention this account to trigger a reply"
232
+ },
233
+ "inboundWhitelist": {
234
+ "type": "array",
235
+ "items": { "type": "string" },
236
+ "default": [],
237
+ "description": "Optional sender allowlist. When non-empty, only listed senders can trigger a reply (and in groups they must @-mention)."
238
+ },
239
+ "disableFriendSdk": {
240
+ "type": "boolean",
241
+ "description": "Per-account override of the channel-level disableFriendSdk"
242
+ }
243
+ },
244
+ "required": ["token", "wsAddr", "apiAddr"]
245
+ }
246
+ }
247
+ }
248
+ },
249
+ "uiHints": {
250
+ "token": { "label": "Token", "sensitive": true },
251
+ "wsAddr": { "label": "WebSocket Endpoint", "placeholder": "ws://127.0.0.1:10001" },
252
+ "apiAddr": { "label": "API Endpoint", "placeholder": "http://127.0.0.1:10002" },
253
+ "requireMention": { "label": "Require @-mention in group" },
254
+ "inboundWhitelist": { "label": "Inbound sender whitelist" },
255
+ "disableFriendSdk": { "label": "Disable OpenIM SDK friend APIs (recommended)" },
256
+ "healthCheckIntervalMinutes": { "label": "Health-monitor interval (minutes)" }
257
+ }
258
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "multi-openim-channel",
3
+ "version": "0.1.0",
4
+ "description": "Multi-account / multi-server OpenIM channel plugin for OpenClaw, with config-driven token refresh and an optional friend-API guard",
5
+ "license": "MIT",
6
+ "author": "Multi-OpenIM",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "openclaw.plugin.json",
15
+ "README.md",
16
+ "SCHEMA.md",
17
+ "LICENSE"
18
+ ],
19
+ "openclaw": {
20
+ "extensions": [
21
+ "./dist/index.js"
22
+ ],
23
+ "channel": {
24
+ "id": "multi-openim",
25
+ "label": "Multi-OpenIM",
26
+ "selectionLabel": "Multi-OpenIM",
27
+ "docsPath": "/channels/multi-openim",
28
+ "blurb": "Multi-account / multi-server OpenIM channel via @openim/client-sdk",
29
+ "order": 70,
30
+ "aliases": ["multi-openim", "multi-openim-im", "mopenim"]
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "watch": "tsc --watch",
36
+ "clean": "rm -rf dist",
37
+ "validate": "node scripts/validate-build.mjs",
38
+ "smoke": "node scripts/smoke-connect.mjs",
39
+ "prepublishOnly": "npm run clean && npm run build && npm run validate"
40
+ },
41
+ "dependencies": {
42
+ "@openim/client-sdk": "^3.8.3"
43
+ },
44
+ "peerDependencies": {
45
+ "openclaw": "*"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "openclaw": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.0.0",
54
+ "typescript": "^5.4.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=22"
58
+ }
59
+ }