@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.1
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/LICENSE +1 -1
- package/README.md +162 -123
- package/contracts/relay-sdk-0.3.0-staging.5.registry.json +69 -0
- package/contracts/relay-v1.lock.json +77 -0
- package/dist/index.js +2 -2
- package/dist/setup-entry.js +1 -2
- package/dist/src/accounts.js +63 -34
- package/dist/src/channel.js +144 -533
- package/dist/src/dispatch.js +257 -0
- package/dist/src/full-sync.js +24 -0
- package/dist/src/gateway.js +171 -0
- package/dist/src/inbound.js +54 -85
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -111
- package/dist/src/runtime.js +2 -3
- package/dist/src/state.js +492 -0
- package/dist/src/types.js +1 -3
- package/index.ts +1 -2
- package/openclaw.plugin.json +15 -18
- package/package.json +114 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -646
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -122
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -149
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -162
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -13
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/invocations.js +0 -47
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -137
- package/dist/src/responding.js +0 -36
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -243
- package/dist/src/vendor/relay-sdk/client.js +0 -163
- package/dist/src/vendor/relay-sdk/errors.js +0 -45
- package/dist/src/vendor/relay-sdk/types.js +0 -2
- package/dist/src/vendor/relay-sdk/url.js +0 -39
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -51
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/invocations.ts +0 -58
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -173
- package/src/responding.ts +0 -52
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -298
- package/src/vendor/relay-sdk/README.md +0 -28
- package/src/vendor/relay-sdk/client.ts +0 -293
- package/src/vendor/relay-sdk/errors.ts +0 -61
- package/src/vendor/relay-sdk/types.ts +0 -82
- package/src/vendor/relay-sdk/url.ts +0 -43
package/src/inbound.ts
CHANGED
|
@@ -1,139 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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:
|
|
104
|
-
params: { agentId: string },
|
|
51
|
+
event: RelayWebhookEvent,
|
|
105
52
|
): RelayInboundFacts | null {
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
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:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
...(
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
params.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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>({
|