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/LICENSE +21 -0
- package/README.md +255 -0
- package/SCHEMA.md +167 -0
- package/dist/channel.d.ts +57 -0
- package/dist/channel.js +104 -0
- package/dist/clients.d.ts +20 -0
- package/dist/clients.js +329 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +256 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.js +8 -0
- package/dist/friend-guard.d.ts +19 -0
- package/dist/friend-guard.js +66 -0
- package/dist/inbound.d.ts +17 -0
- package/dist/inbound.js +639 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +71 -0
- package/dist/media.d.ts +10 -0
- package/dist/media.js +157 -0
- package/dist/polyfills.d.ts +5 -0
- package/dist/polyfills.js +27 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +69 -0
- package/dist/targets.d.ts +7 -0
- package/dist/targets.js +38 -0
- package/dist/token-refresh.d.ts +50 -0
- package/dist/token-refresh.js +383 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +153 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.js +4 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +68 -0
- package/openclaw.plugin.json +258 -0
- package/package.json +59 -0
|
@@ -0,0 +1,383 @@
|
|
|
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 { mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
import { CHANNEL_ID } from "./types.js";
|
|
29
|
+
import { formatSdkError, logTag, truncate } from "./utils.js";
|
|
30
|
+
/**
|
|
31
|
+
* Cap on how long a user-supplied JS hook may run before we treat it as
|
|
32
|
+
* failure. A hooked refresher that hangs (network stall, internal deadlock)
|
|
33
|
+
* would otherwise pin the per-account refresh flag indefinitely. The HTTP
|
|
34
|
+
* path has its own timeout via AbortSignal.timeout, so this guard applies
|
|
35
|
+
* only to `mode: "hook"`.
|
|
36
|
+
*/
|
|
37
|
+
const HOOK_TIMEOUT_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the per-account marker file path. `manualLoginMarkerPath` is a
|
|
40
|
+
* template into which the accountId is inserted:
|
|
41
|
+
*
|
|
42
|
+
* "/path/to/manual-login.json" → "/path/to/manual-login.<accountId>.json"
|
|
43
|
+
* "/path/to/markers/" → "/path/to/markers/<accountId>.json"
|
|
44
|
+
* "/path/no-ext" → "/path/no-ext.<accountId>"
|
|
45
|
+
*/
|
|
46
|
+
export function resolveManualLoginMarkerPath(cfg, accountId) {
|
|
47
|
+
const base = cfg.manualLoginMarkerPath;
|
|
48
|
+
if (!base)
|
|
49
|
+
return undefined;
|
|
50
|
+
const id = String(accountId || "").trim() || "unknown";
|
|
51
|
+
if (base.endsWith("/") || base.endsWith("\\")) {
|
|
52
|
+
return `${base}${id}.json`;
|
|
53
|
+
}
|
|
54
|
+
const dotIdx = base.lastIndexOf(".");
|
|
55
|
+
const slashIdx = Math.max(base.lastIndexOf("/"), base.lastIndexOf("\\"));
|
|
56
|
+
if (dotIdx > slashIdx) {
|
|
57
|
+
return `${base.slice(0, dotIdx)}.${id}${base.slice(dotIdx)}`;
|
|
58
|
+
}
|
|
59
|
+
return `${base}.${id}`;
|
|
60
|
+
}
|
|
61
|
+
export function writeManualLoginMarker(ctx, accountId, detail) {
|
|
62
|
+
const path = resolveManualLoginMarkerPath(ctx.channel.tokenRefresh, accountId);
|
|
63
|
+
if (!path)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ignore: writeFileSync below will surface a real failure
|
|
70
|
+
}
|
|
71
|
+
const payload = {
|
|
72
|
+
accountId: String(accountId || ""),
|
|
73
|
+
at: new Date().toISOString(),
|
|
74
|
+
status: "manual_login_required",
|
|
75
|
+
detail: truncate(String(detail ?? ""), 2_000),
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(path, JSON.stringify(payload, null, 2), "utf8");
|
|
79
|
+
ctx.logger.error?.(`${logTag("token-refresh")} manual-login required for account=${payload.accountId}; marker=${path}`);
|
|
80
|
+
ctx.logger.error?.(`${logTag("token-refresh")} JSON: ${JSON.stringify({
|
|
81
|
+
type: "MANUAL_LOGIN_REQUIRED",
|
|
82
|
+
accountId: payload.accountId,
|
|
83
|
+
ts: payload.at,
|
|
84
|
+
})}`);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
ctx.logger.error?.(`${logTag("token-refresh")} failed to write marker ${path}: ${formatSdkError(e)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function clearManualLoginMarker(cfg, accountId) {
|
|
91
|
+
const path = resolveManualLoginMarkerPath(cfg, accountId);
|
|
92
|
+
if (!path)
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(path);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore: not present is fine
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function callHook(ctx, accountId, reason) {
|
|
102
|
+
const refresher = globalThis.__multiOpenimTokenRefresher;
|
|
103
|
+
if (typeof refresher !== "function") {
|
|
104
|
+
return { ok: false, error: "no globalThis.__multiOpenimTokenRefresher registered", via: "no-hook" };
|
|
105
|
+
}
|
|
106
|
+
const timeoutMarker = Symbol("hook-timeout");
|
|
107
|
+
let timer;
|
|
108
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
109
|
+
timer = setTimeout(() => resolve(timeoutMarker), HOOK_TIMEOUT_MS);
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
const settled = await Promise.race([refresher(accountId, reason), timeoutPromise]);
|
|
113
|
+
if (settled === timeoutMarker) {
|
|
114
|
+
ctx.logger.warn?.(`${logTag("token-refresh")} hook timed out after ${HOOK_TIMEOUT_MS}ms for account=${accountId}`);
|
|
115
|
+
return { ok: false, error: `hook timeout after ${HOOK_TIMEOUT_MS}ms`, via: "hook" };
|
|
116
|
+
}
|
|
117
|
+
const result = settled;
|
|
118
|
+
if (!result || typeof result !== "object" || typeof result.token !== "string" || !result.token) {
|
|
119
|
+
return { ok: false, error: "hook returned no token", via: "hook" };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
token: result.token,
|
|
124
|
+
userID: typeof result.userID === "string" && result.userID ? result.userID : undefined,
|
|
125
|
+
via: "hook",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
const msg = formatSdkError(e);
|
|
130
|
+
ctx.logger.warn?.(`${logTag("token-refresh")} hook threw for account=${accountId}: ${truncate(msg, 200)}`);
|
|
131
|
+
return { ok: false, error: msg, via: "hook" };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (timer)
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function substituteTemplate(template, accountId, state) {
|
|
139
|
+
return template.replace(/\{(accountId|state\.[A-Za-z0-9_]+)\}/g, (_match, key) => {
|
|
140
|
+
if (key === "accountId")
|
|
141
|
+
return accountId;
|
|
142
|
+
const field = key.slice("state.".length);
|
|
143
|
+
const v = state[field];
|
|
144
|
+
if (v == null)
|
|
145
|
+
return "";
|
|
146
|
+
return String(v);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function deepSubstitute(value, accountId, state) {
|
|
150
|
+
if (typeof value === "string") {
|
|
151
|
+
return substituteTemplate(value, accountId, state);
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
return value.map((v) => deepSubstitute(v, accountId, state));
|
|
155
|
+
}
|
|
156
|
+
if (value && typeof value === "object") {
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [k, v] of Object.entries(value)) {
|
|
159
|
+
out[k] = deepSubstitute(v, accountId, state);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
function getByDotPath(root, path) {
|
|
166
|
+
if (root == null)
|
|
167
|
+
return undefined;
|
|
168
|
+
const parts = path.split(".").filter((p) => p.length > 0);
|
|
169
|
+
let cur = root;
|
|
170
|
+
for (const p of parts) {
|
|
171
|
+
if (cur && typeof cur === "object" && p in cur) {
|
|
172
|
+
cur = cur[p];
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return cur;
|
|
179
|
+
}
|
|
180
|
+
function firstNonEmptyString(root, paths) {
|
|
181
|
+
for (const p of paths) {
|
|
182
|
+
const v = getByDotPath(root, p);
|
|
183
|
+
if (typeof v === "string" && v.length > 0)
|
|
184
|
+
return v;
|
|
185
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
186
|
+
return String(v);
|
|
187
|
+
}
|
|
188
|
+
return "";
|
|
189
|
+
}
|
|
190
|
+
function asArray(v) {
|
|
191
|
+
if (!v)
|
|
192
|
+
return [];
|
|
193
|
+
return Array.isArray(v) ? v : [v];
|
|
194
|
+
}
|
|
195
|
+
function readStateFile(ctx, http, accountId) {
|
|
196
|
+
if (!http.stateFile)
|
|
197
|
+
return { all: {}, account: {} };
|
|
198
|
+
try {
|
|
199
|
+
const raw = readFileSync(http.stateFile, "utf8");
|
|
200
|
+
const parsed = JSON.parse(raw);
|
|
201
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
202
|
+
const all = parsed;
|
|
203
|
+
const entry = all[accountId];
|
|
204
|
+
const account = entry && typeof entry === "object" ? entry : {};
|
|
205
|
+
return { all, account };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
ctx.logger.warn?.(`${logTag("token-refresh")} stateFile=${http.stateFile} read failed: ${truncate(formatSdkError(e), 200)}`);
|
|
210
|
+
}
|
|
211
|
+
return { all: {}, account: {} };
|
|
212
|
+
}
|
|
213
|
+
function writeStateFile(ctx, path, all) {
|
|
214
|
+
try {
|
|
215
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// ignore: directory may already exist; failures surface in writeFileSync
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
writeFileSync(path, JSON.stringify(all, null, 2), "utf8");
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
ctx.logger.warn?.(`${logTag("token-refresh")} stateFile=${path} write failed: ${truncate(formatSdkError(e), 200)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Persist the refreshed token back to the gateway's own config file so a
|
|
229
|
+
* restart resumes with the new JWT. Only `channels.<CHANNEL_ID>.accounts.<id>.token`
|
|
230
|
+
* is touched; sibling fields are left intact.
|
|
231
|
+
*/
|
|
232
|
+
function patchOpenclawConfigToken(ctx, accountId, newToken) {
|
|
233
|
+
const path = String(process.env.OPENCLAW_CONFIG_PATH ?? "").trim() || join(homedir(), ".openclaw", "openclaw.json");
|
|
234
|
+
let cfg;
|
|
235
|
+
try {
|
|
236
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
237
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg))
|
|
238
|
+
cfg = {};
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
ctx.logger.warn?.(`${logTag("token-refresh")} openclaw config ${path} read failed; skipping persistent token writeback: ${truncate(formatSdkError(e), 200)}`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const channels = (cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
|
|
245
|
+
? cfg.channels
|
|
246
|
+
: (cfg.channels = {}));
|
|
247
|
+
const block = (channels[CHANNEL_ID] && typeof channels[CHANNEL_ID] === "object"
|
|
248
|
+
? channels[CHANNEL_ID]
|
|
249
|
+
: (channels[CHANNEL_ID] = {}));
|
|
250
|
+
const accounts = (block.accounts && typeof block.accounts === "object" && !Array.isArray(block.accounts)
|
|
251
|
+
? block.accounts
|
|
252
|
+
: (block.accounts = {}));
|
|
253
|
+
const entry = (accounts[accountId] && typeof accounts[accountId] === "object"
|
|
254
|
+
? accounts[accountId]
|
|
255
|
+
: (accounts[accountId] = {}));
|
|
256
|
+
entry.token = newToken;
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
ctx.logger.warn?.(`${logTag("token-refresh")} openclaw config ${path} write failed: ${truncate(formatSdkError(e), 200)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function callHttp(ctx, accountId, reason) {
|
|
265
|
+
const http = ctx.channel.tokenRefresh.http;
|
|
266
|
+
if (!http) {
|
|
267
|
+
return { ok: false, error: "tokenRefresh.http config missing", via: "no-http" };
|
|
268
|
+
}
|
|
269
|
+
const { all, account: stateForAccount } = readStateFile(ctx, http, accountId);
|
|
270
|
+
const subst = (s) => substituteTemplate(s, accountId, stateForAccount);
|
|
271
|
+
const url = subst(http.endpoint);
|
|
272
|
+
if (!url) {
|
|
273
|
+
return { ok: false, error: "endpoint template resolved to empty", via: "http" };
|
|
274
|
+
}
|
|
275
|
+
const headers = {};
|
|
276
|
+
for (const [k, v] of Object.entries(http.headers ?? {})) {
|
|
277
|
+
headers[k] = subst(v);
|
|
278
|
+
}
|
|
279
|
+
let body;
|
|
280
|
+
if (http.body !== undefined && http.body !== null) {
|
|
281
|
+
if (typeof http.body === "string") {
|
|
282
|
+
body = subst(http.body);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
body = JSON.stringify(deepSubstitute(http.body, accountId, stateForAccount));
|
|
286
|
+
const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
|
|
287
|
+
if (!hasContentType)
|
|
288
|
+
headers["content-type"] = "application/json";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const method = String(http.method ?? "POST").trim().toUpperCase() || "POST";
|
|
292
|
+
const timeoutMs = Number.isFinite(http.timeoutMs) ? Number(http.timeoutMs) : 15_000;
|
|
293
|
+
ctx.logger.info?.(`${logTag("token-refresh")} HTTP refresh account=${accountId} ${method} ${url} (reason=${truncate(reason, 100)})`);
|
|
294
|
+
let res;
|
|
295
|
+
try {
|
|
296
|
+
res = await fetch(url, {
|
|
297
|
+
method,
|
|
298
|
+
headers,
|
|
299
|
+
body,
|
|
300
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
return { ok: false, error: `fetch failed: ${truncate(formatSdkError(e), 300)}`, via: "http" };
|
|
305
|
+
}
|
|
306
|
+
if (!res.ok) {
|
|
307
|
+
let detail = `HTTP ${res.status}`;
|
|
308
|
+
try {
|
|
309
|
+
const text = await res.text();
|
|
310
|
+
if (text)
|
|
311
|
+
detail += ` ${truncate(text, 300)}`;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// body read failed; status alone is informative enough
|
|
315
|
+
}
|
|
316
|
+
return { ok: false, error: detail, via: "http" };
|
|
317
|
+
}
|
|
318
|
+
let payload;
|
|
319
|
+
try {
|
|
320
|
+
payload = await res.json();
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
return { ok: false, error: `response not JSON: ${truncate(formatSdkError(e), 200)}`, via: "http" };
|
|
324
|
+
}
|
|
325
|
+
const tokenPaths = asArray(http.responseTokenPath);
|
|
326
|
+
const newToken = firstNonEmptyString(payload, tokenPaths);
|
|
327
|
+
if (!newToken) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
error: `response missing token at any of [${tokenPaths.join(", ")}]`,
|
|
331
|
+
via: "http",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const userIdPaths = asArray(http.responseUserIdPath);
|
|
335
|
+
const newUserId = userIdPaths.length > 0 ? firstNonEmptyString(payload, userIdPaths) : "";
|
|
336
|
+
if (http.stateFile && http.stateWriteBack) {
|
|
337
|
+
const prevEntry = (all[accountId] && typeof all[accountId] === "object"
|
|
338
|
+
? all[accountId]
|
|
339
|
+
: {});
|
|
340
|
+
const updEntry = { ...prevEntry };
|
|
341
|
+
let touched = false;
|
|
342
|
+
for (const [field, pathSpec] of Object.entries(http.stateWriteBack)) {
|
|
343
|
+
const v = firstNonEmptyString(payload, asArray(pathSpec));
|
|
344
|
+
if (v) {
|
|
345
|
+
updEntry[field] = v;
|
|
346
|
+
touched = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (touched) {
|
|
350
|
+
all[accountId] = updEntry;
|
|
351
|
+
writeStateFile(ctx, http.stateFile, all);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
patchOpenclawConfigToken(ctx, accountId, newToken);
|
|
355
|
+
for (const p of http.clearOnSuccess ?? []) {
|
|
356
|
+
const resolved = subst(p);
|
|
357
|
+
if (!resolved)
|
|
358
|
+
continue;
|
|
359
|
+
try {
|
|
360
|
+
unlinkSync(resolved);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// ignore: not present is fine
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
token: newToken,
|
|
369
|
+
userID: newUserId || undefined,
|
|
370
|
+
via: "http",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
export async function refreshAccountToken(args) {
|
|
374
|
+
const { ctx, accountId, reason } = args;
|
|
375
|
+
const mode = ctx.channel.tokenRefresh.mode;
|
|
376
|
+
if (mode === "off") {
|
|
377
|
+
return { ok: false, error: "tokenRefresh.mode = off", via: "off" };
|
|
378
|
+
}
|
|
379
|
+
if (mode === "http") {
|
|
380
|
+
return callHttp(ctx, accountId, reason);
|
|
381
|
+
}
|
|
382
|
+
return callHook(ctx, accountId, reason);
|
|
383
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registration. `accountId` is required on every call — there is no
|
|
3
|
+
* "first connected wins" fallback, so an agent that omits it cannot
|
|
4
|
+
* accidentally fire into the wrong account.
|
|
5
|
+
*/
|
|
6
|
+
import type { PluginApi } from "./types.js";
|
|
7
|
+
export declare function registerTools(api: PluginApi): void;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registration. `accountId` is required on every call — there is no
|
|
3
|
+
* "first connected wins" fallback, so an agent that omits it cannot
|
|
4
|
+
* accidentally fire into the wrong account.
|
|
5
|
+
*/
|
|
6
|
+
import { getConnectedClient, listConnectedAccountIds } from "./clients.js";
|
|
7
|
+
import { sendFileToTarget, sendImageToTarget, sendTextToTarget, sendVideoToTarget, } from "./media.js";
|
|
8
|
+
import { parseTarget } from "./targets.js";
|
|
9
|
+
import { formatSdkError, logTag } from "./utils.js";
|
|
10
|
+
function errText(text) {
|
|
11
|
+
return { content: [{ type: "text", text }] };
|
|
12
|
+
}
|
|
13
|
+
function ensureTargetAndClient(params) {
|
|
14
|
+
const target = parseTarget(params.target);
|
|
15
|
+
if (!target) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
result: errText("Invalid target format. Expected user:<id> or group:<id>."),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const accountId = String(params.accountId ?? "").trim();
|
|
22
|
+
if (!accountId) {
|
|
23
|
+
const available = listConnectedAccountIds();
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
result: errText(`accountId is required (no default fallback). Connected accounts: [${available.join(", ") || "none"}]`),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const client = getConnectedClient(accountId);
|
|
30
|
+
if (!client) {
|
|
31
|
+
const available = listConnectedAccountIds();
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
result: errText(`multi-openim account "${accountId}" is not connected. Connected: [${available.join(", ") || "none"}]`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, target, client };
|
|
38
|
+
}
|
|
39
|
+
function commonAccountIdParam() {
|
|
40
|
+
return {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "Required. Which connected multi-openim account to send from (e.g. \"primary\", \"team-b\").",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function registerTools(api) {
|
|
46
|
+
if (typeof api.registerTool !== "function") {
|
|
47
|
+
api.logger?.warn?.(`${logTag("tools")} api.registerTool not available; skipping MCP tools`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const register = api.registerTool.bind(api);
|
|
51
|
+
register({
|
|
52
|
+
name: "multi_openim_send_text",
|
|
53
|
+
description: "Send a text message via multi-openim. target format: user:ID or group:ID.",
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
target: { type: "string", description: "user:<id> or group:<id>" },
|
|
58
|
+
text: { type: "string", description: "Text body to send" },
|
|
59
|
+
accountId: commonAccountIdParam(),
|
|
60
|
+
},
|
|
61
|
+
required: ["target", "text", "accountId"],
|
|
62
|
+
},
|
|
63
|
+
async execute(_id, params) {
|
|
64
|
+
const checked = ensureTargetAndClient(params);
|
|
65
|
+
if (!checked.ok)
|
|
66
|
+
return checked.result;
|
|
67
|
+
try {
|
|
68
|
+
await sendTextToTarget(checked.client, checked.target, String(params.text ?? ""));
|
|
69
|
+
return errText("Sent successfully");
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return errText(`Send failed: ${formatSdkError(e)}`);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
register({
|
|
77
|
+
name: "multi_openim_send_image",
|
|
78
|
+
description: "Send an image via multi-openim. `image` supports a local path (file:// supported) or http(s) URL.",
|
|
79
|
+
parameters: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
target: { type: "string", description: "user:<id> or group:<id>" },
|
|
83
|
+
image: { type: "string", description: "Local path (file:// supported) or http(s) URL" },
|
|
84
|
+
accountId: commonAccountIdParam(),
|
|
85
|
+
},
|
|
86
|
+
required: ["target", "image", "accountId"],
|
|
87
|
+
},
|
|
88
|
+
async execute(_id, params) {
|
|
89
|
+
const checked = ensureTargetAndClient(params);
|
|
90
|
+
if (!checked.ok)
|
|
91
|
+
return checked.result;
|
|
92
|
+
try {
|
|
93
|
+
await sendImageToTarget(checked.client, checked.target, String(params.image ?? ""));
|
|
94
|
+
return errText("Image sent successfully");
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return errText(`Send failed: ${formatSdkError(e)}`);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
register({
|
|
102
|
+
name: "multi_openim_send_video",
|
|
103
|
+
description: "Send a video via multi-openim (delivered as a file message). `video` supports a local path or URL.",
|
|
104
|
+
parameters: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
target: { type: "string", description: "user:<id> or group:<id>" },
|
|
108
|
+
video: { type: "string", description: "Local path (file:// supported) or http(s) URL" },
|
|
109
|
+
name: { type: "string", description: "Optional filename override (recommended for URL input)" },
|
|
110
|
+
accountId: commonAccountIdParam(),
|
|
111
|
+
},
|
|
112
|
+
required: ["target", "video", "accountId"],
|
|
113
|
+
},
|
|
114
|
+
async execute(_id, params) {
|
|
115
|
+
const checked = ensureTargetAndClient(params);
|
|
116
|
+
if (!checked.ok)
|
|
117
|
+
return checked.result;
|
|
118
|
+
try {
|
|
119
|
+
await sendVideoToTarget(checked.client, checked.target, String(params.video ?? ""), params.name ? String(params.name) : undefined);
|
|
120
|
+
return errText("Video sent successfully as a file");
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
return errText(`Send failed: ${formatSdkError(e)}`);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
register({
|
|
128
|
+
name: "multi_openim_send_file",
|
|
129
|
+
description: "Send a file via multi-openim. `file` supports a local path or URL; `name` is optional.",
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
target: { type: "string", description: "user:<id> or group:<id>" },
|
|
134
|
+
file: { type: "string", description: "Local path (file:// supported) or http(s) URL" },
|
|
135
|
+
name: { type: "string", description: "Optional filename override" },
|
|
136
|
+
accountId: commonAccountIdParam(),
|
|
137
|
+
},
|
|
138
|
+
required: ["target", "file", "accountId"],
|
|
139
|
+
},
|
|
140
|
+
async execute(_id, params) {
|
|
141
|
+
const checked = ensureTargetAndClient(params);
|
|
142
|
+
if (!checked.ok)
|
|
143
|
+
return checked.result;
|
|
144
|
+
try {
|
|
145
|
+
await sendFileToTarget(checked.client, checked.target, String(params.file ?? ""), params.name ? String(params.name) : undefined);
|
|
146
|
+
return errText("File sent successfully");
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
return errText(`Send failed: ${formatSdkError(e)}`);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|