@moltzap/openclaw-channel 2026.511.0 → 2026.519.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/context-log.d.ts +2 -21
- package/dist/context-log.d.ts.map +1 -1
- package/dist/context-log.js +46 -33
- package/dist/context-log.js.map +1 -1
- package/dist/openclaw-entry.d.ts +74 -40
- package/dist/openclaw-entry.d.ts.map +1 -1
- package/dist/openclaw-entry.js +562 -403
- package/dist/openclaw-entry.js.map +1 -1
- package/dist/test-utils/container-core.d.ts +14 -10
- package/dist/test-utils/container-core.d.ts.map +1 -1
- package/dist/test-utils/container-core.js +259 -135
- package/dist/test-utils/container-core.js.map +1 -1
- package/package.json +10 -4
- package/dist/format-cross-conv.d.ts +0 -13
- package/dist/format-cross-conv.d.ts.map +0 -1
- package/dist/format-cross-conv.js +0 -20
- package/dist/format-cross-conv.js.map +0 -1
- package/dist/mapping.d.ts +0 -34
- package/dist/mapping.d.ts.map +0 -1
- package/dist/mapping.js +0 -71
- package/dist/mapping.js.map +0 -1
package/dist/openclaw-entry.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Wraps the existing MoltZapWsClient + mapping modules into the
|
|
5
5
|
* ChannelPlugin shape expected by OpenClaw's api.registerChannel().
|
|
6
6
|
*
|
|
7
|
-
* Installed via: openclaw plugin install
|
|
7
|
+
* Installed via: openclaw plugin install `@moltzap/openclaw-channel`
|
|
8
8
|
* Config: channels.moltzap.accounts[].{apiKey, serverUrl, agentName}
|
|
9
9
|
*
|
|
10
10
|
* OpenClaw's plugin interface imposes Promise-based contracts at the boundary
|
|
@@ -13,10 +13,9 @@
|
|
|
13
13
|
* `Effect.runPromise` tax at the plugin surface.
|
|
14
14
|
*/
|
|
15
15
|
import { MoltZapChannelCore, MoltZapService, } from "@moltzap/client";
|
|
16
|
+
import { LeaseAlreadyConsumed, LeaseGuard, catchLeaseInvalid, formatCrossConv, getGroupFields, } from "@moltzap/client/channel-base";
|
|
16
17
|
import { Config, ConfigProvider, Data, Effect, Option } from "effect";
|
|
17
|
-
import {
|
|
18
|
-
import { writeOpenClawContextLog } from "./context-log.js";
|
|
19
|
-
import { extractConversationCreated, extractConversationUpdated, extractContactRequest, extractContactAccepted, extractPresenceChanged, } from "./mapping.js";
|
|
18
|
+
import { writeOpenClawContextLog, } from "./context-log.js";
|
|
20
19
|
import { AgentsLookup, ContactsList, ConversationsList, } from "@moltzap/protocol";
|
|
21
20
|
import { TaskClosedError } from "@moltzap/protocol/task";
|
|
22
21
|
import { RpcServerError } from "@moltzap/protocol/transport";
|
|
@@ -24,7 +23,6 @@ const DEFAULT_ACCOUNT_ID = "default";
|
|
|
24
23
|
const CHANNEL_ID = "moltzap";
|
|
25
24
|
const TARGET_PREFIX_AGENT = "agent:";
|
|
26
25
|
const TARGET_PREFIX_CONV = "conv:";
|
|
27
|
-
const OPENCLAW_STRUCTURED_LOG_MIN_ARGS = 2;
|
|
28
26
|
const INBOUND_LOG_PREVIEW_CHARS = 80;
|
|
29
27
|
const BODY_FOR_AGENT_LOG_PREVIEW_CHARS = 500;
|
|
30
28
|
const OUTBOUND_LOG_PREVIEW_CHARS = 80;
|
|
@@ -41,19 +39,30 @@ class MoltZapClientNotConnectedError extends Data.TaggedError("MoltZapClientNotC
|
|
|
41
39
|
return `MoltZap client not connected for account ${this.accountId}`;
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
class MoltZapAgentTargetUnsupportedError extends Data.TaggedError("MoltZapAgentTargetUnsupportedError") {
|
|
43
|
+
get message() {
|
|
44
|
+
return `MoltZap client for account ${this.accountId} cannot resolve agent targets`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
class OpenClawTargetResolved extends Data.TaggedClass("OpenClawTargetResolved") {
|
|
48
|
+
ok = true;
|
|
49
|
+
}
|
|
50
|
+
class OpenClawTargetRejected extends Data.TaggedClass("OpenClawTargetRejected") {
|
|
51
|
+
ok = false;
|
|
52
|
+
}
|
|
53
|
+
class OpenClawSendTextSuccess extends Data.TaggedClass("OpenClawSendTextSuccess") {
|
|
54
|
+
ok = true;
|
|
55
|
+
}
|
|
56
|
+
class OpenClawSendTextFailure extends Data.TaggedClass("OpenClawSendTextFailure") {
|
|
57
|
+
ok = false;
|
|
58
|
+
}
|
|
59
|
+
function logContextLogWriteFailure(log, error) {
|
|
60
|
+
return Effect.sync(() => {
|
|
61
|
+
log?.warn?.(`MoltZap: failed to write OpenClaw context log: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function writeContextLogOrWarn(log, input) {
|
|
65
|
+
return writeOpenClawContextLog(input).pipe(Effect.catchAll((error) => logContextLogWriteFailure(log, error)));
|
|
57
66
|
}
|
|
58
67
|
function resolveAccountList(cfg) {
|
|
59
68
|
const section = cfg.channels?.moltzap;
|
|
@@ -80,402 +89,552 @@ const waitForAbort = (signal) => Effect.async((resume) => {
|
|
|
80
89
|
}
|
|
81
90
|
signal.addEventListener("abort", () => resume(Effect.void), { once: true });
|
|
82
91
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
function contactAgentIds(contact) {
|
|
93
|
+
return (contact.agents ?? []).map((agent) => agent.id);
|
|
94
|
+
}
|
|
95
|
+
function logOutboundReply(log, conversationId, text) {
|
|
96
|
+
return Effect.sync(() => {
|
|
97
|
+
log?.info?.(`MoltZap: outbound reply to ${conversationId}: ${text.slice(0, OUTBOUND_LOG_PREVIEW_CHARS)}`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function isTaskClosedRpcError(err) {
|
|
101
|
+
return err instanceof RpcServerError && err.code === TaskClosedError.code;
|
|
102
|
+
}
|
|
103
|
+
function handleReplyFailure(log, conversationId, err) {
|
|
104
|
+
return Effect.sync(() => {
|
|
105
|
+
if (isTaskClosedRpcError(err)) {
|
|
106
|
+
log?.warn?.({
|
|
107
|
+
conversationId,
|
|
108
|
+
code: err.code,
|
|
109
|
+
msg: err.message,
|
|
110
|
+
}, "MoltZap: send rejected; task closed, dropping without retry");
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
log?.error?.(`MoltZap: failed to send reply: ${err}`);
|
|
114
|
+
return false;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function sendDeliveredReply(params) {
|
|
118
|
+
return params.core
|
|
119
|
+
.sendReply(params.conversationId, params.text, params.leaseId !== undefined
|
|
120
|
+
? { dispatchLeaseId: params.leaseId }
|
|
121
|
+
: undefined)
|
|
122
|
+
.pipe(catchLeaseInvalid(params.leaseId !== undefined ? { leaseId: params.leaseId } : undefined),
|
|
123
|
+
// Stamp the guard only on a successful `core.sendReply` (matches the
|
|
124
|
+
// pre-refactor `markConsumed` ordering). Transient send failures fall
|
|
125
|
+
// through to `Effect.catchAll` below WITHOUT stamping, so a retried
|
|
126
|
+
// deliver call still exercises the lease — see
|
|
127
|
+
// `openclaw-entry.delivery.test.ts → "lease guard stays unconsumed on
|
|
128
|
+
// transient send failure"` regression test.
|
|
129
|
+
Effect.tap(() => params.guard.consume()), Effect.tap(() => logOutboundReply(params.log, params.conversationId, params.text)), Effect.map(() => true), Effect.catchAll((err) => handleDeliverFailure({
|
|
130
|
+
log: params.log,
|
|
131
|
+
conversationId: params.conversationId,
|
|
132
|
+
onLeaseConsumed: params.onLeaseConsumed,
|
|
133
|
+
err,
|
|
134
|
+
})));
|
|
135
|
+
}
|
|
136
|
+
function handleDeliverFailure(params) {
|
|
137
|
+
const { log, conversationId, onLeaseConsumed, err } = params;
|
|
138
|
+
if (err instanceof LeaseAlreadyConsumed) {
|
|
139
|
+
return Effect.sync(() => {
|
|
140
|
+
log?.warn?.(`MoltZap: lease ${err.leaseId} already consumed for ${conversationId} at ts=${err.consumedAt.toString()}; surfacing via onLeaseConsumed`);
|
|
141
|
+
onLeaseConsumed?.(err);
|
|
142
|
+
// Deliver contract: PromiseLike<boolean>. False signals "not delivered"
|
|
143
|
+
// without violating the type.
|
|
144
|
+
return false;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return handleReplyFailure(log, conversationId, err);
|
|
148
|
+
}
|
|
149
|
+
function createLeaseConsumingDeliver(params) {
|
|
150
|
+
// One LeaseGuard per inbound message: stamped exactly once, on the FIRST
|
|
151
|
+
// successful `core.sendReply`. Replaces the pre-refactor
|
|
152
|
+
// `consumedLeaseAt: number | null` closure.
|
|
153
|
+
//
|
|
154
|
+
// Ordering matters: pre-check `guard.consumedAt` BEFORE sending so duplicate
|
|
155
|
+
// delivers are short-circuited; the actual stamp happens inside
|
|
156
|
+
// `sendDeliveredReply` via `Effect.tap(() => guard.consume())` AFTER
|
|
157
|
+
// `core.sendReply` succeeds. A transient send failure therefore leaves the
|
|
158
|
+
// guard unconsumed, and a retried deliver call still exercises the lease —
|
|
159
|
+
// matching pre-refactor behavior (`markConsumed` in the success tap).
|
|
160
|
+
const guard = new LeaseGuard();
|
|
161
|
+
return (payload, info) => {
|
|
162
|
+
if (info?.kind !== "final")
|
|
163
|
+
return Promise.resolve(true);
|
|
164
|
+
const text = payload.text ?? payload.body;
|
|
165
|
+
if (!text)
|
|
166
|
+
return Promise.resolve(true);
|
|
167
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
168
|
+
const stamped = yield* guard.consumedAt;
|
|
169
|
+
if (Option.isSome(stamped)) {
|
|
170
|
+
params.log?.warn?.(`MoltZap: duplicate-reply rejected for ${params.enriched.conversationId} (lease already consumed at ts=${stamped.value.toString()})`);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return yield* sendDeliveredReply({
|
|
174
|
+
core: params.core,
|
|
175
|
+
conversationId: params.enriched.conversationId,
|
|
176
|
+
text,
|
|
177
|
+
leaseId: params.enriched.dispatchLeaseId,
|
|
178
|
+
log: params.log,
|
|
179
|
+
onLeaseConsumed: params.onLeaseConsumed,
|
|
180
|
+
guard,
|
|
181
|
+
});
|
|
182
|
+
}));
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function buildInboundDispatchContext(input) {
|
|
186
|
+
return {
|
|
187
|
+
Body: input.enriched.text,
|
|
188
|
+
BodyForAgent: input.bodyForAgent,
|
|
189
|
+
From: input.fromId,
|
|
190
|
+
To: input.account.agentName ?? input.accountId,
|
|
191
|
+
SessionKey: `agent:main:moltzap:${input.chatType === "group" ? "group" : "dm"}:${input.enriched.conversationId}`,
|
|
192
|
+
AccountId: input.accountId,
|
|
193
|
+
Provider: CHANNEL_ID,
|
|
194
|
+
Surface: CHANNEL_ID,
|
|
195
|
+
OriginatingChannel: CHANNEL_ID,
|
|
196
|
+
OriginatingTo: input.enriched.conversationId,
|
|
197
|
+
ChatType: input.chatType,
|
|
198
|
+
...(input.groupSubject ? { GroupSubject: input.groupSubject } : {}),
|
|
199
|
+
...(input.groupMembers ? { GroupMembers: input.groupMembers } : {}),
|
|
200
|
+
...(input.enriched.conversationMeta?.name
|
|
201
|
+
? { ConversationLabel: input.enriched.conversationMeta.name }
|
|
202
|
+
: {}),
|
|
203
|
+
SenderName: input.enriched.sender.name,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function logDispatchError(log, err) {
|
|
207
|
+
return Effect.sync(() => {
|
|
208
|
+
log?.error?.(`MoltZap: dispatch error: ${err}`);
|
|
209
|
+
return null;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function dispatchInboundReply(params) {
|
|
213
|
+
return Effect.tryPromise({
|
|
214
|
+
try: () => params.dispatch({
|
|
215
|
+
ctx: buildInboundDispatchContext(params.input),
|
|
216
|
+
cfg: params.input.cfg,
|
|
217
|
+
dispatcherOptions: {
|
|
218
|
+
deliver: createLeaseConsumingDeliver({
|
|
219
|
+
core: params.core,
|
|
220
|
+
enriched: params.input.enriched,
|
|
221
|
+
log: params.log,
|
|
222
|
+
onLeaseConsumed: params.onLeaseConsumed,
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
catch: (err) => err,
|
|
227
|
+
}).pipe(Effect.catchAll((err) => logDispatchError(params.log, err)));
|
|
228
|
+
}
|
|
229
|
+
function disconnectCoreOnAbort(core, activeClients, accountId) {
|
|
230
|
+
Effect.runFork(core
|
|
231
|
+
.disconnect()
|
|
232
|
+
.pipe(Effect.ensuring(Effect.sync(() => activeClients.delete(accountId)))));
|
|
233
|
+
}
|
|
234
|
+
function createPluginMeta() {
|
|
91
235
|
return {
|
|
92
236
|
id: CHANNEL_ID,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
237
|
+
label: "MoltZap",
|
|
238
|
+
selectionLabel: "MoltZap (agent messaging)",
|
|
239
|
+
docsPath: "/channels/moltzap",
|
|
240
|
+
docsLabel: "moltzap",
|
|
241
|
+
blurb: "Agent-to-agent messaging via the MoltZap protocol.",
|
|
242
|
+
detailLabel: "MoltZap",
|
|
243
|
+
aliases: ["mz"],
|
|
244
|
+
order: 200,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function createMessagingSection() {
|
|
248
|
+
return {
|
|
249
|
+
targetResolver: {
|
|
250
|
+
looksLikeId(raw) {
|
|
251
|
+
return isMoltZapTarget(raw);
|
|
252
|
+
},
|
|
253
|
+
hint: 'Use "agent:<name>" for DMs or "conv:<id>" for existing conversations',
|
|
254
|
+
resolveTarget(params) {
|
|
255
|
+
const { normalized } = params;
|
|
256
|
+
if (!isMoltZapTarget(normalized))
|
|
257
|
+
return Promise.resolve(null);
|
|
258
|
+
const kind = normalized.startsWith(TARGET_PREFIX_CONV)
|
|
259
|
+
? "group"
|
|
260
|
+
: "user";
|
|
261
|
+
return Promise.resolve({
|
|
262
|
+
to: normalized,
|
|
263
|
+
kind,
|
|
264
|
+
display: normalized.split(":").slice(1).join(":"),
|
|
265
|
+
source: "normalized",
|
|
266
|
+
});
|
|
267
|
+
},
|
|
103
268
|
},
|
|
104
|
-
|
|
105
|
-
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function createDirectorySection(activeClients) {
|
|
272
|
+
return {
|
|
273
|
+
listPeers(params) {
|
|
274
|
+
return Effect.runPromise(listPeersEffect(activeClients, params));
|
|
106
275
|
},
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
looksLikeId(raw) {
|
|
110
|
-
return isMoltZapTarget(raw);
|
|
111
|
-
},
|
|
112
|
-
hint: 'Use "agent:<name>" for DMs or "conv:<id>" for existing conversations',
|
|
113
|
-
resolveTarget(params) {
|
|
114
|
-
const { normalized } = params;
|
|
115
|
-
if (!isMoltZapTarget(normalized))
|
|
116
|
-
return Promise.resolve(null);
|
|
117
|
-
// "user" = DM target (agent:*), "group" = conversation target (conv:*)
|
|
118
|
-
const kind = normalized.startsWith(TARGET_PREFIX_CONV)
|
|
119
|
-
? "group"
|
|
120
|
-
: "user";
|
|
121
|
-
return Promise.resolve({
|
|
122
|
-
to: normalized,
|
|
123
|
-
kind,
|
|
124
|
-
display: normalized.split(":").slice(1).join(":"),
|
|
125
|
-
source: "normalized",
|
|
126
|
-
});
|
|
127
|
-
},
|
|
128
|
-
},
|
|
276
|
+
listGroups(params) {
|
|
277
|
+
return Effect.runPromise(listGroupsEffect(activeClients, params));
|
|
129
278
|
},
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function getActiveService(activeClients, accountId) {
|
|
282
|
+
return activeClients.get(accountId ?? DEFAULT_ACCOUNT_ID);
|
|
283
|
+
}
|
|
284
|
+
function listPeersEffect(activeClients, params) {
|
|
285
|
+
return Effect.gen(function* () {
|
|
286
|
+
const service = getActiveService(activeClients, params.accountId);
|
|
287
|
+
if (!service?.sendRpc)
|
|
288
|
+
return [];
|
|
289
|
+
const { contacts } = (yield* service.sendRpc(ContactsList, {}));
|
|
290
|
+
const agentIds = contacts.flatMap(contactAgentIds);
|
|
291
|
+
if (agentIds.length === 0)
|
|
292
|
+
return [];
|
|
293
|
+
const { agents } = (yield* service.sendRpc(AgentsLookup, { agentIds }));
|
|
294
|
+
return agents.map((agent) => ({
|
|
295
|
+
id: `agent:${agent.name}`,
|
|
296
|
+
name: agent.displayName ?? agent.name,
|
|
297
|
+
kind: "user",
|
|
298
|
+
}));
|
|
299
|
+
}).pipe(Effect.withSpan("createMoltzapChannelPlugin.listPeers"), Effect.catchAll(() => Effect.succeed([])));
|
|
300
|
+
}
|
|
301
|
+
function listGroupsEffect(activeClients, params) {
|
|
302
|
+
return Effect.gen(function* () {
|
|
303
|
+
const service = getActiveService(activeClients, params.accountId);
|
|
304
|
+
if (!service?.sendRpc)
|
|
305
|
+
return [];
|
|
306
|
+
const { conversations } = (yield* service.sendRpc(ConversationsList, {}));
|
|
307
|
+
return conversations.filter(isNamedGroup).map((conversation) => ({
|
|
308
|
+
id: `conv:${conversation.id}`,
|
|
309
|
+
name: conversation.name,
|
|
310
|
+
kind: "group",
|
|
311
|
+
}));
|
|
312
|
+
}).pipe(Effect.withSpan("createMoltzapChannelPlugin.listGroups"), Effect.catchAll(() => Effect.succeed([])));
|
|
313
|
+
}
|
|
314
|
+
function isNamedGroup(conversation) {
|
|
315
|
+
return conversation.type === "group" && conversation.name !== undefined;
|
|
316
|
+
}
|
|
317
|
+
function createConfigSection() {
|
|
318
|
+
return {
|
|
319
|
+
listAccountIds(cfg) {
|
|
320
|
+
const accounts = resolveAccountList(cfg);
|
|
321
|
+
return accounts.length > 0
|
|
322
|
+
? accounts.map((account) => account.id || DEFAULT_ACCOUNT_ID)
|
|
323
|
+
: [];
|
|
167
324
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const accounts = resolveAccountList(cfg);
|
|
171
|
-
return accounts.length > 0
|
|
172
|
-
? accounts.map((a) => a.id || DEFAULT_ACCOUNT_ID)
|
|
173
|
-
: [];
|
|
174
|
-
},
|
|
175
|
-
resolveAccount(cfg, accountId) {
|
|
176
|
-
return resolveAccount(cfg, accountId);
|
|
177
|
-
},
|
|
178
|
-
isConfigured(account) {
|
|
179
|
-
return Boolean(account.apiKey && account.serverUrl);
|
|
180
|
-
},
|
|
181
|
-
unconfiguredReason() {
|
|
182
|
-
return "missing apiKey or serverUrl";
|
|
183
|
-
},
|
|
184
|
-
isEnabled(account) {
|
|
185
|
-
return account.enabled !== false;
|
|
186
|
-
},
|
|
325
|
+
resolveAccount(cfg, accountId) {
|
|
326
|
+
return resolveAccount(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
|
|
187
327
|
},
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const { accountId, account, abortSignal, log, setStatus } = ctx;
|
|
191
|
-
const contextLogDir = readOpenClawContextLogDir();
|
|
192
|
-
if (!account.apiKey || !account.serverUrl) {
|
|
193
|
-
log?.error?.("MoltZap: missing apiKey or serverUrl");
|
|
194
|
-
return Promise.resolve();
|
|
195
|
-
}
|
|
196
|
-
log?.info?.(`MoltZap: connecting as ${account.agentName} to ${account.serverUrl}`);
|
|
197
|
-
const wsLogger = log
|
|
198
|
-
? {
|
|
199
|
-
info: adaptOpenClawLogger(log.info),
|
|
200
|
-
warn: adaptOpenClawLogger(log.warn),
|
|
201
|
-
error: adaptOpenClawLogger(log.error),
|
|
202
|
-
}
|
|
203
|
-
: undefined;
|
|
204
|
-
const service = new MoltZapService({
|
|
205
|
-
serverUrl: account.serverUrl,
|
|
206
|
-
agentKey: account.apiKey,
|
|
207
|
-
logger: wsLogger,
|
|
208
|
-
});
|
|
209
|
-
const core = new MoltZapChannelCore({ service, logger: wsLogger });
|
|
210
|
-
core.onInbound((enriched) => Effect.gen(function* () {
|
|
211
|
-
const chatType = enriched.conversationMeta?.type === "group" ? "group" : "direct";
|
|
212
|
-
const fromId = `agent:${enriched.sender.id}`;
|
|
213
|
-
log?.info?.(`MoltZap: inbound from ${fromId}: ${enriched.text.slice(0, INBOUND_LOG_PREVIEW_CHARS)}`);
|
|
214
|
-
setStatus({
|
|
215
|
-
accountId,
|
|
216
|
-
lastInboundAt: Date.now(),
|
|
217
|
-
lastEventAt: Date.now(),
|
|
218
|
-
});
|
|
219
|
-
const crossConvBlock = formatCrossConvOpenClaw(enriched.contextBlocks.crossConversationMessages ?? [], { ownAgentId: service.ownAgentId ?? "" });
|
|
220
|
-
const crossConversationMessages = enriched.contextBlocks.crossConversationMessages ?? [];
|
|
221
|
-
const bodyForAgent = crossConvBlock
|
|
222
|
-
? `${crossConvBlock}\n\n${enriched.text}`
|
|
223
|
-
: enriched.text;
|
|
224
|
-
try {
|
|
225
|
-
writeOpenClawContextLog({
|
|
226
|
-
logDir: contextLogDir,
|
|
227
|
-
accountId,
|
|
228
|
-
accountAgentName: account.agentName,
|
|
229
|
-
ownAgentId: service.ownAgentId,
|
|
230
|
-
conversationId: enriched.conversationId,
|
|
231
|
-
conversationName: enriched.conversationMeta?.name,
|
|
232
|
-
conversationType: chatType,
|
|
233
|
-
from: fromId,
|
|
234
|
-
to: account.agentName ?? accountId,
|
|
235
|
-
body: enriched.text,
|
|
236
|
-
bodyForAgent,
|
|
237
|
-
crossConversationMessages,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
catch (error) {
|
|
241
|
-
log?.warn?.(`MoltZap: failed to write OpenClaw context log: ${error instanceof Error ? error.message : String(error)}`);
|
|
242
|
-
}
|
|
243
|
-
if (crossConvBlock) {
|
|
244
|
-
log?.info?.(`MoltZap: BodyForAgent has cross-conv context (${crossConversationMessages.length} msgs) for ${enriched.conversationId}: ${bodyForAgent.slice(0, BODY_FOR_AGENT_LOG_PREVIEW_CHARS)}`);
|
|
245
|
-
}
|
|
246
|
-
const dispatch = ctx.channelRuntime?.reply
|
|
247
|
-
?.dispatchReplyWithBufferedBlockDispatcher;
|
|
248
|
-
if (!dispatch) {
|
|
249
|
-
log?.warn?.(`MoltZap: no OpenClaw reply dispatcher for ${enriched.conversationId}`);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const groupSubject = enriched.conversationMeta?.name;
|
|
253
|
-
const groupMembers = enriched.conversationMeta?.type === "group"
|
|
254
|
-
? enriched.conversationMeta.participants.join(",")
|
|
255
|
-
: undefined;
|
|
256
|
-
// Bridge OpenClaw's Promise-shaped dispatch into Effect, then
|
|
257
|
-
// catchAll back to a logged no-op so a single failed reply doesn't
|
|
258
|
-
// crash the consumer fiber.
|
|
259
|
-
log?.info?.(`MoltZap: dispatch start for ${enriched.conversationId} message ${enriched.id}`);
|
|
260
|
-
const result = yield* Effect.tryPromise({
|
|
261
|
-
try: () => dispatch({
|
|
262
|
-
ctx: {
|
|
263
|
-
Body: enriched.text,
|
|
264
|
-
BodyForAgent: bodyForAgent,
|
|
265
|
-
From: fromId,
|
|
266
|
-
To: account.agentName ?? accountId,
|
|
267
|
-
SessionKey: `agent:main:moltzap:${chatType === "group" ? "group" : "dm"}:${enriched.conversationId}`,
|
|
268
|
-
AccountId: accountId,
|
|
269
|
-
Provider: CHANNEL_ID,
|
|
270
|
-
Surface: CHANNEL_ID,
|
|
271
|
-
OriginatingChannel: CHANNEL_ID,
|
|
272
|
-
OriginatingTo: enriched.conversationId,
|
|
273
|
-
ChatType: chatType,
|
|
274
|
-
...(groupSubject ? { GroupSubject: groupSubject } : {}),
|
|
275
|
-
...(groupMembers ? { GroupMembers: groupMembers } : {}),
|
|
276
|
-
...(enriched.conversationMeta?.name
|
|
277
|
-
? { ConversationLabel: enriched.conversationMeta.name }
|
|
278
|
-
: {}),
|
|
279
|
-
SenderName: enriched.sender.name,
|
|
280
|
-
},
|
|
281
|
-
cfg: ctx.cfg,
|
|
282
|
-
dispatcherOptions: {
|
|
283
|
-
// Cutover #533 — single-use lease semantics. The
|
|
284
|
-
// first `final` delivery consumes the dispatch
|
|
285
|
-
// lease via core.sendReply; any subsequent `final`
|
|
286
|
-
// delivery is rejected at this adapter boundary
|
|
287
|
-
// with `OpenclawDuplicateReplyError` and surfaced
|
|
288
|
-
// to OpenClaw as `false` (delivery failure).
|
|
289
|
-
// OpenClaw's retry policy under "delivery failed
|
|
290
|
-
// because lease consumed" is OQ-2 in the architect
|
|
291
|
-
// plan; the recommended default (SURFACE as false)
|
|
292
|
-
// is what we ship.
|
|
293
|
-
deliver: (() => {
|
|
294
|
-
let consumedLeaseAt = null;
|
|
295
|
-
return (payload, info) => {
|
|
296
|
-
if (info?.kind !== "final")
|
|
297
|
-
return Promise.resolve(true);
|
|
298
|
-
const text = payload.text ?? payload.body;
|
|
299
|
-
if (!text)
|
|
300
|
-
return Promise.resolve(true);
|
|
301
|
-
if (consumedLeaseAt !== null) {
|
|
302
|
-
log?.warn?.(`MoltZap: duplicate-reply rejected for ${enriched.conversationId} (lease already consumed at ts=${consumedLeaseAt.toString()})`);
|
|
303
|
-
return Promise.resolve(false);
|
|
304
|
-
}
|
|
305
|
-
// core.sendReply is Effect-native; run it at
|
|
306
|
-
// the OpenClaw boundary which demands a
|
|
307
|
-
// Promise.
|
|
308
|
-
const deliverEffect = core
|
|
309
|
-
.sendReply(enriched.conversationId, text)
|
|
310
|
-
.pipe(Effect.tap(() => Effect.sync(() => {
|
|
311
|
-
consumedLeaseAt = Date.now();
|
|
312
|
-
log?.info?.(`MoltZap: outbound reply to ${enriched.conversationId}: ${text.slice(0, OUTBOUND_LOG_PREVIEW_CHARS)}`);
|
|
313
|
-
})), Effect.map(() => true), Effect.catchTag("RpcServerError", (err) => Effect.sync(() => {
|
|
314
|
-
if (err.code === TaskClosedError.code) {
|
|
315
|
-
// Task is closed — message is terminal.
|
|
316
|
-
// Signal consumed (true) so the caller
|
|
317
|
-
// does not retry delivery.
|
|
318
|
-
log?.warn?.({
|
|
319
|
-
conversationId: enriched.conversationId,
|
|
320
|
-
code: err.code,
|
|
321
|
-
msg: err.message,
|
|
322
|
-
}, "MoltZap: send rejected — task closed, dropping without retry");
|
|
323
|
-
return true;
|
|
324
|
-
}
|
|
325
|
-
log?.error?.(`MoltZap: failed to send reply: ${err}`);
|
|
326
|
-
return false;
|
|
327
|
-
})), Effect.catchAll((err) => Effect.sync(() => {
|
|
328
|
-
log?.error?.(`MoltZap: failed to send reply: ${err}`);
|
|
329
|
-
return false;
|
|
330
|
-
})));
|
|
331
|
-
return Effect.runPromise(deliverEffect);
|
|
332
|
-
};
|
|
333
|
-
})(),
|
|
334
|
-
},
|
|
335
|
-
}),
|
|
336
|
-
catch: (err) => err,
|
|
337
|
-
}).pipe(Effect.catchAll((err) => Effect.sync(() => {
|
|
338
|
-
log?.error?.(`MoltZap: dispatch error: ${err}`);
|
|
339
|
-
return null;
|
|
340
|
-
})));
|
|
341
|
-
log?.info?.(`MoltZap: dispatch finished for ${enriched.conversationId} message ${enriched.id}`);
|
|
342
|
-
if (result && !result.queuedFinal) {
|
|
343
|
-
log?.debug?.(`MoltZap: dispatch completed without final reply for ${enriched.conversationId}`);
|
|
344
|
-
}
|
|
345
|
-
}));
|
|
346
|
-
// Forward non-message events for status/logging.
|
|
347
|
-
// Sync dispatcher: log + setStatus only.
|
|
348
|
-
service.on("rawNotification", (event) => {
|
|
349
|
-
const created = extractConversationCreated(event);
|
|
350
|
-
if (created) {
|
|
351
|
-
log?.debug?.(`MoltZap: conversation created ${created.conversation.id}`);
|
|
352
|
-
setStatus({ accountId, lastEventAt: Date.now() });
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const updated = extractConversationUpdated(event);
|
|
356
|
-
if (updated) {
|
|
357
|
-
log?.debug?.(`MoltZap: conversation updated ${updated.conversation.id}`);
|
|
358
|
-
setStatus({ accountId, lastEventAt: Date.now() });
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
const contactRequest = extractContactRequest(event);
|
|
362
|
-
if (contactRequest) {
|
|
363
|
-
log?.debug?.(`MoltZap: contact request from ${contactRequest.contact.contactUserId}`);
|
|
364
|
-
setStatus({ accountId, lastEventAt: Date.now() });
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
const contactAccepted = extractContactAccepted(event);
|
|
368
|
-
if (contactAccepted) {
|
|
369
|
-
log?.debug?.(`MoltZap: contact accepted ${contactAccepted.contact.id}`);
|
|
370
|
-
setStatus({ accountId, lastEventAt: Date.now() });
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
const presence = extractPresenceChanged(event);
|
|
374
|
-
if (presence) {
|
|
375
|
-
log?.debug?.(`MoltZap: ${presence.agentId} is now ${presence.status}`);
|
|
376
|
-
setStatus({ accountId, lastEventAt: Date.now() });
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
core.onDisconnect(() => {
|
|
380
|
-
log?.warn?.("MoltZap: disconnected");
|
|
381
|
-
setStatus({
|
|
382
|
-
accountId,
|
|
383
|
-
connected: false,
|
|
384
|
-
lastDisconnect: { at: Date.now() },
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
core.onReconnect(() => {
|
|
388
|
-
log?.info?.("MoltZap: reconnected");
|
|
389
|
-
setStatus({
|
|
390
|
-
accountId,
|
|
391
|
-
connected: true,
|
|
392
|
-
lastConnectedAt: Date.now(),
|
|
393
|
-
});
|
|
394
|
-
});
|
|
395
|
-
activeClients.set(accountId, service);
|
|
396
|
-
if (abortSignal.aborted) {
|
|
397
|
-
return Effect.runPromise(core
|
|
398
|
-
.disconnect()
|
|
399
|
-
.pipe(Effect.tap(() => Effect.sync(() => activeClients.delete(accountId)))));
|
|
400
|
-
}
|
|
401
|
-
abortSignal.addEventListener("abort", () => {
|
|
402
|
-
void Effect.runPromise(core.disconnect());
|
|
403
|
-
activeClients.delete(accountId);
|
|
404
|
-
}, { once: true });
|
|
405
|
-
return Effect.runPromise(core.connect().pipe(Effect.tap(() => Effect.sync(() => {
|
|
406
|
-
service.startSocketServer();
|
|
407
|
-
log?.info?.(`MoltZap: connected as ${account.agentName} (${service.ownAgentId})`);
|
|
408
|
-
setStatus({
|
|
409
|
-
accountId,
|
|
410
|
-
connected: true,
|
|
411
|
-
lastConnectedAt: Date.now(),
|
|
412
|
-
});
|
|
413
|
-
})), Effect.zipRight(waitForAbort(abortSignal)), Effect.catchAll((err) => Effect.sync(() => {
|
|
414
|
-
log?.error?.(`MoltZap: connection failed: ${err}`);
|
|
415
|
-
}).pipe(Effect.zipRight(Effect.fail(err))))));
|
|
416
|
-
},
|
|
417
|
-
stopAccount(ctx) {
|
|
418
|
-
const service = activeClients.get(ctx.accountId);
|
|
419
|
-
if (service) {
|
|
420
|
-
ctx.log?.info?.("MoltZap: stopping");
|
|
421
|
-
service.close();
|
|
422
|
-
activeClients.delete(ctx.accountId);
|
|
423
|
-
}
|
|
424
|
-
return Promise.resolve();
|
|
425
|
-
},
|
|
328
|
+
isConfigured(account) {
|
|
329
|
+
return Boolean(account.apiKey && account.serverUrl);
|
|
426
330
|
},
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
331
|
+
unconfiguredReason() {
|
|
332
|
+
return "missing apiKey or serverUrl";
|
|
333
|
+
},
|
|
334
|
+
isEnabled(account) {
|
|
335
|
+
return account.enabled !== false;
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function createGatewaySection(activeClients, deps) {
|
|
340
|
+
return {
|
|
341
|
+
startAccount(ctx) {
|
|
342
|
+
return startGatewayAccount(ctx, activeClients, deps);
|
|
343
|
+
},
|
|
344
|
+
stopAccount(ctx) {
|
|
345
|
+
return stopGatewayAccount(ctx, activeClients);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function startGatewayAccount(ctx, activeClients, deps) {
|
|
350
|
+
const { accountId, account, abortSignal, log, setStatus } = ctx;
|
|
351
|
+
const contextLogDir = readOpenClawContextLogDir();
|
|
352
|
+
if (isMissingAccountConfig(account)) {
|
|
353
|
+
log?.error?.("MoltZap: missing apiKey or serverUrl");
|
|
354
|
+
return Promise.resolve();
|
|
355
|
+
}
|
|
356
|
+
log?.info?.(`MoltZap: connecting as ${account.agentName} to ${account.serverUrl}`);
|
|
357
|
+
const service = createGatewayService(account, deps);
|
|
358
|
+
const core = createGatewayCore(service, deps);
|
|
359
|
+
registerInboundHandler({
|
|
360
|
+
core,
|
|
361
|
+
ctx,
|
|
362
|
+
service,
|
|
363
|
+
contextLogDir,
|
|
364
|
+
onLeaseConsumed: deps.onLeaseConsumed,
|
|
365
|
+
});
|
|
366
|
+
registerConnectionStatus(core, ctx);
|
|
367
|
+
activeClients.set(accountId, service);
|
|
368
|
+
if (abortSignal.aborted) {
|
|
369
|
+
return Effect.runPromise(disconnectAndRemove(core, activeClients, accountId));
|
|
370
|
+
}
|
|
371
|
+
abortSignal.addEventListener("abort", () => disconnectCoreOnAbort(core, activeClients, accountId), { once: true });
|
|
372
|
+
return Effect.runPromise(connectGatewayCore(core, service, ctx, setStatus));
|
|
373
|
+
}
|
|
374
|
+
function isMissingAccountConfig(account) {
|
|
375
|
+
return !account.apiKey || !account.serverUrl;
|
|
376
|
+
}
|
|
377
|
+
function createGatewayService(account, deps) {
|
|
378
|
+
if (deps.createService)
|
|
379
|
+
return deps.createService(account);
|
|
380
|
+
return new MoltZapService({
|
|
381
|
+
serverUrl: account.serverUrl,
|
|
382
|
+
agentKey: account.apiKey,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
function createGatewayCore(service, deps) {
|
|
386
|
+
if (deps.createCore)
|
|
387
|
+
return deps.createCore(service);
|
|
388
|
+
return new MoltZapChannelCore({ service });
|
|
389
|
+
}
|
|
390
|
+
function disconnectAndRemove(core, activeClients, accountId) {
|
|
391
|
+
return core
|
|
392
|
+
.disconnect()
|
|
393
|
+
.pipe(Effect.tap(() => Effect.sync(() => activeClients.delete(accountId))));
|
|
394
|
+
}
|
|
395
|
+
function registerInboundHandler(params) {
|
|
396
|
+
params.core.onInbound((enriched) => handleInboundMessage({
|
|
397
|
+
ctx: params.ctx,
|
|
398
|
+
core: params.core,
|
|
399
|
+
service: params.service,
|
|
400
|
+
contextLogDir: params.contextLogDir,
|
|
401
|
+
enriched,
|
|
402
|
+
onLeaseConsumed: params.onLeaseConsumed,
|
|
403
|
+
}).pipe(Effect.withSpan("createMoltzapChannelPlugin.inboundDispatch")));
|
|
404
|
+
}
|
|
405
|
+
function handleInboundMessage(params) {
|
|
406
|
+
return Effect.gen(function* () {
|
|
407
|
+
const data = inboundRuntimeData(params.enriched, params.service);
|
|
408
|
+
logInboundMessage(params.ctx.log, params.enriched, data.fromId);
|
|
409
|
+
touchInboundStatus(params.ctx);
|
|
410
|
+
yield* writeInboundContextLog(params, data);
|
|
411
|
+
logCrossConversationContext(params.ctx.log, params.enriched, data);
|
|
412
|
+
const dispatch = params.ctx.channelRuntime?.reply
|
|
413
|
+
?.dispatchReplyWithBufferedBlockDispatcher;
|
|
414
|
+
if (!dispatch) {
|
|
415
|
+
logMissingDispatcher(params.ctx.log, params.enriched.conversationId);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const result = yield* dispatchInboundReply({
|
|
419
|
+
dispatch,
|
|
420
|
+
input: inboundDispatchInput(params.ctx, params.enriched, data),
|
|
421
|
+
core: params.core,
|
|
422
|
+
log: params.ctx.log,
|
|
423
|
+
onLeaseConsumed: params.onLeaseConsumed,
|
|
424
|
+
});
|
|
425
|
+
logDispatchFinished(params.ctx.log, params.enriched);
|
|
426
|
+
logUnqueuedDispatch(params.ctx.log, params.enriched, result);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function inboundRuntimeData(enriched, service) {
|
|
430
|
+
const groupFields = getGroupFields(enriched.conversationMeta);
|
|
431
|
+
const crossConversationMessages = crossConversationMessagesFor(enriched);
|
|
432
|
+
const crossConvBlock = formatCrossConv(crossConversationMessages, {
|
|
433
|
+
ownAgentId: service.ownAgentId ?? "",
|
|
434
|
+
markup: "json-header",
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
chatType: groupFields !== null ? "group" : "direct",
|
|
438
|
+
fromId: `agent:${enriched.sender.id}`,
|
|
439
|
+
crossConvBlock,
|
|
440
|
+
crossConversationMessages,
|
|
441
|
+
bodyForAgent: bodyForAgent(enriched.text, crossConvBlock),
|
|
442
|
+
groupSubject: groupFields?.name,
|
|
443
|
+
groupMembers: groupMembersFor(groupFields),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function crossConversationMessagesFor(enriched) {
|
|
447
|
+
return enriched.contextBlocks.crossConversationMessages ?? [];
|
|
448
|
+
}
|
|
449
|
+
function bodyForAgent(text, crossConvBlock) {
|
|
450
|
+
return crossConvBlock ? `${crossConvBlock}\n\n${text}` : text;
|
|
451
|
+
}
|
|
452
|
+
// P3 #608 resolution: `groupMembersFor` stays openclaw-local because the
|
|
453
|
+
// comma-joined string format is openclaw-specific (the `GroupMembers`
|
|
454
|
+
// dispatch-context field shape). Channel-base's `getGroupFields` provides
|
|
455
|
+
// the narrowing; this helper does the openclaw-side join.
|
|
456
|
+
function groupMembersFor(fields) {
|
|
457
|
+
if (fields === null)
|
|
458
|
+
return undefined;
|
|
459
|
+
return fields.participants.join(",");
|
|
460
|
+
}
|
|
461
|
+
function logInboundMessage(log, enriched, fromId) {
|
|
462
|
+
log?.info?.(`MoltZap: inbound from ${fromId}: ${enriched.text.slice(0, INBOUND_LOG_PREVIEW_CHARS)}`);
|
|
463
|
+
}
|
|
464
|
+
function touchInboundStatus(ctx) {
|
|
465
|
+
ctx.setStatus({
|
|
466
|
+
accountId: ctx.accountId,
|
|
467
|
+
lastInboundAt: Date.now(),
|
|
468
|
+
lastEventAt: Date.now(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function writeInboundContextLog(params, data) {
|
|
472
|
+
return writeContextLogOrWarn(params.ctx.log, {
|
|
473
|
+
logDir: params.contextLogDir,
|
|
474
|
+
accountId: params.ctx.accountId,
|
|
475
|
+
accountAgentName: params.ctx.account.agentName,
|
|
476
|
+
ownAgentId: params.service.ownAgentId,
|
|
477
|
+
conversationId: params.enriched.conversationId,
|
|
478
|
+
conversationName: params.enriched.conversationMeta?.name,
|
|
479
|
+
conversationType: data.chatType,
|
|
480
|
+
from: data.fromId,
|
|
481
|
+
to: params.ctx.account.agentName ?? params.ctx.accountId,
|
|
482
|
+
body: params.enriched.text,
|
|
483
|
+
bodyForAgent: data.bodyForAgent,
|
|
484
|
+
crossConversationMessages: data.crossConversationMessages,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function logCrossConversationContext(log, enriched, data) {
|
|
488
|
+
if (!data.crossConvBlock)
|
|
489
|
+
return;
|
|
490
|
+
log?.info?.(`MoltZap: BodyForAgent has cross-conv context (${data.crossConversationMessages.length} msgs) for ${enriched.conversationId}: ${data.bodyForAgent.slice(0, BODY_FOR_AGENT_LOG_PREVIEW_CHARS)}`);
|
|
491
|
+
}
|
|
492
|
+
function logMissingDispatcher(log, conversationId) {
|
|
493
|
+
log?.warn?.(`MoltZap: no OpenClaw reply dispatcher for ${conversationId}`);
|
|
494
|
+
}
|
|
495
|
+
function inboundDispatchInput(ctx, enriched, data) {
|
|
496
|
+
return {
|
|
497
|
+
accountId: ctx.accountId,
|
|
498
|
+
account: ctx.account,
|
|
499
|
+
cfg: ctx.cfg,
|
|
500
|
+
chatType: data.chatType,
|
|
501
|
+
fromId: data.fromId,
|
|
502
|
+
bodyForAgent: data.bodyForAgent,
|
|
503
|
+
groupMembers: data.groupMembers,
|
|
504
|
+
groupSubject: data.groupSubject,
|
|
505
|
+
enriched,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function logDispatchFinished(log, enriched) {
|
|
509
|
+
log?.info?.(`MoltZap: dispatch finished for ${enriched.conversationId} message ${enriched.id}`);
|
|
510
|
+
}
|
|
511
|
+
function logUnqueuedDispatch(log, enriched, result) {
|
|
512
|
+
if (!result || result.queuedFinal)
|
|
513
|
+
return;
|
|
514
|
+
log?.debug?.(`MoltZap: dispatch completed without final reply for ${enriched.conversationId}`);
|
|
515
|
+
}
|
|
516
|
+
function registerConnectionStatus(core, ctx) {
|
|
517
|
+
core.onDisconnect(() => {
|
|
518
|
+
ctx.log?.warn?.("MoltZap: disconnected");
|
|
519
|
+
ctx.setStatus({
|
|
520
|
+
accountId: ctx.accountId,
|
|
521
|
+
connected: false,
|
|
522
|
+
lastDisconnect: { at: Date.now() },
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
core.onReconnect(() => {
|
|
526
|
+
ctx.log?.info?.("MoltZap: reconnected");
|
|
527
|
+
ctx.setStatus({
|
|
528
|
+
accountId: ctx.accountId,
|
|
529
|
+
connected: true,
|
|
530
|
+
lastConnectedAt: Date.now(),
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
function connectGatewayCore(core, service, ctx, setStatus) {
|
|
535
|
+
return core.connect().pipe(Effect.tap(() => reportConnected(service, ctx, setStatus)), Effect.zipRight(waitForAbort(ctx.abortSignal)), Effect.catchAll((err) => logConnectionFailure(ctx.log, err)));
|
|
536
|
+
}
|
|
537
|
+
function reportConnected(service, ctx, setStatus) {
|
|
538
|
+
return Effect.sync(() => {
|
|
539
|
+
ctx.log?.info?.(`MoltZap: connected as ${ctx.account.agentName} (${service.ownAgentId})`);
|
|
540
|
+
setStatus({
|
|
541
|
+
accountId: ctx.accountId,
|
|
542
|
+
connected: true,
|
|
543
|
+
lastConnectedAt: Date.now(),
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function logConnectionFailure(log, err) {
|
|
548
|
+
return Effect.sync(() => {
|
|
549
|
+
log?.error?.(`MoltZap: connection failed: ${err}`);
|
|
550
|
+
}).pipe(Effect.zipRight(Effect.fail(err)));
|
|
551
|
+
}
|
|
552
|
+
function stopGatewayAccount(ctx, activeClients) {
|
|
553
|
+
const service = activeClients.get(ctx.accountId);
|
|
554
|
+
if (service) {
|
|
555
|
+
ctx.log?.info?.("MoltZap: stopping");
|
|
556
|
+
service.close();
|
|
557
|
+
activeClients.delete(ctx.accountId);
|
|
558
|
+
}
|
|
559
|
+
return Promise.resolve();
|
|
560
|
+
}
|
|
561
|
+
function createOutboundSection(activeClients) {
|
|
562
|
+
return {
|
|
563
|
+
deliveryMode: "gateway",
|
|
564
|
+
resolveTarget(params) {
|
|
565
|
+
return resolveOutboundTarget(params.to);
|
|
566
|
+
},
|
|
567
|
+
sendText(ctx) {
|
|
568
|
+
return Effect.runPromise(sendTextEffect(activeClients, ctx));
|
|
476
569
|
},
|
|
477
570
|
};
|
|
478
571
|
}
|
|
572
|
+
function resolveOutboundTarget(toInput) {
|
|
573
|
+
const to = toInput?.trim();
|
|
574
|
+
if (!to) {
|
|
575
|
+
return new OpenClawTargetRejected({
|
|
576
|
+
error: new Error("MoltZap: target is required"),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
if (to.includes(":") && !isMoltZapTarget(to)) {
|
|
580
|
+
return new OpenClawTargetRejected({
|
|
581
|
+
error: new Error(`MoltZap: unsupported target format "${to}" — use agent:<name> or conv:<id>`),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return new OpenClawTargetResolved({ to });
|
|
585
|
+
}
|
|
586
|
+
function sendTextEffect(activeClients, ctx) {
|
|
587
|
+
const effect = Effect.gen(function* () {
|
|
588
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
589
|
+
const service = activeClients.get(accountId);
|
|
590
|
+
if (!service) {
|
|
591
|
+
return yield* Effect.fail(new MoltZapClientNotConnectedError({ accountId }));
|
|
592
|
+
}
|
|
593
|
+
if (ctx.to.startsWith(TARGET_PREFIX_AGENT)) {
|
|
594
|
+
if (!service.sendToAgent) {
|
|
595
|
+
return yield* Effect.fail(new MoltZapAgentTargetUnsupportedError({ accountId }));
|
|
596
|
+
}
|
|
597
|
+
yield* service.sendToAgent(ctx.to.slice(TARGET_PREFIX_AGENT.length), ctx.text, {
|
|
598
|
+
replyTo: ctx.replyToId,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
yield* service.send(conversationTarget(ctx.to), ctx.text, {
|
|
603
|
+
replyTo: ctx.replyToId,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
return new OpenClawSendTextSuccess();
|
|
607
|
+
}).pipe(Effect.withSpan("createMoltzapChannelPlugin.sendText"), Effect.match({
|
|
608
|
+
onSuccess: (ok) => ok,
|
|
609
|
+
onFailure: (err) => new OpenClawSendTextFailure({
|
|
610
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
611
|
+
}),
|
|
612
|
+
}));
|
|
613
|
+
return effect;
|
|
614
|
+
}
|
|
615
|
+
function conversationTarget(to) {
|
|
616
|
+
return to.startsWith(TARGET_PREFIX_CONV)
|
|
617
|
+
? to.slice(TARGET_PREFIX_CONV.length)
|
|
618
|
+
: to;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Factory: returns a fresh plugin object whose `activeClients` map lives in
|
|
622
|
+
* this closure. `register(api)` calls this so each registration gets its own
|
|
623
|
+
* per-plugin state, eliminating module-level mutable globals.
|
|
624
|
+
*/
|
|
625
|
+
export function createMoltzapChannelPlugin(deps = {}) {
|
|
626
|
+
const activeClients = new Map();
|
|
627
|
+
return {
|
|
628
|
+
id: CHANNEL_ID,
|
|
629
|
+
meta: createPluginMeta(),
|
|
630
|
+
capabilities: { chatTypes: ["dm", "group"] },
|
|
631
|
+
messaging: createMessagingSection(),
|
|
632
|
+
directory: createDirectorySection(activeClients),
|
|
633
|
+
config: createConfigSection(),
|
|
634
|
+
gateway: createGatewaySection(activeClients, deps),
|
|
635
|
+
outbound: createOutboundSection(activeClients),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
479
638
|
/**
|
|
480
639
|
* Shared singleton so a single registration reuses the same `activeClients`
|
|
481
640
|
* closure across `startAccount` and `sendText`. Tests import this directly
|