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.
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * multi-openim-channel — OpenClaw plugin entry point.
3
+ *
4
+ * Wires up: SDK polyfills → register the channel descriptor → register
5
+ * lifecycle handlers on the channel object so OpenClaw's health-monitor
6
+ * can drive us → register MCP tools → register the setup CLI → register
7
+ * the long-running SDK service.
8
+ */
9
+ import "./polyfills.js";
10
+ import { createChannelPlugin } from "./channel.js";
11
+ import { connectedClientCount, setInboundDispatcher, startAccountClient, stopAllClients, } from "./clients.js";
12
+ import { listEnabledAccountConfigs, normalizeChannelConfig } from "./config.js";
13
+ import { createPluginContext } from "./context.js";
14
+ import { processInboundMessage } from "./inbound.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { CHANNEL_ID } from "./types.js";
17
+ import { formatSdkError, logTag } from "./utils.js";
18
+ export default function register(api) {
19
+ const { channel, issues } = normalizeChannelConfig(api, api.logger);
20
+ for (const issue of issues) {
21
+ api.logger?.warn?.(`${logTag("register")} account=${issue.accountId} skipped: ${issue.reason}`);
22
+ }
23
+ const ctx = createPluginContext(api, channel);
24
+ setInboundDispatcher(processInboundMessage);
25
+ const channelPlugin = createChannelPlugin(ctx);
26
+ api.registerChannel?.({ plugin: channelPlugin });
27
+ if (typeof api.registerCli === "function") {
28
+ api.registerCli((cliCtx) => {
29
+ const prog = cliCtx.program;
30
+ if (prog && typeof prog.command === "function") {
31
+ const sub = prog.command("multi-openim").description("Multi-OpenIM channel configuration");
32
+ sub
33
+ .command("setup")
34
+ .description("Interactive setup for a multi-openim account (default account: \"primary\")")
35
+ .action(async () => {
36
+ const { runSetup } = await import("./setup.js");
37
+ await runSetup();
38
+ });
39
+ }
40
+ }, { commands: ["multi-openim"] });
41
+ }
42
+ registerTools(api);
43
+ api.registerService?.({
44
+ id: `${CHANNEL_ID}-sdk`,
45
+ start: async () => {
46
+ if (connectedClientCount() > 0) {
47
+ ctx.logger.info?.(`${logTag("service")} already started; reusing existing clients`);
48
+ return;
49
+ }
50
+ const accounts = listEnabledAccountConfigs(api);
51
+ if (accounts.length === 0) {
52
+ ctx.logger.warn?.(`${logTag("service")} no enabled accounts found in channels.${CHANNEL_ID}.accounts`);
53
+ return;
54
+ }
55
+ for (const account of accounts) {
56
+ try {
57
+ await startAccountClient(ctx, account);
58
+ }
59
+ catch (e) {
60
+ ctx.logger.error?.(`${logTag("service")} account=${account.accountId} start failed: ${formatSdkError(e)}`);
61
+ }
62
+ }
63
+ ctx.logger.info?.(`${logTag("service")} started with ${connectedClientCount()}/${accounts.length} connected account(s)`);
64
+ },
65
+ stop: async () => {
66
+ await stopAllClients(ctx);
67
+ ctx.logger.info?.(`${logTag("service")} stopped`);
68
+ },
69
+ });
70
+ ctx.logger.info?.(`${logTag("register")} plugin loaded (healthCheck=${channel.healthCheckIntervalMinutes}min, tokenRefresh.mode=${channel.tokenRefresh.mode}, disableFriendSdk=${channel.disableFriendSdk})`);
71
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Outbound media helpers. Wraps OpenIM SDK message-create + sendMessage
3
+ * flows for text / image / file. Video is delivered as a file message
4
+ * because the SDK's native video message has poor cross-client fidelity.
5
+ */
6
+ import type { ClientState, ParsedTarget } from "./types.js";
7
+ export declare function sendTextToTarget(client: ClientState, target: ParsedTarget, text: string): Promise<void>;
8
+ export declare function sendImageToTarget(client: ClientState, target: ParsedTarget, image: string): Promise<void>;
9
+ export declare function sendFileToTarget(client: ClientState, target: ParsedTarget, filePathOrUrl: string, name?: string): Promise<void>;
10
+ export declare function sendVideoToTarget(client: ClientState, target: ParsedTarget, video: string, name?: string): Promise<void>;
package/dist/media.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Outbound media helpers. Wraps OpenIM SDK message-create + sendMessage
3
+ * flows for text / image / file. Video is delivered as a file message
4
+ * because the SDK's native video message has poor cross-client fidelity.
5
+ */
6
+ import { File } from "node:buffer";
7
+ import { randomUUID } from "node:crypto";
8
+ import { readFile, stat } from "node:fs/promises";
9
+ import { basename, extname } from "node:path";
10
+ import { getRecvAndGroupID } from "./targets.js";
11
+ const MIME_TABLE = {
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".png": "image/png",
15
+ ".gif": "image/gif",
16
+ ".bmp": "image/bmp",
17
+ ".webp": "image/webp",
18
+ ".mp4": "video/mp4",
19
+ ".mov": "video/quicktime",
20
+ ".mkv": "video/x-matroska",
21
+ ".avi": "video/x-msvideo",
22
+ ".pdf": "application/pdf",
23
+ ".txt": "text/plain",
24
+ ".json": "application/json",
25
+ ".zip": "application/zip",
26
+ ".doc": "application/msword",
27
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
28
+ ".xls": "application/vnd.ms-excel",
29
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
30
+ };
31
+ function isHttpUrl(input) {
32
+ return /^https?:\/\//i.test(input.trim());
33
+ }
34
+ function fromFileUrl(input) {
35
+ const raw = input.trim();
36
+ if (raw.startsWith("file://")) {
37
+ return decodeURIComponent(raw.slice("file://".length));
38
+ }
39
+ return raw;
40
+ }
41
+ function guessMime(nameOrPath, fallback = "application/octet-stream") {
42
+ const ext = extname(nameOrPath).toLowerCase();
43
+ return MIME_TABLE[ext] ?? fallback;
44
+ }
45
+ function nameFromUrl(url, fallback) {
46
+ try {
47
+ const u = new URL(url);
48
+ const name = basename(u.pathname || "");
49
+ return name || fallback;
50
+ }
51
+ catch {
52
+ return fallback;
53
+ }
54
+ }
55
+ async function readLocalFile(pathInput, forcedName) {
56
+ const filePath = fromFileUrl(pathInput);
57
+ const st = await stat(filePath);
58
+ const data = await readFile(filePath);
59
+ const fileName = forcedName?.trim() || basename(filePath) || `file-${Date.now()}`;
60
+ const mime = guessMime(fileName);
61
+ const file = new File([new Uint8Array(data)], fileName, { type: mime });
62
+ return { file, filePath, fileName, size: st.size, mime };
63
+ }
64
+ export async function sendTextToTarget(client, target, text) {
65
+ const created = await client.sdk.createTextMessage(text);
66
+ const message = created?.data;
67
+ if (!message) {
68
+ throw new Error("createTextMessage returned no message");
69
+ }
70
+ const { recvID, groupID } = getRecvAndGroupID(target);
71
+ await client.sdk.sendMessage({ recvID, groupID, message });
72
+ }
73
+ export async function sendImageToTarget(client, target, image) {
74
+ const input = image.trim();
75
+ if (!input)
76
+ throw new Error("image is empty");
77
+ let message;
78
+ if (isHttpUrl(input)) {
79
+ const name = nameFromUrl(input, "image.jpg");
80
+ const pic = {
81
+ uuid: randomUUID(),
82
+ type: guessMime(name, "image/jpeg"),
83
+ size: 0,
84
+ width: 0,
85
+ height: 0,
86
+ url: input,
87
+ };
88
+ const created = await client.sdk.createImageMessageByURL({
89
+ sourcePicture: pic,
90
+ bigPicture: { ...pic },
91
+ snapshotPicture: { ...pic },
92
+ sourcePath: name,
93
+ });
94
+ message = created?.data;
95
+ }
96
+ else {
97
+ const local = await readLocalFile(input);
98
+ const pic = {
99
+ uuid: randomUUID(),
100
+ type: local.mime,
101
+ size: local.size,
102
+ width: 0,
103
+ height: 0,
104
+ url: "",
105
+ };
106
+ const created = await client.sdk.createImageMessageByFile({
107
+ sourcePicture: pic,
108
+ bigPicture: { ...pic },
109
+ snapshotPicture: { ...pic },
110
+ sourcePath: local.filePath,
111
+ file: local.file,
112
+ });
113
+ message = created?.data;
114
+ }
115
+ if (!message)
116
+ throw new Error("createImageMessage returned no message");
117
+ const { recvID, groupID } = getRecvAndGroupID(target);
118
+ await client.sdk.sendMessage({ recvID, groupID, message });
119
+ }
120
+ export async function sendFileToTarget(client, target, filePathOrUrl, name) {
121
+ const input = filePathOrUrl.trim();
122
+ if (!input)
123
+ throw new Error("file is empty");
124
+ let message;
125
+ if (isHttpUrl(input)) {
126
+ const fileName = name?.trim() || nameFromUrl(input, "file.bin");
127
+ const created = await client.sdk.createFileMessageByURL({
128
+ filePath: fileName,
129
+ fileName,
130
+ uuid: randomUUID(),
131
+ sourceUrl: input,
132
+ fileSize: 0,
133
+ fileType: guessMime(fileName),
134
+ });
135
+ message = created?.data;
136
+ }
137
+ else {
138
+ const local = await readLocalFile(input, name);
139
+ const created = await client.sdk.createFileMessageByFile({
140
+ filePath: local.filePath,
141
+ fileName: local.fileName,
142
+ uuid: randomUUID(),
143
+ sourceUrl: "",
144
+ fileSize: local.size,
145
+ fileType: local.mime,
146
+ file: local.file,
147
+ });
148
+ message = created?.data;
149
+ }
150
+ if (!message)
151
+ throw new Error("createFileMessage returned no message");
152
+ const { recvID, groupID } = getRecvAndGroupID(target);
153
+ await client.sdk.sendMessage({ recvID, groupID, message });
154
+ }
155
+ export async function sendVideoToTarget(client, target, video, name) {
156
+ return sendFileToTarget(client, target, video, name);
157
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Browser-API polyfills required by `@openim/client-sdk` when running under
3
+ * Node. Imported for side-effects at plugin startup.
4
+ */
5
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Browser-API polyfills required by `@openim/client-sdk` when running under
3
+ * Node. Imported for side-effects at plugin startup.
4
+ */
5
+ class NodeFileReader {
6
+ result = null;
7
+ error = null;
8
+ onload = null;
9
+ onerror = null;
10
+ readAsArrayBuffer(blob) {
11
+ blob
12
+ .arrayBuffer()
13
+ .then((buf) => {
14
+ this.result = buf;
15
+ this.onload?.({ target: this });
16
+ })
17
+ .catch((err) => {
18
+ this.error = err instanceof Error ? err : new Error(String(err));
19
+ this.onerror?.(err);
20
+ });
21
+ }
22
+ }
23
+ const g = globalThis;
24
+ if (typeof g.FileReader === "undefined") {
25
+ g.FileReader = NodeFileReader;
26
+ }
27
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Interactive setup wizard: `openclaw multi-openim setup`.
3
+ *
4
+ * Writes a single account into `channels.multi-openim.accounts.<id>` inside
5
+ * `~/.openclaw/openclaw.json`. The default accountId is `primary`.
6
+ */
7
+ export interface SetupOptions {
8
+ accountId?: string;
9
+ }
10
+ export declare function runSetup(opts?: SetupOptions): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Interactive setup wizard: `openclaw multi-openim setup`.
3
+ *
4
+ * Writes a single account into `channels.multi-openim.accounts.<id>` inside
5
+ * `~/.openclaw/openclaw.json`. The default accountId is `primary`.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import * as readline from "node:readline/promises";
11
+ import { CHANNEL_ID } from "./types.js";
12
+ const OPENCLAW_HOME = join(homedir(), ".openclaw");
13
+ const CONFIG_PATH = join(OPENCLAW_HOME, "openclaw.json");
14
+ const DEFAULT_ACCOUNT_NAME = "primary";
15
+ async function ask(rl, label, defaultValue) {
16
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
17
+ const answer = await rl.question(`${label}${suffix}: `);
18
+ const trimmed = String(answer ?? "").trim();
19
+ return trimmed || defaultValue;
20
+ }
21
+ export async function runSetup(opts = {}) {
22
+ const accountId = String(opts.accountId ?? DEFAULT_ACCOUNT_NAME).trim() || DEFAULT_ACCOUNT_NAME;
23
+ process.stdout.write(`\nMulti-OpenIM Channel Setup (account="${accountId}")\n`);
24
+ process.stdout.write("Press Ctrl-C to cancel.\n\n");
25
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
26
+ let token;
27
+ let wsAddr;
28
+ let apiAddr;
29
+ try {
30
+ token = await ask(rl, "OpenIM Access Token", process.env.MULTI_OPENIM_TOKEN || "");
31
+ wsAddr = await ask(rl, "OpenIM WebSocket endpoint", process.env.MULTI_OPENIM_WS_ADDR || "ws://127.0.0.1:10001");
32
+ apiAddr = await ask(rl, "OpenIM REST API endpoint", process.env.MULTI_OPENIM_API_ADDR || "http://127.0.0.1:10002");
33
+ }
34
+ finally {
35
+ rl.close();
36
+ }
37
+ if (!token || !wsAddr || !apiAddr) {
38
+ process.stderr.write("Fields `token`, `wsAddr`, and `apiAddr` cannot be empty.\n");
39
+ process.exit(1);
40
+ }
41
+ let existing = {};
42
+ if (existsSync(CONFIG_PATH)) {
43
+ try {
44
+ existing = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
45
+ }
46
+ catch {
47
+ existing = {};
48
+ }
49
+ }
50
+ const channels = (existing.channels ?? {});
51
+ const channelBlock = (channels[CHANNEL_ID] ?? {});
52
+ const accounts = (channelBlock.accounts ?? {});
53
+ accounts[accountId] = {
54
+ enabled: true,
55
+ token,
56
+ wsAddr,
57
+ apiAddr,
58
+ };
59
+ channelBlock.accounts = accounts;
60
+ if (channelBlock.enabled !== true)
61
+ channelBlock.enabled = true;
62
+ channels[CHANNEL_ID] = channelBlock;
63
+ const next = { ...existing, channels };
64
+ mkdirSync(OPENCLAW_HOME, { recursive: true });
65
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), "utf-8");
66
+ process.stdout.write(`\nAccount "${accountId}" written to ${CONFIG_PATH}\n`);
67
+ process.stdout.write("userID/platformID will be auto-derived from JWT claims.\n");
68
+ process.stdout.write("Run `openclaw gateway restart` to load the updated configuration.\n");
69
+ }
@@ -0,0 +1,7 @@
1
+ import type { ParsedTarget } from "./types.js";
2
+ export declare function parseTarget(to: unknown): ParsedTarget | null;
3
+ export declare function formatTarget(target: ParsedTarget): string;
4
+ export declare function getRecvAndGroupID(target: ParsedTarget): {
5
+ recvID: string;
6
+ groupID: string;
7
+ };
@@ -0,0 +1,38 @@
1
+ import { CHANNEL_ID } from "./types.js";
2
+ const PREFIX_PATTERNS = [
3
+ new RegExp(`^${CHANNEL_ID}:`, "i"),
4
+ /^openim:/i,
5
+ /^im:/i,
6
+ ];
7
+ function stripChannelPrefix(raw) {
8
+ for (const re of PREFIX_PATTERNS) {
9
+ if (re.test(raw)) {
10
+ return raw.replace(re, "");
11
+ }
12
+ }
13
+ return raw;
14
+ }
15
+ export function parseTarget(to) {
16
+ const raw = String(to ?? "").trim();
17
+ if (!raw)
18
+ return null;
19
+ const stripped = stripChannelPrefix(raw);
20
+ if (/^user:/i.test(stripped)) {
21
+ const id = stripped.slice("user:".length).trim();
22
+ return id ? { kind: "user", id } : null;
23
+ }
24
+ if (/^group:/i.test(stripped)) {
25
+ const id = stripped.slice("group:".length).trim();
26
+ return id ? { kind: "group", id } : null;
27
+ }
28
+ return { kind: "user", id: stripped };
29
+ }
30
+ export function formatTarget(target) {
31
+ return `${target.kind}:${target.id}`;
32
+ }
33
+ export function getRecvAndGroupID(target) {
34
+ return {
35
+ recvID: target.kind === "user" ? target.id : "",
36
+ groupID: target.kind === "group" ? target.id : "",
37
+ };
38
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Token-refresh orchestration. Invoked from clients.ts when the SDK reports
3
+ * a token-related failure (OnUserTokenExpired / OnUserTokenInvalid /
4
+ * OnConnectFailed / OnKickedOffline / initial login() rejection).
5
+ *
6
+ * Three modes, picked by `tokenRefresh.mode`:
7
+ *
8
+ * - "http": a fully config-driven HTTP request, performed in-process via
9
+ * the standard `fetch`. Reads context from an optional state JSON file
10
+ * keyed by accountId, substitutes `{accountId}` / `{state.<field>}`
11
+ * placeholders into the configured endpoint / headers / body, posts
12
+ * the request, extracts the new token by dot-path from the response,
13
+ * optionally writes-back response fields into the state file, and
14
+ * patches the gateway's openclaw.json so the new token survives a
15
+ * restart. The channel does not embed any backend-specific strings.
16
+ *
17
+ * - "hook": call `globalThis.__multiOpenimTokenRefresher(accountId,
18
+ * reason)`. Power-user escape hatch when refresh logic is too
19
+ * imperative for HTTP-template config.
20
+ *
21
+ * - "off": skip recovery, write the manual-login marker immediately.
22
+ *
23
+ * The plugin never spawns subprocesses; both recovery paths are pure JS.
24
+ */
25
+ import type { PluginContext } from "./context.js";
26
+ import type { TokenRefreshConfig } from "./types.js";
27
+ export interface RefreshOutcome {
28
+ ok: boolean;
29
+ token?: string;
30
+ userID?: string;
31
+ error?: string;
32
+ via?: "hook" | "off" | "no-hook" | "http" | "no-http";
33
+ }
34
+ /**
35
+ * Resolve the per-account marker file path. `manualLoginMarkerPath` is a
36
+ * template into which the accountId is inserted:
37
+ *
38
+ * "/path/to/manual-login.json" → "/path/to/manual-login.<accountId>.json"
39
+ * "/path/to/markers/" → "/path/to/markers/<accountId>.json"
40
+ * "/path/no-ext" → "/path/no-ext.<accountId>"
41
+ */
42
+ export declare function resolveManualLoginMarkerPath(cfg: TokenRefreshConfig, accountId: string): string | undefined;
43
+ export declare function writeManualLoginMarker(ctx: PluginContext, accountId: string, detail: string): void;
44
+ export declare function clearManualLoginMarker(cfg: TokenRefreshConfig, accountId: string): void;
45
+ export interface RefreshOrchestratorArgs {
46
+ ctx: PluginContext;
47
+ accountId: string;
48
+ reason: string;
49
+ }
50
+ export declare function refreshAccountToken(args: RefreshOrchestratorArgs): Promise<RefreshOutcome>;