@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -533
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -85
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -111
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -646
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -122
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -149
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -162
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -13
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/invocations.js +0 -47
  38. package/dist/src/lifecycle.js +0 -35
  39. package/dist/src/poll-loop.js +0 -137
  40. package/dist/src/responding.js +0 -36
  41. package/dist/src/security.js +0 -26
  42. package/dist/src/state-files.js +0 -243
  43. package/dist/src/vendor/relay-sdk/client.js +0 -163
  44. package/dist/src/vendor/relay-sdk/errors.js +0 -45
  45. package/dist/src/vendor/relay-sdk/types.js +0 -2
  46. package/dist/src/vendor/relay-sdk/url.js +0 -39
  47. package/src/account-lock.ts +0 -108
  48. package/src/client.ts +0 -51
  49. package/src/cursor-store.ts +0 -186
  50. package/src/inbound-dedupe.ts +0 -241
  51. package/src/invocations.ts +0 -58
  52. package/src/lifecycle.ts +0 -42
  53. package/src/poll-loop.ts +0 -173
  54. package/src/responding.ts +0 -52
  55. package/src/security.ts +0 -36
  56. package/src/state-files.ts +0 -298
  57. package/src/vendor/relay-sdk/README.md +0 -28
  58. package/src/vendor/relay-sdk/client.ts +0 -293
  59. package/src/vendor/relay-sdk/errors.ts +0 -61
  60. package/src/vendor/relay-sdk/types.ts +0 -82
  61. package/src/vendor/relay-sdk/url.ts +0 -43
@@ -0,0 +1,257 @@
1
+ import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, } from "openclaw/plugin-sdk/channel-inbound";
2
+ import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
3
+ import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
4
+ import { buildRelayInboundFacts } from "./inbound.js";
5
+ function isReplyToAgentMessage(message, chatId) {
6
+ return message.chat_id === chatId && message.is_from_me === true;
7
+ }
8
+ /**
9
+ * Relay's structured mention and reply facts are authoritative. In
10
+ * particular, this does not infer an activation from visible `@handle` text.
11
+ */
12
+ export async function resolveRelayTurnActivation(params) {
13
+ if (params.facts.chatType === "direct") {
14
+ return {
15
+ kind: "direct",
16
+ wasMentioned: false,
17
+ implicitMentionKinds: [],
18
+ };
19
+ }
20
+ const ownerHandle = params.facts.ownerHandle;
21
+ if (ownerHandle?.kind === "agent" &&
22
+ params.facts.mentionHandles.includes(ownerHandle.handle)) {
23
+ return {
24
+ kind: "mention",
25
+ wasMentioned: true,
26
+ implicitMentionKinds: [],
27
+ };
28
+ }
29
+ if (!params.facts.replyToId)
30
+ return null;
31
+ const replyTarget = await params.relay.messages.retrieve(params.facts.replyToId);
32
+ if (!isReplyToAgentMessage(replyTarget, params.facts.chatId))
33
+ return null;
34
+ return {
35
+ kind: "reply",
36
+ wasMentioned: false,
37
+ implicitMentionKinds: ["reply_to_bot"],
38
+ };
39
+ }
40
+ export async function dispatchRelayEvent(params) {
41
+ const facts = buildRelayInboundFacts(params.event);
42
+ if (!facts) {
43
+ params.warn?.(`relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`);
44
+ return;
45
+ }
46
+ const activation = await resolveRelayTurnActivation({
47
+ facts,
48
+ relay: params.relay,
49
+ });
50
+ if (!activation) {
51
+ params.warn?.(`relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`);
52
+ return;
53
+ }
54
+ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
55
+ cfg: params.cfg,
56
+ channel: "relay",
57
+ accountId: params.account.accountId,
58
+ peer: {
59
+ kind: facts.chatType,
60
+ id: facts.chatId,
61
+ },
62
+ });
63
+ const restricted = params.account.allowFrom.length > 0;
64
+ const effectiveAllowFrom = restricted
65
+ ? params.account.allowFrom
66
+ : ["*"];
67
+ const access = await resolveStableChannelMessageIngress({
68
+ channelId: "relay",
69
+ accountId: params.account.accountId,
70
+ identity: {
71
+ key: "contactId",
72
+ kind: "stable-id",
73
+ entryIdPrefix: "relay-contact",
74
+ aliases: [
75
+ {
76
+ key: "handle",
77
+ kind: "username",
78
+ normalize: (value) => value.trim().replace(/^@/u, "").toLowerCase(),
79
+ dangerous: true,
80
+ },
81
+ ],
82
+ },
83
+ subject: {
84
+ stableId: facts.contactId,
85
+ aliases: { handle: facts.handle },
86
+ },
87
+ conversation: {
88
+ kind: facts.chatType,
89
+ id: facts.chatId,
90
+ title: facts.chatType === "direct" ? facts.displayName : facts.chatId,
91
+ },
92
+ contextBinding: {
93
+ agentId: route.agentId,
94
+ sessionKey: route.sessionKey,
95
+ messageId: facts.messageId,
96
+ nativeChannelId: facts.chatId,
97
+ inboundEventKind: "user_request",
98
+ },
99
+ dmPolicy: restricted ? "allowlist" : "open",
100
+ groupPolicy: restricted ? "allowlist" : "open",
101
+ policy: {
102
+ groupAllowFromFallbackToAllowFrom: true,
103
+ ...(facts.chatType === "group"
104
+ ? {
105
+ activation: {
106
+ requireMention: true,
107
+ allowTextCommands: false,
108
+ implicitMentions: {
109
+ replyToBot: true,
110
+ quotedBot: false,
111
+ threadParticipation: false,
112
+ },
113
+ allowedImplicitMentionKinds: ["reply_to_bot"],
114
+ },
115
+ }
116
+ : {}),
117
+ },
118
+ ...(facts.chatType === "group"
119
+ ? {
120
+ mentionFacts: {
121
+ canDetectMention: true,
122
+ wasMentioned: activation.wasMentioned,
123
+ hasAnyMention: facts.mentionHandles.length > 0,
124
+ implicitMentionKinds: activation.implicitMentionKinds,
125
+ },
126
+ }
127
+ : {}),
128
+ allowFrom: effectiveAllowFrom,
129
+ groupAllowFrom: effectiveAllowFrom,
130
+ });
131
+ if (access.ingress.admission !== "dispatch") {
132
+ params.warn?.(`relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`);
133
+ return;
134
+ }
135
+ const body = buildEnvelope({
136
+ channel: "Relay",
137
+ from: `${facts.displayName} (@${facts.handle})`,
138
+ ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
139
+ body: facts.text,
140
+ });
141
+ const ctxPayload = buildChannelInboundEventContext({
142
+ channel: "relay",
143
+ accountId: route.accountId ?? params.account.accountId,
144
+ messageId: facts.messageId,
145
+ messageIdFull: facts.messageId,
146
+ ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
147
+ from: facts.chatId,
148
+ sender: {
149
+ id: facts.contactId,
150
+ name: facts.displayName,
151
+ username: facts.handle,
152
+ },
153
+ conversation: {
154
+ kind: facts.chatType,
155
+ id: facts.chatId,
156
+ label: facts.chatType === "group" ? facts.chatId : facts.displayName,
157
+ nativeChannelId: facts.chatId,
158
+ },
159
+ route: {
160
+ agentId: route.agentId,
161
+ accountId: route.accountId,
162
+ routeSessionKey: route.sessionKey,
163
+ dispatchSessionKey: route.sessionKey,
164
+ ...(route.dmScope ? { dmScope: route.dmScope } : {}),
165
+ },
166
+ reply: {
167
+ to: facts.chatId,
168
+ originatingTo: facts.chatId,
169
+ ...(facts.replyToId ? { replyToId: facts.replyToId } : {}),
170
+ },
171
+ message: {
172
+ inboundEventKind: "user_request",
173
+ body,
174
+ bodyForAgent: facts.text,
175
+ rawBody: facts.text,
176
+ commandBody: facts.text,
177
+ },
178
+ channelIngress: access,
179
+ access: {
180
+ commands: {
181
+ authorized: access.senderAccess.allowed,
182
+ },
183
+ mentions: {
184
+ canDetectMention: facts.chatType === "group",
185
+ wasMentioned: activation.wasMentioned,
186
+ hasAnyMention: facts.mentionHandles.length > 0,
187
+ explicitlyMentionedBot: activation.kind === "mention",
188
+ implicitMentionKinds: activation.implicitMentionKinds,
189
+ requireMention: facts.chatType === "group",
190
+ effectiveWasMentioned: facts.chatType === "group",
191
+ },
192
+ },
193
+ });
194
+ await Promise.allSettled([
195
+ params.relay.chats.markAsRead(facts.chatId),
196
+ params.relay.chats.startTyping(facts.chatId),
197
+ ]).then((results) => {
198
+ for (const result of results) {
199
+ if (result.status === "rejected") {
200
+ params.warn?.(`relay: pre-dispatch Chat state failed: ${String(result.reason)}`);
201
+ }
202
+ }
203
+ });
204
+ let deliveryError;
205
+ try {
206
+ await params.runtime.channel.inbound.dispatch({
207
+ cfg: params.cfg,
208
+ channel: "relay",
209
+ accountId: params.account.accountId,
210
+ route: {
211
+ agentId: route.agentId,
212
+ sessionKey: route.sessionKey,
213
+ ...(route.dmScope ? { dmScope: route.dmScope } : {}),
214
+ },
215
+ ctxPayload,
216
+ delivery: {
217
+ durable: {
218
+ to: facts.chatId,
219
+ replyToId: null,
220
+ requiredCapabilities: { reconcileUnknownSend: true },
221
+ },
222
+ deliver: async (_payload, info) => {
223
+ if (info.kind === "final") {
224
+ throw new Error("relay: durable final Message delivery was unavailable");
225
+ }
226
+ return { visibleReplySent: false };
227
+ },
228
+ onError: (error) => {
229
+ deliveryError ??= error;
230
+ },
231
+ },
232
+ replyPipeline: {},
233
+ replyOptions: {
234
+ ...bindIngressLifecycleToReplyOptions(params.lifecycle),
235
+ disableBlockStreaming: true,
236
+ },
237
+ record: {
238
+ onRecordError: (error) => {
239
+ throw error instanceof Error
240
+ ? error
241
+ : new Error(`relay: session record failed: ${String(error)}`);
242
+ },
243
+ },
244
+ });
245
+ if (deliveryError) {
246
+ throw deliveryError instanceof Error
247
+ ? deliveryError
248
+ : new Error(`relay: reply delivery failed: ${String(deliveryError)}`);
249
+ }
250
+ }
251
+ finally {
252
+ await params.relay.chats.stopTyping(facts.chatId).catch((error) => {
253
+ params.warn?.(`relay: stop typing failed: ${String(error)}`);
254
+ });
255
+ }
256
+ }
257
+ //# sourceMappingURL=dispatch.js.map
@@ -0,0 +1,24 @@
1
+ export async function readCompleteRelaySnapshot(params) {
2
+ const chats = [];
3
+ const firstChatPage = await params.relay.chats.listChats({ limit: 100 });
4
+ for await (const chat of firstChatPage) {
5
+ const messages = [];
6
+ const firstMessagePage = await params.relay.chats.messages.list(chat.id, { limit: 100 });
7
+ for await (const message of firstMessagePage) {
8
+ messages.push(message);
9
+ }
10
+ chats.push({ chat, messages });
11
+ }
12
+ return {
13
+ version: 1,
14
+ throughSequence: params.context.throughSequence,
15
+ reason: params.context.reason,
16
+ completedAt: new Date().toISOString(),
17
+ chats,
18
+ };
19
+ }
20
+ export async function commitRelayFullSync(params) {
21
+ const snapshot = await readCompleteRelaySnapshot(params);
22
+ await params.state.replaceSnapshot(snapshot);
23
+ }
24
+ //# sourceMappingURL=full-sync.js.map
@@ -0,0 +1,171 @@
1
+ import { RelayWebhookConfiguredError, } from "@relaymessenger/sdk";
2
+ import { createHash } from "node:crypto";
3
+ import { dispatchRelayEvent } from "./dispatch.js";
4
+ import { commitRelayFullSync } from "./full-sync.js";
5
+ import { createRelayIngressMonitor } from "./ingress.js";
6
+ import { createRelaySdkClient } from "./outbound.js";
7
+ import { getRelayRuntime } from "./runtime.js";
8
+ import { openRelayStateStore, } from "./state.js";
9
+ const runningCredentials = new Map();
10
+ const accountControllers = new Map();
11
+ function credentialKey(account) {
12
+ return createHash("sha256")
13
+ .update(`${account.baseUrl}\0${account.token}`)
14
+ .digest("hex");
15
+ }
16
+ function openIngressQueue(params) {
17
+ try {
18
+ return getRelayRuntime().state.openChannelIngressQueue({
19
+ accountId: params.transportId,
20
+ });
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ if (!message.includes("only available for trusted plugins"))
25
+ throw error;
26
+ params.warn("relay: OpenClaw trusted ingress state is unavailable for this install; using the plugin's private SQLite queue");
27
+ return params.state.ingressQueue;
28
+ }
29
+ }
30
+ export async function assertRelayWebSocketAvailable(params) {
31
+ const { subscriptions } = await params.relay.webhookSubscriptions.list(params.signal ? { signal: params.signal } : undefined);
32
+ if (subscriptions.length > 0) {
33
+ throw new RelayWebhookConfiguredError(`relay: account "${params.accountId}" has saved Webhook subscriptions; delete them before using OpenClaw WebSocket delivery`);
34
+ }
35
+ }
36
+ export async function startRelayAccount(ctx) {
37
+ const account = ctx.account;
38
+ if (!account.configured) {
39
+ throw new Error(`relay: account "${account.accountId}" is missing a Relay Agent Token`);
40
+ }
41
+ const runtime = getRelayRuntime();
42
+ const controller = new AbortController();
43
+ const abortSignal = AbortSignal.any([ctx.abortSignal, controller.signal]);
44
+ const key = credentialKey(account);
45
+ const transportId = `transport-${key}`;
46
+ const existing = runningCredentials.get(key);
47
+ if (existing) {
48
+ throw new Error(`relay: account "${account.accountId}" reuses the Agent Token already active in account "${existing}"`);
49
+ }
50
+ runningCredentials.set(key, account.accountId);
51
+ accountControllers.set(account.accountId, controller);
52
+ const warn = (message) => ctx.log?.warn?.(message);
53
+ const state = openRelayStateStore({
54
+ stateDir: runtime.state.resolveStateDir(),
55
+ // Bind pending events and FULL-sync state to the authenticated transport,
56
+ // not a mutable OpenClaw account label. Token rotation cannot dispatch old
57
+ // rows through a different Relay Contact, and account renames keep state.
58
+ accountId: transportId,
59
+ });
60
+ const relay = createRelaySdkClient(account);
61
+ const ingress = createRelayIngressMonitor({
62
+ queue: openIngressQueue({
63
+ transportId,
64
+ state,
65
+ warn,
66
+ }),
67
+ abortSignal,
68
+ onError: (error) => ctx.log?.error?.(`relay: ingress drain failed: ${String(error)}`),
69
+ dispatch: async (event, lifecycle) => {
70
+ ctx.setStatus({
71
+ accountId: account.accountId,
72
+ running: true,
73
+ connected: true,
74
+ lastInboundAt: Date.now(),
75
+ });
76
+ await dispatchRelayEvent({
77
+ event,
78
+ lifecycle,
79
+ account,
80
+ cfg: ctx.cfg,
81
+ relay,
82
+ runtime,
83
+ warn,
84
+ });
85
+ },
86
+ });
87
+ ctx.setStatus({
88
+ accountId: account.accountId,
89
+ running: true,
90
+ connected: false,
91
+ lifecycle: "starting",
92
+ configured: true,
93
+ enabled: account.enabled,
94
+ });
95
+ try {
96
+ await assertRelayWebSocketAvailable({
97
+ relay,
98
+ accountId: account.accountId,
99
+ signal: abortSignal,
100
+ });
101
+ ingress.start();
102
+ ctx.setStatus({
103
+ accountId: account.accountId,
104
+ running: true,
105
+ connected: true,
106
+ lifecycle: "ready",
107
+ lastError: null,
108
+ });
109
+ await relay.websocket.run({
110
+ signal: abortSignal,
111
+ onEvent: async (event) => {
112
+ const admission = await ingress.receive(event);
113
+ if (admission.kind === "invalid") {
114
+ throw new Error(admission.message);
115
+ }
116
+ },
117
+ onFullSync: async (context) => {
118
+ await commitRelayFullSync({ relay, state, context });
119
+ },
120
+ onError: (error) => {
121
+ ctx.log?.warn?.(`relay: WebSocket reconnecting after ${String(error)}`);
122
+ ctx.setStatus({
123
+ accountId: account.accountId,
124
+ running: true,
125
+ connected: false,
126
+ lifecycle: "recovering",
127
+ lastError: error instanceof Error ? error.message : String(error),
128
+ });
129
+ },
130
+ });
131
+ }
132
+ catch (error) {
133
+ if (abortSignal.aborted)
134
+ return;
135
+ if (error instanceof RelayWebhookConfiguredError) {
136
+ ctx.setStatus({
137
+ accountId: account.accountId,
138
+ running: false,
139
+ connected: false,
140
+ terminalDisconnect: true,
141
+ lastError: error.message,
142
+ });
143
+ }
144
+ throw error;
145
+ }
146
+ finally {
147
+ await ingress.stop();
148
+ if (runningCredentials.get(key) === account.accountId) {
149
+ runningCredentials.delete(key);
150
+ }
151
+ if (accountControllers.get(account.accountId) === controller) {
152
+ accountControllers.delete(account.accountId);
153
+ }
154
+ ctx.setStatus({
155
+ accountId: account.accountId,
156
+ running: false,
157
+ connected: false,
158
+ lifecycle: "stopped",
159
+ });
160
+ }
161
+ }
162
+ export async function stopRelayAccount(ctx) {
163
+ accountControllers.get(ctx.accountId)?.abort(new Error(`relay: account "${ctx.accountId}" stopped`));
164
+ ctx.setStatus({
165
+ accountId: ctx.accountId,
166
+ running: false,
167
+ connected: false,
168
+ lifecycle: "stopped",
169
+ });
170
+ }
171
+ //# sourceMappingURL=gateway.js.map
@@ -1,99 +1,68 @@
1
- export function classifyRelayEvent(event) {
2
- switch (event.event_type) {
3
- case "message.received":
4
- return "message";
5
- case "reaction.added":
6
- case "reaction.removed":
7
- return "reaction";
8
- case "message.delivered":
9
- case "message.read":
10
- return "lifecycle";
11
- default:
12
- return "unknown";
1
+ function renderPart(part) {
2
+ switch (part.type) {
3
+ case "text":
4
+ return part.value;
5
+ case "link":
6
+ return part.value;
7
+ case "media":
8
+ return `[Attachment: ${part.filename} (${part.mime_type})] ${part.url}`;
9
+ case "system":
10
+ return part.value;
13
11
  }
14
12
  }
15
- /**
16
- * Render typed parts into agent-facing text: text parts joined, link URLs
17
- * inlined, `data` parts as a compact JSON fence, media/voice as a labeled
18
- * fetchable URL. The URL is a capability link: it is the authorization, so
19
- * any HTTP client can fetch the bytes without an Agent Token.
20
- */
21
- export function renderRelayPartsText(parts) {
22
- const lines = [];
23
- for (const part of parts) {
24
- switch (part.type) {
25
- case "text":
26
- if (part.text) {
27
- lines.push(part.text);
28
- }
29
- break;
30
- case "link_preview":
31
- lines.push(part.url);
32
- break;
33
- case "data": {
34
- let rendered;
35
- try {
36
- rendered = JSON.stringify(part.data);
37
- }
38
- catch {
39
- rendered = String(part.data);
40
- }
41
- lines.push("```json\n" + rendered + "\n```");
42
- break;
43
- }
44
- case "media":
45
- lines.push(`[attachment] ${part.url}`);
46
- break;
47
- case "voice_memo":
48
- lines.push(part.duration_ms
49
- ? `[voice memo, ${Math.round(part.duration_ms / 1000)}s] ${part.url}`
50
- : `[voice memo] ${part.url}`);
51
- break;
52
- }
53
- }
54
- return lines.join("\n");
13
+ export function renderRelayMessageParts(parts) {
14
+ return parts
15
+ .map(renderPart)
16
+ .filter((value) => Boolean(value?.trim()))
17
+ .join("\n");
55
18
  }
56
- /** Drop the agent's own sends echoed back on the event stream. */
57
- export function isRelayEchoMessage(message, agentId) {
58
- return message.sender.kind === "agent" && message.sender.id === agentId;
19
+ export function isRelayMessageReceivedEvent(event) {
20
+ if (event.event_type !== "message.received")
21
+ return false;
22
+ const data = event.data;
23
+ return (typeof data.id === "string" &&
24
+ typeof data.chat?.id === "string" &&
25
+ data.direction === "inbound" &&
26
+ typeof data.sender_handle?.id === "string" &&
27
+ Array.isArray(data.parts));
59
28
  }
60
29
  /**
61
- * Build the dispatchable fact bundle for a message.received event. Returns
62
- * null when the event should not start a turn: echoes of our own agent,
63
- * non-message events, or messages with no renderable content.
30
+ * Map the current Relay v1 Message event to OpenClaw facts. Agent-authored
31
+ * Messages are accepted at the transport boundary but do not start turns.
64
32
  */
65
- export function buildRelayInboundFacts(event, params) {
66
- if (classifyRelayEvent(event) !== "message") {
67
- return null;
68
- }
69
- const message = event.data.message;
70
- if (!message || !message.id || !message.conversation_id) {
33
+ export function buildRelayInboundFacts(event) {
34
+ if (!isRelayMessageReceivedEvent(event))
71
35
  return null;
72
- }
73
- // Agent-authored messages never start a local agent turn. This drops our
74
- // own event echo and prevents agent-to-agent loops even if an id is
75
- // mistakenly added to the user allowlist.
76
- if (message.sender.kind !== "user" || isRelayEchoMessage(message, params.agentId)) {
36
+ if (event.data.sender_handle.kind !== "user")
77
37
  return null;
78
- }
79
- const text = renderRelayPartsText(message.parts) || message.fallback_text || "";
80
- if (!text.trim()) {
38
+ const text = renderRelayMessageParts(event.data.parts);
39
+ if (!text.trim())
81
40
  return null;
82
- }
83
- const createdAtMs = Date.parse(message.created_at);
84
- const invocationId = typeof event.data.invocation_id === "string"
85
- && event.data.invocation_id.trim()
86
- ? event.data.invocation_id
87
- : undefined;
41
+ const mentionHandles = event.data.parts.flatMap((part) => part.type === "text" &&
42
+ typeof part.mention === "string" &&
43
+ part.mention.length > 0
44
+ ? [part.mention]
45
+ : []);
46
+ const timestampValue = event.data.sent_at ?? event.created_at;
47
+ const timestamp = Date.parse(timestampValue);
88
48
  return {
89
49
  eventId: event.event_id,
90
- messageId: message.id,
91
- conversationId: message.conversation_id,
92
- senderId: message.sender.id,
93
- senderKind: message.sender.kind,
94
- ...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
50
+ messageId: event.data.id,
51
+ chatId: event.data.chat.id,
52
+ chatType: event.data.chat.is_group === true ? "group" : "direct",
53
+ contactId: event.data.sender_handle.id,
54
+ handle: event.data.sender_handle.handle,
55
+ displayName: event.data.sender_handle.display_name?.trim() ||
56
+ event.data.sender_handle.handle,
95
57
  text,
96
- ...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
97
- ...(invocationId ? { invocationId } : {}),
58
+ mentionHandles,
59
+ ...(event.data.chat.owner_handle
60
+ ? { ownerHandle: event.data.chat.owner_handle }
61
+ : {}),
62
+ ...(event.data.reply_to?.message_id
63
+ ? { replyToId: event.data.reply_to.message_id }
64
+ : {}),
65
+ ...(Number.isFinite(timestamp) ? { timestamp } : {}),
98
66
  };
99
67
  }
68
+ //# sourceMappingURL=inbound.js.map
@@ -0,0 +1,64 @@
1
+ import { createStandardRawEventIngressMonitor } from "openclaw/plugin-sdk/channel-ingress-runtime";
2
+ import { createChannelIngressError, } from "openclaw/plugin-sdk/channel-outbound";
3
+ const RelayIngressPermanentError = createChannelIngressError("RelayIngressPermanentError", { withReason: true });
4
+ function isRecord(value) {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+ function inspectRelayEvent(event) {
8
+ if (!isRecord(event)) {
9
+ throw new RelayIngressPermanentError("invalid-event", "Relay WebSocket event must be an object.");
10
+ }
11
+ const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
12
+ const eventType = typeof event.event_type === "string" ? event.event_type.trim() : "";
13
+ if (!eventId || !eventType) {
14
+ throw new RelayIngressPermanentError("invalid-event", "Relay WebSocket event is missing event_id or event_type.");
15
+ }
16
+ const data = isRecord(event.data) ? event.data : undefined;
17
+ const chat = data && isRecord(data.chat) ? data.chat : undefined;
18
+ const chatId = typeof chat?.id === "string"
19
+ ? chat.id.trim()
20
+ : typeof data?.chat_id === "string"
21
+ ? data.chat_id.trim()
22
+ : "";
23
+ return {
24
+ eventId,
25
+ laneKey: chatId ? `chat:${chatId}` : `event:${eventType}`,
26
+ };
27
+ }
28
+ function decodeRelayEvent(rawEvent, claimedId) {
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(rawEvent);
32
+ }
33
+ catch (error) {
34
+ throw new RelayIngressPermanentError("invalid-event", `Relay ingress row ${claimedId} contains invalid JSON.`, { cause: error });
35
+ }
36
+ inspectRelayEvent(parsed);
37
+ return parsed;
38
+ }
39
+ export function createRelayIngressMonitor(options) {
40
+ return createStandardRawEventIngressMonitor({
41
+ queue: options.queue,
42
+ inspect: inspectRelayEvent,
43
+ payload: {
44
+ serialize: (event) => JSON.stringify(event),
45
+ deserialize: (rawEvent, { claim }) => decodeRelayEvent(rawEvent, claim.id),
46
+ createClaimError: (kind, claim) => new RelayIngressPermanentError("invalid-event", kind === "invalid-version"
47
+ ? `Relay ingress row ${claim.id} has an invalid payload version.`
48
+ : `Relay ingress row ${claim.id} changed identity after admission.`),
49
+ },
50
+ deliver: async (event, lifecycle) => {
51
+ await options.dispatch(event, lifecycle);
52
+ },
53
+ ...(options.pollIntervalMs === undefined
54
+ ? {}
55
+ : { pollIntervalMs: options.pollIntervalMs }),
56
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
57
+ createStoppedError: () => new Error("Relay ingress monitor is stopped."),
58
+ onError: (error) => options.onError?.(error),
59
+ classifyAdmissionError: (error) => error instanceof RelayIngressPermanentError
60
+ ? error.message
61
+ : undefined,
62
+ });
63
+ }
64
+ //# sourceMappingURL=ingress.js.map