@brantrusnak/openclaw-omadeus 1.0.5 → 1.0.6

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.
@@ -16,7 +16,7 @@ async function sendRoomMessage(opts, params) {
16
16
  method: "SEND",
17
17
  body: JSON.stringify({
18
18
  body: params.body,
19
- temporaryId: generateTemporaryId(),
19
+ temporaryId: params.temporaryId ?? generateTemporaryId(),
20
20
  links: "[]"
21
21
  })
22
22
  });
@@ -38,6 +38,17 @@ async function sendRoomMessage(opts, params) {
38
38
  };
39
39
  }
40
40
  }
41
+ async function seeMessage(opts, params) {
42
+ const res = await jaguarFetch(opts, `/messages/${params.messageId}`, {
43
+ method: "SEE",
44
+ body: "{}"
45
+ });
46
+ if (!res.ok) {
47
+ const text = await res.text().catch(() => "");
48
+ throw new Error(`Omadeus see message failed (${res.status}): ${text.slice(0, 200)}`);
49
+ }
50
+ return await res.json();
51
+ }
41
52
  async function editMessage(opts, params) {
42
53
  const res = await jaguarFetch(opts, `/messages/${params.messageId}`, {
43
54
  method: "EDIT",
@@ -73,4 +84,4 @@ async function addMessageReaction(opts, params) {
73
84
  return readJsonOrEmpty(res);
74
85
  }
75
86
  //#endregion
76
- export { addMessageReaction, deleteMessage, editMessage, sendRoomMessage };
87
+ export { addMessageReaction, deleteMessage, editMessage, seeMessage, sendRoomMessage };
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_ACCOUNT_ID, missingTargetError } from "../runtime-api.js";
2
2
  import { ALLOWED_OMADEUS_REACTION_EMOJI_LIST, isAllowedOmadeusReactionEmoji } from "./allowed-reaction-emojis.js";
3
+ import { generateTemporaryId } from "./utils/http.util.js";
3
4
  import { createNugget, resolveTaskRoomIdByNumber } from "./api/nugget.api.js";
4
5
  import { addMessageReaction, deleteMessage, editMessage } from "./api/message.api.js";
5
6
  import { getOmadeusChannelConfig, listOmadeusAccountIds, resolveDefaultOmadeusAccountId, resolveOmadeusAccount } from "./config.js";
@@ -8,6 +9,7 @@ import { parseTaskChannelTargetIntent } from "./nugget-lookup.js";
8
9
  import { sendOmadeusMessage } from "./outbound.js";
9
10
  import { getOmadeusRuntime } from "./runtime.js";
10
11
  import { createOmadeusMessageHandler } from "./message-handler.js";
12
+ import { SentMessageTracker } from "./sent-message-tracker.js";
11
13
  import { omadeusSetupAdapter } from "./setup-core.js";
12
14
  import { omadeusSetupWizard } from "./onboarding.js";
13
15
  import "./setup-surface.js";
@@ -23,7 +25,8 @@ const CHANNEL_ID = "omadeus";
23
25
  const gatewayState = {
24
26
  tokenManager: null,
25
27
  dolphin: null,
26
- jaguar: null
28
+ jaguar: null,
29
+ sentTracker: null
27
30
  };
28
31
  const isUnconfigured = (account) => account.credentialSource === "none";
29
32
  let lastPersistedToken = null;
@@ -251,10 +254,17 @@ const omadeusPlugin = {
251
254
  if (messageId == null) return actionError("Omadeus edit requires `messageId` (Jaguar message id) or current inbound MessageSid.", "Missing messageId for edit.");
252
255
  if (!body) return actionError("Omadeus edit requires new text in `message`, `text`, or `content`.", "Missing body for edit.");
253
256
  try {
254
- await editMessage(apiOpts(), {
255
- messageId,
257
+ const temporaryId = generateTemporaryId();
258
+ gatewayState.sentTracker?.trackOutbound({
259
+ temporaryId,
256
260
  body
257
261
  });
262
+ const edited = await editMessage(apiOpts(), {
263
+ messageId,
264
+ body,
265
+ temporaryId
266
+ });
267
+ if (typeof edited?.id === "number") gatewayState.sentTracker?.trackId(edited.id);
258
268
  } catch (err) {
259
269
  return actionError(err instanceof Error ? err.message : String(err));
260
270
  }
@@ -376,7 +386,8 @@ const omadeusPlugin = {
376
386
  maestroUrl: resolveOmadeusAccount({ cfg }).maestroUrl,
377
387
  tokenManager: gatewayState.tokenManager
378
388
  },
379
- jaguarSocket: gatewayState.jaguar
389
+ jaguarSocket: gatewayState.jaguar,
390
+ sentTracker: gatewayState.sentTracker ?? void 0
380
391
  }, {
381
392
  to,
382
393
  text
@@ -500,12 +511,15 @@ const omadeusPlugin = {
500
511
  tokenManager.startAutoRefresh();
501
512
  gatewayState.tokenManager = tokenManager;
502
513
  const selfReferenceId = tokenManager.getPayload().referenceId;
514
+ const sentTracker = new SentMessageTracker();
515
+ gatewayState.sentTracker = sentTracker;
503
516
  const outboundDeps = {
504
517
  apiOpts: {
505
518
  maestroUrl: account.maestroUrl,
506
519
  tokenManager
507
520
  },
508
- jaguarSocket: null
521
+ jaguarSocket: null,
522
+ sentTracker
509
523
  };
510
524
  const handleMessage = createOmadeusMessageHandler({
511
525
  cfg,
@@ -521,6 +535,16 @@ const omadeusPlugin = {
521
535
  onMessage: (msg) => {
522
536
  const label = msg.subscribableKind === "direct" ? `DM from ${msg.senderReferenceId}` : `${msg.subscribableKind}/${msg.roomName ?? msg.roomId} from ${msg.senderReferenceId}`;
523
537
  log.info(`[jaguar] ${label}: ${msg.body.slice(0, 80)}`);
538
+ if (sentTracker.isEcho({
539
+ id: msg.id,
540
+ temporaryId: msg.temporaryId,
541
+ body: msg.body,
542
+ roomId: msg.roomId,
543
+ fromSelf: msg.senderReferenceId === selfReferenceId
544
+ })) {
545
+ log.debug?.(`[jaguar] suppressed self-echo id=${msg.id}`);
546
+ return;
547
+ }
524
548
  const inbound = parseJaguarMessage(msg, { selfReferenceId }, log);
525
549
  if (inbound) {
526
550
  log.info(`[jaguar] inbound: ${inbound.subscribableKind} room=${inbound.roomId} from=${inbound.from} mention=${inbound.isMention}`);
@@ -607,6 +631,7 @@ const omadeusPlugin = {
607
631
  gatewayState.tokenManager = null;
608
632
  gatewayState.jaguar = null;
609
633
  gatewayState.dolphin = null;
634
+ gatewayState.sentTracker = null;
610
635
  lastPersistedToken = null;
611
636
  ctx.setStatus({
612
637
  accountId: account.accountId,
@@ -42,7 +42,8 @@ function surfaceForKind(kind) {
42
42
  if (kind === "channel") return "channel";
43
43
  return "entity";
44
44
  }
45
- function senderAllowed(allowed, fromReferenceId) {
45
+ function senderAllowed(allowed, fromReferenceId, selfReferenceId) {
46
+ if (fromReferenceId === selfReferenceId) return true;
46
47
  if (!allowed || allowed.length === 0) return true;
47
48
  return allowed.includes(fromReferenceId);
48
49
  }
@@ -85,18 +86,14 @@ function mentionRequired(params) {
85
86
  }
86
87
  /**
87
88
  * Evaluate whether a normalized Jaguar inbound should be dispatched to OpenClaw.
88
- * Callers must drop self-authored messages separately if they prefer logging there.
89
+ *
90
+ * The logged-in user (`selfReferenceId`) is always treated as an allowed sender
91
+ * so they can message their own OpenClaw even if the stored allowlist predates
92
+ * them. Self-authored *echoes* (the reply loop) are filtered earlier, at socket
93
+ * ingestion, by the {@link SentMessageTracker} — not here.
89
94
  */
90
95
  function evaluateOmadeusInboundPolicy(params) {
91
96
  const { inbound, omadeusCfg, selfReferenceId } = params;
92
- if (inbound.fromReferenceId === selfReferenceId) return {
93
- allow: false,
94
- reason: "self_message",
95
- details: {
96
- fromReferenceId: inbound.fromReferenceId,
97
- selfReferenceId
98
- }
99
- };
100
97
  const policy = mergePolicy(omadeusCfg);
101
98
  const surface = surfaceForKind(inbound.subscribableKind);
102
99
  if (surface === "direct") {
@@ -105,7 +102,7 @@ function evaluateOmadeusInboundPolicy(params) {
105
102
  reason: "direct_disabled",
106
103
  details: { surface }
107
104
  };
108
- if (!senderAllowed(policy.direct.allowedSenderReferenceIds, inbound.fromReferenceId)) return {
105
+ if (!senderAllowed(policy.direct.allowedSenderReferenceIds, inbound.fromReferenceId, selfReferenceId)) return {
109
106
  allow: false,
110
107
  reason: "direct_sender_not_allowed",
111
108
  details: { fromReferenceId: inbound.fromReferenceId }
@@ -128,7 +125,7 @@ function evaluateOmadeusInboundPolicy(params) {
128
125
  reason: "channels_disabled",
129
126
  details: { surface }
130
127
  };
131
- if (!senderAllowed(policy.channels.allowedSenderReferenceIds, inbound.fromReferenceId)) return {
128
+ if (!senderAllowed(policy.channels.allowedSenderReferenceIds, inbound.fromReferenceId, selfReferenceId)) return {
132
129
  allow: false,
133
130
  reason: "channel_sender_not_allowed",
134
131
  details: { fromReferenceId: inbound.fromReferenceId }
@@ -139,7 +136,7 @@ function evaluateOmadeusInboundPolicy(params) {
139
136
  allowedRoomIds: policy.channels.allowedRoomIds,
140
137
  allowedChannelViewIds: policy.channels.allowedChannelViewIds
141
138
  });
142
- const senderInList = !policy.channels.allowedSenderReferenceIds || policy.channels.allowedSenderReferenceIds.length === 0 || policy.channels.allowedSenderReferenceIds.includes(inbound.fromReferenceId);
139
+ const senderInList = inbound.fromReferenceId === selfReferenceId || !policy.channels.allowedSenderReferenceIds || policy.channels.allowedSenderReferenceIds.length === 0 || policy.channels.allowedSenderReferenceIds.includes(inbound.fromReferenceId);
143
140
  const inAllowlist = rv.geoInAllowlist && senderInList;
144
141
  const channelMention = policy.channels.requireMention ?? DEFAULT_INBOUND_POLICY.channels.requireMention;
145
142
  if (mentionRequired({
@@ -170,7 +167,7 @@ function evaluateOmadeusInboundPolicy(params) {
170
167
  allowedKinds: policy.entities.allowedKinds
171
168
  }
172
169
  };
173
- if (!senderAllowed(policy.entities.allowedSenderReferenceIds, inbound.fromReferenceId)) return {
170
+ if (!senderAllowed(policy.entities.allowedSenderReferenceIds, inbound.fromReferenceId, selfReferenceId)) return {
174
171
  allow: false,
175
172
  reason: "entity_sender_not_allowed",
176
173
  details: { fromReferenceId: inbound.fromReferenceId }
@@ -1,5 +1,6 @@
1
1
  import { logInboundDrop, resolveControlCommandGate } from "../runtime-api.js";
2
2
  import { createNugget, findNuggetByTaskChannelRoom, resolveTaskChannelRoomId, searchNuggetByNumber } from "./api/nugget.api.js";
3
+ import { seeMessage } from "./api/message.api.js";
3
4
  import { getOmadeusChannelConfig } from "./config.js";
4
5
  import { appendNuggetContextForTaskOrNuggetRoom, appendNuggetLookupContextForAgent, parseChannelTaskCreateIntent, parseNuggetLookupIntent, parseRecurringScheduleIntent } from "./nugget-lookup.js";
5
6
  import { getOmadeusRuntime } from "./runtime.js";
@@ -36,7 +37,17 @@ function createOmadeusMessageHandler(deps) {
36
37
  cfg,
37
38
  channel: "omadeus"
38
39
  });
39
- const handleMessageNow = async (inbound) => {
40
+ /** Mark inbound messages as seen in Omadeus (fire-and-forget). */
41
+ const markMessagesSeen = (messageIds) => {
42
+ for (const messageId of messageIds) {
43
+ if (!Number.isFinite(messageId)) continue;
44
+ log.info(`omadeus: marking message ${messageId} seen`);
45
+ seeMessage(outboundDeps.apiOpts, { messageId }).then(() => log.debug?.(`omadeus: marked message ${messageId} seen`)).catch((err) => {
46
+ log.warn(`omadeus: failed to mark message ${messageId} seen: ${err instanceof Error ? err.message : String(err)}`);
47
+ });
48
+ }
49
+ };
50
+ const handleMessageNow = async (inbound, ackMessageIds = [inbound.messageId]) => {
40
51
  const isDirectMessage = inbound.subscribableKind === "direct";
41
52
  const senderId = String(inbound.fromReferenceId);
42
53
  const senderName = inbound.from;
@@ -77,6 +88,7 @@ function createOmadeusMessageHandler(deps) {
77
88
  });
78
89
  return;
79
90
  }
91
+ if (inbound.fromReferenceId !== selfReferenceId) markMessagesSeen(ackMessageIds);
80
92
  let bodyForAgent = rawBody;
81
93
  const createIntent = parseChannelTaskCreateIntent(rawBody);
82
94
  if (createIntent) try {
@@ -248,7 +260,7 @@ function createOmadeusMessageHandler(deps) {
248
260
  ...last,
249
261
  content: combinedContent,
250
262
  isMention: entries.some((e) => e.isMention)
251
- });
263
+ }, entries.map((e) => e.messageId));
252
264
  },
253
265
  onError: (err) => {
254
266
  runtime.error?.(`omadeus debounce flush failed: ${String(err)}`);
@@ -152,28 +152,43 @@ async function promptCredentials(prompter, existing) {
152
152
  })).trim()
153
153
  };
154
154
  }
155
- async function promptSenderAllowlist(params) {
156
- const { prompter, message, members, existingReferenceIds } = params;
157
- if (members.length === 0) throw new Error("No organization members found.");
158
- if (await prompter.select({
159
- message,
160
- options: [{
161
- value: "all",
162
- label: "All users",
163
- hint: "No sender allowlist"
164
- }, {
165
- value: "specific",
166
- label: "Specific users",
167
- hint: "Select one or more users"
168
- }],
169
- initialValue: existingReferenceIds && existingReferenceIds.length > 0 ? "specific" : "all"
170
- }) === "all") return;
171
- return readReferenceIds(await promptMultiSelect({
172
- prompter,
173
- message: `${message} (specific users)`,
174
- options: memberOptions(members),
175
- initialValues: existingReferenceIds?.map(String)
176
- }));
155
+ /**
156
+ * Prompt for the set of users allowed to message this OpenClaw instance.
157
+ *
158
+ * This single allowlist governs direct messages, channels, and entity rooms.
159
+ * There is no "all users" option: only whitelisted members may message
160
+ * OpenClaw. The logged-in user is always added
161
+ * to the allowlist (and is excluded from the selectable list by the caller) so
162
+ * they can interact with their own OpenClaw. Self-authored echoes (the reply
163
+ * loop) are filtered earlier, at socket ingestion, by the SentMessageTracker —
164
+ * not by the inbound policy — so allowing yourself here cannot cause a loop.
165
+ */
166
+ async function promptMessagingAllowlist(params) {
167
+ const { prompter, members, selfReferenceId, existingReferenceIds } = params;
168
+ let selected = [];
169
+ if (members.length === 0) await prompter.note("No other organization members found. Only you will be able to message OpenClaw.", "Omadeus messaging allowlist");
170
+ else {
171
+ const memberReferenceIds = new Set(members.map((member) => member.referenceId));
172
+ const initialValues = (existingReferenceIds ?? []).filter((id) => id !== selfReferenceId && memberReferenceIds.has(id)).map(String);
173
+ selected = readReferenceIds(await promptMultiSelect({
174
+ prompter,
175
+ message: "Which users do you want to be able to message this OpenClaw instance? (You are always allowed.)",
176
+ options: memberOptions(members),
177
+ initialValues
178
+ }));
179
+ }
180
+ return Array.from(new Set([selfReferenceId, ...selected]));
181
+ }
182
+ /**
183
+ * Ask whether an @mention is required to trigger OpenClaw in a given surface
184
+ * (channels or entity rooms). DMs never use this — you can't @mention in a DM.
185
+ */
186
+ async function promptRequireMention(params) {
187
+ if (params.existing === "outsideAllowlist") return "outsideAllowlist";
188
+ return await params.prompter.confirm({
189
+ message: `Require an @mention to trigger OpenClaw in ${params.surfaceLabel}?`,
190
+ initialValue: params.existing ? params.existing !== "never" : true
191
+ }) ? "always" : "never";
177
192
  }
178
193
  async function promptEntityKindSelection(params) {
179
194
  const selected = await promptMultiSelect({
@@ -269,11 +284,15 @@ const omadeusSetupWizard = {
269
284
  excludeReferenceIds: [selfReferenceId]
270
285
  });
271
286
  const existingInbound = section.inbound;
272
- const directSenderIds = await promptSenderAllowlist({
287
+ const allowedUserReferenceIds = await promptMessagingAllowlist({
273
288
  prompter,
274
- message: "Which users can DM OpenClaw directly?",
275
289
  members,
276
- existingReferenceIds: existingInbound?.direct?.allowedSenderReferenceIds
290
+ selfReferenceId,
291
+ existingReferenceIds: Array.from(new Set([
292
+ ...existingInbound?.direct?.allowedSenderReferenceIds ?? [],
293
+ ...existingInbound?.channels?.allowedSenderReferenceIds ?? [],
294
+ ...existingInbound?.entities?.allowedSenderReferenceIds ?? []
295
+ ]))
277
296
  });
278
297
  const selectedChannels = await promptChannelSelection({
279
298
  prompter,
@@ -282,33 +301,34 @@ const omadeusSetupWizard = {
282
301
  memberReferenceId: selfReferenceId,
283
302
  existingChannelViewIds: existingInbound?.channels?.allowedChannelViewIds
284
303
  });
285
- const channelSenderIds = selectedChannels.length > 0 ? await promptSenderAllowlist({
304
+ const channelSenderIds = selectedChannels.length > 0 ? allowedUserReferenceIds : void 0;
305
+ const channelRequireMention = selectedChannels.length > 0 ? await promptRequireMention({
286
306
  prompter,
287
- message: "Which users can trigger OpenClaw from allowed channels?",
288
- members,
289
- existingReferenceIds: existingInbound?.channels?.allowedSenderReferenceIds
290
- }) : void 0;
307
+ surfaceLabel: "allowed channels",
308
+ existing: existingInbound?.channels?.requireMention
309
+ }) : "never";
291
310
  const entityKinds = await promptEntityKindSelection({
292
311
  prompter,
293
312
  existingKinds: existingInbound?.entities?.allowedKinds
294
313
  });
295
- const entitySenderIds = entityKinds.length > 0 ? await promptSenderAllowlist({
314
+ const entitySenderIds = entityKinds.length > 0 ? allowedUserReferenceIds : void 0;
315
+ const entityRequireMention = entityKinds.length > 0 ? await promptRequireMention({
296
316
  prompter,
297
- message: "Which users can trigger OpenClaw from entity rooms?",
298
- members,
299
- existingReferenceIds: existingInbound?.entities?.allowedSenderReferenceIds
300
- }) : void 0;
317
+ surfaceLabel: "entity rooms",
318
+ existing: existingInbound?.entities?.requireMention
319
+ }) : "never";
301
320
  const channelRoomIds = selectedChannels.flatMap((selectedChannel) => [selectedChannel.publicRoomId, selectedChannel.privateRoomId]).filter((id) => typeof id === "number");
302
321
  const channelViewIds = selectedChannels.map((selectedChannel) => selectedChannel.id);
303
322
  const channelTitles = selectedChannels.map((selectedChannel) => selectedChannel.title || `Channel ${selectedChannel.id}`).join(", ");
304
323
  const senderSummary = (ids) => ids && ids.length > 0 ? ids.join(", ") : "all users";
305
- const entityKindSummary = entityKinds.length > 0 ? entityKinds.join(", ") : "none (entity rooms disabled)";
306
- const channelSummary = selectedChannels.length > 0 ? `- Channels "${channelTitles}": rooms ${channelRoomIds.join(", ") || "(no room ids)"} from ${senderSummary(channelSenderIds)}; @mention not required in those rooms.` : "- Channels: disabled (none selected).";
324
+ const mentionSummary = (require) => require === "never" ? "no @mention required" : require === "outsideAllowlist" ? "@mention required outside the allowlist" : "@mention required";
325
+ const channelSummary = selectedChannels.length > 0 ? `- Channels "${channelTitles}": rooms ${channelRoomIds.join(", ") || "(no room ids)"} from ${senderSummary(channelSenderIds)}; ${mentionSummary(channelRequireMention)}.` : "- Channels: disabled (none selected).";
326
+ const entitySummary = entityKinds.length > 0 ? `- Entity rooms (${entityKinds.join(", ")}): ${senderSummary(entitySenderIds)}; ${mentionSummary(entityRequireMention)}.` : "- Entity rooms: disabled (no room types selected).";
307
327
  await prompter.note([
308
328
  `Inbound policy (Jaguar chat):`,
309
- `- Direct messages: enabled for ${senderSummary(directSenderIds)} (no @mention required).`,
329
+ `- Direct messages: enabled for ${senderSummary(allowedUserReferenceIds)}.`,
310
330
  channelSummary,
311
- `- Entity rooms (${entityKindSummary}): ${senderSummary(entitySenderIds)}; @mention required.`
331
+ entitySummary
312
332
  ].join("\n"), "Omadeus inbound policy");
313
333
  next = {
314
334
  ...next,
@@ -326,7 +346,7 @@ const omadeusSetupWizard = {
326
346
  version: 1,
327
347
  direct: {
328
348
  enabled: true,
329
- ...directSenderIds ? { allowedSenderReferenceIds: directSenderIds } : {},
349
+ allowedSenderReferenceIds: allowedUserReferenceIds,
330
350
  requireMention: "never"
331
351
  },
332
352
  channels: {
@@ -334,13 +354,13 @@ const omadeusSetupWizard = {
334
354
  allowedRoomIds: channelRoomIds,
335
355
  allowedChannelViewIds: channelViewIds,
336
356
  ...channelSenderIds ? { allowedSenderReferenceIds: channelSenderIds } : {},
337
- requireMention: "outsideAllowlist"
357
+ requireMention: channelRequireMention
338
358
  },
339
359
  entities: {
340
360
  enabled: entityKinds.length > 0,
341
361
  allowedKinds: entityKinds,
342
362
  ...entitySenderIds ? { allowedSenderReferenceIds: entitySenderIds } : {},
343
- requireMention: "always"
363
+ requireMention: entityRequireMention
344
364
  }
345
365
  }
346
366
  }
@@ -1,12 +1,21 @@
1
+ import { generateTemporaryId } from "./utils/http.util.js";
1
2
  import { sendRoomMessage } from "./api/message.api.js";
2
3
  //#region src/outbound.ts
3
4
  async function sendOmadeusMessage(deps, params) {
4
5
  const { to, text } = params;
6
+ const temporaryId = generateTemporaryId();
7
+ deps.sentTracker?.trackOutbound({
8
+ temporaryId,
9
+ body: text,
10
+ roomId: to
11
+ });
5
12
  const result = await sendRoomMessage(deps.apiOpts, {
6
13
  roomId: to,
7
- body: text
14
+ body: text,
15
+ temporaryId
8
16
  });
9
17
  if (!result.ok) throw new Error(`Omadeus send failed: ${result.error}`);
18
+ if (typeof result.message?.id === "number") deps.sentTracker?.trackId(result.message.id);
10
19
  return {
11
20
  channel: "omadeus",
12
21
  messageId: String(result.message?.id ?? ""),
@@ -0,0 +1,116 @@
1
+ //#region src/sent-message-tracker.ts
2
+ /**
3
+ * Tracks messages this plugin sent so their Jaguar socket echoes can be
4
+ * suppressed, instead of dropping every message authored by the logged-in
5
+ * account.
6
+ *
7
+ * OpenClaw sends as the same Omadeus account it listens on, so each outbound
8
+ * message is broadcast back to us over the socket. We register up to three keys
9
+ * per send:
10
+ *
11
+ * - the client-generated `temporaryId` — known *before* the HTTP round-trip, so
12
+ * it matches even when the socket echo beats the send response (the common
13
+ * race);
14
+ * - the backend message `id` — known once the send response returns;
15
+ * - a normalized copy of the body scoped to its room — a last-resort fallback
16
+ * used only for self-authored echoes that somehow arrive without a
17
+ * recognizable id. Scoping by room prevents the same text sent in one chat
18
+ * from suppressing an identical message in a different chat.
19
+ *
20
+ * `id` and `temporaryId` are kept in separate maps. Entries expire after a
21
+ * short TTL and each map is size-capped, so the tracker cannot grow unbounded.
22
+ */
23
+ const DEFAULT_TTL_MS = 120 * 1e3;
24
+ const DEFAULT_MAX_ENTRIES = 500;
25
+ function normalizeContent(body) {
26
+ return body.trim();
27
+ }
28
+ /** Normalize a room identity so outbound (`"room:123"`/`"123"`) and the socket
29
+ * echo (numeric `123`) map to the same key. */
30
+ function roomKey(roomId) {
31
+ return String(roomId).replace(/^room:/, "").trim();
32
+ }
33
+ /** Build the room-scoped content key, or undefined when the body is empty. */
34
+ function contentKey(roomId, body) {
35
+ const normalized = normalizeContent(body);
36
+ if (!normalized) return void 0;
37
+ return `${roomKey(roomId)}\n${normalized}`;
38
+ }
39
+ var SentMessageTracker = class {
40
+ ttlMs;
41
+ maxEntries;
42
+ now;
43
+ ids = /* @__PURE__ */ new Map();
44
+ temporaryIds = /* @__PURE__ */ new Map();
45
+ contents = /* @__PURE__ */ new Map();
46
+ constructor(options = {}) {
47
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
48
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
49
+ this.now = options.now ?? Date.now;
50
+ }
51
+ /** Register a client-generated temporaryId. Call before sending. */
52
+ trackTemporaryId(temporaryId) {
53
+ if (!temporaryId) return;
54
+ this.remember(this.temporaryIds, temporaryId);
55
+ }
56
+ /** Register the backend message id once the send response returns. */
57
+ trackId(id) {
58
+ if (!Number.isFinite(id)) return;
59
+ this.remember(this.ids, id);
60
+ }
61
+ /** Register a message body, scoped to its room, as a fallback match key. */
62
+ trackContent(roomId, body) {
63
+ const key = contentKey(roomId, body);
64
+ if (!key) return;
65
+ this.remember(this.contents, key);
66
+ }
67
+ /** Convenience: register whichever keys are available for one outbound message. */
68
+ trackOutbound(params) {
69
+ if (params.temporaryId) this.trackTemporaryId(params.temporaryId);
70
+ if (typeof params.id === "number") this.trackId(params.id);
71
+ if (typeof params.body === "string" && params.roomId !== void 0) this.trackContent(params.roomId, params.body);
72
+ }
73
+ /**
74
+ * Returns true when an inbound socket message is an echo of something we sent.
75
+ *
76
+ * `id`/`temporaryId` matches are authoritative. The content fallback only
77
+ * applies to self-authored messages — the only ones that can form a reply
78
+ * loop — and is scoped to the message's room, so a different user repeating
79
+ * our text (or the same text in another room) is never suppressed.
80
+ */
81
+ isEcho(msg) {
82
+ if (typeof msg.id === "number" && this.has(this.ids, msg.id)) return true;
83
+ if (msg.temporaryId && this.has(this.temporaryIds, msg.temporaryId)) return true;
84
+ if (msg.fromSelf && typeof msg.body === "string" && msg.roomId !== void 0) {
85
+ const key = contentKey(msg.roomId, msg.body);
86
+ if (key && this.has(this.contents, key)) return true;
87
+ }
88
+ return false;
89
+ }
90
+ remember(map, key) {
91
+ map.delete(key);
92
+ map.set(key, this.now() + this.ttlMs);
93
+ this.prune(map);
94
+ }
95
+ has(map, key) {
96
+ const expiry = map.get(key);
97
+ if (expiry === void 0) return false;
98
+ if (expiry <= this.now()) {
99
+ map.delete(key);
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ prune(map) {
105
+ const now = this.now();
106
+ for (const [key, expiry] of map) if (expiry <= now) map.delete(key);
107
+ else break;
108
+ while (map.size > this.maxEntries) {
109
+ const oldest = map.keys().next().value;
110
+ if (oldest === void 0) break;
111
+ map.delete(oldest);
112
+ }
113
+ }
114
+ };
115
+ //#endregion
116
+ export { SentMessageTracker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brantrusnak/openclaw-omadeus",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "private": false,
5
5
  "description": "OpenClaw Omadeus project management channel plugin",
6
6
  "homepage": "https://github.com/brantrusnak/openclaw-omadeus-plugin#readme",
@@ -19,14 +19,14 @@ async function readJsonOrEmpty(res: Response): Promise<unknown> {
19
19
 
20
20
  export async function sendRoomMessage(
21
21
  opts: OmadeusApiOptions,
22
- params: { roomId: number | string; body: string },
22
+ params: { roomId: number | string; body: string; temporaryId?: string },
23
23
  ): Promise<{ ok: boolean; message?: OmadeusMessage; error?: string }> {
24
24
  try {
25
25
  const res = await jaguarFetch(opts, `/rooms/${params.roomId}/messages`, {
26
26
  method: "SEND",
27
27
  body: JSON.stringify({
28
28
  body: params.body,
29
- temporaryId: generateTemporaryId(),
29
+ temporaryId: params.temporaryId ?? generateTemporaryId(),
30
30
  links: "[]",
31
31
  }),
32
32
  });
@@ -68,6 +68,8 @@ export async function seeMessage(
68
68
  ): Promise<OmadeusMessage> {
69
69
  const res = await jaguarFetch(opts, `/messages/${params.messageId}`, {
70
70
  method: "SEE",
71
+ // The server requires a Content-Length header; an empty JSON body supplies one.
72
+ body: "{}",
71
73
  });
72
74
  if (!res.ok) {
73
75
  const text = await res.text().catch(() => "");
package/src/channel.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type OmadeusNuggetPriority,
28
28
  } from "./api/nugget.api.js";
29
29
  import { addMessageReaction, deleteMessage, editMessage } from "./api/message.api.js";
30
+ import { generateTemporaryId } from "./utils/http.util.js";
30
31
  import {
31
32
  getOmadeusChannelConfig,
32
33
  listOmadeusAccountIds,
@@ -38,6 +39,7 @@ import { createOmadeusMessageHandler } from "./message-handler.js";
38
39
  import { parseTaskChannelTargetIntent } from "./nugget-lookup.js";
39
40
  import { sendOmadeusMessage, type OutboundDeps } from "./outbound.js";
40
41
  import { getOmadeusRuntime } from "./runtime.js";
42
+ import { SentMessageTracker } from "./sent-message-tracker.js";
41
43
  import { omadeusSetupAdapter } from "./setup-core.js";
42
44
  import { omadeusSetupWizard } from "./setup-surface.js";
43
45
  import { createDolphinSocketClient, type DolphinSocketClient } from "./socket/dolphin.socket.js";
@@ -51,7 +53,8 @@ const gatewayState: {
51
53
  tokenManager: OmadeusTokenManager | null;
52
54
  dolphin: DolphinSocketClient | null;
53
55
  jaguar: JaguarSocketClient | null;
54
- } = { tokenManager: null, dolphin: null, jaguar: null };
56
+ sentTracker: SentMessageTracker | null;
57
+ } = { tokenManager: null, dolphin: null, jaguar: null, sentTracker: null };
55
58
 
56
59
  const isUnconfigured = (account: Account) => account.credentialSource === "none";
57
60
 
@@ -319,7 +322,13 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
319
322
  );
320
323
  }
321
324
  try {
322
- await editMessage(apiOpts(), { messageId, body });
325
+ const temporaryId = generateTemporaryId();
326
+ // Track before editing so the edit's socket echo is recognized as ours.
327
+ gatewayState.sentTracker?.trackOutbound({ temporaryId, body });
328
+ const edited = await editMessage(apiOpts(), { messageId, body, temporaryId });
329
+ if (typeof edited?.id === "number") {
330
+ gatewayState.sentTracker?.trackId(edited.id);
331
+ }
323
332
  } catch (err) {
324
333
  const msg = err instanceof Error ? err.message : String(err);
325
334
  return actionError(msg);
@@ -466,6 +475,7 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
466
475
  tokenManager: gatewayState.tokenManager,
467
476
  },
468
477
  jaguarSocket: gatewayState.jaguar,
478
+ sentTracker: gatewayState.sentTracker ?? undefined,
469
479
  };
470
480
  return await sendOmadeusMessage(deps, { to, text });
471
481
  },
@@ -589,9 +599,13 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
589
599
 
590
600
  const selfReferenceId = tokenManager.getPayload().referenceId;
591
601
 
602
+ const sentTracker = new SentMessageTracker();
603
+ gatewayState.sentTracker = sentTracker;
604
+
592
605
  const outboundDeps: OutboundDeps = {
593
606
  apiOpts: { maestroUrl: account.maestroUrl, tokenManager },
594
607
  jaguarSocket: null as unknown as JaguarSocketClient,
608
+ sentTracker,
595
609
  };
596
610
 
597
611
  const handleMessage = createOmadeusMessageHandler({
@@ -613,6 +627,23 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
613
627
  : `${msg.subscribableKind}/${msg.roomName ?? msg.roomId} from ${msg.senderReferenceId}`;
614
628
  log.info(`[jaguar] ${label}: ${msg.body.slice(0, 80)}`);
615
629
 
630
+ // Suppress echoes of messages we sent (we send as the logged-in
631
+ // account, so our own messages come back over the socket). This
632
+ // replaces the old "drop everything from self" rule, letting the
633
+ // logged-in user message their own OpenClaw.
634
+ if (
635
+ sentTracker.isEcho({
636
+ id: msg.id,
637
+ temporaryId: msg.temporaryId,
638
+ body: msg.body,
639
+ roomId: msg.roomId,
640
+ fromSelf: msg.senderReferenceId === selfReferenceId,
641
+ })
642
+ ) {
643
+ log.debug?.(`[jaguar] suppressed self-echo id=${msg.id}`);
644
+ return;
645
+ }
646
+
616
647
  const inbound = parseJaguarMessage(msg, { selfReferenceId }, log);
617
648
  if (inbound) {
618
649
  log.info(
@@ -688,6 +719,7 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
688
719
  gatewayState.tokenManager = null;
689
720
  gatewayState.jaguar = null;
690
721
  gatewayState.dolphin = null;
722
+ gatewayState.sentTracker = null;
691
723
  lastPersistedToken = null;
692
724
  ctx.setStatus({
693
725
  accountId: account.accountId,
@@ -49,7 +49,15 @@ function surfaceForKind(kind: OmadeusSubscribableKind): "direct" | "channel" | "
49
49
  return "entity";
50
50
  }
51
51
 
52
- function senderAllowed(allowed: number[] | undefined, fromReferenceId: number): boolean {
52
+ function senderAllowed(
53
+ allowed: number[] | undefined,
54
+ fromReferenceId: number,
55
+ selfReferenceId: number,
56
+ ): boolean {
57
+ // The logged-in user can always reach their own instance, regardless of the
58
+ // configured allowlist. Their own echoes are filtered earlier by the
59
+ // SentMessageTracker, so this cannot create a reply loop.
60
+ if (fromReferenceId === selfReferenceId) return true;
53
61
  if (!allowed || allowed.length === 0) return true;
54
62
  return allowed.includes(fromReferenceId);
55
63
  }
@@ -115,7 +123,11 @@ function mentionRequired(params: {
115
123
 
116
124
  /**
117
125
  * Evaluate whether a normalized Jaguar inbound should be dispatched to OpenClaw.
118
- * Callers must drop self-authored messages separately if they prefer logging there.
126
+ *
127
+ * The logged-in user (`selfReferenceId`) is always treated as an allowed sender
128
+ * so they can message their own OpenClaw even if the stored allowlist predates
129
+ * them. Self-authored *echoes* (the reply loop) are filtered earlier, at socket
130
+ * ingestion, by the {@link SentMessageTracker} — not here.
119
131
  */
120
132
  export function evaluateOmadeusInboundPolicy(params: {
121
133
  inbound: OmadeusInboundMessage;
@@ -124,14 +136,6 @@ export function evaluateOmadeusInboundPolicy(params: {
124
136
  }): InboundPolicyDecision {
125
137
  const { inbound, omadeusCfg, selfReferenceId } = params;
126
138
 
127
- if (inbound.fromReferenceId === selfReferenceId) {
128
- return {
129
- allow: false,
130
- reason: "self_message",
131
- details: { fromReferenceId: inbound.fromReferenceId, selfReferenceId },
132
- };
133
- }
134
-
135
139
  const policy = mergePolicy(omadeusCfg);
136
140
  const surface = surfaceForKind(inbound.subscribableKind);
137
141
 
@@ -139,7 +143,9 @@ export function evaluateOmadeusInboundPolicy(params: {
139
143
  if (!policy.direct.enabled) {
140
144
  return { allow: false, reason: "direct_disabled", details: { surface } };
141
145
  }
142
- if (!senderAllowed(policy.direct.allowedSenderReferenceIds, inbound.fromReferenceId)) {
146
+ if (
147
+ !senderAllowed(policy.direct.allowedSenderReferenceIds, inbound.fromReferenceId, selfReferenceId)
148
+ ) {
143
149
  return {
144
150
  allow: false,
145
151
  reason: "direct_sender_not_allowed",
@@ -157,7 +163,9 @@ export function evaluateOmadeusInboundPolicy(params: {
157
163
  if (!policy.channels.enabled) {
158
164
  return { allow: false, reason: "channels_disabled", details: { surface } };
159
165
  }
160
- if (!senderAllowed(policy.channels.allowedSenderReferenceIds, inbound.fromReferenceId)) {
166
+ if (
167
+ !senderAllowed(policy.channels.allowedSenderReferenceIds, inbound.fromReferenceId, selfReferenceId)
168
+ ) {
161
169
  return {
162
170
  allow: false,
163
171
  reason: "channel_sender_not_allowed",
@@ -171,6 +179,7 @@ export function evaluateOmadeusInboundPolicy(params: {
171
179
  allowedChannelViewIds: policy.channels.allowedChannelViewIds,
172
180
  });
173
181
  const senderInList =
182
+ inbound.fromReferenceId === selfReferenceId ||
174
183
  !policy.channels.allowedSenderReferenceIds ||
175
184
  policy.channels.allowedSenderReferenceIds.length === 0 ||
176
185
  policy.channels.allowedSenderReferenceIds.includes(inbound.fromReferenceId);
@@ -208,7 +217,9 @@ export function evaluateOmadeusInboundPolicy(params: {
208
217
  details: { kind: inbound.subscribableKind, allowedKinds: policy.entities.allowedKinds },
209
218
  };
210
219
  }
211
- if (!senderAllowed(policy.entities.allowedSenderReferenceIds, inbound.fromReferenceId)) {
220
+ if (
221
+ !senderAllowed(policy.entities.allowedSenderReferenceIds, inbound.fromReferenceId, selfReferenceId)
222
+ ) {
212
223
  return {
213
224
  allow: false,
214
225
  reason: "entity_sender_not_allowed",
@@ -6,6 +6,7 @@ import {
6
6
  type RuntimeEnv,
7
7
  } from "../runtime-api.js";
8
8
  import { createNugget, findNuggetByTaskChannelRoom, resolveTaskChannelRoomId, searchNuggetByNumber } from "./api/nugget.api.js";
9
+ import { seeMessage } from "./api/message.api.js";
9
10
  import {
10
11
  appendNuggetContextForTaskOrNuggetRoom,
11
12
  appendNuggetLookupContextForAgent,
@@ -68,7 +69,7 @@ export type OmadeusMessageHandlerDeps = {
68
69
  runtime: RuntimeEnv;
69
70
  log: Log;
70
71
  outboundDeps: OutboundDeps;
71
- /** Authenticated Omadeus user; used to drop self-authored messages and inbound policy. */
72
+ /** Authenticated Omadeus user reference id. */
72
73
  selfReferenceId: number;
73
74
  };
74
75
 
@@ -82,7 +83,25 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
82
83
  channel: "omadeus",
83
84
  });
84
85
 
85
- const handleMessageNow = async (inbound: OmadeusInboundMessage) => {
86
+ /** Mark inbound messages as seen in Omadeus (fire-and-forget). */
87
+ const markMessagesSeen = (messageIds: number[]) => {
88
+ for (const messageId of messageIds) {
89
+ if (!Number.isFinite(messageId)) continue;
90
+ log.info(`omadeus: marking message ${messageId} seen`);
91
+ seeMessage(outboundDeps.apiOpts, { messageId })
92
+ .then(() => log.debug?.(`omadeus: marked message ${messageId} seen`))
93
+ .catch((err) => {
94
+ log.warn(
95
+ `omadeus: failed to mark message ${messageId} seen: ${err instanceof Error ? err.message : String(err)}`,
96
+ );
97
+ });
98
+ }
99
+ };
100
+
101
+ const handleMessageNow = async (
102
+ inbound: OmadeusInboundMessage,
103
+ ackMessageIds: number[] = [inbound.messageId],
104
+ ) => {
86
105
  const isDirectMessage = inbound.subscribableKind === "direct";
87
106
  const senderId = String(inbound.fromReferenceId);
88
107
  const senderName = inbound.from;
@@ -132,6 +151,12 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
132
151
  return;
133
152
  }
134
153
 
154
+ // Committed to dispatching to the agent — mark the source message(s) seen.
155
+ // Never mark our own messages seen (the user can DM their own instance).
156
+ if (inbound.fromReferenceId !== selfReferenceId) {
157
+ markMessagesSeen(ackMessageIds);
158
+ }
159
+
135
160
  let bodyForAgent = rawBody;
136
161
  const createIntent = parseChannelTaskCreateIntent(rawBody);
137
162
  if (createIntent) {
@@ -386,11 +411,14 @@ export function createOmadeusMessageHandler(deps: OmadeusMessageHandlerDeps) {
386
411
  .join("\n");
387
412
  if (!combinedContent.trim()) return;
388
413
 
389
- await handleMessageNow({
390
- ...last,
391
- content: combinedContent,
392
- isMention: entries.some((e) => e.isMention),
393
- });
414
+ await handleMessageNow(
415
+ {
416
+ ...last,
417
+ content: combinedContent,
418
+ isMention: entries.some((e) => e.isMention),
419
+ },
420
+ entries.map((e) => e.messageId),
421
+ );
394
422
  },
395
423
  onError: (err) => {
396
424
  runtime.error?.(`omadeus debounce flush failed: ${String(err)}`);
package/src/onboarding.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  OmadeusChannelConfig,
20
20
  OmadeusChannelView,
21
21
  OmadeusInboundEntityKind,
22
+ OmadeusInboundMentionPolicy,
22
23
  OmadeusOrganizationMember,
23
24
  } from "./types.js";
24
25
  import { OMADEUS_INBOUND_ENTITY_KINDS } from "./types.js";
@@ -249,36 +250,68 @@ async function promptCredentials(
249
250
  return { email, password };
250
251
  }
251
252
 
252
- async function promptSenderAllowlist(params: {
253
+ /**
254
+ * Prompt for the set of users allowed to message this OpenClaw instance.
255
+ *
256
+ * This single allowlist governs direct messages, channels, and entity rooms.
257
+ * There is no "all users" option: only whitelisted members may message
258
+ * OpenClaw. The logged-in user is always added
259
+ * to the allowlist (and is excluded from the selectable list by the caller) so
260
+ * they can interact with their own OpenClaw. Self-authored echoes (the reply
261
+ * loop) are filtered earlier, at socket ingestion, by the SentMessageTracker —
262
+ * not by the inbound policy — so allowing yourself here cannot cause a loop.
263
+ */
264
+ async function promptMessagingAllowlist(params: {
253
265
  prompter: WizardPrompter;
254
- message: string;
255
266
  members: OmadeusOrganizationMember[];
267
+ selfReferenceId: number;
256
268
  existingReferenceIds?: number[];
257
- }): Promise<number[] | undefined> {
258
- const { prompter, message, members, existingReferenceIds } = params;
269
+ }): Promise<number[]> {
270
+ const { prompter, members, selfReferenceId, existingReferenceIds } = params;
271
+
272
+ let selected: number[] = [];
259
273
  if (members.length === 0) {
260
- throw new Error("No organization members found.");
274
+ await prompter.note(
275
+ "No other organization members found. Only you will be able to message OpenClaw.",
276
+ "Omadeus messaging allowlist",
277
+ );
278
+ } else {
279
+ const memberReferenceIds = new Set(members.map((member) => member.referenceId));
280
+ const initialValues = (existingReferenceIds ?? [])
281
+ .filter((id) => id !== selfReferenceId && memberReferenceIds.has(id))
282
+ .map(String);
283
+ const chosen = await promptMultiSelect({
284
+ prompter,
285
+ message: "Which users do you want to be able to message this OpenClaw instance? (You are always allowed.)",
286
+ options: memberOptions(members),
287
+ initialValues,
288
+ });
289
+ selected = readReferenceIds(chosen);
261
290
  }
262
291
 
263
- const mode = await prompter.select({
264
- message,
265
- options: [
266
- { value: "all", label: "All users", hint: "No sender allowlist" },
267
- { value: "specific", label: "Specific users", hint: "Select one or more users" },
268
- ],
269
- initialValue: existingReferenceIds && existingReferenceIds.length > 0 ? "specific" : "all",
270
- });
271
- if (mode === "all") {
272
- return undefined;
273
- }
292
+ // Always allow the logged-in user so they can message their own OpenClaw.
293
+ return Array.from(new Set([selfReferenceId, ...selected]));
294
+ }
274
295
 
275
- const selected = await promptMultiSelect({
276
- prompter,
277
- message: `${message} (specific users)`,
278
- options: memberOptions(members),
279
- initialValues: existingReferenceIds?.map(String),
296
+ /**
297
+ * Ask whether an @mention is required to trigger OpenClaw in a given surface
298
+ * (channels or entity rooms). DMs never use this — you can't @mention in a DM.
299
+ */
300
+ async function promptRequireMention(params: {
301
+ prompter: WizardPrompter;
302
+ surfaceLabel: string;
303
+ existing?: OmadeusInboundMentionPolicy;
304
+ }): Promise<OmadeusInboundMentionPolicy> {
305
+ // Preserve an existing "outsideAllowlist" policy so rerunning onboarding does
306
+ // not force previously allowlisted users to start @mentioning OpenClaw.
307
+ if (params.existing === "outsideAllowlist") {
308
+ return "outsideAllowlist";
309
+ }
310
+ const required = await params.prompter.confirm({
311
+ message: `Require an @mention to trigger OpenClaw in ${params.surfaceLabel}?`,
312
+ initialValue: params.existing ? params.existing !== "never" : true,
280
313
  });
281
- return readReferenceIds(selected);
314
+ return required ? "always" : "never";
282
315
  }
283
316
 
284
317
  async function promptEntityKindSelection(params: {
@@ -414,11 +447,17 @@ export const omadeusSetupWizard: ChannelSetupWizard = {
414
447
  });
415
448
  const existingInbound = section.inbound;
416
449
 
417
- const directSenderIds = await promptSenderAllowlist({
450
+ const allowedUserReferenceIds = await promptMessagingAllowlist({
418
451
  prompter,
419
- message: "Which users can DM OpenClaw directly?",
420
452
  members,
421
- existingReferenceIds: existingInbound?.direct?.allowedSenderReferenceIds,
453
+ selfReferenceId,
454
+ existingReferenceIds: Array.from(
455
+ new Set([
456
+ ...(existingInbound?.direct?.allowedSenderReferenceIds ?? []),
457
+ ...(existingInbound?.channels?.allowedSenderReferenceIds ?? []),
458
+ ...(existingInbound?.entities?.allowedSenderReferenceIds ?? []),
459
+ ]),
460
+ ),
422
461
  });
423
462
 
424
463
  const selectedChannels = await promptChannelSelection({
@@ -429,30 +468,32 @@ export const omadeusSetupWizard: ChannelSetupWizard = {
429
468
  existingChannelViewIds: existingInbound?.channels?.allowedChannelViewIds,
430
469
  });
431
470
 
432
- const channelSenderIds =
471
+ // Channels reuse the same messaging allowlist as direct messages.
472
+ const channelSenderIds = selectedChannels.length > 0 ? allowedUserReferenceIds : undefined;
473
+ const channelRequireMention =
433
474
  selectedChannels.length > 0
434
- ? await promptSenderAllowlist({
475
+ ? await promptRequireMention({
435
476
  prompter,
436
- message: "Which users can trigger OpenClaw from allowed channels?",
437
- members,
438
- existingReferenceIds: existingInbound?.channels?.allowedSenderReferenceIds,
477
+ surfaceLabel: "allowed channels",
478
+ existing: existingInbound?.channels?.requireMention,
439
479
  })
440
- : undefined;
480
+ : "never";
441
481
 
442
482
  const entityKinds = await promptEntityKindSelection({
443
483
  prompter,
444
484
  existingKinds: existingInbound?.entities?.allowedKinds,
445
485
  });
446
486
 
447
- const entitySenderIds =
487
+ // Entity rooms reuse the same messaging allowlist as direct messages.
488
+ const entitySenderIds = entityKinds.length > 0 ? allowedUserReferenceIds : undefined;
489
+ const entityRequireMention =
448
490
  entityKinds.length > 0
449
- ? await promptSenderAllowlist({
491
+ ? await promptRequireMention({
450
492
  prompter,
451
- message: "Which users can trigger OpenClaw from entity rooms?",
452
- members,
453
- existingReferenceIds: existingInbound?.entities?.allowedSenderReferenceIds,
493
+ surfaceLabel: "entity rooms",
494
+ existing: existingInbound?.entities?.requireMention,
454
495
  })
455
- : undefined;
496
+ : "never";
456
497
 
457
498
  const channelRoomIds = selectedChannels
458
499
  .flatMap((selectedChannel) => [
@@ -467,20 +508,29 @@ export const omadeusSetupWizard: ChannelSetupWizard = {
467
508
 
468
509
  const senderSummary = (ids: number[] | undefined) =>
469
510
  ids && ids.length > 0 ? ids.join(", ") : "all users";
470
- const entityKindSummary =
471
- entityKinds.length > 0 ? entityKinds.join(", ") : "none (entity rooms disabled)";
511
+ const mentionSummary = (require: OmadeusInboundMentionPolicy) =>
512
+ require === "never"
513
+ ? "no @mention required"
514
+ : require === "outsideAllowlist"
515
+ ? "@mention required outside the allowlist"
516
+ : "@mention required";
472
517
 
473
518
  const channelSummary =
474
519
  selectedChannels.length > 0
475
- ? `- Channels "${channelTitles}": rooms ${channelRoomIds.join(", ") || "(no room ids)"} from ${senderSummary(channelSenderIds)}; @mention not required in those rooms.`
520
+ ? `- Channels "${channelTitles}": rooms ${channelRoomIds.join(", ") || "(no room ids)"} from ${senderSummary(channelSenderIds)}; ${mentionSummary(channelRequireMention)}.`
476
521
  : "- Channels: disabled (none selected).";
477
522
 
523
+ const entitySummary =
524
+ entityKinds.length > 0
525
+ ? `- Entity rooms (${entityKinds.join(", ")}): ${senderSummary(entitySenderIds)}; ${mentionSummary(entityRequireMention)}.`
526
+ : "- Entity rooms: disabled (no room types selected).";
527
+
478
528
  await prompter.note(
479
529
  [
480
530
  `Inbound policy (Jaguar chat):`,
481
- `- Direct messages: enabled for ${senderSummary(directSenderIds)} (no @mention required).`,
531
+ `- Direct messages: enabled for ${senderSummary(allowedUserReferenceIds)}.`,
482
532
  channelSummary,
483
- `- Entity rooms (${entityKindSummary}): ${senderSummary(entitySenderIds)}; @mention required.`,
533
+ entitySummary,
484
534
  ].join("\n"),
485
535
  "Omadeus inbound policy",
486
536
  );
@@ -501,7 +551,7 @@ export const omadeusSetupWizard: ChannelSetupWizard = {
501
551
  version: 1,
502
552
  direct: {
503
553
  enabled: true,
504
- ...(directSenderIds ? { allowedSenderReferenceIds: directSenderIds } : {}),
554
+ allowedSenderReferenceIds: allowedUserReferenceIds,
505
555
  requireMention: "never",
506
556
  },
507
557
  channels: {
@@ -509,13 +559,13 @@ export const omadeusSetupWizard: ChannelSetupWizard = {
509
559
  allowedRoomIds: channelRoomIds,
510
560
  allowedChannelViewIds: channelViewIds,
511
561
  ...(channelSenderIds ? { allowedSenderReferenceIds: channelSenderIds } : {}),
512
- requireMention: "outsideAllowlist",
562
+ requireMention: channelRequireMention,
513
563
  },
514
564
  entities: {
515
565
  enabled: entityKinds.length > 0,
516
566
  allowedKinds: entityKinds,
517
567
  ...(entitySenderIds ? { allowedSenderReferenceIds: entitySenderIds } : {}),
518
- requireMention: "always",
568
+ requireMention: entityRequireMention,
519
569
  },
520
570
  },
521
571
  },
package/src/outbound.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { sendRoomMessage } from "./api/message.api.js";
2
+ import type { SentMessageTracker } from "./sent-message-tracker.js";
2
3
  import type { JaguarSocketClient } from "./socket/jaguar.socket.js";
3
- import type { OmadeusApiOptions } from "./utils/http.util.js";
4
+ import { generateTemporaryId, type OmadeusApiOptions } from "./utils/http.util.js";
4
5
 
5
6
  export type OutboundDeps = {
6
7
  apiOpts: OmadeusApiOptions;
7
8
  jaguarSocket: JaguarSocketClient;
9
+ /** Records messages we send so their socket echoes can be suppressed. */
10
+ sentTracker?: SentMessageTracker;
8
11
  };
9
12
 
10
13
  export async function sendOmadeusMessage(
@@ -13,11 +16,20 @@ export async function sendOmadeusMessage(
13
16
  ): Promise<{ channel: string; messageId: string; chatId: string }> {
14
17
  const { to, text } = params;
15
18
 
16
- const result = await sendRoomMessage(deps.apiOpts, { roomId: to, body: text });
19
+ const temporaryId = generateTemporaryId();
20
+ // Register before sending: the socket echo can arrive before this HTTP call
21
+ // returns, so the temporaryId (and body fallback) must already be tracked.
22
+ deps.sentTracker?.trackOutbound({ temporaryId, body: text, roomId: to });
23
+
24
+ const result = await sendRoomMessage(deps.apiOpts, { roomId: to, body: text, temporaryId });
17
25
  if (!result.ok) {
18
26
  throw new Error(`Omadeus send failed: ${result.error}`);
19
27
  }
20
28
 
29
+ if (typeof result.message?.id === "number") {
30
+ deps.sentTracker?.trackId(result.message.id);
31
+ }
32
+
21
33
  return {
22
34
  channel: "omadeus",
23
35
  messageId: String(result.message?.id ?? ""),
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Tracks messages this plugin sent so their Jaguar socket echoes can be
3
+ * suppressed, instead of dropping every message authored by the logged-in
4
+ * account.
5
+ *
6
+ * OpenClaw sends as the same Omadeus account it listens on, so each outbound
7
+ * message is broadcast back to us over the socket. We register up to three keys
8
+ * per send:
9
+ *
10
+ * - the client-generated `temporaryId` — known *before* the HTTP round-trip, so
11
+ * it matches even when the socket echo beats the send response (the common
12
+ * race);
13
+ * - the backend message `id` — known once the send response returns;
14
+ * - a normalized copy of the body scoped to its room — a last-resort fallback
15
+ * used only for self-authored echoes that somehow arrive without a
16
+ * recognizable id. Scoping by room prevents the same text sent in one chat
17
+ * from suppressing an identical message in a different chat.
18
+ *
19
+ * `id` and `temporaryId` are kept in separate maps. Entries expire after a
20
+ * short TTL and each map is size-capped, so the tracker cannot grow unbounded.
21
+ */
22
+
23
+ const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes — comfortably covers echo latency.
24
+ const DEFAULT_MAX_ENTRIES = 500;
25
+
26
+ export type SentMessageTrackerOptions = {
27
+ ttlMs?: number;
28
+ maxEntries?: number;
29
+ now?: () => number;
30
+ };
31
+
32
+ function normalizeContent(body: string): string {
33
+ return body.trim();
34
+ }
35
+
36
+ /** Normalize a room identity so outbound (`"room:123"`/`"123"`) and the socket
37
+ * echo (numeric `123`) map to the same key. */
38
+ function roomKey(roomId: string | number): string {
39
+ return String(roomId).replace(/^room:/, "").trim();
40
+ }
41
+
42
+ /** Build the room-scoped content key, or undefined when the body is empty. */
43
+ function contentKey(roomId: string | number, body: string): string | undefined {
44
+ const normalized = normalizeContent(body);
45
+ if (!normalized) return undefined;
46
+ return `${roomKey(roomId)}\n${normalized}`;
47
+ }
48
+
49
+ export class SentMessageTracker {
50
+ private readonly ttlMs: number;
51
+ private readonly maxEntries: number;
52
+ private readonly now: () => number;
53
+ private readonly ids = new Map<number, number>();
54
+ private readonly temporaryIds = new Map<string, number>();
55
+ private readonly contents = new Map<string, number>();
56
+
57
+ constructor(options: SentMessageTrackerOptions = {}) {
58
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
59
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
60
+ this.now = options.now ?? Date.now;
61
+ }
62
+
63
+ /** Register a client-generated temporaryId. Call before sending. */
64
+ trackTemporaryId(temporaryId: string): void {
65
+ if (!temporaryId) return;
66
+ this.remember(this.temporaryIds, temporaryId);
67
+ }
68
+
69
+ /** Register the backend message id once the send response returns. */
70
+ trackId(id: number): void {
71
+ if (!Number.isFinite(id)) return;
72
+ this.remember(this.ids, id);
73
+ }
74
+
75
+ /** Register a message body, scoped to its room, as a fallback match key. */
76
+ trackContent(roomId: string | number, body: string): void {
77
+ const key = contentKey(roomId, body);
78
+ if (!key) return;
79
+ this.remember(this.contents, key);
80
+ }
81
+
82
+ /** Convenience: register whichever keys are available for one outbound message. */
83
+ trackOutbound(params: {
84
+ temporaryId?: string;
85
+ id?: number;
86
+ body?: string;
87
+ roomId?: string | number;
88
+ }): void {
89
+ if (params.temporaryId) this.trackTemporaryId(params.temporaryId);
90
+ if (typeof params.id === "number") this.trackId(params.id);
91
+ if (typeof params.body === "string" && params.roomId !== undefined) {
92
+ this.trackContent(params.roomId, params.body);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Returns true when an inbound socket message is an echo of something we sent.
98
+ *
99
+ * `id`/`temporaryId` matches are authoritative. The content fallback only
100
+ * applies to self-authored messages — the only ones that can form a reply
101
+ * loop — and is scoped to the message's room, so a different user repeating
102
+ * our text (or the same text in another room) is never suppressed.
103
+ */
104
+ isEcho(msg: {
105
+ id?: number;
106
+ temporaryId?: string;
107
+ body?: string;
108
+ roomId?: string | number;
109
+ fromSelf: boolean;
110
+ }): boolean {
111
+ if (typeof msg.id === "number" && this.has(this.ids, msg.id)) return true;
112
+ if (msg.temporaryId && this.has(this.temporaryIds, msg.temporaryId)) return true;
113
+ if (msg.fromSelf && typeof msg.body === "string" && msg.roomId !== undefined) {
114
+ const key = contentKey(msg.roomId, msg.body);
115
+ if (key && this.has(this.contents, key)) return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ private remember<K>(map: Map<K, number>, key: K): void {
121
+ // Delete-then-set so re-registered keys move to the end, keeping insertion
122
+ // order aligned with expiry order (the TTL is constant).
123
+ map.delete(key);
124
+ map.set(key, this.now() + this.ttlMs);
125
+ this.prune(map);
126
+ }
127
+
128
+ private has<K>(map: Map<K, number>, key: K): boolean {
129
+ const expiry = map.get(key);
130
+ if (expiry === undefined) return false;
131
+ if (expiry <= this.now()) {
132
+ map.delete(key);
133
+ return false;
134
+ }
135
+ return true;
136
+ }
137
+
138
+ private prune<K>(map: Map<K, number>): void {
139
+ const now = this.now();
140
+ for (const [key, expiry] of map) {
141
+ if (expiry <= now) {
142
+ map.delete(key);
143
+ } else {
144
+ // Insertion order matches expiry order, so the first live entry means
145
+ // everything after it is also live.
146
+ break;
147
+ }
148
+ }
149
+ while (map.size > this.maxEntries) {
150
+ const oldest = map.keys().next().value as K | undefined;
151
+ if (oldest === undefined) break;
152
+ map.delete(oldest);
153
+ }
154
+ }
155
+ }