@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.
@@ -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 @moltzap/openclaw-channel
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 { formatCrossConvOpenClaw } from "./format-cross-conv.js";
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
- function adaptOpenClawLogger(logMethod) {
45
- return (...args) => {
46
- if (!logMethod)
47
- return;
48
- if (args.length >= OPENCLAW_STRUCTURED_LOG_MIN_ARGS &&
49
- typeof args[0] === "object" &&
50
- args[0] !== null &&
51
- typeof args[1] === "string") {
52
- logMethod(args[1], args[0]);
53
- return;
54
- }
55
- logMethod(...args);
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
- * Factory: returns a fresh plugin object whose `activeClients` map lives in
85
- * this closure. `register(api)` calls this so each registration gets its own
86
- * per-plugin state, eliminating module-level mutable globals.
87
- */
88
- // eslint-disable-next-line agent-code-guard/manual-result -- OpenClaw plugin factory returns the channel object shape, not a reusable Result/Either algebra.
89
- export function createMoltzapChannelPlugin() {
90
- const activeClients = new Map();
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
- meta: {
94
- id: CHANNEL_ID,
95
- label: "MoltZap",
96
- selectionLabel: "MoltZap (agent messaging)",
97
- docsPath: "/channels/moltzap",
98
- docsLabel: "moltzap",
99
- blurb: "Agent-to-agent messaging via the MoltZap protocol.",
100
- detailLabel: "MoltZap",
101
- aliases: ["mz"],
102
- order: 200,
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
- capabilities: {
105
- chatTypes: ["dm", "group"],
269
+ };
270
+ }
271
+ function createDirectorySection(activeClients) {
272
+ return {
273
+ listPeers(params) {
274
+ return Effect.runPromise(listPeersEffect(activeClients, params));
106
275
  },
107
- messaging: {
108
- targetResolver: {
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
- directory: {
131
- listPeers(params) {
132
- const effect = Effect.gen(function* () {
133
- const service = activeClients.get(params.accountId ?? DEFAULT_ACCOUNT_ID);
134
- if (!service)
135
- return [];
136
- const { contacts } = (yield* service.sendRpc(ContactsList, {}));
137
- const agentIds = contacts.flatMap((c) => (c.agents ?? []).map((a) => a.id));
138
- if (agentIds.length === 0)
139
- return [];
140
- const { agents } = (yield* service.sendRpc(AgentsLookup, {
141
- agentIds,
142
- }));
143
- return agents.map((a) => ({
144
- id: `agent:${a.name}`,
145
- name: a.displayName ?? a.name,
146
- kind: "user",
147
- }));
148
- }).pipe(Effect.catchAll(() => Effect.succeed([])));
149
- return Effect.runPromise(effect);
150
- },
151
- listGroups(params) {
152
- const effect = Effect.gen(function* () {
153
- const service = activeClients.get(params.accountId ?? DEFAULT_ACCOUNT_ID);
154
- if (!service)
155
- return [];
156
- const { conversations } = (yield* service.sendRpc(ConversationsList, {}));
157
- return conversations
158
- .filter((c) => c.type === "group" && c.name)
159
- .map((c) => ({
160
- id: `conv:${c.id}`,
161
- name: c.name,
162
- kind: "group",
163
- }));
164
- }).pipe(Effect.catchAll(() => Effect.succeed([])));
165
- return Effect.runPromise(effect);
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
- config: {
169
- listAccountIds(cfg) {
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
- gateway: {
189
- startAccount(ctx) {
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
- outbound: {
428
- deliveryMode: "gateway",
429
- resolveTarget(params) {
430
- const to = params.to?.trim();
431
- if (!to) {
432
- return {
433
- ok: false,
434
- error: new Error("MoltZap: target is required"),
435
- };
436
- }
437
- if (to.includes(":") && !isMoltZapTarget(to)) {
438
- return {
439
- ok: false,
440
- error: new Error(`MoltZap: unsupported target format "${to}" — use agent:<name> or conv:<id>`),
441
- };
442
- }
443
- return { ok: true, to };
444
- },
445
- sendText(ctx) {
446
- const effect = Effect.gen(function* () {
447
- const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
448
- const service = activeClients.get(accountId);
449
- if (!service) {
450
- return yield* Effect.fail(new MoltZapClientNotConnectedError({ accountId }));
451
- }
452
- if (ctx.to.startsWith(TARGET_PREFIX_AGENT)) {
453
- const agentName = ctx.to.slice(TARGET_PREFIX_AGENT.length);
454
- yield* service.sendToAgent(agentName, ctx.text, {
455
- replyTo: ctx.replyToId,
456
- });
457
- }
458
- else {
459
- const conversationId = ctx.to.startsWith(TARGET_PREFIX_CONV)
460
- ? ctx.to.slice(TARGET_PREFIX_CONV.length)
461
- : ctx.to;
462
- yield* service.send(conversationId, ctx.text, {
463
- replyTo: ctx.replyToId,
464
- });
465
- }
466
- return { ok: true };
467
- }).pipe(Effect.match({
468
- onSuccess: (ok) => ok,
469
- onFailure: (err) => ({
470
- ok: false,
471
- error: err instanceof Error ? err : new Error(String(err)),
472
- }),
473
- }));
474
- return Effect.runPromise(effect);
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