@mutirolabs/openclaw-brain 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/CHANGELOG.md +50 -0
- package/LICENSE +15 -0
- package/README.md +266 -0
- package/docs/guides/manage-allowlist.md +184 -0
- package/docs/guides/use-openclaw-as-brain.md +401 -0
- package/index.ts +21 -0
- package/openclaw.plugin.json +47 -0
- package/package.json +79 -0
- package/src/actions.ts +53 -0
- package/src/agent-tools.ts +640 -0
- package/src/bridge-client.ts +236 -0
- package/src/bridge-messages.ts +307 -0
- package/src/bridge-protocol.ts +77 -0
- package/src/bridge-session.ts +433 -0
- package/src/channel.runtime.ts +454 -0
- package/src/channel.ts +130 -0
- package/src/config.ts +100 -0
- package/src/inbound.ts +151 -0
- package/src/live-snapshot.ts +210 -0
- package/src/outbound.ts +326 -0
- package/src/setup-surface.ts +281 -0
- package/src/signal-forwarder.ts +153 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// Heavy runtime surface for the Mutiro channel plugin. Owns the registry of
|
|
2
|
+
// active BridgeSession instances keyed by account and serves inbound/outbound
|
|
3
|
+
// calls from the plugin's gateway and outbound adapters.
|
|
4
|
+
//
|
|
5
|
+
// Keeping this file separate from `channel.ts` means the light plugin entry
|
|
6
|
+
// does not pull the NDJSON + child_process machinery into gateway startup.
|
|
7
|
+
|
|
8
|
+
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/core";
|
|
9
|
+
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
|
10
|
+
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
11
|
+
import { recordInboundSessionAndDispatchReply } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
|
12
|
+
import {
|
|
13
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
14
|
+
finalizeInboundContext,
|
|
15
|
+
} from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
|
16
|
+
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
17
|
+
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
|
18
|
+
|
|
19
|
+
type ChannelOutboundContext = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
|
|
20
|
+
type OutboundDeliveryResult = Awaited<
|
|
21
|
+
ReturnType<NonNullable<ChannelOutboundAdapter["sendText"]>>
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
import { startBridgeSession, type BridgeSession } from "./bridge-session.js";
|
|
25
|
+
import type { ResolvedMutiroAccount } from "./config.js";
|
|
26
|
+
import type { InboundDeliver, InboundMessage } from "./inbound.js";
|
|
27
|
+
import { normalizeOutputText } from "./bridge-messages.js";
|
|
28
|
+
|
|
29
|
+
type StartContext = ChannelGatewayContext<ResolvedMutiroAccount>;
|
|
30
|
+
|
|
31
|
+
const sessions = new Map<string, BridgeSession>();
|
|
32
|
+
|
|
33
|
+
const sessionKey = (channel: string, accountId: string) => `${channel}:${accountId}`;
|
|
34
|
+
|
|
35
|
+
const requireSessionForAccount = (accountId: string | null | undefined): BridgeSession => {
|
|
36
|
+
const session = sessions.get(sessionKey("mutiro", accountId ?? "default"));
|
|
37
|
+
if (!session) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`mutiro channel: no active bridge session for account "${accountId ?? "default"}". gateway.startAccount must run first.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return session;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Public accessor used by agent tools that need to reach the active bridge
|
|
46
|
+
// session without throwing when the channel is not running yet.
|
|
47
|
+
export const getMutiroBridgeSession = (
|
|
48
|
+
accountId: string | null | undefined,
|
|
49
|
+
): BridgeSession | undefined =>
|
|
50
|
+
sessions.get(sessionKey("mutiro", accountId ?? "default"));
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs the agent against a delegated task prompt and returns the
|
|
54
|
+
* accumulated reply text. Used by `task.request`, which — unlike
|
|
55
|
+
* `message.observed` — expects the full reply text inside the
|
|
56
|
+
* `ChatBridgeTaskResult` envelope instead of on the outbound bridge.
|
|
57
|
+
*
|
|
58
|
+
* We reuse the same reply-dispatch path as buildDeliverBridge but swap
|
|
59
|
+
* the deliver callback: instead of shipping chunks via bridge.message.send
|
|
60
|
+
* we accumulate them into a buffer the caller returns to the host.
|
|
61
|
+
*
|
|
62
|
+
* Tool side-effects (mutiro_send_voice_message, mutiro_send_card, etc.)
|
|
63
|
+
* still fire normally through their own execute() paths — only the
|
|
64
|
+
* agent's plain reply text is captured for the task result.
|
|
65
|
+
*/
|
|
66
|
+
const buildResolveTaskRequest = (ctx: StartContext) =>
|
|
67
|
+
async (params: {
|
|
68
|
+
conversationId: string;
|
|
69
|
+
accountId: string;
|
|
70
|
+
username?: string;
|
|
71
|
+
prompt: string;
|
|
72
|
+
promptData?: Record<string, string>;
|
|
73
|
+
metadata?: Record<string, string>;
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
requestId?: string;
|
|
76
|
+
}): Promise<string> => {
|
|
77
|
+
const senderUsername = (params.username ?? "").trim() || "system";
|
|
78
|
+
const route = resolveAgentRoute({
|
|
79
|
+
cfg: ctx.cfg,
|
|
80
|
+
channel: "mutiro",
|
|
81
|
+
accountId: params.accountId,
|
|
82
|
+
peer: { kind: "direct", id: senderUsername },
|
|
83
|
+
});
|
|
84
|
+
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId: route.agentId });
|
|
85
|
+
const messageSid = params.requestId ?? `task-${Date.now()}`;
|
|
86
|
+
const ctxPayload = finalizeInboundContext({
|
|
87
|
+
Body: params.prompt,
|
|
88
|
+
BodyForAgent: params.prompt,
|
|
89
|
+
RawBody: params.prompt,
|
|
90
|
+
CommandBody: params.prompt,
|
|
91
|
+
From: senderUsername,
|
|
92
|
+
To: params.conversationId,
|
|
93
|
+
SessionKey: route.sessionKey,
|
|
94
|
+
AccountId: route.accountId ?? params.accountId,
|
|
95
|
+
ChatType: "direct",
|
|
96
|
+
ConversationLabel: params.conversationId,
|
|
97
|
+
SenderId: senderUsername,
|
|
98
|
+
Provider: "mutiro",
|
|
99
|
+
Surface: "mutiro",
|
|
100
|
+
MessageSid: messageSid,
|
|
101
|
+
MessageSidFull: messageSid,
|
|
102
|
+
Timestamp: Date.now(),
|
|
103
|
+
OriginatingChannel: "mutiro",
|
|
104
|
+
OriginatingTo: params.conversationId,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const accumulator: string[] = [];
|
|
108
|
+
const dispatchPromise = recordInboundSessionAndDispatchReply({
|
|
109
|
+
cfg: ctx.cfg,
|
|
110
|
+
channel: "mutiro",
|
|
111
|
+
accountId: params.accountId,
|
|
112
|
+
agentId: route.agentId,
|
|
113
|
+
routeSessionKey: route.sessionKey,
|
|
114
|
+
storePath,
|
|
115
|
+
ctxPayload,
|
|
116
|
+
recordInboundSession,
|
|
117
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
118
|
+
deliver: async (payload) => {
|
|
119
|
+
const chunk = String(payload.text ?? "");
|
|
120
|
+
if (chunk) accumulator.push(chunk);
|
|
121
|
+
},
|
|
122
|
+
onRecordError: (err) =>
|
|
123
|
+
ctx.log?.warn?.(`mutiro: task record error: ${formatError(err)}`),
|
|
124
|
+
onDispatchError: (err, info) =>
|
|
125
|
+
ctx.log?.warn?.(`mutiro: task dispatch error (${info.kind}): ${formatError(err)}`),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Honor timeout_ms: whichever finishes first, use what accumulated.
|
|
129
|
+
// If the dispatch ran past the deadline we return partial output and
|
|
130
|
+
// let the background promise drain; the host already has its answer.
|
|
131
|
+
if (params.timeoutMs && params.timeoutMs > 0) {
|
|
132
|
+
await Promise.race([
|
|
133
|
+
dispatchPromise,
|
|
134
|
+
new Promise<void>((resolve) => setTimeout(resolve, params.timeoutMs)),
|
|
135
|
+
]);
|
|
136
|
+
} else {
|
|
137
|
+
await dispatchPromise;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return normalizeOutputText(accumulator.join(""));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const buildDeliverBridge = (ctx: StartContext): InboundDeliver =>
|
|
144
|
+
async (inbound: InboundMessage) => {
|
|
145
|
+
const session = requireSessionForAccount(inbound.accountId);
|
|
146
|
+
|
|
147
|
+
// Resolve the routing / session / ctxPayload pieces directly from the
|
|
148
|
+
// public plugin-sdk helpers rather than relying on `ctx.channelRuntime`
|
|
149
|
+
// to carry the full runtime surface (it does not for bundled channels).
|
|
150
|
+
const route = resolveAgentRoute({
|
|
151
|
+
cfg: ctx.cfg,
|
|
152
|
+
channel: "mutiro",
|
|
153
|
+
accountId: inbound.accountId,
|
|
154
|
+
peer: { kind: "direct", id: inbound.senderUsername },
|
|
155
|
+
});
|
|
156
|
+
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId: route.agentId });
|
|
157
|
+
|
|
158
|
+
const target = {
|
|
159
|
+
conversationId: inbound.conversationId,
|
|
160
|
+
replyToMessageId: inbound.messageId,
|
|
161
|
+
};
|
|
162
|
+
const { createSignalForwarder } = await import("./signal-forwarder.js");
|
|
163
|
+
const signals = createSignalForwarder(session, target);
|
|
164
|
+
// Fire a THINKING pulse immediately so the user sees feedback while
|
|
165
|
+
// dispatch warms up (model selection, memory loads, etc.). Subsequent
|
|
166
|
+
// on* callbacks replace it with more specific signals.
|
|
167
|
+
signals.thinking();
|
|
168
|
+
|
|
169
|
+
const mediaPaths = inbound.mediaPaths ?? [];
|
|
170
|
+
const mediaTypes = inbound.mediaTypes ?? [];
|
|
171
|
+
const ctxPayload = finalizeInboundContext({
|
|
172
|
+
Body: inbound.text,
|
|
173
|
+
BodyForAgent: inbound.text,
|
|
174
|
+
RawBody: inbound.text,
|
|
175
|
+
CommandBody: inbound.text,
|
|
176
|
+
From: inbound.senderUsername,
|
|
177
|
+
To: inbound.conversationId,
|
|
178
|
+
SessionKey: route.sessionKey,
|
|
179
|
+
AccountId: route.accountId ?? inbound.accountId,
|
|
180
|
+
ChatType: "direct",
|
|
181
|
+
ConversationLabel: inbound.conversationId,
|
|
182
|
+
SenderId: inbound.senderUsername,
|
|
183
|
+
Provider: "mutiro",
|
|
184
|
+
Surface: "mutiro",
|
|
185
|
+
MessageSid: inbound.messageId,
|
|
186
|
+
MessageSidFull: inbound.messageId,
|
|
187
|
+
Timestamp: Date.now(),
|
|
188
|
+
OriginatingChannel: "mutiro",
|
|
189
|
+
OriginatingTo: inbound.conversationId,
|
|
190
|
+
...(mediaPaths.length > 0
|
|
191
|
+
? {
|
|
192
|
+
MediaPath: mediaPaths[0],
|
|
193
|
+
MediaPaths: mediaPaths,
|
|
194
|
+
MediaType: mediaTypes[0],
|
|
195
|
+
MediaTypes: mediaTypes,
|
|
196
|
+
}
|
|
197
|
+
: {}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await recordInboundSessionAndDispatchReply({
|
|
201
|
+
cfg: ctx.cfg,
|
|
202
|
+
channel: "mutiro",
|
|
203
|
+
accountId: inbound.accountId,
|
|
204
|
+
agentId: route.agentId,
|
|
205
|
+
routeSessionKey: route.sessionKey,
|
|
206
|
+
storePath,
|
|
207
|
+
ctxPayload,
|
|
208
|
+
recordInboundSession,
|
|
209
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
210
|
+
deliver: async (payload) => {
|
|
211
|
+
const text = normalizeOutputText(String(payload.text ?? ""));
|
|
212
|
+
if (!text) return;
|
|
213
|
+
await session.outbound.sendText(target, text);
|
|
214
|
+
},
|
|
215
|
+
// replyOptions taps OpenClaw's mid-turn hooks to forward progress
|
|
216
|
+
// into Mutiro's signal stream. Each on* callback maps to a specific
|
|
217
|
+
// SIGNAL_TYPE_* so the user sees "searching web", "remembering",
|
|
218
|
+
// "writing response" pills instead of a single static "thinking".
|
|
219
|
+
replyOptions: {
|
|
220
|
+
onAssistantMessageStart: () => signals.typing(),
|
|
221
|
+
onReasoningStream: () => signals.reasoning(),
|
|
222
|
+
onToolStart: (payload) => signals.toolStart(payload.name, payload.phase),
|
|
223
|
+
// onItemEvent carries richer detail than onToolStart — `title` is
|
|
224
|
+
// resolved from tool args (e.g. "read src/x.ts"). Refine only on
|
|
225
|
+
// the start phase; "end"/"update" would thrash the pill.
|
|
226
|
+
onItemEvent: (payload) => {
|
|
227
|
+
if (payload.phase && payload.phase !== "start") return;
|
|
228
|
+
signals.itemStart({
|
|
229
|
+
name: payload.name,
|
|
230
|
+
title: payload.title,
|
|
231
|
+
phase: payload.phase,
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
onCompactionStart: () => signals.compactionStart(),
|
|
235
|
+
onCompactionEnd: () => signals.compactionEnd(),
|
|
236
|
+
onPlanUpdate: (payload) => signals.planUpdate(payload.title),
|
|
237
|
+
},
|
|
238
|
+
onRecordError: (err) => ctx.log?.warn?.(`mutiro: record session error: ${formatError(err)}`),
|
|
239
|
+
onDispatchError: (err, info) =>
|
|
240
|
+
ctx.log?.warn?.(`mutiro: dispatch error (${info.kind}): ${formatError(err)}`),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Close the signal stream and the host-owned turn lifecycle.
|
|
244
|
+
// TURN_COMPLETE clears any lingering pill in the Mutiro UI; endTurn
|
|
245
|
+
// releases the host-side pending turn regardless of whether visible
|
|
246
|
+
// replies were emitted.
|
|
247
|
+
signals.turnComplete();
|
|
248
|
+
session.outbound.endTurn(target);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const startMutiroAccount = async (ctx: StartContext) => {
|
|
252
|
+
const key = sessionKey("mutiro", ctx.accountId);
|
|
253
|
+
const existing = sessions.get(key);
|
|
254
|
+
if (existing) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { account } = ctx;
|
|
259
|
+
if (!account.configured || !account.config.agentDir) {
|
|
260
|
+
ctx.log?.warn?.(`mutiro: account "${ctx.accountId}" is not configured (missing agentDir)`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The gateway expects startAccount to stay pending for the lifetime of the
|
|
265
|
+
// channel. Resolving early is interpreted as "channel exited" and triggers
|
|
266
|
+
// an auto-restart loop. We block on exit-or-abort via this deferred.
|
|
267
|
+
let settleLifecycle: () => void = () => {};
|
|
268
|
+
const lifecycle = new Promise<void>((resolve) => {
|
|
269
|
+
settleLifecycle = resolve;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const session = await startBridgeSession({
|
|
273
|
+
accountId: ctx.accountId,
|
|
274
|
+
agentDir: account.config.agentDir,
|
|
275
|
+
clientName: account.config.clientName,
|
|
276
|
+
requestedOptionalCapabilities: account.config.requestedOptionalCapabilities,
|
|
277
|
+
deliver: buildDeliverBridge(ctx),
|
|
278
|
+
resolveTaskRequest: buildResolveTaskRequest(ctx),
|
|
279
|
+
resolveLiveSnapshot: async (params) => {
|
|
280
|
+
// Lazy-load the snapshot module so startup stays clean and the
|
|
281
|
+
// plugin-sdk/config-runtime surface is only touched when the host
|
|
282
|
+
// actually requests a live handoff (i.e. a voice call starts).
|
|
283
|
+
const { buildLiveSnapshot } = await import("./live-snapshot.js");
|
|
284
|
+
return buildLiveSnapshot({
|
|
285
|
+
cfg: ctx.cfg,
|
|
286
|
+
accountId: params.accountId,
|
|
287
|
+
conversationId: params.conversationId,
|
|
288
|
+
callerUsername: params.username,
|
|
289
|
+
callId: params.callId,
|
|
290
|
+
agentUsername: session?.getAgentUsername() ?? "",
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
logger: ctx.log
|
|
294
|
+
? {
|
|
295
|
+
info: ctx.log.info,
|
|
296
|
+
warn: ctx.log.warn,
|
|
297
|
+
error: ctx.log.error,
|
|
298
|
+
}
|
|
299
|
+
: undefined,
|
|
300
|
+
onHostExit: (code) => {
|
|
301
|
+
sessions.delete(key);
|
|
302
|
+
ctx.log?.info?.(`mutiro: host (${ctx.accountId}) exited with code ${code}`);
|
|
303
|
+
ctx.setStatus({
|
|
304
|
+
...ctx.getStatus(),
|
|
305
|
+
running: false,
|
|
306
|
+
connected: false,
|
|
307
|
+
lastDisconnect: { at: Date.now(), status: code ?? undefined },
|
|
308
|
+
});
|
|
309
|
+
settleLifecycle();
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
sessions.set(key, session);
|
|
314
|
+
ctx.setStatus({
|
|
315
|
+
...ctx.getStatus(),
|
|
316
|
+
running: true,
|
|
317
|
+
connected: true,
|
|
318
|
+
lastConnectedAt: Date.now(),
|
|
319
|
+
lastStartAt: Date.now(),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const onAbort = () => {
|
|
323
|
+
void stopSession(key).finally(settleLifecycle);
|
|
324
|
+
};
|
|
325
|
+
if (ctx.abortSignal.aborted) {
|
|
326
|
+
onAbort();
|
|
327
|
+
} else {
|
|
328
|
+
ctx.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await lifecycle;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const stopMutiroAccount = async (ctx: StartContext) => {
|
|
335
|
+
await stopSession(sessionKey("mutiro", ctx.accountId));
|
|
336
|
+
ctx.setStatus({
|
|
337
|
+
...ctx.getStatus(),
|
|
338
|
+
running: false,
|
|
339
|
+
connected: false,
|
|
340
|
+
lastStopAt: Date.now(),
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const stopSession = async (key: string) => {
|
|
345
|
+
const session = sessions.get(key);
|
|
346
|
+
if (!session) return;
|
|
347
|
+
sessions.delete(key);
|
|
348
|
+
await session.shutdown();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export const sendMutiroText = async (
|
|
352
|
+
ctx: ChannelOutboundContext,
|
|
353
|
+
): Promise<OutboundDeliveryResult> => {
|
|
354
|
+
const session = requireSessionForAccount(ctx.accountId);
|
|
355
|
+
await session.outbound.sendText(
|
|
356
|
+
{
|
|
357
|
+
conversationId: ctx.to,
|
|
358
|
+
replyToMessageId: ctx.replyToId ?? ctx.threadId?.toString() ?? "",
|
|
359
|
+
},
|
|
360
|
+
ctx.text,
|
|
361
|
+
);
|
|
362
|
+
return {
|
|
363
|
+
channel: "mutiro",
|
|
364
|
+
messageId: ctx.replyToId ?? `mutiro-${Date.now()}`,
|
|
365
|
+
conversationId: ctx.to,
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export const sendMutiroMedia = async (
|
|
370
|
+
ctx: ChannelOutboundContext,
|
|
371
|
+
): Promise<OutboundDeliveryResult> => {
|
|
372
|
+
if (!ctx.mediaUrl) {
|
|
373
|
+
throw new Error("sendMedia called without mediaUrl");
|
|
374
|
+
}
|
|
375
|
+
const session = requireSessionForAccount(ctx.accountId);
|
|
376
|
+
await session.outbound.sendFile(
|
|
377
|
+
{
|
|
378
|
+
conversationId: ctx.to,
|
|
379
|
+
replyToMessageId: ctx.replyToId ?? ctx.threadId?.toString() ?? "",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
filePath: ctx.mediaUrl,
|
|
383
|
+
caption: ctx.text,
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
return {
|
|
387
|
+
channel: "mutiro",
|
|
388
|
+
messageId: ctx.replyToId ?? `mutiro-${Date.now()}`,
|
|
389
|
+
conversationId: ctx.to,
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const formatError = (err: unknown) =>
|
|
394
|
+
err instanceof Error ? err.message : JSON.stringify(err);
|
|
395
|
+
|
|
396
|
+
// Dispatcher for ChannelMessageActionAdapter.handleAction. Declared here so
|
|
397
|
+
// the heavy runtime does the bridge work, and the light `actions.ts` file
|
|
398
|
+
// stays a pure control-plane adapter that loads this lazily.
|
|
399
|
+
export const handleMutiroMessageAction = async (params: {
|
|
400
|
+
action: string;
|
|
401
|
+
params: Record<string, unknown>;
|
|
402
|
+
accountId?: string;
|
|
403
|
+
readStringArg: (params: Record<string, unknown>, ...keys: string[]) => string | undefined;
|
|
404
|
+
}) => {
|
|
405
|
+
const session = requireSessionForAccount(params.accountId);
|
|
406
|
+
|
|
407
|
+
if (params.action === "react") {
|
|
408
|
+
const messageId = params.readStringArg(params.params, "messageId", "message_id", "to");
|
|
409
|
+
const emoji = params.readStringArg(params.params, "emoji", "reaction");
|
|
410
|
+
if (!messageId) {
|
|
411
|
+
return {
|
|
412
|
+
content: [{ type: "text" as const, text: "react requires a messageId." }],
|
|
413
|
+
details: { ok: false, reason: "missing_message_id" },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (!emoji) {
|
|
417
|
+
return {
|
|
418
|
+
content: [{ type: "text" as const, text: "react requires an emoji." }],
|
|
419
|
+
details: { ok: false, reason: "missing_emoji" },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const raw = await session.outbound.react({ messageId, emoji });
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: "text" as const, text: `Reacted ${emoji} to ${messageId}.` }],
|
|
426
|
+
details: { ok: true, raw },
|
|
427
|
+
};
|
|
428
|
+
} catch (err) {
|
|
429
|
+
const message = formatError(err);
|
|
430
|
+
return {
|
|
431
|
+
content: [{ type: "text" as const, text: `Failed to react: ${message}` }],
|
|
432
|
+
details: { ok: false, reason: "bridge_error", error: message },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
content: [
|
|
439
|
+
{
|
|
440
|
+
type: "text" as const,
|
|
441
|
+
text: `Unsupported Mutiro message action: ${params.action}`,
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
details: { ok: false, reason: "unsupported_action", action: params.action },
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// Barrel export consumed by the plugin entry via `loadBundledEntryExportSync`.
|
|
449
|
+
export const mutiroChannelRuntime = {
|
|
450
|
+
startMutiroAccount,
|
|
451
|
+
stopMutiroAccount,
|
|
452
|
+
sendMutiroText,
|
|
453
|
+
sendMutiroMedia,
|
|
454
|
+
};
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// OpenClaw channel plugin definition. This file is the "hot" import path
|
|
2
|
+
// loaded during gateway startup and plugin discovery, so it stays narrow:
|
|
3
|
+
// manifest metadata plus a lazy handle into the heavier runtime module.
|
|
4
|
+
//
|
|
5
|
+
// The runtime (`channel.runtime.ts`) owns subprocess lifecycle, envelope
|
|
6
|
+
// dispatch, and the per-account bridge session registry.
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ChannelOutboundAdapter,
|
|
10
|
+
ChannelPlugin,
|
|
11
|
+
} from "openclaw/plugin-sdk/core";
|
|
12
|
+
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
13
|
+
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
|
14
|
+
|
|
15
|
+
import { mutiroMessageActions } from "./actions.js";
|
|
16
|
+
import { mutiroAgentTools } from "./agent-tools.js";
|
|
17
|
+
import { mutiroConfigAdapter, type ResolvedMutiroAccount } from "./config.js";
|
|
18
|
+
import { mutiroSetupAdapter, mutiroSetupWizard } from "./setup-surface.js";
|
|
19
|
+
|
|
20
|
+
const loadMutiroChannelRuntime = createLazyRuntimeNamedExport(
|
|
21
|
+
() => import("./channel.runtime.js"),
|
|
22
|
+
"mutiroChannelRuntime",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const outbound: ChannelOutboundAdapter = {
|
|
26
|
+
deliveryMode: "direct",
|
|
27
|
+
|
|
28
|
+
// `sendText` is called whenever OpenClaw wants to push a text reply into a
|
|
29
|
+
// Mutiro conversation. The `accountId` selects which active bridge session
|
|
30
|
+
// to route through; `to` is the Mutiro `conversation_id`; `replyToId` is the
|
|
31
|
+
// message the reply threads under.
|
|
32
|
+
async sendText(ctx) {
|
|
33
|
+
const runtime = await loadMutiroChannelRuntime();
|
|
34
|
+
return runtime.sendMutiroText(ctx);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// `sendMedia` is the single-shot media path. The channel runtime uses the
|
|
38
|
+
// bridge-local `media.upload` command to stage the file, then attaches it
|
|
39
|
+
// to a `message.send`.
|
|
40
|
+
async sendMedia(ctx) {
|
|
41
|
+
const runtime = await loadMutiroChannelRuntime();
|
|
42
|
+
return runtime.sendMutiroMedia(ctx);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const mutiroPlugin: ChannelPlugin<ResolvedMutiroAccount> = createChatChannelPlugin<
|
|
47
|
+
ResolvedMutiroAccount
|
|
48
|
+
>({
|
|
49
|
+
base: {
|
|
50
|
+
id: "mutiro",
|
|
51
|
+
meta: {
|
|
52
|
+
id: "mutiro",
|
|
53
|
+
label: "Mutiro",
|
|
54
|
+
selectionLabel: "Mutiro (plugin)",
|
|
55
|
+
docsPath: "/channels/mutiro",
|
|
56
|
+
docsLabel: "mutiro",
|
|
57
|
+
blurb: "chatbridge channel; configure a Mutiro agent directory to enable.",
|
|
58
|
+
order: 80,
|
|
59
|
+
quickstartAllowFrom: true,
|
|
60
|
+
markdownCapable: true,
|
|
61
|
+
},
|
|
62
|
+
capabilities: {
|
|
63
|
+
chatTypes: ["direct", "group"],
|
|
64
|
+
reactions: true,
|
|
65
|
+
reply: true,
|
|
66
|
+
media: true,
|
|
67
|
+
},
|
|
68
|
+
config: mutiroConfigAdapter,
|
|
69
|
+
agentTools: mutiroAgentTools,
|
|
70
|
+
actions: mutiroMessageActions,
|
|
71
|
+
|
|
72
|
+
// Setup surfaces: `setup` is the non-interactive adapter path
|
|
73
|
+
// (`openclaw channels add --channel mutiro [flags]`); `setupWizard` is what
|
|
74
|
+
// runs when the user invokes `openclaw channels add` with no flags and
|
|
75
|
+
// picks `mutiro` from the selection list.
|
|
76
|
+
setup: mutiroSetupAdapter,
|
|
77
|
+
setupWizard: mutiroSetupWizard,
|
|
78
|
+
|
|
79
|
+
// Messaging adapter: teaches OpenClaw how to recognize a Mutiro target.
|
|
80
|
+
// Without it, reactions/forwards/cross-channel sends fail with
|
|
81
|
+
// "Unknown target" because the core resolver can't match a
|
|
82
|
+
// `conv_<uuid>` conversation id or a leading-@ username against any
|
|
83
|
+
// directory/id pattern it knows about.
|
|
84
|
+
messaging: {
|
|
85
|
+
normalizeTarget: (raw: string) => {
|
|
86
|
+
const trimmed = raw.trim();
|
|
87
|
+
if (!trimmed) return undefined;
|
|
88
|
+
// Strip a leading @ on usernames so downstream comparisons and the
|
|
89
|
+
// bridge's `to_username` field see the raw handle.
|
|
90
|
+
return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
91
|
+
},
|
|
92
|
+
targetResolver: {
|
|
93
|
+
hint: "Use a Mutiro conversation id (e.g. conv_<uuid>) or @username.",
|
|
94
|
+
looksLikeId: (raw: string, normalized?: string) => {
|
|
95
|
+
const value = (normalized ?? raw).trim();
|
|
96
|
+
if (!value) return false;
|
|
97
|
+
// conv_<...> = conversation id; bare @handle or a plain alphanumeric
|
|
98
|
+
// username both route to message.send_voice / react / etc.
|
|
99
|
+
return /^conv_/i.test(value) || /^@/.test(raw) || /^[A-Za-z0-9_.-]+$/.test(value);
|
|
100
|
+
},
|
|
101
|
+
resolveTarget: async ({ input, normalized }) => {
|
|
102
|
+
const value = (normalized || input).trim().replace(/^@/, "");
|
|
103
|
+
if (!value) return null;
|
|
104
|
+
const isConversation = /^conv_/i.test(value);
|
|
105
|
+
return {
|
|
106
|
+
to: value,
|
|
107
|
+
kind: isConversation ? "group" : "user",
|
|
108
|
+
display: isConversation ? value : `@${value}`,
|
|
109
|
+
source: "normalized",
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Gateway lifecycle: startAccount spawns the bridge subprocess for this
|
|
116
|
+
// account and wires inbound observed messages into OpenClaw's reply
|
|
117
|
+
// dispatcher. stopAccount tears the subprocess down.
|
|
118
|
+
gateway: {
|
|
119
|
+
async startAccount(ctx) {
|
|
120
|
+
const runtime = await loadMutiroChannelRuntime();
|
|
121
|
+
return runtime.startMutiroAccount(ctx);
|
|
122
|
+
},
|
|
123
|
+
async stopAccount(ctx) {
|
|
124
|
+
const runtime = await loadMutiroChannelRuntime();
|
|
125
|
+
await runtime.stopMutiroAccount(ctx);
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
outbound,
|
|
130
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Mutiro channel configuration and per-account resolution.
|
|
2
|
+
//
|
|
3
|
+
// The channel supports one or more named accounts; each account pins a
|
|
4
|
+
// specific Mutiro agent directory that `mutiro agent host --mode=bridge`
|
|
5
|
+
// should run from. We reuse OpenClaw's `createScopedChannelConfigAdapter` so
|
|
6
|
+
// the account lifecycle (list/default/resolve) flows through the same shape
|
|
7
|
+
// the Plugin SDK already knows how to drive.
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
10
|
+
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
11
|
+
|
|
12
|
+
export { DEFAULT_ACCOUNT_ID };
|
|
13
|
+
|
|
14
|
+
export type MutiroAccountConfig = {
|
|
15
|
+
agentDir: string;
|
|
16
|
+
clientName?: string;
|
|
17
|
+
requestedOptionalCapabilities?: string[];
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
name?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ResolvedMutiroAccount = {
|
|
23
|
+
accountId: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
configured: boolean;
|
|
26
|
+
name?: string;
|
|
27
|
+
config: MutiroAccountConfig;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type MutiroChannelSection = {
|
|
31
|
+
accounts?: Record<string, MutiroAccountConfig & { name?: string; enabled?: boolean }>;
|
|
32
|
+
// Support single-account shorthand by keeping top-level fields too.
|
|
33
|
+
agentDir?: string;
|
|
34
|
+
clientName?: string;
|
|
35
|
+
requestedOptionalCapabilities?: string[];
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const readMutiroSection = (cfg: OpenClawConfig): MutiroChannelSection | undefined => {
|
|
40
|
+
const channels = (cfg as { channels?: Record<string, unknown> }).channels;
|
|
41
|
+
return channels?.mutiro as MutiroChannelSection | undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const resolveAccountConfig = (
|
|
45
|
+
cfg: OpenClawConfig,
|
|
46
|
+
accountId: string,
|
|
47
|
+
): MutiroAccountConfig | undefined => {
|
|
48
|
+
const section = readMutiroSection(cfg);
|
|
49
|
+
if (!section) return undefined;
|
|
50
|
+
|
|
51
|
+
if (accountId === DEFAULT_ACCOUNT_ID && section.agentDir) {
|
|
52
|
+
return {
|
|
53
|
+
agentDir: section.agentDir,
|
|
54
|
+
clientName: section.clientName,
|
|
55
|
+
requestedOptionalCapabilities: section.requestedOptionalCapabilities,
|
|
56
|
+
enabled: section.enabled,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return section.accounts?.[accountId];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const listMutiroAccountIds = (cfg: OpenClawConfig): string[] => {
|
|
64
|
+
const section = readMutiroSection(cfg);
|
|
65
|
+
const named = Object.keys(section?.accounts ?? {});
|
|
66
|
+
if (named.length > 0) return named;
|
|
67
|
+
return section?.agentDir ? [DEFAULT_ACCOUNT_ID] : [];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const resolveDefaultMutiroAccountId = (cfg: OpenClawConfig): string => {
|
|
71
|
+
const ids = listMutiroAccountIds(cfg);
|
|
72
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const resolveMutiroAccount = (
|
|
76
|
+
cfg: OpenClawConfig,
|
|
77
|
+
accountId?: string | null,
|
|
78
|
+
): ResolvedMutiroAccount => {
|
|
79
|
+
const id = accountId || resolveDefaultMutiroAccountId(cfg);
|
|
80
|
+
const base = resolveAccountConfig(cfg, id) ?? { agentDir: "" };
|
|
81
|
+
const configured = Boolean(base.agentDir);
|
|
82
|
+
return {
|
|
83
|
+
accountId: id,
|
|
84
|
+
enabled: base.enabled !== false,
|
|
85
|
+
configured,
|
|
86
|
+
name: base.name,
|
|
87
|
+
config: base,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Build the ChannelConfigAdapter directly. The helpers in
|
|
92
|
+
// `channel-config-helpers` expect allowlist and clear-base-field accessors
|
|
93
|
+
// that Mutiro does not use, so we wire the two required hooks manually.
|
|
94
|
+
export const mutiroConfigAdapter: ChannelPlugin<ResolvedMutiroAccount>["config"] = {
|
|
95
|
+
listAccountIds: listMutiroAccountIds,
|
|
96
|
+
resolveAccount: resolveMutiroAccount,
|
|
97
|
+
defaultAccountId: resolveDefaultMutiroAccountId,
|
|
98
|
+
isEnabled: (account: ResolvedMutiroAccount) => account.enabled,
|
|
99
|
+
isConfigured: (account: ResolvedMutiroAccount) => account.configured,
|
|
100
|
+
};
|