@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.
- package/dist/src/api/message.api.js +13 -2
- package/dist/src/channel.js +30 -5
- package/dist/src/inbound-policy.js +11 -14
- package/dist/src/message-handler.js +14 -2
- package/dist/src/onboarding.js +62 -42
- package/dist/src/outbound.js +10 -1
- package/dist/src/sent-message-tracker.js +116 -0
- package/package.json +1 -1
- package/src/api/message.api.ts +4 -2
- package/src/channel.ts +34 -2
- package/src/inbound-policy.ts +24 -13
- package/src/message-handler.ts +35 -7
- package/src/onboarding.ts +95 -45
- package/src/outbound.ts +14 -2
- package/src/sent-message-tracker.ts +155 -0
|
@@ -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 };
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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)}`);
|
package/dist/src/onboarding.js
CHANGED
|
@@ -152,28 +152,43 @@ async function promptCredentials(prompter, existing) {
|
|
|
152
152
|
})).trim()
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
287
|
+
const allowedUserReferenceIds = await promptMessagingAllowlist({
|
|
273
288
|
prompter,
|
|
274
|
-
message: "Which users can DM OpenClaw directly?",
|
|
275
289
|
members,
|
|
276
|
-
|
|
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 ?
|
|
304
|
+
const channelSenderIds = selectedChannels.length > 0 ? allowedUserReferenceIds : void 0;
|
|
305
|
+
const channelRequireMention = selectedChannels.length > 0 ? await promptRequireMention({
|
|
286
306
|
prompter,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 ?
|
|
314
|
+
const entitySenderIds = entityKinds.length > 0 ? allowedUserReferenceIds : void 0;
|
|
315
|
+
const entityRequireMention = entityKinds.length > 0 ? await promptRequireMention({
|
|
296
316
|
prompter,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
306
|
-
const channelSummary = selectedChannels.length > 0 ? `- Channels "${channelTitles}": rooms ${channelRoomIds.join(", ") || "(no room ids)"} from ${senderSummary(channelSenderIds)};
|
|
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(
|
|
329
|
+
`- Direct messages: enabled for ${senderSummary(allowedUserReferenceIds)}.`,
|
|
310
330
|
channelSummary,
|
|
311
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
363
|
+
requireMention: entityRequireMention
|
|
344
364
|
}
|
|
345
365
|
}
|
|
346
366
|
}
|
package/dist/src/outbound.js
CHANGED
|
@@ -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
package/src/api/message.api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/src/inbound-policy.ts
CHANGED
|
@@ -49,7 +49,15 @@ function surfaceForKind(kind: OmadeusSubscribableKind): "direct" | "channel" | "
|
|
|
49
49
|
return "entity";
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function senderAllowed(
|
|
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
|
-
*
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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",
|
package/src/message-handler.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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[]
|
|
258
|
-
const { prompter,
|
|
269
|
+
}): Promise<number[]> {
|
|
270
|
+
const { prompter, members, selfReferenceId, existingReferenceIds } = params;
|
|
271
|
+
|
|
272
|
+
let selected: number[] = [];
|
|
259
273
|
if (members.length === 0) {
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
|
450
|
+
const allowedUserReferenceIds = await promptMessagingAllowlist({
|
|
418
451
|
prompter,
|
|
419
|
-
message: "Which users can DM OpenClaw directly?",
|
|
420
452
|
members,
|
|
421
|
-
|
|
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
|
-
|
|
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
|
|
475
|
+
? await promptRequireMention({
|
|
435
476
|
prompter,
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
existingReferenceIds: existingInbound?.channels?.allowedSenderReferenceIds,
|
|
477
|
+
surfaceLabel: "allowed channels",
|
|
478
|
+
existing: existingInbound?.channels?.requireMention,
|
|
439
479
|
})
|
|
440
|
-
:
|
|
480
|
+
: "never";
|
|
441
481
|
|
|
442
482
|
const entityKinds = await promptEntityKindSelection({
|
|
443
483
|
prompter,
|
|
444
484
|
existingKinds: existingInbound?.entities?.allowedKinds,
|
|
445
485
|
});
|
|
446
486
|
|
|
447
|
-
|
|
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
|
|
491
|
+
? await promptRequireMention({
|
|
450
492
|
prompter,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
existingReferenceIds: existingInbound?.entities?.allowedSenderReferenceIds,
|
|
493
|
+
surfaceLabel: "entity rooms",
|
|
494
|
+
existing: existingInbound?.entities?.requireMention,
|
|
454
495
|
})
|
|
455
|
-
:
|
|
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
|
|
471
|
-
|
|
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)};
|
|
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(
|
|
531
|
+
`- Direct messages: enabled for ${senderSummary(allowedUserReferenceIds)}.`,
|
|
482
532
|
channelSummary,
|
|
483
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
+
}
|