@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.
- package/api.ts +5 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +88 -0
- package/openclaw.plugin.json +21 -0
- package/package.json +1 -17
- package/runtime-api.ts +2 -0
- package/src/accounts.test.ts +236 -0
- package/src/accounts.ts +96 -0
- package/src/bot.test.ts +520 -0
- package/src/bot.ts +345 -0
- package/src/channel-actions.test.ts +289 -0
- package/src/channel-actions.ts +242 -0
- package/src/channel.test.ts +150 -0
- package/src/channel.ts +256 -0
- package/src/cli-metadata.ts +19 -0
- package/src/cli.test.ts +311 -0
- package/src/cli.ts +341 -0
- package/src/client.test.ts +550 -0
- package/src/client.ts +685 -0
- package/src/connect-code.test.ts +98 -0
- package/src/connect-code.ts +108 -0
- package/src/connect-verify.test.ts +210 -0
- package/src/connect-verify.ts +201 -0
- package/src/dedup.ts +53 -0
- package/src/fc-keepalive.test.ts +200 -0
- package/src/fc-keepalive.ts +184 -0
- package/src/host-pairing-access.ts +48 -0
- package/src/host-sdk.ts +43 -0
- package/src/monitor-secret-file.test.ts +151 -0
- package/src/monitor-ws.test.ts +155 -0
- package/src/monitor.ts +344 -0
- package/src/outbound.ts +92 -0
- package/src/probe.test.ts +191 -0
- package/src/probe.ts +90 -0
- package/src/register.test.ts +158 -0
- package/src/register.ts +116 -0
- package/src/reply-dispatcher.test.ts +419 -0
- package/src/reply-dispatcher.ts +398 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +65 -0
- package/src/send.ts +30 -0
- package/src/setup-surface.ts +388 -0
- package/src/subagent-keepalive-hooks.test.ts +50 -0
- package/src/subagent-keepalive-hooks.ts +47 -0
- package/src/types.ts +162 -0
- package/src/webhook.test.ts +88 -0
- package/src/webhook.ts +51 -0
- package/src/ws.test.ts +341 -0
- package/src/ws.ts +294 -0
- package/dist/api-lhR0QgC_.js +0 -12
- package/dist/api.js +0 -3
- package/dist/channel-plugin-api-YGbkWmVM.js +0 -729
- package/dist/channel-plugin-api.js +0 -2
- package/dist/client-AsD46gcK.js +0 -367
- package/dist/index.js +0 -55
- package/dist/monitor-CHFjRu2J.js +0 -1277
- package/dist/runtime-api.js +0 -2
- 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
|
+
}
|