@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
package/src/inbound.ts CHANGED
@@ -1,139 +1,88 @@
1
- // Pure inbound mapping: Relay events -> normalized fact bundles.
2
- // No SDK imports so the mapping is unit-testable without an OpenClaw runtime;
3
- // the runtime dispatch wiring lives in channel.ts.
4
- import type { RelayEvent, RelayMessage, RelayPart } from "./types.js";
1
+ import type {
2
+ MessagePartResponse,
3
+ RelayWebhookEvent,
4
+ } from "@relaymessenger/sdk";
5
+ import type {
6
+ RelayInboundFacts,
7
+ RelayMessageReceivedEvent,
8
+ } from "./types.js";
5
9
 
6
- /** Coarse classification deciding whether an event can start an agent turn. */
7
- export type RelayEventClass =
8
- | "message" // message.received -> can start an agent turn
9
- | "reaction" // reaction.added/removed -> observe-only at v1
10
- | "lifecycle" // message.delivered/read -> bookkeeping, never dispatch
11
- | "unknown"; // forward-compatible: ignore quietly
12
-
13
- export function classifyRelayEvent(event: Pick<RelayEvent, "event_type">): RelayEventClass {
14
- switch (event.event_type) {
15
- case "message.received":
16
- return "message";
17
- case "reaction.added":
18
- case "reaction.removed":
19
- return "reaction";
20
- case "message.delivered":
21
- case "message.read":
22
- return "lifecycle";
23
- default:
24
- return "unknown";
10
+ function renderPart(part: MessagePartResponse): string | undefined {
11
+ switch (part.type) {
12
+ case "text":
13
+ return part.value;
14
+ case "link":
15
+ return part.value;
16
+ case "media":
17
+ return `[Attachment: ${part.filename} (${part.mime_type})] ${part.url}`;
18
+ case "system":
19
+ return part.value;
25
20
  }
26
21
  }
27
22
 
28
- /**
29
- * Render typed parts into agent-facing text: text parts joined, link URLs
30
- * inlined, `data` parts as a compact JSON fence, media/voice as a labeled
31
- * fetchable URL. The URL is a capability link: it is the authorization, so
32
- * any HTTP client can fetch the bytes without an Agent Token.
33
- */
34
- export function renderRelayPartsText(parts: readonly RelayPart[]): string {
35
- const lines: string[] = [];
36
- for (const part of parts) {
37
- switch (part.type) {
38
- case "text":
39
- if (part.text) {
40
- lines.push(part.text);
41
- }
42
- break;
43
- case "link_preview":
44
- lines.push(part.url);
45
- break;
46
- case "data": {
47
- let rendered: string;
48
- try {
49
- rendered = JSON.stringify(part.data);
50
- } catch {
51
- rendered = String(part.data);
52
- }
53
- lines.push("```json\n" + rendered + "\n```");
54
- break;
55
- }
56
- case "media":
57
- lines.push(`[attachment] ${part.url}`);
58
- break;
59
- case "voice_memo":
60
- lines.push(
61
- part.duration_ms
62
- ? `[voice memo, ${Math.round(part.duration_ms / 1000)}s] ${part.url}`
63
- : `[voice memo] ${part.url}`,
64
- );
65
- break;
66
- }
67
- }
68
- return lines.join("\n");
23
+ export function renderRelayMessageParts(
24
+ parts: readonly MessagePartResponse[],
25
+ ): string {
26
+ return parts
27
+ .map(renderPart)
28
+ .filter((value): value is string => Boolean(value?.trim()))
29
+ .join("\n");
69
30
  }
70
31
 
71
- /** Drop the agent's own sends echoed back on the event stream. */
72
- export function isRelayEchoMessage(
73
- message: Pick<RelayMessage, "sender">,
74
- agentId: string,
75
- ): boolean {
76
- return message.sender.kind === "agent" && message.sender.id === agentId;
32
+ export function isRelayMessageReceivedEvent(
33
+ event: RelayWebhookEvent,
34
+ ): event is RelayMessageReceivedEvent {
35
+ if (event.event_type !== "message.received") return false;
36
+ const data = event.data as Partial<RelayMessageReceivedEvent["data"]>;
37
+ return (
38
+ typeof data.id === "string" &&
39
+ typeof data.chat?.id === "string" &&
40
+ data.direction === "inbound" &&
41
+ typeof data.sender_handle?.id === "string" &&
42
+ Array.isArray(data.parts)
43
+ );
77
44
  }
78
45
 
79
- /** Normalized facts for one dispatchable inbound message. */
80
- export type RelayInboundFacts = {
81
- eventId: string;
82
- messageId: string;
83
- conversationId: string;
84
- senderId: string;
85
- senderKind: "user" | "agent";
86
- replyToId?: string;
87
- text: string;
88
- timestamp?: number;
89
- /**
90
- * The group invocation this message belongs to, when it is group work.
91
- * Every subsequent server call about this message must carry it back, and
92
- * because only a group mints one, its presence is also the group signal.
93
- */
94
- invocationId?: string;
95
- };
96
-
97
46
  /**
98
- * Build the dispatchable fact bundle for a message.received event. Returns
99
- * null when the event should not start a turn: echoes of our own agent,
100
- * non-message events, or messages with no renderable content.
47
+ * Map the current Relay v1 Message event to OpenClaw facts. Agent-authored
48
+ * Messages are accepted at the transport boundary but do not start turns.
101
49
  */
102
50
  export function buildRelayInboundFacts(
103
- event: RelayEvent,
104
- params: { agentId: string },
51
+ event: RelayWebhookEvent,
105
52
  ): RelayInboundFacts | null {
106
- if (classifyRelayEvent(event) !== "message") {
107
- return null;
108
- }
109
- const message = event.data.message;
110
- if (!message || !message.id || !message.conversation_id) {
111
- return null;
112
- }
113
- // Agent-authored messages never start a local agent turn. This drops our
114
- // own event echo and prevents agent-to-agent loops even if an id is
115
- // mistakenly added to the user allowlist.
116
- if (message.sender.kind !== "user" || isRelayEchoMessage(message, params.agentId)) {
117
- return null;
118
- }
119
- const text = renderRelayPartsText(message.parts) || message.fallback_text || "";
120
- if (!text.trim()) {
121
- return null;
122
- }
123
- const createdAtMs = Date.parse(message.created_at);
124
- const invocationId = typeof event.data.invocation_id === "string"
125
- && event.data.invocation_id.trim()
126
- ? event.data.invocation_id
127
- : undefined;
53
+ if (!isRelayMessageReceivedEvent(event)) return null;
54
+ if (event.data.sender_handle.kind !== "user") return null;
55
+
56
+ const text = renderRelayMessageParts(event.data.parts);
57
+ if (!text.trim()) return null;
58
+
59
+ const mentionHandles = event.data.parts.flatMap((part) =>
60
+ part.type === "text" &&
61
+ typeof part.mention === "string" &&
62
+ part.mention.length > 0
63
+ ? [part.mention]
64
+ : [],
65
+ );
66
+ const timestampValue = event.data.sent_at ?? event.created_at;
67
+ const timestamp = Date.parse(timestampValue);
128
68
  return {
129
69
  eventId: event.event_id,
130
- messageId: message.id,
131
- conversationId: message.conversation_id,
132
- senderId: message.sender.id,
133
- senderKind: message.sender.kind,
134
- ...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
70
+ messageId: event.data.id,
71
+ chatId: event.data.chat.id,
72
+ chatType: event.data.chat.is_group === true ? "group" : "direct",
73
+ contactId: event.data.sender_handle.id,
74
+ handle: event.data.sender_handle.handle,
75
+ displayName:
76
+ event.data.sender_handle.display_name?.trim() ||
77
+ event.data.sender_handle.handle,
135
78
  text,
136
- ...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
137
- ...(invocationId ? { invocationId } : {}),
79
+ mentionHandles,
80
+ ...(event.data.chat.owner_handle
81
+ ? { ownerHandle: event.data.chat.owner_handle }
82
+ : {}),
83
+ ...(event.data.reply_to?.message_id
84
+ ? { replyToId: event.data.reply_to.message_id }
85
+ : {}),
86
+ ...(Number.isFinite(timestamp) ? { timestamp } : {}),
138
87
  };
139
88
  }
package/src/ingress.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type { RelayWebhookEvent } from "@relaymessenger/sdk";
2
+ import { createStandardRawEventIngressMonitor } from "openclaw/plugin-sdk/channel-ingress-runtime";
3
+ import {
4
+ createChannelIngressError,
5
+ type ChannelIngressMonitorLifecycle,
6
+ type ChannelIngressQueue,
7
+ } from "openclaw/plugin-sdk/channel-outbound";
8
+ import type { RelayIngressPayload } from "./types.js";
9
+
10
+ export type RelayIngressLifecycle = Omit<
11
+ ChannelIngressMonitorLifecycle,
12
+ "admission"
13
+ >;
14
+
15
+ const RelayIngressPermanentError = createChannelIngressError<"invalid-event">(
16
+ "RelayIngressPermanentError",
17
+ { withReason: true },
18
+ );
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return value !== null && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ function inspectRelayEvent(event: RelayWebhookEvent): {
25
+ eventId: string;
26
+ laneKey: string;
27
+ } {
28
+ if (!isRecord(event)) {
29
+ throw new RelayIngressPermanentError(
30
+ "invalid-event",
31
+ "Relay WebSocket event must be an object.",
32
+ );
33
+ }
34
+ const eventId =
35
+ typeof event.event_id === "string" ? event.event_id.trim() : "";
36
+ const eventType =
37
+ typeof event.event_type === "string" ? event.event_type.trim() : "";
38
+ if (!eventId || !eventType) {
39
+ throw new RelayIngressPermanentError(
40
+ "invalid-event",
41
+ "Relay WebSocket event is missing event_id or event_type.",
42
+ );
43
+ }
44
+ const data = isRecord(event.data) ? event.data : undefined;
45
+ const chat = data && isRecord(data.chat) ? data.chat : undefined;
46
+ const chatId =
47
+ typeof chat?.id === "string"
48
+ ? chat.id.trim()
49
+ : typeof data?.chat_id === "string"
50
+ ? data.chat_id.trim()
51
+ : "";
52
+ return {
53
+ eventId,
54
+ laneKey: chatId ? `chat:${chatId}` : `event:${eventType}`,
55
+ };
56
+ }
57
+
58
+ function decodeRelayEvent(
59
+ rawEvent: string,
60
+ claimedId: string,
61
+ ): RelayWebhookEvent {
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = JSON.parse(rawEvent);
65
+ } catch (error) {
66
+ throw new RelayIngressPermanentError(
67
+ "invalid-event",
68
+ `Relay ingress row ${claimedId} contains invalid JSON.`,
69
+ { cause: error },
70
+ );
71
+ }
72
+ inspectRelayEvent(parsed as RelayWebhookEvent);
73
+ return parsed as RelayWebhookEvent;
74
+ }
75
+
76
+ export function createRelayIngressMonitor(options: {
77
+ queue: ChannelIngressQueue<RelayIngressPayload>;
78
+ dispatch: (
79
+ event: RelayWebhookEvent,
80
+ lifecycle: RelayIngressLifecycle,
81
+ ) => Promise<void>;
82
+ abortSignal?: AbortSignal;
83
+ pollIntervalMs?: number;
84
+ onError?: (error: unknown) => void;
85
+ }) {
86
+ return createStandardRawEventIngressMonitor<
87
+ RelayWebhookEvent,
88
+ unknown,
89
+ { eventId: string; laneKey: string }
90
+ >({
91
+ queue: options.queue,
92
+ inspect: inspectRelayEvent,
93
+ payload: {
94
+ serialize: (event) => JSON.stringify(event),
95
+ deserialize: (rawEvent, { claim }) =>
96
+ decodeRelayEvent(rawEvent, claim.id),
97
+ createClaimError: (kind, claim) =>
98
+ new RelayIngressPermanentError(
99
+ "invalid-event",
100
+ kind === "invalid-version"
101
+ ? `Relay ingress row ${claim.id} has an invalid payload version.`
102
+ : `Relay ingress row ${claim.id} changed identity after admission.`,
103
+ ),
104
+ },
105
+ deliver: async (event, lifecycle) => {
106
+ await options.dispatch(event, lifecycle);
107
+ },
108
+ ...(options.pollIntervalMs === undefined
109
+ ? {}
110
+ : { pollIntervalMs: options.pollIntervalMs }),
111
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
112
+ createStoppedError: () => new Error("Relay ingress monitor is stopped."),
113
+ onError: (error) => options.onError?.(error),
114
+ classifyAdmissionError: (error) =>
115
+ error instanceof RelayIngressPermanentError
116
+ ? error.message
117
+ : undefined,
118
+ });
119
+ }
120
+
121
+ export type RelayIngressMonitor = ReturnType<
122
+ typeof createRelayIngressMonitor
123
+ >;
package/src/outbound.ts CHANGED
@@ -1,166 +1,87 @@
1
- // Durable outbound sends: every logical send carries an
2
- // Idempotency-Key; internal retries and unknown-send reconciliation replay
3
- // the same key, so a retry can never duplicate a visible message
4
- // (server contract: commitMessage.ts idempotent replay).
5
- import { createHash } from "node:crypto";
6
- import { RelayApiError } from "./client.js";
7
- import type { RelayClient, RelaySentMessage } from "./client.js";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ Relay,
4
+ RelayAPIError,
5
+ type MessageSendResponse,
6
+ } from "@relaymessenger/sdk";
7
+ import type { ResolvedRelayAccount } from "./types.js";
8
8
 
9
- /**
10
- * Per-part text ceiling declared to core's renderer so long agent replies are
11
- * split into multiple messages instead of truncated. Server caps a text part at 8 KiB UTF-8
12
- * (server/src/domain/commitMessage.ts MAX_TEXT_BYTES); 2000 chars is safe for
13
- * any UTF-8 content (4 bytes/char worst case).
14
- */
15
- export const RELAY_TEXT_CHUNK_LIMIT = 2_000;
9
+ export const RELAY_TEXT_CHUNK_LIMIT = 10_000;
10
+ const IDEMPOTENCY_KEY_MAX_LENGTH = 255;
16
11
 
17
- const IDEMPOTENCY_KEY_MAX = 255;
12
+ export function createRelaySdkClient(
13
+ account: Pick<ResolvedRelayAccount, "baseUrl" | "token">,
14
+ ): Relay {
15
+ return new Relay({
16
+ apiKey: account.token,
17
+ baseURL: account.baseUrl,
18
+ });
19
+ }
18
20
 
19
- /**
20
- * Idempotency key for one logical send. When core supplies a durable delivery
21
- * queue id, the key is a stable function of (queueId, part) so internal
22
- * retries and reconciliation replay the exact same key. Without a queue id a
23
- * fresh key is minted: identical intentional sends must remain distinct.
24
- *
25
- * The part term names WHICH piece of one delivery this is. Core supplies
26
- * `deliveryPartIndex` from 2026.7.2-beta.5 onward and that is authoritative.
27
- * Older cores do not: they call `enqueueDelivery` once, mint ONE queue id, and
28
- * then hand the channel each chunk of a long reply under it. Defaulting the
29
- * missing index to 0 would key every chunk to `...:0`, so the server would
30
- * replay the first chunk for each of the rest and the person would receive a
31
- * long answer truncated to its opening chunk with no error anywhere.
32
- *
33
- * So when core cannot say which part this is, the part term is a digest of the
34
- * part's own text. Sibling chunks differ, so each commits; a retry reproduces
35
- * the same text, so it replays. This is not the content-in-the-key mistake
36
- * that defeats conflict detection: the digest stands in FOR the position core
37
- * did not give us, it does not replace it. Two byte-identical chunks in one
38
- * delivery do collapse to one message, which is the residual cost of an
39
- * unindexed core and is bounded to a repeat the reader would see twice.
40
- */
41
21
  export function deriveRelayIdempotencyKey(params: {
42
- deliveryQueueId?: string;
43
- deliveryPartIndex?: number;
44
- /** Part text, used only when core supplies no `deliveryPartIndex`. */
45
- partText?: string;
22
+ deliveryQueueId?: string | undefined;
23
+ deliveryPartIndex?: number | undefined;
46
24
  random?: () => string;
47
25
  }): string {
48
26
  const queueId = params.deliveryQueueId?.trim();
49
- const part = params.deliveryPartIndex ?? (
50
- params.partText === undefined
51
- ? 0
52
- : `t${createHash("sha256").update(params.partText).digest("hex").slice(0, 16)}`
53
- );
54
- const key = queueId
55
- ? `relay-send:${queueId}:${part}`
56
- : `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
57
- // Server accepts 8-255 chars; the prefix guarantees the minimum.
58
- if (key.length <= IDEMPOTENCY_KEY_MAX) {
59
- return key;
60
- }
61
- // Preserve uniqueness when an opaque core queue id is unusually long; a
62
- // simple prefix slice could erase the part index and collapse two chunks.
63
- return `relay-send:h:${createHash("sha256").update(key).digest("hex")}`;
27
+ const raw = queueId
28
+ ? `relay-openclaw:${queueId}:${params.deliveryPartIndex ?? 0}`
29
+ : `relay-openclaw:${(params.random ?? randomUUID)()}`;
30
+ return raw.length <= IDEMPOTENCY_KEY_MAX_LENGTH
31
+ ? raw
32
+ : `relay-openclaw:sha256:${createHash("sha256").update(raw).digest("hex")}`;
64
33
  }
65
34
 
66
- export type RelayOutboundSendResult = {
67
- /** Id of the first committed message; core's receipt APIs name one id. */
68
- messageId: string;
69
- /**
70
- * Every message the send committed, in display order. A single text part
71
- * commits exactly one, but the 202 is always an array and the receipt
72
- * should name everything the server stored.
73
- */
74
- messages: RelaySentMessage[];
75
- };
76
-
77
35
  export async function sendRelayText(params: {
78
- client: RelayClient;
79
- conversationId: string;
36
+ relay: Pick<Relay, "chats">;
37
+ chatId: string;
80
38
  text: string;
81
- replyToId?: string | null;
82
- /**
83
- * Required when replying into a group: the server refuses an agent's group
84
- * message that does not name the invocation it is answering.
85
- */
86
- invocationId?: string;
39
+ replyToId?: string | null | undefined;
87
40
  idempotencyKey: string;
88
41
  signal?: AbortSignal;
89
- }): Promise<RelayOutboundSendResult> {
90
- let lastError: unknown;
91
- for (let attempt = 0; attempt < 3; attempt += 1) {
92
- try {
93
- const result = await params.client.sendMessage({
94
- conversationId: params.conversationId,
95
- parts: [{ type: "text", text: params.text }],
96
- ...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
97
- ...(params.invocationId ? { invocationId: params.invocationId } : {}),
98
- idempotencyKey: params.idempotencyKey,
99
- ...(params.signal ? { signal: params.signal } : {}),
100
- });
101
- const first = result.messages[0];
102
- if (!first) {
103
- throw new RelayApiError("relay: 202 carried no messages", { kind: "retryable" });
104
- }
105
- return { messageId: first.id, messages: result.messages };
106
- } catch (error) {
107
- lastError = error;
108
- if (!(error instanceof RelayApiError) || !error.retryable || params.signal?.aborted) {
109
- throw error;
110
- }
111
- }
112
- }
113
- throw lastError;
42
+ onPlatformSendDispatch?: () => Promise<void>;
43
+ }): Promise<MessageSendResponse> {
44
+ await params.onPlatformSendDispatch?.();
45
+ return await params.relay.chats.messages.send(
46
+ params.chatId,
47
+ {
48
+ message: {
49
+ parts: [{ type: "text", value: params.text }],
50
+ idempotency_key: params.idempotencyKey,
51
+ ...(params.replyToId
52
+ ? { reply_to: { message_id: params.replyToId } }
53
+ : {}),
54
+ },
55
+ },
56
+ params.signal ? { signal: params.signal } : undefined,
57
+ );
114
58
  }
115
59
 
116
- export type RelayUnknownSendVerdict =
117
- | { status: "sent"; messageId: string; messages: RelaySentMessage[] }
118
- | { status: "not_sent" }
119
- | { status: "unresolved"; error?: string; retryable?: boolean };
120
-
121
- /**
122
- * Reconcile a send whose platform outcome is unknown: replay the POST with the
123
- * same idempotency key and body. By server contract the replay either performs
124
- * the send exactly once or returns the originally committed messages — either
125
- * way the visible outcome is the one set of messages the key names, never a
126
- * duplicate.
127
- */
128
- export async function reconcileRelayUnknownSend(params: {
129
- client: RelayClient;
130
- conversationId: string;
131
- text: string;
132
- replyToId?: string | null;
133
- invocationId?: string;
134
- idempotencyKey: string;
135
- }): Promise<RelayUnknownSendVerdict> {
136
- try {
137
- const result = await sendRelayText({
138
- client: params.client,
139
- conversationId: params.conversationId,
140
- text: params.text,
141
- replyToId: params.replyToId ?? null,
142
- ...(params.invocationId ? { invocationId: params.invocationId } : {}),
143
- idempotencyKey: params.idempotencyKey,
144
- });
145
- return { status: "sent", messageId: result.messageId, messages: result.messages };
146
- } catch (error) {
147
- if (error instanceof RelayApiError) {
148
- if (error.kind === "conflict") {
149
- // Key already used with a different request body: the original send
150
- // reached the server but we cannot recover its receipt. Do not retry —
151
- // a retry with a fresh key would duplicate the visible message.
152
- return { status: "unresolved", error: error.message, retryable: false };
153
- }
154
- if (error.retryable) {
155
- return { status: "unresolved", error: error.message, retryable: true };
156
- }
157
- if (error.kind === "auth") {
158
- return { status: "unresolved", error: error.message, retryable: false };
159
- }
160
- // Deterministic rejection (403/404/422): the original request would have
161
- // been rejected identically, so nothing reached the conversation.
162
- return { status: "not_sent" };
163
- }
164
- return { status: "unresolved", error: String(error), retryable: true };
60
+ export function classifyUnknownRelaySend(error: unknown): {
61
+ status: "not_sent" | "unresolved";
62
+ error?: string;
63
+ retryable?: boolean;
64
+ } {
65
+ if (!(error instanceof RelayAPIError)) {
66
+ return {
67
+ status: "unresolved",
68
+ error: error instanceof Error ? error.message : String(error),
69
+ retryable: true,
70
+ };
71
+ }
72
+ if (error.retryable) {
73
+ return {
74
+ status: "unresolved",
75
+ error: error.message,
76
+ retryable: true,
77
+ };
78
+ }
79
+ if (error.status === 409) {
80
+ return {
81
+ status: "unresolved",
82
+ error: error.message,
83
+ retryable: false,
84
+ };
165
85
  }
86
+ return { status: "not_sent" };
166
87
  }
package/src/runtime.ts CHANGED
@@ -1,7 +1,7 @@
1
- // Injected plugin runtime store (qa-channel pattern): defineChannelPluginEntry
2
- // calls setRelayRuntime, and gateway/inbound code reads it lazily.
3
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
4
- import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
1
+ import {
2
+ createPluginRuntimeStore,
3
+ type PluginRuntime,
4
+ } from "openclaw/plugin-sdk/runtime-store";
5
5
 
6
6
  const { setRuntime: setRelayRuntime, getRuntime: getRelayRuntime } =
7
7
  createPluginRuntimeStore<PluginRuntime>({