r2-relay-channel 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/dist/channel.d.ts +16 -0
  4. package/dist/channel.js +271 -0
  5. package/dist/checkpoint-store.d.ts +4 -0
  6. package/dist/checkpoint-store.js +16 -0
  7. package/dist/commands.d.ts +2 -0
  8. package/dist/commands.js +54 -0
  9. package/dist/config.d.ts +76 -0
  10. package/dist/config.js +123 -0
  11. package/dist/conversation-targets.d.ts +32 -0
  12. package/dist/conversation-targets.js +120 -0
  13. package/dist/inbound.d.ts +38 -0
  14. package/dist/inbound.js +440 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +26 -0
  17. package/dist/lifecycle.d.ts +64 -0
  18. package/dist/lifecycle.js +296 -0
  19. package/dist/media.d.ts +30 -0
  20. package/dist/media.js +361 -0
  21. package/dist/message-adapter.d.ts +27 -0
  22. package/dist/message-adapter.js +53 -0
  23. package/dist/openclaw.plugin.json +41 -0
  24. package/dist/outbound.d.ts +28 -0
  25. package/dist/outbound.js +141 -0
  26. package/dist/protocol.d.ts +2 -0
  27. package/dist/protocol.js +1 -0
  28. package/dist/r2-relay.d.ts +1 -0
  29. package/dist/r2-relay.js +1 -0
  30. package/dist/r2client.d.ts +33 -0
  31. package/dist/r2client.js +137 -0
  32. package/dist/relay-core/checkpoint.d.ts +31 -0
  33. package/dist/relay-core/checkpoint.js +95 -0
  34. package/dist/relay-core/errors.d.ts +9 -0
  35. package/dist/relay-core/errors.js +18 -0
  36. package/dist/relay-core/index.d.ts +6 -0
  37. package/dist/relay-core/index.js +6 -0
  38. package/dist/relay-core/keyspace.d.ts +24 -0
  39. package/dist/relay-core/keyspace.js +60 -0
  40. package/dist/relay-core/object-store.d.ts +20 -0
  41. package/dist/relay-core/object-store.js +1 -0
  42. package/dist/relay-core/transport.d.ts +48 -0
  43. package/dist/relay-core/transport.js +326 -0
  44. package/dist/relay-core/types.d.ts +220 -0
  45. package/dist/relay-core/types.js +1 -0
  46. package/dist/runtime.d.ts +9 -0
  47. package/dist/runtime.js +54 -0
  48. package/dist/service.d.ts +51 -0
  49. package/dist/service.js +186 -0
  50. package/dist/service.test.d.ts +1 -0
  51. package/dist/service.test.js +27 -0
  52. package/dist/session-publication.d.ts +7 -0
  53. package/dist/session-publication.js +365 -0
  54. package/dist/setup-entry.d.ts +6 -0
  55. package/dist/setup-entry.js +49 -0
  56. package/dist/setup.d.ts +3 -0
  57. package/dist/setup.js +291 -0
  58. package/dist/target.d.ts +6 -0
  59. package/dist/target.js +53 -0
  60. package/dist/webhook.d.ts +18 -0
  61. package/dist/webhook.js +225 -0
  62. package/openclaw.plugin.json +41 -0
  63. package/package.json +62 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wei Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,309 @@
1
+ # r2-relay-channel
2
+
3
+ - [English](#english)
4
+ - [简体中文](#simplified-chinese)
5
+
6
+ ---
7
+
8
+ <a id="english"></a>
9
+
10
+ ## English
11
+
12
+ `r2-relay-channel` is an OpenClaw channel plugin that uses Cloudflare R2 as a lightweight relay layer for chat delivery.
13
+
14
+ AgentDeck is the companion iOS app. The complete app and plugin source is fully
15
+ open source under the MIT License. Build AgentDeck for free from the
16
+ [repository](https://github.com/dodid/AgentDeck), or install the signed public
17
+ app from the App Store as a paid app by searching for **AgentDeck**.
18
+
19
+ It is designed for setups where an OpenClaw server publishes session metadata and exchanges messages through an object-store-backed protocol instead of a conventional always-on websocket or direct mobile push transport.
20
+
21
+ ### Why this exists
22
+
23
+ - **No ports open** — your server stays behind its firewall instead of being exposed directly to the internet.
24
+ - **Instant setup** — connect once without juggling bots, webhooks, or extra chat platform plumbing.
25
+ - **You own your data** — conversations live in your own Cloudflare R2 bucket, not on someone else's hosted chat service.
26
+ - **Chat your way** — the same OpenClaw backend can power a friendly mobile chat UI or a more terminal-style interface for power users.
27
+
28
+ ### Security note
29
+
30
+ OpenClaw's plugin code-safety audit may warn that this plugin reads local files and sends data over the network. That warning is expected here: outbound media delivery intentionally reads agent-produced local files (for example images referenced by reply payloads) and uploads them to the configured Cloudflare R2 relay bucket so they can be delivered as attachments. Review this behavior in `src/channel.ts` (`buildRelayAttachments`) and use the plugin only in workspaces and relay buckets you trust.
31
+
32
+ ### Requirements
33
+
34
+ - OpenClaw `2026.7.1` or later
35
+ - a Cloudflare R2 bucket
36
+ - an R2 endpoint URL
37
+ - an access key id and secret access key with bucket access
38
+
39
+ ### Install the plugin in OpenClaw
40
+
41
+ Install directly from ClawHub by plugin id:
42
+
43
+ ```bash
44
+ openclaw plugins install clawhub:r2-relay-channel
45
+ openclaw gateway restart
46
+ ```
47
+
48
+ ### Configure OpenClaw
49
+
50
+ After installing the plugin, run:
51
+
52
+ ```bash
53
+ openclaw configure
54
+ ```
55
+
56
+ OpenClaw should expose `R2 Relay` as a channel setup flow and prompt for:
57
+
58
+ - R2 endpoint URL
59
+ - bucket name
60
+ - access key ID
61
+ - secret access key
62
+ - gateway server ID
63
+
64
+ The wizard suggests a short random server ID candidate automatically, and you can keep it or edit it. Keep it unique if you have multiple gateways sharing the same R2 bucket.
65
+
66
+ ### Install AgentDeck on iOS
67
+
68
+ To use this relay from iPhone or iPad, install AgentDeck from the App Store by
69
+ searching for **AgentDeck**. For a free self-built version, follow the
70
+ [iOS build instructions](../../apps/ios/README.md).
71
+
72
+ Open AgentDeck and enter the same R2 connection details you configured for this plugin.
73
+
74
+ ### `/session-target` command
75
+
76
+ The plugin registers a command that shows the current conversation's relay targets, which can be used for cron job delivery or direct webhook POSTing.
77
+
78
+ After at least one inbound relay message has established conversation state, run:
79
+
80
+ ```text
81
+ /session-target
82
+ ```
83
+
84
+ It returns two copy-pasteable code blocks:
85
+
86
+ - the cron target string
87
+ - the webhook URL
88
+
89
+ #### Cron target string
90
+
91
+ The plugin supports native OpenClaw delivery targeting through `--channel r2-relay-channel` and a provider-specific `--to` format.
92
+
93
+ The target format:
94
+
95
+ ```text
96
+ peer=<peer>,session=<sessionKey>
97
+ ```
98
+
99
+ Example:
100
+
101
+ ```bash
102
+ openclaw cron add \
103
+ --name "Morning brief" \
104
+ --cron "0 7 * * *" \
105
+ --tz "America/Los_Angeles" \
106
+ --session isolated \
107
+ --message "Summarize overnight updates." \
108
+ --announce \
109
+ --channel r2-relay-channel \
110
+ --to 'peer=phone-abc123,session=agent:main:main'
111
+ ```
112
+
113
+ Notes:
114
+
115
+ - `peer` is the relay recipient id.
116
+ - `session` is the exact OpenClaw session key to route into.
117
+ - `,` and `=` are reserved inside values in this minimal format.
118
+
119
+ #### Webhook URL
120
+
121
+ The plugin exposes a minimal webhook endpoint for cron jobs.
122
+
123
+ Authentication uses OpenClaw's existing `cron.webhookToken`; there is no separate plugin token.
124
+
125
+ Set a cron webhook token in your OpenClaw config:
126
+
127
+ ```json5
128
+ {
129
+ cron: {
130
+ webhookToken: "replace-with-a-random-secret"
131
+ }
132
+ }
133
+ ```
134
+
135
+ The endpoint expects a JSON POST body. It accepts:
136
+
137
+ - cron finished-event payloads with a `summary` field
138
+ - simple manual payloads with a `text` field
139
+ - error payloads with an `error` field
140
+
141
+ It forwards the first non-empty value from `text`, `summary`, or `error` into the specified relay session.
142
+
143
+ Curl example:
144
+
145
+ ```bash
146
+ curl -X POST 'http://127.0.0.1:18789/r2-relay-channel/webhook/moss-river-w5/agent%3Amain%3Amain' \
147
+ -H 'Authorization: Bearer <cron.webhookToken>' \
148
+ -H 'Content-Type: application/json' \
149
+ --data '{"text":"hello from curl"}'
150
+ ```
151
+
152
+ ### Uninstall the plugin from OpenClaw
153
+
154
+ To remove the plugin:
155
+
156
+ ```bash
157
+ openclaw plugins uninstall r2-relay-channel
158
+ openclaw gateway restart
159
+ ```
160
+
161
+ ---
162
+
163
+ <a id="simplified-chinese"></a>
164
+
165
+ ## 简体中文
166
+
167
+ `r2-relay-channel` 是一个 OpenClaw 通道插件,使用 Cloudflare R2 作为轻量中继层来传递聊天消息。
168
+
169
+ 它适用于这样的部署方式:OpenClaw 服务器负责发布会话元数据与消息交换,但底层通过对象存储协议完成中继,而不是依赖常驻 WebSocket 或直接移动推送。
170
+
171
+ ### 这个插件为什么存在
172
+
173
+ - **无需开放端口**:你的服务器可以继续留在防火墙后,不必直接暴露到公网。
174
+ - **部署快速**:一次接入即可工作,无需再拼装 bot、webhook 或额外聊天平台链路。
175
+ - **数据归你所有**:会话数据存放在你自己的 Cloudflare R2 bucket,而不是第三方托管聊天服务。
176
+ - **聊天形态自由**:同一个 OpenClaw 后端既可驱动移动端友好 UI,也可支持偏终端风格的高阶交互。
177
+
178
+ ### 依赖要求
179
+
180
+ - OpenClaw `2026.7.1` 或更高版本
181
+ - 一个 Cloudflare R2 bucket
182
+ - 一个 R2 endpoint URL
183
+ - 对该 bucket 有访问权限的 access key id 与 secret access key
184
+
185
+ ### 在 OpenClaw 中安装插件
186
+
187
+ 通过 ClawHub 使用插件 ID 直接安装:
188
+
189
+ ```bash
190
+ openclaw plugins install clawhub:r2-relay-channel
191
+ openclaw gateway restart
192
+ ```
193
+
194
+ ### 配置 OpenClaw
195
+
196
+ 安装后执行:
197
+
198
+ ```bash
199
+ openclaw configure
200
+ ```
201
+
202
+ OpenClaw 应显示 `R2 Relay` 的通道配置流程,并提示你输入:
203
+
204
+ - R2 endpoint URL
205
+ - bucket name
206
+ - access key ID
207
+ - secret access key
208
+ - gateway server ID
209
+
210
+ 向导会自动给出一个简短的随机 server ID 候选值。你可以直接使用,也可以修改。若多个 gateway 共享同一个 R2 bucket,请确保每个 server ID 唯一。
211
+
212
+ ### 在 iOS 上安装 AgentDeck
213
+
214
+ 若要在 iPhone 或 iPad 上使用这个 relay,请在 App Store 中搜索
215
+ **AgentDeck** 并安装。你也可以按照
216
+ [iOS 构建说明](../../apps/ios/README.md) 免费自行构建。
217
+
218
+ 打开 AgentDeck,并填写与本插件一致的 R2 连接信息。
219
+
220
+ ### `/session-target` 命令
221
+
222
+ 插件会注册一个命令,用于显示当前会话可用的中继目标,可用于 cron 投递或直接 webhook POST。
223
+
224
+ 至少先让一条入站中继消息建立会话状态,然后运行:
225
+
226
+ ```text
227
+ /session-target
228
+ ```
229
+
230
+ 命令会返回两个可复制的代码块:
231
+
232
+ - cron target string
233
+ - webhook URL
234
+
235
+ #### Cron target string
236
+
237
+ 插件支持 OpenClaw 原生投递目标:`--channel r2-relay-channel` 搭配 provider 专用 `--to` 格式。
238
+
239
+ 目标格式:
240
+
241
+ ```text
242
+ peer=<peer>,session=<sessionKey>
243
+ ```
244
+
245
+ 示例:
246
+
247
+ ```bash
248
+ openclaw cron add \
249
+ --name "Morning brief" \
250
+ --cron "0 7 * * *" \
251
+ --tz "America/Los_Angeles" \
252
+ --session isolated \
253
+ --message "Summarize overnight updates." \
254
+ --announce \
255
+ --channel r2-relay-channel \
256
+ --to 'peer=phone-abc123,session=agent:main:main'
257
+ ```
258
+
259
+ 说明:
260
+
261
+ - `peer` 是中继接收端 id。
262
+ - `session` 是需要路由到的 OpenClaw 精确 session key。
263
+ - 该最小格式中,值内部保留字符为 `,` 与 `=`。
264
+
265
+ #### Webhook URL
266
+
267
+ 插件提供一个简化 webhook 端点供 cron 调用。
268
+
269
+ 认证沿用 OpenClaw 已有的 `cron.webhookToken`,无需单独插件 token。
270
+
271
+ 请先在 OpenClaw 配置中设置 cron webhook token:
272
+
273
+ ```json5
274
+ {
275
+ cron: {
276
+ webhookToken: "replace-with-a-random-secret"
277
+ }
278
+ }
279
+ ```
280
+
281
+ 端点接收 JSON POST 请求,支持:
282
+
283
+ - 包含 `summary` 字段的 cron finished event 负载
284
+ - 包含 `text` 字段的手工请求负载
285
+ - 包含 `error` 字段的错误负载
286
+
287
+ 插件会按 `text`、`summary`、`error` 的优先顺序选择第一个非空值,并转发到指定 relay session。
288
+
289
+ Curl 示例:
290
+
291
+ ```bash
292
+ curl -X POST 'http://127.0.0.1:18789/r2-relay-channel/webhook/moss-river-w5/agent%3Amain%3Amain' \
293
+ -H 'Authorization: Bearer <cron.webhookToken>' \
294
+ -H 'Content-Type: application/json' \
295
+ --data '{"text":"hello from curl"}'
296
+ ```
297
+
298
+ ### 从 OpenClaw 卸载插件
299
+
300
+ 执行以下命令:
301
+
302
+ ```bash
303
+ openclaw plugins uninstall r2-relay-channel
304
+ openclaw gateway restart
305
+ ```
306
+
307
+ ## License
308
+
309
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,16 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import { resolveR2RelayAccount, type ResolvedR2RelayAccount } from "./config.js";
3
+ type RelayApprovalCapability = {
4
+ getExecInitiatingSurfaceState: (params: {
5
+ cfg: Parameters<typeof resolveR2RelayAccount>[0]["cfg"];
6
+ accountId?: string | null;
7
+ action: "approve";
8
+ }) => {
9
+ kind: "enabled" | "disabled";
10
+ };
11
+ };
12
+ type RelayChannelPlugin = ChannelPlugin<ResolvedR2RelayAccount> & {
13
+ approvalCapability: RelayApprovalCapability;
14
+ };
15
+ export declare const r2RelayPlugin: RelayChannelPlugin;
16
+ export {};
@@ -0,0 +1,271 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
2
+ import { buildBaseChannelStatusSummary, collectStatusIssuesFromLastError, } from "openclaw/plugin-sdk/status-helpers";
3
+ import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
4
+ import { r2RelayChannelConfigSchema, resolveR2RelayAccount, listR2RelayAccountIds, } from "./config.js";
5
+ import { r2RelaySetupAdapter, r2RelaySetupWizard } from "./setup.js";
6
+ import { loadCheckpointState } from "./checkpoint-store.js";
7
+ import { formatInboundRelayBody, classifyInboundRelayMessageFailure, dispatchInboundMessage } from "./inbound.js";
8
+ import { clearService, createDefaultRuntimeState, getOrCreateService, pollRelayInbox, redactEndpoint, runSweeperLoop, setRuntimeSnapshot, touchRuntime, } from "./lifecycle.js";
9
+ import { r2RelayMessageAdapter } from "./message-adapter.js";
10
+ import { syncPublishedIdentity } from "./session-publication.js";
11
+ import { formatRelayTargetHint, parseRelayTarget } from "./target.js";
12
+ function jsonResult(payload) {
13
+ return {
14
+ content: [
15
+ {
16
+ type: "text",
17
+ text: JSON.stringify(payload, null, 2),
18
+ },
19
+ ],
20
+ details: payload,
21
+ channelData: payload,
22
+ };
23
+ }
24
+ function readStringParam(params, key, options) {
25
+ const value = params[key];
26
+ if (typeof value === "string") {
27
+ const trimmed = value.trim();
28
+ if (trimmed.length > 0 || !options?.required) {
29
+ return trimmed;
30
+ }
31
+ }
32
+ if (options?.required) {
33
+ throw new Error(`Missing required parameter: ${key}`);
34
+ }
35
+ return "";
36
+ }
37
+ function readBooleanParam(params, key) {
38
+ const value = params[key];
39
+ if (typeof value === "boolean") {
40
+ return value;
41
+ }
42
+ if (typeof value === "number") {
43
+ return value !== 0;
44
+ }
45
+ if (typeof value === "string") {
46
+ const normalized = value.trim().toLowerCase();
47
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
48
+ return true;
49
+ }
50
+ if (["0", "false", "no", "n", "off", ""].includes(normalized)) {
51
+ return false;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ function readReactionParams(params, options) {
57
+ const emoji = readStringParam(params, "emoji") || readStringParam(params, "reaction");
58
+ const remove = readBooleanParam(params, "remove");
59
+ const isEmpty = emoji.length === 0;
60
+ if (remove && isEmpty) {
61
+ throw new Error(options?.removeErrorMessage ?? "remove=true requires a specific emoji.");
62
+ }
63
+ if (!remove && isEmpty) {
64
+ throw new Error("Missing required parameter: emoji");
65
+ }
66
+ return { emoji, remove, isEmpty };
67
+ }
68
+ function agentIdFromConversationId(conversationId) {
69
+ const normalized = conversationId?.trim();
70
+ if (!normalized) {
71
+ return null;
72
+ }
73
+ const parts = normalized.split(":");
74
+ if (parts.length >= 2 && parts[0] === "agent" && parts[1]?.trim()) {
75
+ return parts[1].trim();
76
+ }
77
+ return null;
78
+ }
79
+ export const r2RelayPlugin = {
80
+ id: "r2-relay-channel",
81
+ meta: {
82
+ id: "r2-relay-channel",
83
+ label: "R2 Relay",
84
+ selectionLabel: "R2 Relay",
85
+ docsPath: "/channels/r2-relay-channel",
86
+ docsLabel: "r2-relay-channel",
87
+ blurb: "Cloudflare R2-backed relay channel for direct text messaging.",
88
+ order: 120,
89
+ },
90
+ capabilities: {
91
+ chatTypes: ["direct"],
92
+ reactions: true,
93
+ media: true,
94
+ },
95
+ approvalCapability: {
96
+ getExecInitiatingSurfaceState: ({ cfg, accountId, }) => {
97
+ const account = resolveR2RelayAccount({ cfg, accountId });
98
+ if (!account.enabled || !account.configured) {
99
+ return { kind: "disabled" };
100
+ }
101
+ return { kind: "enabled" };
102
+ },
103
+ },
104
+ reload: { configPrefixes: ["channels.r2-relay-channel"] },
105
+ configSchema: r2RelayChannelConfigSchema,
106
+ setupWizard: r2RelaySetupWizard,
107
+ setup: r2RelaySetupAdapter,
108
+ config: {
109
+ listAccountIds: (cfg) => listR2RelayAccountIds(cfg),
110
+ resolveAccount: (cfg, accountId) => resolveR2RelayAccount({ cfg, accountId }),
111
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
112
+ isConfigured: (account) => account.configured,
113
+ describeAccount: (account) => ({
114
+ accountId: account.accountId,
115
+ enabled: account.enabled,
116
+ configured: account.configured,
117
+ serverId: account.serverId,
118
+ bucket: account.bucket,
119
+ endpoint: redactEndpoint(account.endpoint),
120
+ }),
121
+ },
122
+ actions: {
123
+ describeMessageTool: () => ({
124
+ actions: ["react"],
125
+ }),
126
+ supportsAction: ({ action }) => action === "react",
127
+ handleAction: async ({ action, params, cfg, accountId }) => {
128
+ const account = resolveR2RelayAccount({ cfg, accountId });
129
+ const service = getOrCreateService(account);
130
+ if (action === "react") {
131
+ const to = readStringParam(params, "to", { required: true });
132
+ const messageId = readStringParam(params, "messageId", { required: true });
133
+ const reaction = readReactionParams(params, {
134
+ removeErrorMessage: "R2 Relay reactions support remove=true only with a specific emoji.",
135
+ });
136
+ const target = parseRelayTarget(to);
137
+ const result = await service.sendMessage(target.peer, {
138
+ route: {
139
+ agent_id: target.conversationId ? agentIdFromConversationId(target.conversationId) ?? "main" : "main",
140
+ conversation_id: target.conversationId ?? null,
141
+ instance_id: null,
142
+ },
143
+ content: {
144
+ type: "reaction",
145
+ target_msg_id: messageId,
146
+ emoji: reaction.emoji || "✅",
147
+ remove: reaction.remove || reaction.isEmpty,
148
+ },
149
+ });
150
+ touchRuntime(account.accountId, {
151
+ lastOutboundAt: Date.now(),
152
+ lastError: null,
153
+ });
154
+ return jsonResult({
155
+ ok: true,
156
+ channel: "r2-relay-channel",
157
+ action: "react",
158
+ to: to.trim(),
159
+ messageId: result.messageId,
160
+ targetMessageId: messageId,
161
+ emoji: reaction.emoji,
162
+ remove: reaction.remove || reaction.isEmpty,
163
+ });
164
+ }
165
+ throw new Error(`Unsupported r2-relay-channel action: ${action}`);
166
+ },
167
+ },
168
+ messaging: {
169
+ normalizeTarget: (target) => target.trim(),
170
+ targetResolver: {
171
+ looksLikeId: (input) => Boolean(input.trim()),
172
+ hint: `${formatRelayTargetHint()} or <peer>`,
173
+ },
174
+ },
175
+ message: r2RelayMessageAdapter,
176
+ status: {
177
+ defaultRuntime: createDefaultRuntimeState(DEFAULT_ACCOUNT_ID),
178
+ collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("r2-relay-channel", accounts),
179
+ buildChannelSummary: ({ snapshot }) => {
180
+ const extended = snapshot;
181
+ return {
182
+ ...buildBaseChannelStatusSummary(snapshot),
183
+ serverId: extended.serverId ?? null,
184
+ bucket: extended.bucket ?? null,
185
+ endpoint: extended.endpoint ?? null,
186
+ lastPollAt: extended.lastPollAt ?? null,
187
+ checkpointHeadKey: extended.checkpointHeadKey ?? null,
188
+ lastInboundAt: extended.lastInboundAt ?? null,
189
+ lastOutboundAt: extended.lastOutboundAt ?? null,
190
+ };
191
+ },
192
+ buildAccountSnapshot: ({ account, runtime }) => {
193
+ const extendedRuntime = (runtime ?? null);
194
+ return {
195
+ accountId: account.accountId,
196
+ enabled: account.enabled,
197
+ configured: account.configured,
198
+ running: runtime?.running ?? false,
199
+ lastStartAt: runtime?.lastStartAt ?? null,
200
+ lastStopAt: runtime?.lastStopAt ?? null,
201
+ lastError: runtime?.lastError ?? null,
202
+ lastPollAt: extendedRuntime?.lastPollAt ?? null,
203
+ lastInboundAt: extendedRuntime?.lastInboundAt ?? null,
204
+ lastOutboundAt: extendedRuntime?.lastOutboundAt ?? null,
205
+ serverId: account.serverId,
206
+ bucket: account.bucket,
207
+ endpoint: redactEndpoint(account.endpoint),
208
+ checkpointHeadKey: extendedRuntime?.checkpointHeadKey ?? null,
209
+ };
210
+ },
211
+ },
212
+ gateway: {
213
+ startAccount: async (ctx) => {
214
+ const account = ctx.account;
215
+ const runtime = createDefaultRuntimeState(account.accountId);
216
+ runtime.serverId = account.serverId;
217
+ runtime.bucket = account.bucket;
218
+ runtime.endpoint = redactEndpoint(account.endpoint);
219
+ setRuntimeSnapshot(account.accountId, runtime);
220
+ ctx.setStatus({
221
+ accountId: account.accountId,
222
+ });
223
+ return runPassiveAccountLifecycle({
224
+ abortSignal: ctx.abortSignal,
225
+ start: async () => {
226
+ const service = getOrCreateService(account);
227
+ await syncPublishedIdentity(service, account, true);
228
+ void runSweeperLoop({
229
+ account,
230
+ service,
231
+ abortSignal: ctx.abortSignal,
232
+ log: ctx.log,
233
+ });
234
+ touchRuntime(account.accountId, {
235
+ running: true,
236
+ lastStartAt: Date.now(),
237
+ lastError: null,
238
+ serverId: account.serverId,
239
+ bucket: account.bucket,
240
+ endpoint: redactEndpoint(account.endpoint),
241
+ });
242
+ ctx.setStatus({
243
+ accountId: account.accountId,
244
+ });
245
+ const state = await loadCheckpointState(account.accountId);
246
+ await pollRelayInbox({
247
+ cfg: ctx.cfg,
248
+ account,
249
+ service,
250
+ abortSignal: ctx.abortSignal,
251
+ log: ctx.log,
252
+ setStatus: ctx.setStatus,
253
+ state,
254
+ syncPublishedIdentity,
255
+ dispatchInboundMessage,
256
+ formatInboundRelayBody,
257
+ classifyInboundRelayMessageFailure,
258
+ });
259
+ return { service };
260
+ },
261
+ stop: async () => {
262
+ clearService(account.accountId);
263
+ touchRuntime(account.accountId, {
264
+ running: false,
265
+ lastStopAt: Date.now(),
266
+ });
267
+ },
268
+ });
269
+ },
270
+ },
271
+ };
@@ -0,0 +1,4 @@
1
+ import { defaultCheckpointState, hasSeenMessage, rememberMessage, type RelayCheckpointState } from "./relay-core/index.js";
2
+ export declare function loadCheckpointState(accountId?: string): Promise<RelayCheckpointState>;
3
+ export declare function saveCheckpointState(state: RelayCheckpointState, accountId?: string): Promise<void>;
4
+ export { defaultCheckpointState, hasSeenMessage, rememberMessage, type RelayCheckpointState, };
@@ -0,0 +1,16 @@
1
+ import path from "node:path";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
3
+ import { defaultCheckpointState, hasSeenMessage, loadCheckpointStateFromFile, rememberMessage, saveCheckpointStateToFile, } from "./relay-core/index.js";
4
+ import { getRelayRuntime } from "./runtime.js";
5
+ import { PLUGIN_ID } from "./config.js";
6
+ function stateFilePath(accountId = DEFAULT_ACCOUNT_ID) {
7
+ const baseDir = getRelayRuntime().state.resolveStateDir();
8
+ return path.join(baseDir, "plugins", PLUGIN_ID, `${accountId}.json`);
9
+ }
10
+ export async function loadCheckpointState(accountId = DEFAULT_ACCOUNT_ID) {
11
+ return await loadCheckpointStateFromFile(stateFilePath(accountId));
12
+ }
13
+ export async function saveCheckpointState(state, accountId = DEFAULT_ACCOUNT_ID) {
14
+ await saveCheckpointStateToFile(stateFilePath(accountId), state);
15
+ }
16
+ export { defaultCheckpointState, hasSeenMessage, rememberMessage, };
@@ -0,0 +1,2 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ export declare function registerRelayCommands(api: OpenClawPluginApi): void;