@brantrusnak/openclaw-omadeus 1.0.4 → 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.
@@ -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 };
@@ -1,3 +1,4 @@
1
+ import { resolveOmadeusEnvironment } from "./defaults.js";
1
2
  //#region src/setup-core.ts
2
3
  function readSetupStringField(input, key) {
3
4
  const value = input[key];
@@ -18,8 +19,8 @@ const omadeusSetupAdapter = {
18
19
  },
19
20
  applyAccountConfig: ({ cfg, input }) => {
20
21
  const rawInput = input;
21
- const casUrl = input.httpUrl?.trim() || void 0;
22
- const maestroUrl = input.url?.trim() || void 0;
22
+ const environmentRaw = readSetupStringField(rawInput, "environment");
23
+ const environment = environmentRaw ? resolveOmadeusEnvironment(environmentRaw) : void 0;
23
24
  const email = readSetupStringField(rawInput, "email");
24
25
  const password = input.password?.trim() || void 0;
25
26
  const organizationId = readSetupNumberField(rawInput, "organizationId");
@@ -32,8 +33,7 @@ const omadeusSetupAdapter = {
32
33
  omadeus: {
33
34
  ...omadeusPrevious,
34
35
  enabled: true,
35
- ...casUrl ? { casUrl } : {},
36
- ...maestroUrl ? { maestroUrl } : {},
36
+ ...environment ? { environment } : {},
37
37
  ...email ? { email } : {},
38
38
  ...password ? { password } : {},
39
39
  ...organizationId ? { organizationId } : {}
@@ -6,12 +6,18 @@
6
6
  "additionalProperties": false,
7
7
  "properties": {
8
8
  "enabled": { "type": "boolean" },
9
- "casUrl": { "type": "string" },
10
- "maestroUrl": { "type": "string" },
9
+ "environment": {
10
+ "type": "string",
11
+ "enum": ["production", "staging", "dev"]
12
+ },
11
13
  "email": { "type": "string" },
12
14
  "password": { "type": "string" },
13
15
  "organizationId": { "type": "number" },
14
16
  "sessionToken": { "type": "string" },
17
+ "sessionTokenEnvironment": {
18
+ "type": "string",
19
+ "enum": ["production", "staging", "dev"]
20
+ },
15
21
  "inbound": {
16
22
  "type": "object",
17
23
  "additionalProperties": false,
@@ -92,12 +98,18 @@
92
98
  "additionalProperties": false,
93
99
  "properties": {
94
100
  "enabled": { "type": "boolean" },
95
- "casUrl": { "type": "string" },
96
- "maestroUrl": { "type": "string" },
101
+ "environment": {
102
+ "type": "string",
103
+ "enum": ["production", "staging", "dev"]
104
+ },
97
105
  "email": { "type": "string" },
98
106
  "password": { "type": "string" },
99
107
  "organizationId": { "type": "number" },
100
108
  "sessionToken": { "type": "string" },
109
+ "sessionTokenEnvironment": {
110
+ "type": "string",
111
+ "enum": ["production", "staging", "dev"]
112
+ },
101
113
  "inbound": {
102
114
  "type": "object",
103
115
  "additionalProperties": false,
@@ -172,13 +184,8 @@
172
184
  }
173
185
  },
174
186
  "uiHints": {
175
- "casUrl": {
176
- "label": "CAS URL",
177
- "placeholder": "https://dev1-cas.rouztech.com"
178
- },
179
- "maestroUrl": {
180
- "label": "Maestro URL",
181
- "placeholder": "https://dev3-maestro.rouztech.com"
187
+ "environment": {
188
+ "label": "Environment"
182
189
  },
183
190
  "email": {
184
191
  "label": "Email"
@@ -195,6 +202,10 @@
195
202
  "sensitive": true,
196
203
  "advanced": true
197
204
  },
205
+ "sessionTokenEnvironment": {
206
+ "label": "Session token environment",
207
+ "advanced": true
208
+ },
198
209
  "inbound": {
199
210
  "label": "Inbound policy (Jaguar chat)",
200
211
  "advanced": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brantrusnak/openclaw-omadeus",
3
- "version": "1.0.4",
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,18 +53,22 @@ 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
 
58
61
  let lastPersistedToken: string | null = null;
59
62
 
60
- async function persistSessionToken(token: string): Promise<void> {
63
+ async function persistSessionToken(
64
+ token: string,
65
+ environment: Account["environment"],
66
+ ): Promise<void> {
61
67
  if (lastPersistedToken === token) return;
62
68
  const runtime = getOmadeusRuntime();
63
69
  const cfg = runtime.config.current() as OpenClawConfig;
64
70
  const section = getOmadeusChannelConfig(cfg) ?? {};
65
- if (section.sessionToken === token) {
71
+ if (section.sessionToken === token && section.sessionTokenEnvironment === environment) {
66
72
  lastPersistedToken = token;
67
73
  return;
68
74
  }
@@ -74,6 +80,7 @@ async function persistSessionToken(token: string): Promise<void> {
74
80
  omadeus: {
75
81
  ...(getOmadeusChannelConfig(draft) ?? {}),
76
82
  sessionToken: token,
83
+ sessionTokenEnvironment: environment,
77
84
  },
78
85
  };
79
86
  },
@@ -103,12 +110,12 @@ const omadeusConfigAdapter = createTopLevelChannelConfigAdapter<Account>({
103
110
  defaultAccountId: resolveDefaultOmadeusAccountId,
104
111
  deleteMode: "clear-fields",
105
112
  clearBaseFields: [
106
- "casUrl",
107
- "maestroUrl",
113
+ "environment",
108
114
  "email",
109
115
  "password",
110
116
  "organizationId",
111
117
  "sessionToken",
118
+ "sessionTokenEnvironment",
112
119
  "inbound",
113
120
  ],
114
121
  // Keep adapter contract satisfied even though Omadeus no longer uses DM allowlists.
@@ -315,7 +322,13 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
315
322
  );
316
323
  }
317
324
  try {
318
- 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
+ }
319
332
  } catch (err) {
320
333
  const msg = err instanceof Error ? err.message : String(err);
321
334
  return actionError(msg);
@@ -462,6 +475,7 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
462
475
  tokenManager: gatewayState.tokenManager,
463
476
  },
464
477
  jaguarSocket: gatewayState.jaguar,
478
+ sentTracker: gatewayState.sentTracker ?? undefined,
465
479
  };
466
480
  return await sendOmadeusMessage(deps, { to, text });
467
481
  },
@@ -561,7 +575,7 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
561
575
  initialToken: account.sessionToken,
562
576
  onRefresh: (token) => {
563
577
  log.info("[omadeus] token refreshed");
564
- void persistSessionToken(token).catch((err) =>
578
+ void persistSessionToken(token, account.environment).catch((err) =>
565
579
  log.warn(`[omadeus] failed to persist session token: ${String(err)}`),
566
580
  );
567
581
  },
@@ -585,9 +599,13 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
585
599
 
586
600
  const selfReferenceId = tokenManager.getPayload().referenceId;
587
601
 
602
+ const sentTracker = new SentMessageTracker();
603
+ gatewayState.sentTracker = sentTracker;
604
+
588
605
  const outboundDeps: OutboundDeps = {
589
606
  apiOpts: { maestroUrl: account.maestroUrl, tokenManager },
590
607
  jaguarSocket: null as unknown as JaguarSocketClient,
608
+ sentTracker,
591
609
  };
592
610
 
593
611
  const handleMessage = createOmadeusMessageHandler({
@@ -609,6 +627,23 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
609
627
  : `${msg.subscribableKind}/${msg.roomName ?? msg.roomId} from ${msg.senderReferenceId}`;
610
628
  log.info(`[jaguar] ${label}: ${msg.body.slice(0, 80)}`);
611
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
+
612
647
  const inbound = parseJaguarMessage(msg, { selfReferenceId }, log);
613
648
  if (inbound) {
614
649
  log.info(
@@ -684,6 +719,7 @@ export const omadeusPlugin: ChannelPlugin<Account> = {
684
719
  gatewayState.tokenManager = null;
685
720
  gatewayState.jaguar = null;
686
721
  gatewayState.dolphin = null;
722
+ gatewayState.sentTracker = null;
687
723
  lastPersistedToken = null;
688
724
  ctx.setStatus({
689
725
  accountId: account.accountId,
package/src/config.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "../runtime-api.js";
2
- import { OMADEUS_CAS_URL, OMADEUS_MAESTRO_URL } from "./defaults.js";
2
+ import {
3
+ getOmadeusEnvironmentUrls,
4
+ resolveOmadeusEnvironment,
5
+ } from "./defaults.js";
3
6
  import type { OmadeusChannelConfig, ResolvedOmadeusAccount } from "./types.js";
4
7
 
5
8
  export function getOmadeusChannelConfig(cfg: OpenClawConfig): OmadeusChannelConfig | undefined {
@@ -24,11 +27,17 @@ export function resolveOmadeusAccount(params: {
24
27
  }): ResolvedOmadeusAccount {
25
28
  const { cfg } = params;
26
29
  const section = getOmadeusChannelConfig(cfg) ?? {};
30
+ const environment = resolveOmadeusEnvironment(section.environment);
31
+ const { casUrl, maestroUrl } = getOmadeusEnvironmentUrls(environment);
27
32
  const envCredentials = resolveOmadeusEnvCredentials();
28
33
  const email = section.email?.trim() || envCredentials?.email || "";
29
34
  const password = section.password?.trim() || envCredentials?.password || "";
30
35
  const orgId = section.organizationId ?? envCredentials?.organizationId;
31
- const sessionToken = section.sessionToken?.trim() ?? "";
36
+ const rawSessionToken = section.sessionToken?.trim() ?? "";
37
+ const sessionTokenEnvironment = resolveOmadeusEnvironment(section.sessionTokenEnvironment);
38
+ const sessionTokenValid =
39
+ Boolean(rawSessionToken) && sessionTokenEnvironment === environment;
40
+ const sessionToken = sessionTokenValid ? rawSessionToken : "";
32
41
  const hasCredentials = Boolean(email && password && orgId);
33
42
  const hasSessionToken = Boolean(sessionToken);
34
43
  const hasConfigCredentials = Boolean(
@@ -47,8 +56,9 @@ export function resolveOmadeusAccount(params: {
47
56
  name: "Omadeus",
48
57
  enabled: section.enabled !== false,
49
58
  config: section,
50
- casUrl: section.casUrl?.trim() || OMADEUS_CAS_URL,
51
- maestroUrl: section.maestroUrl?.trim() || OMADEUS_MAESTRO_URL,
59
+ environment,
60
+ casUrl,
61
+ maestroUrl,
52
62
  email,
53
63
  password,
54
64
  organizationId: orgId ?? 0,
package/src/defaults.ts CHANGED
@@ -1,2 +1,44 @@
1
- export const OMADEUS_CAS_URL = "https://dev1-cas.rouztech.com";
2
- export const OMADEUS_MAESTRO_URL = "https://dev3-maestro.rouztech.com";
1
+ export type OmadeusEnvironment = "production" | "staging" | "dev";
2
+
3
+ export const OMADEUS_DEFAULT_ENVIRONMENT: OmadeusEnvironment = "dev";
4
+
5
+ export type OmadeusEnvironmentConfig = {
6
+ label: string;
7
+ casUrl: string;
8
+ maestroUrl: string;
9
+ };
10
+
11
+ export const OMADEUS_ENVIRONMENTS: Record<OmadeusEnvironment, OmadeusEnvironmentConfig> = {
12
+ production: {
13
+ label: "Production",
14
+ casUrl: "https://xas.xeba.tech",
15
+ maestroUrl: "https://maestro.xeba.tech",
16
+ },
17
+ staging: {
18
+ label: "Staging",
19
+ casUrl: "https://staging-xas.xeba.tech",
20
+ maestroUrl: "https://staging.xeba.tech",
21
+ },
22
+ dev: {
23
+ label: "Dev",
24
+ casUrl: "https://dev1-cas.rouztech.com",
25
+ maestroUrl: "https://dev1-maestro.rouztech.com",
26
+ },
27
+ };
28
+
29
+ const OMADEUS_ENVIRONMENT_SET = new Set<string>(Object.keys(OMADEUS_ENVIRONMENTS));
30
+
31
+ export function resolveOmadeusEnvironment(value: unknown): OmadeusEnvironment {
32
+ if (typeof value === "string" && OMADEUS_ENVIRONMENT_SET.has(value)) {
33
+ return value as OmadeusEnvironment;
34
+ }
35
+ return OMADEUS_DEFAULT_ENVIRONMENT;
36
+ }
37
+
38
+ export function getOmadeusEnvironmentUrls(env: OmadeusEnvironment): {
39
+ casUrl: string;
40
+ maestroUrl: string;
41
+ } {
42
+ const config = OMADEUS_ENVIRONMENTS[env];
43
+ return { casUrl: config.casUrl, maestroUrl: config.maestroUrl };
44
+ }
@@ -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)}`);