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