@relaymessenger/openclaw-plugin 0.3.3 → 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.
- package/LICENSE +1 -1
- package/README.md +159 -124
- package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -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 -498
- 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 -80
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -109
- 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 +113 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -611
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -111
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -142
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -148
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -229
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -125
- package/dist/src/responding.js +0 -13
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -167
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -330
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -161
- package/src/responding.ts +0 -21
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -212
package/src/channel.ts
CHANGED
|
@@ -1,28 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Read core's part index without requiring it to exist. Cores before
|
|
11
|
-
* 2026.7.2-beta.5 have no `deliveryPartIndex` in their outbound context at all,
|
|
12
|
-
* so naming the field directly would not typecheck against them. Reading it
|
|
13
|
-
* through a widened shape keeps one source compiling on every supported core;
|
|
14
|
-
* `deriveRelayIdempotencyKey` handles the undefined case.
|
|
15
|
-
*/
|
|
16
|
-
function deliveryPartIndexOf(ctx: unknown): number | undefined {
|
|
17
|
-
const index = (ctx as { deliveryPartIndex?: unknown }).deliveryPartIndex;
|
|
18
|
-
return typeof index === "number" ? index : undefined;
|
|
19
|
-
}
|
|
20
|
-
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
1
|
+
import type {
|
|
2
|
+
MessageSendResponse,
|
|
3
|
+
} from "@relaymessenger/sdk";
|
|
4
|
+
import {
|
|
5
|
+
createChatChannelPlugin,
|
|
6
|
+
type ChannelPlugin,
|
|
7
|
+
type OpenClawConfig,
|
|
8
|
+
} from "openclaw/plugin-sdk/channel-core";
|
|
21
9
|
import {
|
|
22
10
|
createMessageReceiptFromOutboundResults,
|
|
23
11
|
defineChannelMessageAdapter,
|
|
12
|
+
type ChannelMessageUnknownSendContext,
|
|
13
|
+
type ChannelMessageUnknownSendReconciliationResult,
|
|
24
14
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
25
|
-
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
26
15
|
import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
|
|
27
16
|
import {
|
|
28
17
|
DEFAULT_ACCOUNT_ID,
|
|
@@ -30,30 +19,21 @@ import {
|
|
|
30
19
|
resolveDefaultRelayAccountId,
|
|
31
20
|
resolveRelayAccount,
|
|
32
21
|
} from "./accounts.js";
|
|
33
|
-
import { RelayAccountLock } from "./account-lock.js";
|
|
34
22
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
RelayApiError,
|
|
39
|
-
} from "./client.js";
|
|
40
|
-
import type { RelayClient } from "./client.js";
|
|
41
|
-
import { createRelayCursorStore, openRelayCursorStateStore } from "./cursor-store.js";
|
|
42
|
-
import { createRelayInboundDedupeGuard, createRelayInboundDeduper } from "./inbound-dedupe.js";
|
|
43
|
-
import { buildRelayInboundFacts } from "./inbound.js";
|
|
44
|
-
import type { RelayInboundFacts } from "./inbound.js";
|
|
45
|
-
import { createRelayAccountLifecycleRegistry } from "./lifecycle.js";
|
|
23
|
+
startRelayAccount,
|
|
24
|
+
stopRelayAccount,
|
|
25
|
+
} from "./gateway.js";
|
|
46
26
|
import {
|
|
27
|
+
classifyUnknownRelaySend,
|
|
28
|
+
createRelaySdkClient,
|
|
47
29
|
deriveRelayIdempotencyKey,
|
|
48
30
|
RELAY_TEXT_CHUNK_LIMIT,
|
|
49
|
-
reconcileRelayUnknownSend,
|
|
50
31
|
sendRelayText,
|
|
51
32
|
} from "./outbound.js";
|
|
52
|
-
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
import type { RelayCoreConfig, ResolvedRelayAccount } from "./types.js";
|
|
33
|
+
import type {
|
|
34
|
+
RelayCoreConfig,
|
|
35
|
+
ResolvedRelayAccount,
|
|
36
|
+
} from "./types.js";
|
|
57
37
|
|
|
58
38
|
export const RELAY_CHANNEL_ID = "relay" as const;
|
|
59
39
|
|
|
@@ -61,625 +41,305 @@ const relayMeta = {
|
|
|
61
41
|
id: RELAY_CHANNEL_ID,
|
|
62
42
|
label: "Relay",
|
|
63
43
|
selectionLabel: "Relay",
|
|
64
|
-
detailLabel: "Relay",
|
|
44
|
+
detailLabel: "Relay Messenger",
|
|
65
45
|
docsPath: "https://docs.relayapp.im/integrations/openclaw",
|
|
66
|
-
|
|
46
|
+
docsLabel: "Relay OpenClaw",
|
|
47
|
+
blurb: "Message your OpenClaw through Relay.",
|
|
67
48
|
systemImage: "message",
|
|
68
|
-
// Relay renders plain text plus typed parts; no markdown dialect, so core
|
|
69
|
-
// strips formatting instead of leaking `**`.
|
|
70
49
|
markdownCapable: false,
|
|
50
|
+
order: 70,
|
|
71
51
|
};
|
|
72
52
|
|
|
73
|
-
function
|
|
74
|
-
|
|
53
|
+
function requireAccount(
|
|
54
|
+
cfg: RelayCoreConfig,
|
|
55
|
+
accountId?: string | null | undefined,
|
|
56
|
+
): ResolvedRelayAccount {
|
|
57
|
+
const account = resolveRelayAccount({ cfg, accountId });
|
|
58
|
+
if (!account.configured) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`relay: account "${account.accountId}" has no Relay Agent Token`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return account;
|
|
75
64
|
}
|
|
76
65
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
function receipt(
|
|
67
|
+
messages: readonly MessageSendResponse[],
|
|
68
|
+
replyToId?: string | null | undefined,
|
|
69
|
+
) {
|
|
70
|
+
return createMessageReceiptFromOutboundResults({
|
|
71
|
+
results: messages.map((result) => ({
|
|
72
|
+
channel: RELAY_CHANNEL_ID,
|
|
73
|
+
messageId: result.message.id,
|
|
74
|
+
chatId: result.chat_id,
|
|
75
|
+
conversationId: result.chat_id,
|
|
76
|
+
})),
|
|
77
|
+
...(replyToId ? { replyToId } : {}),
|
|
78
|
+
kind: "text",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
renderedBatchPlan?: { textCount: number; mediaCount: number; payloadCount: number };
|
|
91
|
-
}): string | null {
|
|
92
|
-
if (ctx.payloads.length !== 1) {
|
|
82
|
+
function reconciliationText(
|
|
83
|
+
ctx: ChannelMessageUnknownSendContext,
|
|
84
|
+
): string | null {
|
|
85
|
+
if (ctx.payloads.length !== 1) return null;
|
|
86
|
+
if (
|
|
87
|
+
ctx.renderedBatchPlan &&
|
|
88
|
+
(ctx.renderedBatchPlan.payloadCount !== 1 ||
|
|
89
|
+
ctx.renderedBatchPlan.mediaCount > 0)
|
|
90
|
+
) {
|
|
93
91
|
return null;
|
|
94
92
|
}
|
|
93
|
+
const planned = ctx.renderedBatchPlan?.items[0]?.text;
|
|
95
94
|
const payload = ctx.payloads[0];
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
95
|
+
const text = planned ?? payload?.text;
|
|
96
|
+
return typeof text === "string" && text.trim() ? text : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function reconcileRelayUnknownSend(
|
|
100
|
+
ctx: ChannelMessageUnknownSendContext,
|
|
101
|
+
): Promise<ChannelMessageUnknownSendReconciliationResult | null> {
|
|
102
|
+
const text = reconciliationText(ctx);
|
|
103
|
+
if (text === null) return null;
|
|
104
|
+
|
|
105
|
+
const account = requireAccount(
|
|
106
|
+
ctx.cfg as RelayCoreConfig,
|
|
107
|
+
ctx.accountId,
|
|
108
|
+
);
|
|
109
|
+
const relay = createRelaySdkClient(account);
|
|
110
|
+
const responses: MessageSendResponse[] = [];
|
|
111
|
+
const effectiveReplyToId =
|
|
112
|
+
ctx.effectiveReplyToId !== undefined
|
|
113
|
+
? ctx.effectiveReplyToId
|
|
114
|
+
: ctx.replyToId;
|
|
115
|
+
try {
|
|
116
|
+
const chunks = chunkText(text, RELAY_TEXT_CHUNK_LIMIT);
|
|
117
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
118
|
+
responses.push(
|
|
119
|
+
await sendRelayText({
|
|
120
|
+
relay,
|
|
121
|
+
chatId: ctx.to,
|
|
122
|
+
text: chunk,
|
|
123
|
+
replyToId: effectiveReplyToId,
|
|
124
|
+
idempotencyKey: deriveRelayIdempotencyKey({
|
|
125
|
+
deliveryQueueId: ctx.queueId,
|
|
126
|
+
deliveryPartIndex: index,
|
|
127
|
+
}),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const first = responses[0];
|
|
132
|
+
if (!first) {
|
|
133
|
+
return {
|
|
134
|
+
status: "unresolved",
|
|
135
|
+
error: "relay: reconciliation produced no Message",
|
|
136
|
+
retryable: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
status: "sent",
|
|
141
|
+
messageId: first.message.id,
|
|
142
|
+
receipt: receipt(responses, effectiveReplyToId),
|
|
143
|
+
};
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return classifyUnknownRelaySend(error);
|
|
109
146
|
}
|
|
110
|
-
return text;
|
|
111
147
|
}
|
|
112
148
|
|
|
113
|
-
const relayMessageAdapter = defineChannelMessageAdapter({
|
|
149
|
+
export const relayMessageAdapter = defineChannelMessageAdapter({
|
|
114
150
|
id: RELAY_CHANNEL_ID,
|
|
115
151
|
durableFinal: {
|
|
152
|
+
automaticUnknownSendReconciliation: true,
|
|
116
153
|
capabilities: {
|
|
117
154
|
text: true,
|
|
118
155
|
replyTo: true,
|
|
119
|
-
// Plain per-send adapter functions: core's message-sending hooks run
|
|
120
|
-
// around every send, which the default durable requirement derivation
|
|
121
|
-
// demands (capabilities.ts requires it unless explicitly waived).
|
|
122
156
|
messageSendingHooks: true,
|
|
123
157
|
reconcileUnknownSend: true,
|
|
124
158
|
},
|
|
125
|
-
// Only single-part text sends: that is what replaying one idempotency key
|
|
126
|
-
// actually proves (multi-chunk sends have per-part keys and stay with the
|
|
127
|
-
// normal retry path).
|
|
128
159
|
reconcileUnknownSendKinds: { text: true },
|
|
129
|
-
reconcileUnknownSend:
|
|
130
|
-
const account = resolveRelayAccount({
|
|
131
|
-
cfg: ctx.cfg as RelayCoreConfig,
|
|
132
|
-
accountId: ctx.accountId,
|
|
133
|
-
});
|
|
134
|
-
if (!account.configured) {
|
|
135
|
-
return { status: "unresolved", error: "relay account not configured", retryable: false };
|
|
136
|
-
}
|
|
137
|
-
const text = resolveSingleReconcilableText(ctx);
|
|
138
|
-
if (text === null) {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
const verdict = await reconcileRelayUnknownSend({
|
|
142
|
-
client: relayClientForAccount(account),
|
|
143
|
-
conversationId: ctx.to,
|
|
144
|
-
text,
|
|
145
|
-
replyToId: ctx.effectiveReplyToId ?? ctx.replyToId ?? null,
|
|
146
|
-
idempotencyKey: deriveRelayIdempotencyKey({ deliveryQueueId: ctx.queueId }),
|
|
147
|
-
});
|
|
148
|
-
if (verdict.status === "sent") {
|
|
149
|
-
return {
|
|
150
|
-
status: "sent",
|
|
151
|
-
messageId: verdict.messageId,
|
|
152
|
-
// The 202 is an array: name every message the send committed.
|
|
153
|
-
receipt: createMessageReceiptFromOutboundResults({
|
|
154
|
-
results: verdict.messages.map((message) => ({
|
|
155
|
-
channel: RELAY_CHANNEL_ID,
|
|
156
|
-
messageId: message.id,
|
|
157
|
-
})),
|
|
158
|
-
kind: "text",
|
|
159
|
-
}),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
return verdict;
|
|
163
|
-
},
|
|
160
|
+
reconcileUnknownSend: reconcileRelayUnknownSend,
|
|
164
161
|
},
|
|
165
162
|
send: {
|
|
166
163
|
text: async (ctx) => {
|
|
167
|
-
const account =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const result = await sendRelayText({
|
|
175
|
-
client: relayClientForAccount(account),
|
|
176
|
-
conversationId: ctx.to,
|
|
164
|
+
const account = requireAccount(
|
|
165
|
+
ctx.cfg as RelayCoreConfig,
|
|
166
|
+
ctx.accountId,
|
|
167
|
+
);
|
|
168
|
+
const response = await sendRelayText({
|
|
169
|
+
relay: createRelaySdkClient(account),
|
|
170
|
+
chatId: ctx.to,
|
|
177
171
|
text: ctx.text,
|
|
178
|
-
replyToId: ctx.replyToId
|
|
179
|
-
// Stable per (queueId, part): internal retries replay the same key,
|
|
180
|
-
// so the server-side idempotent commit makes duplicates impossible by
|
|
181
|
-
// contract. On a core with no part index the text names the part.
|
|
172
|
+
replyToId: ctx.replyToId,
|
|
182
173
|
idempotencyKey: deriveRelayIdempotencyKey({
|
|
183
174
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
184
|
-
deliveryPartIndex:
|
|
185
|
-
partText: ctx.text,
|
|
175
|
+
deliveryPartIndex: ctx.deliveryPartIndex,
|
|
186
176
|
}),
|
|
187
177
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
178
|
+
...(ctx.onPlatformSendDispatch
|
|
179
|
+
? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
|
|
180
|
+
: {}),
|
|
188
181
|
});
|
|
189
182
|
return {
|
|
190
|
-
messageId:
|
|
191
|
-
|
|
192
|
-
receipt: createMessageReceiptFromOutboundResults({
|
|
193
|
-
results: result.messages.map((message) => ({
|
|
194
|
-
channel: RELAY_CHANNEL_ID,
|
|
195
|
-
messageId: message.id,
|
|
196
|
-
})),
|
|
197
|
-
replyToId: ctx.replyToId ?? undefined,
|
|
198
|
-
kind: "text",
|
|
199
|
-
}),
|
|
183
|
+
messageId: response.message.id,
|
|
184
|
+
receipt: receipt([response], ctx.replyToId),
|
|
200
185
|
};
|
|
201
186
|
},
|
|
202
187
|
},
|
|
203
|
-
receive: {
|
|
204
|
-
// Cursor acks after a durable at-most-once attempt marker is written.
|
|
205
|
-
defaultAckPolicy: "after_agent_dispatch",
|
|
206
|
-
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
|
207
|
-
},
|
|
208
188
|
});
|
|
209
189
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// allowlist entries may start an OpenClaw turn.
|
|
226
|
-
const dmPolicy = "allowlist" as const;
|
|
227
|
-
const allowFrom = [...params.allowedSenderIds];
|
|
228
|
-
const access = await resolveStableChannelMessageIngress({
|
|
229
|
-
channelId: RELAY_CHANNEL_ID,
|
|
230
|
-
accountId: account.accountId,
|
|
231
|
-
identity: { key: "sender", entryIdPrefix: "relay-entry" },
|
|
232
|
-
subject: { stableId: facts.senderId },
|
|
233
|
-
conversation: { kind: "direct", id: facts.conversationId },
|
|
234
|
-
dmPolicy,
|
|
235
|
-
allowFrom,
|
|
236
|
-
});
|
|
237
|
-
if (access.ingress.admission !== "dispatch") {
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
const runtime = getRelayRuntime();
|
|
241
|
-
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
|
242
|
-
cfg: params.cfg,
|
|
243
|
-
channel: RELAY_CHANNEL_ID,
|
|
244
|
-
accountId: account.accountId,
|
|
245
|
-
peer: { kind: "direct", id: facts.conversationId },
|
|
246
|
-
runtime: runtime.channel,
|
|
247
|
-
sessionStore: (params.cfg as RelayCoreConfig).session?.store,
|
|
248
|
-
});
|
|
249
|
-
const commandAuthorized = relaySenderIsAllowed(params.allowedSenderIds, facts.senderId);
|
|
250
|
-
const { storePath, body } = buildEnvelope({
|
|
251
|
-
channel: relayMeta.label,
|
|
252
|
-
from: facts.senderId,
|
|
253
|
-
...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
|
|
254
|
-
body: facts.text,
|
|
255
|
-
});
|
|
256
|
-
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
257
|
-
Body: body,
|
|
258
|
-
BodyForAgent: facts.text,
|
|
259
|
-
RawBody: facts.text,
|
|
260
|
-
CommandBody: facts.text,
|
|
261
|
-
From: facts.conversationId,
|
|
262
|
-
To: facts.conversationId,
|
|
263
|
-
SessionKey: route.sessionKey,
|
|
264
|
-
AccountId: route.accountId ?? account.accountId,
|
|
265
|
-
ChatType: "direct",
|
|
266
|
-
ConversationLabel: facts.conversationId,
|
|
267
|
-
SenderId: facts.senderId,
|
|
268
|
-
SenderName: facts.senderId,
|
|
269
|
-
Provider: RELAY_CHANNEL_ID,
|
|
270
|
-
Surface: RELAY_CHANNEL_ID,
|
|
271
|
-
MessageSid: facts.messageId,
|
|
272
|
-
MessageSidFull: facts.messageId,
|
|
273
|
-
...(facts.replyToId ? { ReplyToId: facts.replyToId } : {}),
|
|
274
|
-
...(facts.timestamp ? { Timestamp: facts.timestamp } : {}),
|
|
275
|
-
OriginatingChannel: RELAY_CHANNEL_ID,
|
|
276
|
-
OriginatingTo: facts.conversationId,
|
|
277
|
-
CommandAuthorized: commandAuthorized,
|
|
278
|
-
});
|
|
279
|
-
// A consumed inbound message with a silently lost reply is the worst
|
|
280
|
-
// outcome. Delivery failures are surfaced, but the inbound attempt marker
|
|
281
|
-
// prevents replaying an agent turn whose tools may already have run.
|
|
282
|
-
let deliveryError: unknown;
|
|
283
|
-
let fallbackDeliveryIndex = 0;
|
|
284
|
-
const recordDeliveryError = (error: unknown) => {
|
|
285
|
-
deliveryError ??= error;
|
|
286
|
-
};
|
|
287
|
-
// Admission, runtime resolution, route/session lookup, envelope building,
|
|
288
|
-
// and context finalization above are replay-safe. The durable attempt starts
|
|
289
|
-
// immediately before OpenClaw can invoke the agent or its tools.
|
|
290
|
-
await markRespondingBeforeAttempt({
|
|
291
|
-
client: params.client,
|
|
292
|
-
conversationId: facts.conversationId,
|
|
293
|
-
messageId: facts.messageId,
|
|
294
|
-
label: "OpenClaw",
|
|
295
|
-
markAttempt: params.markAttempt,
|
|
296
|
-
});
|
|
297
|
-
await runtime.channel.inbound.dispatchReply({
|
|
298
|
-
cfg: params.cfg,
|
|
299
|
-
channel: RELAY_CHANNEL_ID,
|
|
300
|
-
accountId: account.accountId,
|
|
301
|
-
agentId: route.agentId,
|
|
302
|
-
routeSessionKey: route.sessionKey,
|
|
303
|
-
storePath,
|
|
304
|
-
ctxPayload,
|
|
305
|
-
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
306
|
-
dispatchReplyWithBufferedBlockDispatcher:
|
|
307
|
-
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
308
|
-
delivery: {
|
|
309
|
-
// Final replies go through the durable message adapter: core renders
|
|
310
|
-
// and chunks them (chunker + textChunkLimit) and tracks the send as a
|
|
311
|
-
// durable queue intent. Requiring reconcileUnknownSend forces
|
|
312
|
-
// `durability: "required"`, so single-payload finals carry a stable
|
|
313
|
-
// deliveryQueueId into send.text (stable idempotency key + exact
|
|
314
|
-
// replay), and multi-chunk finals get core's queue-level crash
|
|
315
|
-
// recovery. Replies land as plain messages, not quotes
|
|
316
|
-
// (`replyToId: null`).
|
|
317
|
-
durable: {
|
|
318
|
-
to: facts.conversationId,
|
|
319
|
-
replyToId: null,
|
|
320
|
-
requiredCapabilities: { reconcileUnknownSend: true },
|
|
190
|
+
export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> =
|
|
191
|
+
createChatChannelPlugin({
|
|
192
|
+
base: {
|
|
193
|
+
id: RELAY_CHANNEL_ID,
|
|
194
|
+
meta: relayMeta,
|
|
195
|
+
capabilities: {
|
|
196
|
+
chatTypes: ["direct", "group"],
|
|
197
|
+
reply: true,
|
|
198
|
+
threads: false,
|
|
199
|
+
media: false,
|
|
200
|
+
reactions: false,
|
|
201
|
+
edit: false,
|
|
202
|
+
unsend: false,
|
|
203
|
+
effects: false,
|
|
204
|
+
blockStreaming: false,
|
|
321
205
|
},
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
} catch (error) {
|
|
351
|
-
recordDeliveryError(error);
|
|
352
|
-
throw error;
|
|
353
|
-
}
|
|
206
|
+
reload: { configPrefixes: ["channels.relay"] },
|
|
207
|
+
setup: {
|
|
208
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
209
|
+
const core = cfg as RelayCoreConfig;
|
|
210
|
+
const section = { ...core.channels?.relay };
|
|
211
|
+
const patch = input as Record<string, unknown>;
|
|
212
|
+
const relay =
|
|
213
|
+
!accountId || accountId === DEFAULT_ACCOUNT_ID
|
|
214
|
+
? { ...section, ...patch }
|
|
215
|
+
: {
|
|
216
|
+
...section,
|
|
217
|
+
accounts: {
|
|
218
|
+
...section.accounts,
|
|
219
|
+
[accountId]: {
|
|
220
|
+
...section.accounts?.[accountId],
|
|
221
|
+
...patch,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
...cfg,
|
|
227
|
+
channels: {
|
|
228
|
+
...core.channels,
|
|
229
|
+
relay,
|
|
230
|
+
},
|
|
231
|
+
} as OpenClawConfig;
|
|
232
|
+
},
|
|
354
233
|
},
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
const log = (line: string) => ctx.log?.info?.(line);
|
|
390
|
-
const warn = (line: string) => ctx.log?.warn?.(line);
|
|
391
|
-
const client = relayClientForAccount(account);
|
|
392
|
-
const lifecycle = relayAccountLifecycles.acquire(account.accountId, ctx.abortSignal);
|
|
393
|
-
const abortSignal = lifecycle.signal;
|
|
394
|
-
let agentKey: string | undefined;
|
|
395
|
-
let accountLock: RelayAccountLock | undefined;
|
|
396
|
-
|
|
397
|
-
const markTerminalDisconnect = (error: Error) => {
|
|
398
|
-
// Operator action required: flag terminalDisconnect so the supervisor
|
|
399
|
-
// does not auto-restart (server-channels.ts:718).
|
|
400
|
-
ctx.setStatus({
|
|
401
|
-
accountId: account.accountId,
|
|
402
|
-
running: false,
|
|
403
|
-
connected: false,
|
|
404
|
-
terminalDisconnect: true,
|
|
405
|
-
lastError: error.message,
|
|
406
|
-
});
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
const me = await client.getMe({ signal: abortSignal });
|
|
411
|
-
const allowedSenderIds = resolveRelayAllowedSenderIds({
|
|
412
|
-
profile: me,
|
|
413
|
-
allowFrom: account.config.allowFrom,
|
|
414
|
-
});
|
|
415
|
-
if (allowedSenderIds.length === 0) {
|
|
416
|
-
const error = new Error(
|
|
417
|
-
`relay: account "${account.accountId}" has no owner pin. ` +
|
|
418
|
-
"The Relay API did not return owner_user_id and channels.relay.allowFrom is empty.",
|
|
419
|
-
);
|
|
420
|
-
markTerminalDisconnect(error);
|
|
421
|
-
throw error;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Two accounts configured with the same token would fight over the
|
|
425
|
-
// server's single consumer slot forever; keep the second one down until
|
|
426
|
-
// the operator fixes the config. account.baseUrl is already canonical.
|
|
427
|
-
agentKey = relayAgentAccountKey(account.baseUrl, me.id);
|
|
428
|
-
const owner = runningRelayAgentAccounts.get(agentKey);
|
|
429
|
-
if (owner !== undefined) {
|
|
430
|
-
const error = new Error(
|
|
431
|
-
`relay: agent ${me.id} is already polled by account "${owner}"; account "${account.accountId}" appears to reuse the same Agent Token. Give each account its own token.`,
|
|
432
|
-
);
|
|
433
|
-
markTerminalDisconnect(error);
|
|
434
|
-
throw error;
|
|
435
|
-
}
|
|
436
|
-
accountLock = new RelayAccountLock(account.baseUrl, me.id, account.accountId);
|
|
437
|
-
try {
|
|
438
|
-
accountLock.acquire();
|
|
439
|
-
} catch (error) {
|
|
440
|
-
const lockError = error instanceof Error ? error : new Error(String(error));
|
|
441
|
-
markTerminalDisconnect(lockError);
|
|
442
|
-
throw lockError;
|
|
443
|
-
}
|
|
444
|
-
runningRelayAgentAccounts.set(agentKey, account.accountId);
|
|
445
|
-
|
|
446
|
-
ctx.setStatus({
|
|
447
|
-
accountId: account.accountId,
|
|
448
|
-
running: true,
|
|
449
|
-
connected: true,
|
|
450
|
-
configured: true,
|
|
451
|
-
enabled: account.enabled,
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
const cursorStore = createRelayCursorStore({
|
|
455
|
-
store: openRelayCursorStateStore(warn),
|
|
456
|
-
baseUrl: account.baseUrl,
|
|
457
|
-
agentId: me.id,
|
|
458
|
-
onPersistError: (error) => warn(`[relay] cursor persistence failed: ${String(error)}`),
|
|
459
|
-
});
|
|
460
|
-
await cursorStore.load();
|
|
461
|
-
const deduper = createRelayInboundDeduper({
|
|
462
|
-
guard: createRelayInboundDedupeGuard({
|
|
463
|
-
onDiskError: (error) => warn(`[relay] inbound dedupe persistence failed: ${String(error)}`),
|
|
464
|
-
}),
|
|
465
|
-
baseUrl: account.baseUrl,
|
|
466
|
-
agentId: me.id,
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
await runRelayPollLoop({
|
|
470
|
-
client,
|
|
471
|
-
cursorStore,
|
|
472
|
-
deduper,
|
|
473
|
-
abortSignal,
|
|
474
|
-
timeoutSeconds: account.pollTimeoutSeconds,
|
|
475
|
-
limit: 100,
|
|
476
|
-
log,
|
|
477
|
-
// Receipts, reactions, and echoes are acked without a dedupe row or a
|
|
478
|
-
// dispatch: reaction.* is observe-only at v1, delivered/read
|
|
479
|
-
// are bookkeeping.
|
|
480
|
-
shouldProcess: (event) => buildRelayInboundFacts(event, { agentId: me.id }) !== null,
|
|
481
|
-
onBatch: () => {
|
|
482
|
-
ctx.setStatus({
|
|
483
|
-
accountId: account.accountId,
|
|
484
|
-
running: true,
|
|
485
|
-
connected: true,
|
|
486
|
-
lastInboundAt: Date.now(),
|
|
487
|
-
});
|
|
234
|
+
config: {
|
|
235
|
+
listAccountIds: (cfg) =>
|
|
236
|
+
listRelayAccountIds(cfg as RelayCoreConfig),
|
|
237
|
+
resolveAccount: (cfg, accountId) =>
|
|
238
|
+
resolveRelayAccount({
|
|
239
|
+
cfg: cfg as RelayCoreConfig,
|
|
240
|
+
accountId,
|
|
241
|
+
}),
|
|
242
|
+
defaultAccountId: (cfg) =>
|
|
243
|
+
resolveDefaultRelayAccountId(cfg as RelayCoreConfig),
|
|
244
|
+
isConfigured: (account) => account.configured,
|
|
245
|
+
inspectAccount: (cfg, accountId) => {
|
|
246
|
+
const account = resolveRelayAccount({
|
|
247
|
+
cfg: cfg as RelayCoreConfig,
|
|
248
|
+
accountId,
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
enabled: account.enabled,
|
|
252
|
+
configured: account.configured,
|
|
253
|
+
tokenStatus: account.configured ? "available" : "missing",
|
|
254
|
+
baseUrl: account.baseUrl,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
258
|
+
(() => {
|
|
259
|
+
const account = resolveRelayAccount({
|
|
260
|
+
cfg: cfg as RelayCoreConfig,
|
|
261
|
+
accountId,
|
|
262
|
+
});
|
|
263
|
+
return account.allowFrom.length > 0
|
|
264
|
+
? account.allowFrom
|
|
265
|
+
: ["*"];
|
|
266
|
+
})(),
|
|
488
267
|
},
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
268
|
+
messaging: {
|
|
269
|
+
targetPrefixes: ["relay"],
|
|
270
|
+
normalizeTarget: (target) => {
|
|
271
|
+
const normalized = target.trim().replace(/^relay:/iu, "");
|
|
272
|
+
return normalized || undefined;
|
|
273
|
+
},
|
|
274
|
+
targetResolver: {
|
|
275
|
+
looksLikeId: (raw) =>
|
|
276
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
|
|
277
|
+
raw.trim().replace(/^relay:/iu, ""),
|
|
278
|
+
),
|
|
279
|
+
hint: "<Relay Chat ID>",
|
|
280
|
+
},
|
|
502
281
|
},
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
if (error instanceof RelayApiError && error.terminal) {
|
|
509
|
-
markTerminalDisconnect(error);
|
|
510
|
-
} else if (isRelayWebhookConflict(error)) {
|
|
511
|
-
// Webhook XOR: long polling stays 409 until the operator
|
|
512
|
-
// disables the webhook endpoint — restarting cannot fix it.
|
|
513
|
-
// `terminated_by_other_consumer` intentionally falls through to the
|
|
514
|
-
// supervisor's normal restart/backoff arbitration.
|
|
515
|
-
markTerminalDisconnect(error);
|
|
516
|
-
}
|
|
517
|
-
throw error;
|
|
518
|
-
} finally {
|
|
519
|
-
if (agentKey && runningRelayAgentAccounts.get(agentKey) === account.accountId) {
|
|
520
|
-
runningRelayAgentAccounts.delete(agentKey);
|
|
521
|
-
}
|
|
522
|
-
accountLock?.release();
|
|
523
|
-
lifecycle.release();
|
|
524
|
-
ctx.setStatus({
|
|
525
|
-
accountId: account.accountId,
|
|
526
|
-
running: false,
|
|
527
|
-
connected: false,
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async function stopRelayAccount(
|
|
533
|
-
ctx: ChannelGatewayContext<ResolvedRelayAccount>,
|
|
534
|
-
): Promise<void> {
|
|
535
|
-
relayAccountLifecycles.stop(ctx.accountId);
|
|
536
|
-
ctx.setStatus({
|
|
537
|
-
accountId: ctx.accountId,
|
|
538
|
-
running: false,
|
|
539
|
-
connected: false,
|
|
540
|
-
});
|
|
541
|
-
ctx.log?.info?.(`[relay] stopped account "${ctx.accountId}"`);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// ---------------------------------------------------------------------------
|
|
545
|
-
// Plugin object.
|
|
546
|
-
// ---------------------------------------------------------------------------
|
|
547
|
-
|
|
548
|
-
export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> = createChatChannelPlugin({
|
|
549
|
-
base: {
|
|
550
|
-
id: RELAY_CHANNEL_ID,
|
|
551
|
-
meta: relayMeta,
|
|
552
|
-
capabilities: {
|
|
553
|
-
// v1: direct conversations only; media flips on when the agent
|
|
554
|
-
// attachment path ships. Reactions are observe-only.
|
|
555
|
-
chatTypes: ["direct"],
|
|
556
|
-
reply: true,
|
|
557
|
-
threads: false,
|
|
558
|
-
media: false,
|
|
559
|
-
reactions: false,
|
|
560
|
-
},
|
|
561
|
-
reload: { configPrefixes: ["channels.relay"] },
|
|
562
|
-
setup: {
|
|
563
|
-
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
564
|
-
const coreCfg = cfg as RelayCoreConfig;
|
|
565
|
-
const channelSection = { ...coreCfg.channels?.relay };
|
|
566
|
-
const patch = input as Record<string, unknown>;
|
|
567
|
-
const next =
|
|
568
|
-
!accountId || accountId === DEFAULT_ACCOUNT_ID
|
|
569
|
-
? { ...channelSection, ...patch }
|
|
570
|
-
: {
|
|
571
|
-
...channelSection,
|
|
572
|
-
accounts: {
|
|
573
|
-
...channelSection.accounts,
|
|
574
|
-
[accountId]: {
|
|
575
|
-
...channelSection.accounts?.[accountId],
|
|
576
|
-
...patch,
|
|
577
|
-
},
|
|
578
|
-
},
|
|
579
|
-
};
|
|
580
|
-
return {
|
|
581
|
-
...cfg,
|
|
582
|
-
channels: {
|
|
583
|
-
...coreCfg.channels,
|
|
584
|
-
relay: next,
|
|
585
|
-
},
|
|
586
|
-
} as OpenClawConfig;
|
|
282
|
+
gateway: {
|
|
283
|
+
startAccount: startRelayAccount,
|
|
284
|
+
stopAccount: stopRelayAccount,
|
|
587
285
|
},
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
286
|
+
heartbeat: {
|
|
287
|
+
sendTyping: async ({ cfg, to, accountId }) => {
|
|
288
|
+
const account = requireAccount(
|
|
289
|
+
cfg as RelayCoreConfig,
|
|
290
|
+
accountId,
|
|
291
|
+
);
|
|
292
|
+
await createRelaySdkClient(account).chats.startTyping(to);
|
|
293
|
+
},
|
|
294
|
+
clearTyping: async ({ cfg, to, accountId }) => {
|
|
295
|
+
const account = requireAccount(
|
|
296
|
+
cfg as RelayCoreConfig,
|
|
297
|
+
accountId,
|
|
298
|
+
);
|
|
299
|
+
await createRelaySdkClient(account).chats.stopTyping(to);
|
|
300
|
+
},
|
|
603
301
|
},
|
|
604
|
-
|
|
605
|
-
resolveRelayAllowedSenderIds({
|
|
606
|
-
profile: {},
|
|
607
|
-
allowFrom: resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId }).config
|
|
608
|
-
.allowFrom,
|
|
609
|
-
}),
|
|
302
|
+
message: relayMessageAdapter,
|
|
610
303
|
},
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
304
|
+
security: {
|
|
305
|
+
dm: {
|
|
306
|
+
channelKey: RELAY_CHANNEL_ID,
|
|
307
|
+
resolvePolicy: (account) =>
|
|
308
|
+
account.allowFrom.length > 0 ? "allowlist" : "open",
|
|
309
|
+
resolveAllowFrom: (account) =>
|
|
310
|
+
account.allowFrom.length > 0 ? account.allowFrom : ["*"],
|
|
311
|
+
defaultPolicy: "open",
|
|
615
312
|
},
|
|
616
313
|
},
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
sendTyping: async ({ cfg, to, accountId }) => {
|
|
624
|
-
const account = resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId });
|
|
625
|
-
if (!account.configured) {
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
await relayClientForAccount(account).setTyping({ conversationId: to, started: true });
|
|
314
|
+
outbound: {
|
|
315
|
+
base: {
|
|
316
|
+
deliveryMode: "direct",
|
|
317
|
+
chunker: (text, limit) => chunkText(text, limit),
|
|
318
|
+
chunkerMode: "text",
|
|
319
|
+
textChunkLimit: RELAY_TEXT_CHUNK_LIMIT,
|
|
629
320
|
},
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
321
|
+
attachedResults: {
|
|
322
|
+
channel: RELAY_CHANNEL_ID,
|
|
323
|
+
sendText: async (ctx) => {
|
|
324
|
+
const account = requireAccount(
|
|
325
|
+
ctx.cfg as RelayCoreConfig,
|
|
326
|
+
ctx.accountId,
|
|
327
|
+
);
|
|
328
|
+
const response = await sendRelayText({
|
|
329
|
+
relay: createRelaySdkClient(account),
|
|
330
|
+
chatId: ctx.to,
|
|
331
|
+
text: ctx.text,
|
|
332
|
+
replyToId: ctx.replyToId,
|
|
333
|
+
idempotencyKey: deriveRelayIdempotencyKey({
|
|
334
|
+
deliveryQueueId: ctx.deliveryQueueId,
|
|
335
|
+
deliveryPartIndex: ctx.deliveryPartIndex,
|
|
336
|
+
}),
|
|
337
|
+
...(ctx.onPlatformSendDispatch
|
|
338
|
+
? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
|
|
339
|
+
: {}),
|
|
340
|
+
});
|
|
341
|
+
return { messageId: response.message.id };
|
|
342
|
+
},
|
|
636
343
|
},
|
|
637
344
|
},
|
|
638
|
-
|
|
639
|
-
},
|
|
640
|
-
security: {
|
|
641
|
-
dm: {
|
|
642
|
-
channelKey: RELAY_CHANNEL_ID,
|
|
643
|
-
resolvePolicy: () => "allowlist",
|
|
644
|
-
resolveAllowFrom: (account) =>
|
|
645
|
-
resolveRelayAllowedSenderIds({ profile: {}, allowFrom: account.config.allowFrom }),
|
|
646
|
-
defaultPolicy: "allowlist",
|
|
647
|
-
},
|
|
648
|
-
},
|
|
649
|
-
outbound: {
|
|
650
|
-
base: {
|
|
651
|
-
deliveryMode: "direct",
|
|
652
|
-
// Core's renderer splits long replies before the adapter sees them
|
|
653
|
-
// without a chunker the plan falls back to one oversized
|
|
654
|
-
// unit, which the server 422s at its 8 KiB per-part cap.
|
|
655
|
-
chunker: (text, limit) => chunkText(text, limit),
|
|
656
|
-
chunkerMode: "text",
|
|
657
|
-
textChunkLimit: RELAY_TEXT_CHUNK_LIMIT,
|
|
658
|
-
},
|
|
659
|
-
attachedResults: {
|
|
660
|
-
channel: RELAY_CHANNEL_ID,
|
|
661
|
-
sendText: async (ctx) => {
|
|
662
|
-
const { cfg, to, text, accountId, replyToId } = ctx;
|
|
663
|
-
const account = resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId });
|
|
664
|
-
if (!account.configured) {
|
|
665
|
-
throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
|
|
666
|
-
}
|
|
667
|
-
const normalizedReplyToId = replyToId == null ? null : String(replyToId);
|
|
668
|
-
const result = await sendRelayText({
|
|
669
|
-
client: relayClientForAccount(account),
|
|
670
|
-
conversationId: to,
|
|
671
|
-
text,
|
|
672
|
-
replyToId: normalizedReplyToId,
|
|
673
|
-
// Stable when core supplies a logical queue id; otherwise fresh for
|
|
674
|
-
// this invocation so two intentional identical sends remain two.
|
|
675
|
-
idempotencyKey: deriveRelayIdempotencyKey({
|
|
676
|
-
deliveryQueueId: ctx.deliveryQueueId,
|
|
677
|
-
deliveryPartIndex: deliveryPartIndexOf(ctx),
|
|
678
|
-
partText: text,
|
|
679
|
-
}),
|
|
680
|
-
});
|
|
681
|
-
return { messageId: result.messageId };
|
|
682
|
-
},
|
|
683
|
-
},
|
|
684
|
-
},
|
|
685
|
-
});
|
|
345
|
+
});
|