@vama/openclaw 2026.5.5-4 → 2026.5.5-6

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 (58) hide show
  1. package/api.ts +5 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/index.ts +88 -0
  4. package/openclaw.plugin.json +21 -0
  5. package/package.json +1 -17
  6. package/runtime-api.ts +2 -0
  7. package/src/accounts.test.ts +236 -0
  8. package/src/accounts.ts +96 -0
  9. package/src/bot.test.ts +520 -0
  10. package/src/bot.ts +345 -0
  11. package/src/channel-actions.test.ts +289 -0
  12. package/src/channel-actions.ts +242 -0
  13. package/src/channel.test.ts +150 -0
  14. package/src/channel.ts +256 -0
  15. package/src/cli-metadata.ts +19 -0
  16. package/src/cli.test.ts +311 -0
  17. package/src/cli.ts +341 -0
  18. package/src/client.test.ts +550 -0
  19. package/src/client.ts +685 -0
  20. package/src/connect-code.test.ts +98 -0
  21. package/src/connect-code.ts +108 -0
  22. package/src/connect-verify.test.ts +210 -0
  23. package/src/connect-verify.ts +201 -0
  24. package/src/dedup.ts +53 -0
  25. package/src/fc-keepalive.test.ts +200 -0
  26. package/src/fc-keepalive.ts +184 -0
  27. package/src/host-pairing-access.ts +48 -0
  28. package/src/host-sdk.ts +43 -0
  29. package/src/monitor-secret-file.test.ts +151 -0
  30. package/src/monitor-ws.test.ts +155 -0
  31. package/src/monitor.ts +344 -0
  32. package/src/outbound.ts +92 -0
  33. package/src/probe.test.ts +191 -0
  34. package/src/probe.ts +90 -0
  35. package/src/register.test.ts +158 -0
  36. package/src/register.ts +116 -0
  37. package/src/reply-dispatcher.test.ts +419 -0
  38. package/src/reply-dispatcher.ts +398 -0
  39. package/src/runtime.ts +14 -0
  40. package/src/send.test.ts +65 -0
  41. package/src/send.ts +30 -0
  42. package/src/setup-surface.ts +388 -0
  43. package/src/subagent-keepalive-hooks.test.ts +50 -0
  44. package/src/subagent-keepalive-hooks.ts +47 -0
  45. package/src/types.ts +162 -0
  46. package/src/webhook.test.ts +88 -0
  47. package/src/webhook.ts +51 -0
  48. package/src/ws.test.ts +341 -0
  49. package/src/ws.ts +294 -0
  50. package/dist/api-lhR0QgC_.js +0 -12
  51. package/dist/api.js +0 -3
  52. package/dist/channel-plugin-api-YGbkWmVM.js +0 -729
  53. package/dist/channel-plugin-api.js +0 -2
  54. package/dist/client-AsD46gcK.js +0 -367
  55. package/dist/index.js +0 -55
  56. package/dist/monitor-CHFjRu2J.js +0 -1277
  57. package/dist/runtime-api.js +0 -2
  58. package/dist/runtime-w-1oL50p.js +0 -11
@@ -0,0 +1,242 @@
1
+ import {
2
+ jsonResult,
3
+ readStringOrNumberParam,
4
+ readStringParam,
5
+ } from "openclaw/plugin-sdk/channel-actions";
6
+ import type {
7
+ ChannelMessageActionAdapter,
8
+ ChannelMessageActionName,
9
+ ChannelMessageToolDiscovery,
10
+ } from "openclaw/plugin-sdk/channel-contract";
11
+ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
12
+ import { resolveVamaAccount } from "./accounts.js";
13
+ import { attachmentHintFromExtension, createBotHubClient } from "./client.js";
14
+ import { sendMessageVama } from "./send.js";
15
+
16
+ /**
17
+ * Channel-actions adapter for the shared `message` agent tool.
18
+ *
19
+ * Vama bots previously had exactly one outbound-media mechanism: emit a
20
+ * `MEDIA:/path/to/file` line inside the assistant reply text, which
21
+ * `reply-dispatcher.ts` parses out and uploads via `client.sendFile`. That
22
+ * path still works (see `reply-dispatcher.ts:parseInlineMediaTargets`) and
23
+ * remains the default for normal turn replies — but it is brittle:
24
+ *
25
+ * - The model has to remember the exact format (line-start `MEDIA:` + path)
26
+ * - Code-fenced or quoted variants don't match the line-anchored regex
27
+ * - Long-context decay degrades format-directive following more than
28
+ * tool-call adherence on every frontier model
29
+ * - There is no schema validation; mistakes silently become text replies
30
+ *
31
+ * Registering a `ChannelMessageActionAdapter` here mirrors the pattern used
32
+ * by every other multimedia channel in openclaw (telegram, discord,
33
+ * matrix, etc.). It exposes a structured `message({action: "send", to:
34
+ * "<channelId>", media: "/path/to/file.html", caption: "..."})` tool call.
35
+ * The platform's `message-action-discovery` layer picks this up and
36
+ * publishes the right schema fragments on the agent's `message` tool. The
37
+ * model now has two paths:
38
+ *
39
+ * 1. (Preferred) Call the `message` tool with `media`/`path`/`filePath`
40
+ * — schema-validated, returns a typed `{messageId, channelId}` tool
41
+ * result the model can read.
42
+ * 2. (Fallback) Emit `MEDIA:/path` in plain text — `reply-dispatcher`
43
+ * still parses it.
44
+ *
45
+ * Both paths converge on the same `client.sendFile` underneath and the
46
+ * same BotHub `POST /v1/bot/messages/file` endpoint. Existing dedup,
47
+ * filename preservation, and attachment-hint inference are unchanged.
48
+ */
49
+
50
+ /**
51
+ * Extension-only file-extension helper. Duplicated from `outbound.ts` and
52
+ * `reply-dispatcher.ts` rather than factored out — those copies are
53
+ * intentionally narrow (basename-of-path slice, no normalisation) and we
54
+ * want this adapter to behave identically. If a future refactor centralises
55
+ * extension parsing in the plugin-SDK, all three sites can collapse.
56
+ */
57
+ function extensionOf(path: string): string {
58
+ const idx = path.lastIndexOf(".");
59
+ if (idx < 0 || idx === path.length - 1) {
60
+ return "";
61
+ }
62
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
63
+ if (idx < slash) {
64
+ return "";
65
+ }
66
+ return path.slice(idx + 1).toLowerCase();
67
+ }
68
+
69
+ /**
70
+ * Resolve a media argument (typically the `media`, `path`, or `filePath`
71
+ * field of the tool call) to a local filesystem path that
72
+ * `BotHubClient.sendFile` can read. Mirrors `localPathFromMediaUrl` in
73
+ * `outbound.ts` and `reply-dispatcher.ts` so all three Vama outbound
74
+ * paths agree on which inputs count as "uploadable from disk".
75
+ *
76
+ * Remote URLs (`http://` / `https://`) return `null` — the outbound side
77
+ * doesn't fetch remote bytes yet (no SSRF guard, no allowlist), so the
78
+ * caller falls back to sending the URL inline as text. That matches the
79
+ * fallback `outbound.ts:sendMedia` already implements.
80
+ */
81
+ function localPathFromMediaArg(raw: string): string | null {
82
+ if (raw.startsWith("file://")) {
83
+ return raw.slice("file://".length);
84
+ }
85
+ if (raw.startsWith("/") || raw.startsWith("./")) {
86
+ return raw;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function describeVamaMessageTool({
92
+ cfg,
93
+ accountId,
94
+ }: Parameters<
95
+ NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
96
+ >[0]): ChannelMessageToolDiscovery {
97
+ // The tool-builder treats an empty `actions` list as "channel
98
+ // unavailable for this turn", which is what we want when the account
99
+ // has no botToken/bothubUrl: better to hide the tool than to expose
100
+ // it and have every call error at handleAction time.
101
+ const account = resolveVamaAccount({ cfg, accountId: accountId ?? undefined });
102
+ if (!account.configured) {
103
+ return { actions: [], capabilities: [], schema: null };
104
+ }
105
+ const actions: ChannelMessageActionName[] = ["send"];
106
+ return {
107
+ actions,
108
+ capabilities: [],
109
+ schema: null,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Read the channel/conversation target from the tool params. The model can
115
+ * supply any of `to` / `channelId` / `target` (the last is the routing
116
+ * schema's canonical name). Accept all three so a structured caller can
117
+ * use whichever maps best to their context.
118
+ */
119
+ function readVamaTarget(params: Record<string, unknown>): string {
120
+ const value =
121
+ readStringOrNumberParam(params, "to") ??
122
+ readStringOrNumberParam(params, "channelId") ??
123
+ readStringOrNumberParam(params, "target", { required: true });
124
+ if (!value) {
125
+ throw new Error("Vama send: target channel id required (use `to` or `channelId`)");
126
+ }
127
+ return value;
128
+ }
129
+
130
+ /**
131
+ * Read the media argument the model supplied. The shared message tool
132
+ * exposes three optional fields that can carry an attachment path: `media`
133
+ * is openclaw's canonical name, `path` and `filePath` are the
134
+ * historical aliases. Accept all three so we don't second-guess which
135
+ * name the model picks.
136
+ */
137
+ function readVamaMediaArg(params: Record<string, unknown>): string | undefined {
138
+ return (
139
+ readStringParam(params, "media") ??
140
+ readStringParam(params, "path") ??
141
+ readStringParam(params, "filePath")
142
+ );
143
+ }
144
+
145
+ function readVamaCaption(params: Record<string, unknown>): string | undefined {
146
+ // `caption` matches Telegram's convention; `message` matches the
147
+ // shared message tool's primary text slot. Either is fine — we use
148
+ // whichever was provided as the text body of the outbound send.
149
+ return readStringParam(params, "caption") ?? readStringParam(params, "message");
150
+ }
151
+
152
+ function readVamaParentId(params: Record<string, unknown>): string | undefined {
153
+ return (
154
+ readStringOrNumberParam(params, "replyTo") ??
155
+ readStringOrNumberParam(params, "replyToMessageId") ??
156
+ readStringOrNumberParam(params, "parentId")
157
+ );
158
+ }
159
+
160
+ export const vamaMessageActions: ChannelMessageActionAdapter = {
161
+ describeMessageTool: describeVamaMessageTool,
162
+ resolveExecutionMode: () => "gateway",
163
+ extractToolSend: ({ args }) => extractToolSend(args, "send"),
164
+ handleAction: async ({ action, params, cfg, accountId }) => {
165
+ if (action !== "send") {
166
+ throw new Error(`Unsupported Vama action: ${action}`);
167
+ }
168
+ const account = resolveVamaAccount({ cfg, accountId: accountId ?? undefined });
169
+ if (!account.configured) {
170
+ throw new Error(`Vama account "${account.accountId}" not configured`);
171
+ }
172
+
173
+ const to = readVamaTarget(params);
174
+ const mediaArg = readVamaMediaArg(params);
175
+ const caption = readVamaCaption(params);
176
+ const parentId = readVamaParentId(params);
177
+
178
+ if (!mediaArg) {
179
+ // Text-only send. Caption (or `message`) is the body.
180
+ const result = await sendMessageVama({
181
+ cfg,
182
+ to,
183
+ text: caption ?? "",
184
+ accountId: account.accountId,
185
+ parentId,
186
+ });
187
+ return jsonResult({
188
+ ok: true,
189
+ messageId: result.messageId,
190
+ channelId: result.channelId,
191
+ });
192
+ }
193
+
194
+ const localPath = localPathFromMediaArg(mediaArg);
195
+ if (!localPath) {
196
+ // Remote URL: outbound side can't fetch remote bytes safely yet
197
+ // (no SSRF guard, no allowlist). Fall back to sending the URL
198
+ // inline as text so the user at least gets a clickable link.
199
+ // Surfacing `warning` in the tool result tells the agent its
200
+ // attempt was downgraded — useful when the model decides whether
201
+ // to retry with a local path.
202
+ const text = caption?.trim() ? `${caption.trim()}\n${mediaArg}` : mediaArg;
203
+ const result = await sendMessageVama({
204
+ cfg,
205
+ to,
206
+ text,
207
+ accountId: account.accountId,
208
+ parentId,
209
+ });
210
+ return jsonResult({
211
+ ok: true,
212
+ messageId: result.messageId,
213
+ channelId: result.channelId,
214
+ warning: "remote_media_unsupported_sent_as_link",
215
+ });
216
+ }
217
+
218
+ // Local workspace artefact — upload via BotHub multipart endpoint.
219
+ // Note on sandbox enforcement: `mediaLocalRoots` IS available on the
220
+ // action context and Telegram threads it into `loadWebMedia` for
221
+ // sandbox-rooted reads. We deliberately don't enforce it here yet:
222
+ // the parallel `MEDIA:` inline-directive path in
223
+ // `reply-dispatcher.ts` doesn't enforce it either, and adding the
224
+ // check ONLY on the tool-call path would create asymmetric behaviour
225
+ // (a path that works through one channel and fails through the
226
+ // other). Sandbox enforcement is tracked as a separate follow-up
227
+ // covering both paths together.
228
+ const client = createBotHubClient(account);
229
+ const result = await client.sendFile({
230
+ channelId: to,
231
+ path: localPath,
232
+ caption: caption?.trim() || undefined,
233
+ attachmentType: attachmentHintFromExtension(extensionOf(localPath)),
234
+ parentId,
235
+ });
236
+ return jsonResult({
237
+ ok: true,
238
+ messageId: result.message_id,
239
+ channelId: result.channel_id,
240
+ });
241
+ },
242
+ };
@@ -0,0 +1,150 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
2
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/vama";
3
+ import { describe, expect, it } from "vitest";
4
+ import { vamaPlugin } from "./channel.js";
5
+
6
+ function makeCfg(vama?: Record<string, unknown>): ClawdbotConfig {
7
+ return { channels: { vama } } as ClawdbotConfig;
8
+ }
9
+
10
+ describe("vamaPlugin", () => {
11
+ it("has correct meta", () => {
12
+ expect(vamaPlugin.id).toBe("vama");
13
+ expect(vamaPlugin.meta.label).toBe("Vama");
14
+ });
15
+
16
+ it("reports correct capabilities", () => {
17
+ expect(vamaPlugin.capabilities.chatTypes).toEqual(["direct"]);
18
+ expect(vamaPlugin.capabilities.threads).toBe(true);
19
+ // Media outbound: enabled in 2026.4.15-13 — `outbound.sendMedia` now uploads
20
+ // local workspace artefacts (e.g. `MEDIA:/home/app/.openclaw/...`)
21
+ // through BotHub's `/v1/bot/messages/file` endpoint instead of stripping
22
+ // them. See `outbound.ts` and `reply-dispatcher.ts` for the full path.
23
+ expect(vamaPlugin.capabilities.media).toBe(true);
24
+ expect(vamaPlugin.capabilities.reactions).toBe(false);
25
+ expect(vamaPlugin.capabilities.edit).toBe(false);
26
+ expect(vamaPlugin.capabilities.reply).toBe(true);
27
+ });
28
+
29
+ it("registers a message-actions adapter so the shared `message` tool is exposed", () => {
30
+ // Regression guard: dropping `actions:` from the plugin object collapses
31
+ // the structured outbound-file path back to MEDIA:-only, which is what
32
+ // caused bryan-talos to silently never attach files. Keep this assertion
33
+ // tight and minimal — the adapter's own behaviour is covered in
34
+ // `channel-actions.test.ts`.
35
+ expect(vamaPlugin.actions).toBeDefined();
36
+ expect(typeof vamaPlugin.actions?.describeMessageTool).toBe("function");
37
+ const discovery = vamaPlugin.actions!.describeMessageTool({
38
+ cfg: makeCfg({ enabled: true, botToken: "tok", bothubUrl: "https://b.example" }),
39
+ accountId: DEFAULT_ACCOUNT_ID,
40
+ });
41
+ expect(discovery?.actions).toContain("send");
42
+ });
43
+
44
+ describe("config.listAccountIds", () => {
45
+ it("returns default when no accounts configured", () => {
46
+ const ids = vamaPlugin.config.listAccountIds(makeCfg({ enabled: true }));
47
+ expect(ids).toEqual([DEFAULT_ACCOUNT_ID]);
48
+ });
49
+ });
50
+
51
+ describe("config.setAccountEnabled", () => {
52
+ it("sets enabled on default account", () => {
53
+ const cfg = makeCfg({ botToken: "tok" });
54
+ const next = vamaPlugin.config.setAccountEnabled!({
55
+ cfg,
56
+ accountId: DEFAULT_ACCOUNT_ID,
57
+ enabled: false,
58
+ });
59
+ expect((next.channels?.vama as Record<string, unknown>)?.enabled).toBe(false);
60
+ });
61
+
62
+ it("sets enabled on named account", () => {
63
+ const cfg = makeCfg({ accounts: { staging: { botToken: "tok" } } });
64
+ const next = vamaPlugin.config.setAccountEnabled!({
65
+ cfg,
66
+ accountId: "staging",
67
+ enabled: false,
68
+ });
69
+ const accounts = (next.channels?.vama as Record<string, unknown>)?.accounts as Record<
70
+ string,
71
+ Record<string, unknown>
72
+ >;
73
+ expect(accounts?.staging?.enabled).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("config.deleteAccount", () => {
78
+ it("removes vama config for default account", () => {
79
+ const cfg = makeCfg({ enabled: true, botToken: "tok" });
80
+ const next = vamaPlugin.config.deleteAccount!({ cfg, accountId: DEFAULT_ACCOUNT_ID });
81
+ expect((next.channels as Record<string, unknown>)?.vama).toBeUndefined();
82
+ });
83
+
84
+ it("removes named account from accounts map", () => {
85
+ const cfg = makeCfg({
86
+ enabled: true,
87
+ accounts: { staging: { botToken: "tok" }, prod: { botToken: "tok2" } },
88
+ });
89
+ const next = vamaPlugin.config.deleteAccount!({ cfg, accountId: "staging" });
90
+ const vama = next.channels?.vama as Record<string, unknown>;
91
+ const accounts = vama?.accounts as Record<string, unknown> | undefined;
92
+ expect(accounts?.staging).toBeUndefined();
93
+ expect(accounts?.prod).toBeDefined();
94
+ });
95
+ });
96
+
97
+ describe("config.isConfigured", () => {
98
+ it("returns true when configured", () => {
99
+ const cfg = makeCfg({ enabled: true, botToken: "tok", bothubUrl: "https://b.com" });
100
+ const account = vamaPlugin.config.resolveAccount(cfg, DEFAULT_ACCOUNT_ID);
101
+ expect(vamaPlugin.config.isConfigured!(account, cfg)).toBe(true);
102
+ });
103
+
104
+ it("returns false when not configured", () => {
105
+ const cfg = makeCfg({ enabled: true });
106
+ const account = vamaPlugin.config.resolveAccount(cfg, DEFAULT_ACCOUNT_ID);
107
+ expect(vamaPlugin.config.isConfigured!(account, cfg)).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe("security.collectWarnings", () => {
112
+ it("warns when dmPolicy is open", () => {
113
+ const cfg = makeCfg({ enabled: true, botToken: "tok", bothubUrl: "https://b.com" });
114
+ const account = vamaPlugin.config.resolveAccount(cfg, DEFAULT_ACCOUNT_ID);
115
+ const warnings = vamaPlugin.security!.collectWarnings!({
116
+ cfg,
117
+ accountId: DEFAULT_ACCOUNT_ID,
118
+ account,
119
+ });
120
+ expect(warnings).toHaveLength(1);
121
+ expect((warnings as string[])[0]).toMatch(/dmPolicy="open"/);
122
+ });
123
+
124
+ it("does not warn when dmPolicy is pairing", () => {
125
+ const cfg = makeCfg({
126
+ enabled: true,
127
+ botToken: "tok",
128
+ bothubUrl: "https://b.com",
129
+ dmPolicy: "pairing",
130
+ });
131
+ const account = vamaPlugin.config.resolveAccount(cfg, DEFAULT_ACCOUNT_ID);
132
+ const warnings = vamaPlugin.security!.collectWarnings!({
133
+ cfg,
134
+ accountId: DEFAULT_ACCOUNT_ID,
135
+ account,
136
+ });
137
+ expect(warnings).toEqual([]);
138
+ });
139
+ });
140
+
141
+ describe("pairing", () => {
142
+ it("has idLabel set to vamaUserId", () => {
143
+ expect(vamaPlugin.pairing!.idLabel).toBe("vamaUserId");
144
+ });
145
+
146
+ it("normalizes allowlist entries", () => {
147
+ expect(vamaPlugin.pairing!.normalizeAllowEntry!(" user_123 ")).toBe("user_123");
148
+ });
149
+ });
150
+ });
package/src/channel.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { resolveVamaAccount, listVamaAccountIds, resolveDefaultVamaAccountId } from "./accounts.js";
2
+ import { vamaMessageActions } from "./channel-actions.js";
3
+ import type { ChannelPlugin, ClawdbotConfig } from "./host-sdk.js";
4
+ import {
5
+ buildBaseChannelStatusSummary,
6
+ createDefaultChannelRuntimeState,
7
+ DEFAULT_ACCOUNT_ID,
8
+ PAIRING_APPROVED_MESSAGE,
9
+ } from "./host-sdk.js";
10
+ import { monitorVamaProvider } from "./monitor.js";
11
+ import { vamaOutbound } from "./outbound.js";
12
+ import { probeVama } from "./probe.js";
13
+ import { vamaSetupWizard } from "./setup-surface.js";
14
+ import type { ResolvedVamaAccount, VamaConfig } from "./types.js";
15
+
16
+ const meta = {
17
+ id: "vama",
18
+ label: "Vama",
19
+ selectionLabel: "Vama",
20
+ docsPath: "/channels/vama",
21
+ docsLabel: "vama",
22
+ blurb: "Vama chat integration via BotHub.",
23
+ order: 80,
24
+ };
25
+
26
+ export const vamaPlugin: ChannelPlugin<ResolvedVamaAccount> = {
27
+ id: "vama",
28
+ meta: { ...meta },
29
+ pairing: {
30
+ idLabel: "vamaUserId",
31
+ normalizeAllowEntry: (entry) => entry.trim(),
32
+ notifyApproval: async ({ cfg, id }) => {
33
+ // Send approval notification directly via BotHub user_id targeting.
34
+ const account = resolveVamaAccount({ cfg });
35
+ if (!account.configured) {
36
+ return;
37
+ }
38
+ try {
39
+ const { createBotHubClient } = await import("./client.js");
40
+ const client = createBotHubClient(account);
41
+ await client.sendMessage({ userId: id, text: PAIRING_APPROVED_MESSAGE });
42
+ } catch {
43
+ // Best-effort — user will see the effect when they next message the bot.
44
+ }
45
+ },
46
+ },
47
+ capabilities: {
48
+ chatTypes: ["direct"],
49
+ polls: false,
50
+ threads: true,
51
+ media: true,
52
+ reactions: false,
53
+ edit: false,
54
+ reply: true,
55
+ },
56
+ reload: { configPrefixes: ["channels.vama"] },
57
+ configSchema: {
58
+ schema: {
59
+ type: "object",
60
+ additionalProperties: false,
61
+ properties: {
62
+ enabled: { type: "boolean" },
63
+ botToken: { type: "string" },
64
+ webhookSecret: { type: "string" },
65
+ webhookSecretFile: { type: "string" },
66
+ bothubUrl: { type: "string" },
67
+ webhookPath: { type: "string" },
68
+ webhookPort: { type: "integer", minimum: 1 },
69
+ webhookHost: { type: "string" },
70
+ webhookUrl: { type: "string" },
71
+ transport: { type: "string", enum: ["auto", "webhook", "websocket"] },
72
+ dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
73
+ allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
74
+ textChunkLimit: { type: "integer", minimum: 1 },
75
+ accounts: {
76
+ type: "object",
77
+ additionalProperties: {
78
+ type: "object",
79
+ properties: {
80
+ enabled: { type: "boolean" },
81
+ name: { type: "string" },
82
+ botToken: { type: "string" },
83
+ webhookSecret: { type: "string" },
84
+ webhookSecretFile: { type: "string" },
85
+ bothubUrl: { type: "string" },
86
+ webhookPath: { type: "string" },
87
+ webhookPort: { type: "integer", minimum: 1 },
88
+ webhookHost: { type: "string" },
89
+ webhookUrl: { type: "string" },
90
+ transport: { type: "string", enum: ["auto", "webhook", "websocket"] },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ },
97
+ config: {
98
+ listAccountIds: (cfg) => listVamaAccountIds(cfg),
99
+ resolveAccount: (cfg, accountId) => resolveVamaAccount({ cfg, accountId }),
100
+ defaultAccountId: (cfg) => resolveDefaultVamaAccountId(cfg),
101
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
102
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
103
+ if (isDefault) {
104
+ return {
105
+ ...cfg,
106
+ channels: {
107
+ ...cfg.channels,
108
+ vama: { ...cfg.channels?.vama, enabled },
109
+ },
110
+ };
111
+ }
112
+ const vamaCfg = cfg.channels?.vama as VamaConfig | undefined;
113
+ return {
114
+ ...cfg,
115
+ channels: {
116
+ ...cfg.channels,
117
+ vama: {
118
+ ...vamaCfg,
119
+ accounts: {
120
+ ...vamaCfg?.accounts,
121
+ [accountId]: { ...vamaCfg?.accounts?.[accountId], enabled },
122
+ },
123
+ },
124
+ },
125
+ };
126
+ },
127
+ deleteAccount: ({ cfg, accountId }) => {
128
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
129
+ if (isDefault) {
130
+ const next = { ...cfg } as ClawdbotConfig;
131
+ const nextChannels = { ...cfg.channels };
132
+ delete (nextChannels as Record<string, unknown>).vama;
133
+ if (Object.keys(nextChannels).length > 0) {
134
+ next.channels = nextChannels;
135
+ } else {
136
+ delete next.channels;
137
+ }
138
+ return next;
139
+ }
140
+ const vamaCfg = cfg.channels?.vama as VamaConfig | undefined;
141
+ const accounts = { ...vamaCfg?.accounts };
142
+ delete accounts[accountId];
143
+ return {
144
+ ...cfg,
145
+ channels: {
146
+ ...cfg.channels,
147
+ vama: {
148
+ ...vamaCfg,
149
+ accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
150
+ },
151
+ },
152
+ };
153
+ },
154
+ isConfigured: (account) => account.configured,
155
+ describeAccount: (account) => ({
156
+ accountId: account.accountId,
157
+ enabled: account.enabled,
158
+ configured: account.configured,
159
+ name: account.name,
160
+ bothubUrl: account.bothubUrl,
161
+ }),
162
+ resolveAllowFrom: ({ cfg, accountId }) => {
163
+ const account = resolveVamaAccount({ cfg, accountId });
164
+ return (account.config?.allowFrom ?? []).map((entry) => String(entry));
165
+ },
166
+ formatAllowFrom: ({ allowFrom }) =>
167
+ allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
168
+ },
169
+ security: {
170
+ collectWarnings: ({ cfg, accountId }) => {
171
+ const account = resolveVamaAccount({ cfg, accountId });
172
+ const dmPolicy = account.config?.dmPolicy ?? "open";
173
+ if (dmPolicy !== "open") {
174
+ return [];
175
+ }
176
+ return [
177
+ `- Vama[${account.accountId}]: dmPolicy="open" allows any user to DM the bot. Set channels.vama.dmPolicy="allowlist" + channels.vama.allowFrom to restrict senders.`,
178
+ ];
179
+ },
180
+ },
181
+ setup: {
182
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
183
+ applyAccountConfig: ({ cfg, accountId }) => {
184
+ const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
185
+ if (isDefault) {
186
+ return {
187
+ ...cfg,
188
+ channels: {
189
+ ...cfg.channels,
190
+ vama: { ...cfg.channels?.vama, enabled: true },
191
+ },
192
+ };
193
+ }
194
+ const vamaCfg = cfg.channels?.vama as VamaConfig | undefined;
195
+ return {
196
+ ...cfg,
197
+ channels: {
198
+ ...cfg.channels,
199
+ vama: {
200
+ ...vamaCfg,
201
+ accounts: {
202
+ ...vamaCfg?.accounts,
203
+ [accountId]: { ...vamaCfg?.accounts?.[accountId], enabled: true },
204
+ },
205
+ },
206
+ },
207
+ };
208
+ },
209
+ },
210
+ setupWizard: vamaSetupWizard,
211
+ outbound: vamaOutbound,
212
+ actions: vamaMessageActions,
213
+ status: {
214
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
215
+ buildChannelSummary: ({ snapshot }) => ({
216
+ ...buildBaseChannelStatusSummary(snapshot),
217
+ port: snapshot.port ?? null,
218
+ probe: snapshot.probe,
219
+ lastProbeAt: snapshot.lastProbeAt ?? null,
220
+ }),
221
+ probeAccount: async ({ account }) => await probeVama(account),
222
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
223
+ accountId: account.accountId,
224
+ enabled: account.enabled,
225
+ configured: account.configured,
226
+ name: account.name,
227
+ bothubUrl: account.bothubUrl,
228
+ running: runtime?.running ?? false,
229
+ lastStartAt: runtime?.lastStartAt ?? null,
230
+ lastStopAt: runtime?.lastStopAt ?? null,
231
+ lastError: runtime?.lastError ?? null,
232
+ port: runtime?.port ?? null,
233
+ probe,
234
+ }),
235
+ },
236
+ gateway: {
237
+ startAccount: async (ctx) => {
238
+ // NOTE: import statically (not via `await import("./monitor.js")`).
239
+ // A dynamic import here is resolved at runtime by Node's native ESM
240
+ // loader, which bypasses Jiti and produces a *separate* instance of
241
+ // ./runtime.js (transitively imported by monitor->bot). The channel
242
+ // entry's setVamaRuntime() then writes to a different module instance
243
+ // than the one bot.ts reads from, causing "Vama runtime not initialized".
244
+ const account = resolveVamaAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
245
+ const port = account.config?.webhookPort ?? null;
246
+ ctx.setStatus({ accountId: ctx.accountId, port });
247
+ ctx.log?.info(`starting vama[${ctx.accountId}]`);
248
+ return monitorVamaProvider({
249
+ config: ctx.cfg,
250
+ runtime: ctx.runtime,
251
+ abortSignal: ctx.abortSignal,
252
+ accountId: ctx.accountId,
253
+ });
254
+ },
255
+ },
256
+ };
@@ -0,0 +1,19 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
2
+
3
+ export function registerVamaCliMetadata(api: OpenClawPluginApi) {
4
+ api.registerCli(
5
+ async ({ program }) => {
6
+ const { registerVamaCli } = await import("./cli.js");
7
+ registerVamaCli({ program });
8
+ },
9
+ {
10
+ descriptors: [
11
+ {
12
+ name: "vama",
13
+ description: "Connect this OpenClaw to Vama and manage the Vama channel",
14
+ hasSubcommands: true,
15
+ },
16
+ ],
17
+ },
18
+ );
19
+ }