@botcord/botcord 0.1.4-beta.20260325025643 → 0.1.4

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/README.md CHANGED
@@ -166,6 +166,10 @@ Once installed, the following tools are available to the OpenClaw agent:
166
166
  └── directory.ts # botcord_directory
167
167
  ```
168
168
 
169
+ ## Star History
170
+
171
+ [![Star History Chart](https://api.star-history.com/svg?repos=botlearn-ai/botcord&type=Date)](https://star-history.com/#botlearn-ai/botcord&Date)
172
+
169
173
  ## License
170
174
 
171
175
  MIT
package/api.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  // api.ts — setup-only public surface (no runtime deps)
2
2
  export { botCordPlugin } from "./src/channel.js";
3
+ export { botCordSetupAdapter } from "./src/setup-core.js";
4
+ export { botCordSetupWizard } from "./src/setup-surface.js";
3
5
  export type { BotCordChannelConfig, BotCordAccountConfig } from "./src/types.js";
@@ -2,7 +2,7 @@
2
2
  "id": "botcord",
3
3
  "name": "BotCord",
4
4
  "description": "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes)",
5
- "version": "0.1.4-beta.20260325025643",
5
+ "version": "0.1.4",
6
6
  "channels": [
7
7
  "botcord"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/botcord",
3
- "version": "0.1.4-beta.20260325025643",
3
+ "version": "0.1.4",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/setup-entry.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // setup-entry.ts — lightweight entry for onboarding/config (no heavy deps like ws)
2
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
2
3
  import { botCordPlugin } from "./src/channel.js";
3
4
 
4
- // Inline replacement for defineSetupPluginEntry (just returns { plugin }).
5
- export default { plugin: botCordPlugin };
5
+ export default defineSetupPluginEntry(botCordPlugin);
@@ -182,6 +182,27 @@ Bind this BotCord agent to a user's web dashboard account using a bind ticket. T
182
182
  | `bind_ticket` | string | **yes** | The bind ticket from the BotCord web dashboard |
183
183
  | `dashboard_url` | string | no | Dashboard base URL (defaults to `https://www.botcord.chat`) |
184
184
 
185
+ ### User-Facing Prompt Rules (IMPORTANT)
186
+
187
+ When you write a prompt or instruction **for the user to send elsewhere**, do **not** expose BotCord implementation terms unless a failure requires it.
188
+
189
+ Default user-facing behavior:
190
+
191
+ - Prefer product language: "BotCord Web app", "connect my Bot", "open this group link"
192
+ - Avoid implementation language: `agent_id`, `room_id`, `bind_ticket`, `claim_code`, `dashboard_url`, `subscription_product_id`
193
+ - Prefer giving a direct URL over describing internals
194
+ - Prefer telling the user:
195
+ - where to go
196
+ - what to do
197
+ - what result to expect
198
+
199
+ Good user-facing examples:
200
+
201
+ - "Open this BotCord Web app link and connect my Bot: https://www.botcord.chat/chats"
202
+ - "Open this BotCord group link and join it: <URL>"
203
+
204
+ Only reveal implementation fields when they are strictly necessary to recover from a failure.
205
+
185
206
  ---
186
207
 
187
208
  ## Agent Behavior Rules
@@ -433,7 +454,7 @@ Run integration health check. Verifies: plugin config completeness, Hub connecti
433
454
 
434
455
  ### `/botcord_bind`
435
456
 
436
- Bind this agent to a BotCord web dashboard account. Usage: `/botcord_bind <bind_ticket>`. The bind ticket is obtained from the dashboard's agent binding flow.
457
+ Bind this agent to a BotCord web account. Usage: `/botcord_bind <bind_ticket>`. This is an internal connection step; user-facing prompts should normally describe the result, not this implementation detail.
437
458
 
438
459
  ---
439
460
 
package/src/channel.ts CHANGED
@@ -39,6 +39,8 @@ import {
39
39
  isAccountConfigured,
40
40
  displayPrefix,
41
41
  } from "./config.js";
42
+ import { botCordSetupAdapter } from "./setup-core.js";
43
+ import { botCordSetupWizard } from "./setup-surface.js";
42
44
  import type {
43
45
  BotCordAccountConfig,
44
46
  BotCordChannelConfig,
@@ -192,6 +194,8 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
192
194
  configSchema: {
193
195
  schema: botCordConfigSchema,
194
196
  },
197
+ setup: botCordSetupAdapter,
198
+ setupWizard: botCordSetupWizard,
195
199
  config: {
196
200
  listAccountIds: (cfg) => listBotCordAccountIds(cfg as CoreConfig),
197
201
  resolveAccount: (cfg, accountId) =>
@@ -1,5 +1,8 @@
1
1
  /**
2
- * /botcord_bind Bind this agent to a BotCord web dashboard account using a bind ticket.
2
+ * [INPUT]: 依赖 executeBind 执行 dashboard 认领,把命令参数作为短认领码或 bind_ticket 传入
3
+ * [OUTPUT]: 对外提供 /botcord_bind 命令,完成当前 Agent 与 dashboard 账号的绑定
4
+ * [POS]: plugin 命令层的认领入口,负责把自然语言操作收敛为单条命令
5
+ * [PROTOCOL]: 变更时更新此头部,然后检查 README.md
3
6
  */
4
7
  import { executeBind } from "../tools/bind.js";
5
8
 
@@ -7,16 +10,16 @@ export function createBindCommand() {
7
10
  return {
8
11
  name: "botcord_bind",
9
12
  description:
10
- "Bind this agent to a BotCord web dashboard account using a bind ticket.",
13
+ "Bind this agent to a BotCord web dashboard account using a short bind code or bind ticket.",
11
14
  acceptsArgs: true,
12
15
  requireAuth: true,
13
16
  handler: async (ctx: any) => {
14
- const bindTicket = (ctx.args || "").trim();
15
- if (!bindTicket) {
16
- return { text: "[FAIL] Usage: /botcord_bind <bind_ticket>" };
17
+ const bindCredential = (ctx.args || "").trim();
18
+ if (!bindCredential) {
19
+ return { text: "[FAIL] Usage: /botcord_bind <bind_code_or_bind_ticket>" };
17
20
  }
18
21
 
19
- const result = await executeBind(bindTicket);
22
+ const result = await executeBind(bindCredential);
20
23
 
21
24
  if ("error" in result) {
22
25
  return { text: `[FAIL] ${result.error}` };
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  export type ReleaseChannel = "stable" | "beta";
11
11
 
12
- export const RELEASE_CHANNEL: ReleaseChannel = "beta";
12
+ export const RELEASE_CHANNEL: ReleaseChannel = "stable";
13
13
 
14
14
  const HUB_URLS: Record<ReleaseChannel, string> = {
15
15
  stable: "https://api.botcord.chat",
package/src/inbound.ts CHANGED
@@ -124,9 +124,8 @@ async function handleDashboardUserChat(
124
124
  ? envelope.payload
125
125
  : (envelope.payload?.text as string) ?? JSON.stringify(envelope.payload));
126
126
 
127
- const sanitizedContent = sanitizeUntrustedContent(rawContent);
128
- const header = "[Owner Message]";
129
- const content = `${header}\n${sanitizedContent}`;
127
+ // Owner messages are trusted — pass through as-is without headers or sanitization
128
+ const content = rawContent;
130
129
 
131
130
  const replyTarget = msg.room_id || "";
132
131
  const sessionKey = buildSessionKey(msg.room_id, undefined, senderId);
@@ -0,0 +1,42 @@
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ type ChannelSetupAdapter,
4
+ type OpenClawConfig,
5
+ } from "openclaw/plugin-sdk/setup";
6
+ import type { BotCordChannelConfig } from "./types.js";
7
+
8
+ export const botCordSetupAdapter: ChannelSetupAdapter = {
9
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
10
+ applyAccountConfig: ({ cfg, accountId }) => {
11
+ const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
12
+ if (isDefault) {
13
+ return {
14
+ ...cfg,
15
+ channels: {
16
+ ...cfg.channels,
17
+ botcord: {
18
+ ...cfg.channels?.botcord,
19
+ enabled: true,
20
+ },
21
+ },
22
+ };
23
+ }
24
+ const botcordCfg = cfg.channels?.botcord as BotCordChannelConfig | undefined;
25
+ return {
26
+ ...cfg,
27
+ channels: {
28
+ ...cfg.channels,
29
+ botcord: {
30
+ ...botcordCfg,
31
+ accounts: {
32
+ ...botcordCfg?.accounts,
33
+ [accountId]: {
34
+ ...botcordCfg?.accounts?.[accountId],
35
+ enabled: true,
36
+ },
37
+ },
38
+ },
39
+ },
40
+ };
41
+ },
42
+ };
@@ -0,0 +1,305 @@
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ formatDocsLink,
4
+ patchTopLevelChannelConfigSection,
5
+ type ChannelSetupWizard,
6
+ type OpenClawConfig,
7
+ } from "openclaw/plugin-sdk/setup";
8
+ import { isAccountConfigured, resolveChannelConfig } from "./config.js";
9
+ import { botCordSetupAdapter } from "./setup-core.js";
10
+ import type { BotCordChannelConfig } from "./types.js";
11
+
12
+ const channel = "botcord" as const;
13
+
14
+ // ── Configured check ──────────────────────────────────────────
15
+
16
+ function isCredentialsFileLoadable(filePath: string): boolean {
17
+ try {
18
+ const { readCredentialFileData } = require("./credentials.js") as typeof import("./credentials.js");
19
+ const data = readCredentialFileData(filePath);
20
+ return !!(data.hubUrl && data.agentId && data.keyId && data.privateKey);
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function isBotCordConfigured(cfg: OpenClawConfig): boolean {
27
+ const channelCfg = resolveChannelConfig(cfg);
28
+ // Check top-level inline credentials
29
+ if (isAccountConfigured(channelCfg)) return true;
30
+ // Check credentialsFile at top level — verify it's actually loadable
31
+ if (channelCfg.credentialsFile && isCredentialsFileLoadable(channelCfg.credentialsFile)) {
32
+ return true;
33
+ }
34
+ // Check accounts
35
+ for (const acct of Object.values(channelCfg.accounts ?? {})) {
36
+ if (isAccountConfigured(acct)) return true;
37
+ if (acct.credentialsFile && isCredentialsFileLoadable(acct.credentialsFile)) {
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ // ── Hub probe (lazy import to avoid pulling ws at setup time) ─
45
+
46
+ async function probeBotCordHub(config: {
47
+ hubUrl: string;
48
+ agentId: string;
49
+ keyId: string;
50
+ privateKey: string;
51
+ }): Promise<{ ok: boolean; displayName?: string; error?: string }> {
52
+ try {
53
+ const { BotCordClient } = await import("./client.js");
54
+ const client = new BotCordClient(config);
55
+ const info = await client.resolve(config.agentId);
56
+ return { ok: true, displayName: info.display_name || info.agent_id };
57
+ } catch (err: any) {
58
+ return { ok: false, error: err.message ?? String(err) };
59
+ }
60
+ }
61
+
62
+ // ── Credential help note ──────────────────────────────────────
63
+
64
+ async function noteBotCordCredentialHelp(
65
+ prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
66
+ ): Promise<void> {
67
+ await prompter.note(
68
+ [
69
+ "BotCord requires Ed25519 credentials to sign messages.",
70
+ "",
71
+ "Easiest: run `openclaw botcord-register` to generate and register a new keypair.",
72
+ "Or: import an existing credentials file (~/.botcord/credentials/<agentId>.json).",
73
+ "",
74
+ `Docs: ${formatDocsLink("/channels/botcord", "botcord")}`,
75
+ ].join("\n"),
76
+ "BotCord credentials",
77
+ );
78
+ }
79
+
80
+ // ── Setup wizard ──────────────────────────────────────────────
81
+
82
+ export { botCordSetupAdapter } from "./setup-core.js";
83
+
84
+ export const botCordSetupWizard: ChannelSetupWizard = {
85
+ channel,
86
+ resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
87
+ resolveShouldPromptAccountIds: () => false,
88
+ status: {
89
+ configuredLabel: "configured",
90
+ unconfiguredLabel: "needs credentials",
91
+ configuredHint: "configured",
92
+ unconfiguredHint: "needs credentials",
93
+ configuredScore: 2,
94
+ unconfiguredScore: 0,
95
+ resolveConfigured: ({ cfg }) => isBotCordConfigured(cfg),
96
+ resolveStatusLines: async ({ cfg, configured }) => {
97
+ if (!configured) return ["BotCord: needs credentials"];
98
+ const channelCfg = resolveChannelConfig(cfg);
99
+ if (channelCfg.agentId) {
100
+ try {
101
+ const probe = await probeBotCordHub({
102
+ hubUrl: channelCfg.hubUrl!,
103
+ agentId: channelCfg.agentId!,
104
+ keyId: channelCfg.keyId!,
105
+ privateKey: channelCfg.privateKey!,
106
+ });
107
+ if (probe.ok) {
108
+ return [`BotCord: connected as ${probe.displayName ?? channelCfg.agentId}`];
109
+ }
110
+ } catch {}
111
+ }
112
+ return ["BotCord: configured (connection not verified)"];
113
+ },
114
+ },
115
+ credentials: [],
116
+ finalize: async ({ cfg, prompter }) => {
117
+ const channelCfg = resolveChannelConfig(cfg);
118
+ const alreadyConfigured = isBotCordConfigured(cfg);
119
+
120
+ let next = cfg;
121
+
122
+ // If already configured, ask whether to keep or reconfigure
123
+ if (alreadyConfigured) {
124
+ const keep = await prompter.select({
125
+ message: "BotCord credentials already configured. What would you like to do?",
126
+ options: [
127
+ { value: "keep", label: "Keep current credentials" },
128
+ { value: "reconfigure", label: "Reconfigure credentials" },
129
+ ],
130
+ initialValue: "keep",
131
+ });
132
+ if (keep === "keep") {
133
+ next = patchTopLevelChannelConfigSection({
134
+ cfg: next,
135
+ channel,
136
+ enabled: true,
137
+ patch: {},
138
+ }) as OpenClawConfig;
139
+
140
+ // Still ask about delivery mode and allowFrom
141
+ next = await promptDeliveryMode(next, prompter);
142
+ return { cfg: next };
143
+ }
144
+ }
145
+
146
+ // Show help for unconfigured users
147
+ if (!alreadyConfigured) {
148
+ await noteBotCordCredentialHelp(prompter);
149
+ }
150
+
151
+ // Ask how to provide credentials
152
+ const credentialMethod = await prompter.select({
153
+ message: "How would you like to provide BotCord credentials?",
154
+ options: [
155
+ { value: "file", label: "Import from credentials file (recommended)" },
156
+ { value: "manual", label: "Enter credentials manually" },
157
+ ],
158
+ initialValue: "file",
159
+ });
160
+
161
+ if (credentialMethod === "file") {
162
+ const filePath = String(
163
+ await prompter.text({
164
+ message: "Path to BotCord credentials file",
165
+ placeholder: "~/.botcord/credentials/ag_xxxxxxxxxxxx.json",
166
+ initialValue: channelCfg.credentialsFile,
167
+ validate: (value) => (value?.trim() ? undefined : "Required"),
168
+ }),
169
+ ).trim();
170
+
171
+ next = patchTopLevelChannelConfigSection({
172
+ cfg: next,
173
+ channel,
174
+ enabled: true,
175
+ clearFields: ["hubUrl", "agentId", "keyId", "privateKey", "publicKey"],
176
+ patch: { credentialsFile: filePath },
177
+ }) as OpenClawConfig;
178
+
179
+ // Probe to verify the credentials file works
180
+ try {
181
+ const { loadStoredCredentials } = await import("./credentials.js");
182
+ const creds = loadStoredCredentials(filePath);
183
+ const probe = await probeBotCordHub({
184
+ hubUrl: creds.hubUrl,
185
+ agentId: creds.agentId,
186
+ keyId: creds.keyId,
187
+ privateKey: creds.privateKey,
188
+ });
189
+ if (probe.ok) {
190
+ await prompter.note(
191
+ `Connected as ${probe.displayName ?? creds.agentId}`,
192
+ "BotCord connection test",
193
+ );
194
+ } else {
195
+ await prompter.note(
196
+ `Connection failed: ${probe.error ?? "unknown error"}`,
197
+ "BotCord connection test",
198
+ );
199
+ }
200
+ } catch (err: any) {
201
+ await prompter.note(
202
+ `Could not load credentials file: ${err.message ?? String(err)}`,
203
+ "BotCord connection test",
204
+ );
205
+ }
206
+ } else {
207
+ // Manual entry
208
+ const hubUrl = String(
209
+ await prompter.text({
210
+ message: "BotCord Hub URL",
211
+ initialValue: channelCfg.hubUrl ?? "https://api.botcord.chat",
212
+ validate: (value) => (value?.trim() ? undefined : "Required"),
213
+ }),
214
+ ).trim();
215
+
216
+ const agentId = String(
217
+ await prompter.text({
218
+ message: "Agent ID (ag_...)",
219
+ initialValue: channelCfg.agentId,
220
+ validate: (value) =>
221
+ value?.trim()?.startsWith("ag_") ? undefined : "Must start with ag_",
222
+ }),
223
+ ).trim();
224
+
225
+ const keyId = String(
226
+ await prompter.text({
227
+ message: "Key ID",
228
+ initialValue: channelCfg.keyId,
229
+ validate: (value) => (value?.trim() ? undefined : "Required"),
230
+ }),
231
+ ).trim();
232
+
233
+ const privateKey = String(
234
+ await prompter.text({
235
+ message: "Ed25519 private key (base64)",
236
+ initialValue: channelCfg.privateKey,
237
+ validate: (value) => (value?.trim() ? undefined : "Required"),
238
+ }),
239
+ ).trim();
240
+
241
+ next = patchTopLevelChannelConfigSection({
242
+ cfg: next,
243
+ channel,
244
+ enabled: true,
245
+ clearFields: ["credentialsFile"],
246
+ patch: { hubUrl, agentId, keyId, privateKey },
247
+ }) as OpenClawConfig;
248
+
249
+ // Probe to verify
250
+ try {
251
+ const probe = await probeBotCordHub({ hubUrl, agentId, keyId, privateKey });
252
+ if (probe.ok) {
253
+ await prompter.note(
254
+ `Connected as ${probe.displayName ?? agentId}`,
255
+ "BotCord connection test",
256
+ );
257
+ } else {
258
+ await prompter.note(
259
+ `Connection failed: ${probe.error ?? "unknown error"}`,
260
+ "BotCord connection test",
261
+ );
262
+ }
263
+ } catch (err: any) {
264
+ await prompter.note(
265
+ `Connection test failed: ${String(err)}`,
266
+ "BotCord connection test",
267
+ );
268
+ }
269
+ }
270
+
271
+ // Delivery mode
272
+ next = await promptDeliveryMode(next, prompter);
273
+
274
+ return { cfg: next };
275
+ },
276
+ disable: (cfg) =>
277
+ patchTopLevelChannelConfigSection({
278
+ cfg,
279
+ channel,
280
+ patch: { enabled: false },
281
+ }),
282
+ };
283
+
284
+ // ── Delivery mode prompt ──────────────────────────────────────
285
+
286
+ async function promptDeliveryMode(
287
+ cfg: OpenClawConfig,
288
+ prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
289
+ ): Promise<OpenClawConfig> {
290
+ const currentMode =
291
+ (cfg.channels?.botcord as BotCordChannelConfig | undefined)?.deliveryMode ?? "websocket";
292
+ const deliveryMode = (await prompter.select({
293
+ message: "BotCord delivery mode",
294
+ options: [
295
+ { value: "websocket", label: "WebSocket (recommended, real-time)" },
296
+ { value: "polling", label: "Polling (works everywhere)" },
297
+ ],
298
+ initialValue: currentMode,
299
+ })) as "websocket" | "polling";
300
+ return patchTopLevelChannelConfigSection({
301
+ cfg,
302
+ channel,
303
+ patch: { deliveryMode },
304
+ }) as OpenClawConfig;
305
+ }
package/src/tools/bind.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * botcord_bind Bind this BotCord agent to a user's web dashboard account.
3
- *
4
- * Also exports `executeBind()` shared helper used by both the tool and the
5
- * `/botcord_bind` command.
2
+ * [INPUT]: 依赖 runtime/config 读取当前 Agent 身份,依赖 BotCordClient 获取 agent_token 并访问 dashboard 绑定接口
3
+ * [OUTPUT]: 对外提供 botcord_bind 工具与 executeBind 助手,支持短认领码或原始 bind_ticket
4
+ * [POS]: plugin dashboard 认领执行器,把命令行参数翻译成稳定的绑定请求
5
+ * [PROTOCOL]: 变更时更新此头部,然后检查 README.md
6
6
  */
7
7
  import {
8
8
  getSingleAccountModeError,
@@ -18,7 +18,7 @@ const DEFAULT_DASHBOARD_URL = "https://www.botcord.chat";
18
18
  * Shared bind logic used by both the tool and the command.
19
19
  */
20
20
  export async function executeBind(
21
- bindTicket: string,
21
+ bindCredential: string,
22
22
  dashboardUrl?: string,
23
23
  ): Promise<{ ok: true; [key: string]: unknown } | { error: string }> {
24
24
  const cfg = getAppConfig();
@@ -49,7 +49,9 @@ export async function executeBind(
49
49
  agent_id: agentId,
50
50
  display_name: displayName,
51
51
  agent_token: agentToken,
52
- bind_ticket: bindTicket,
52
+ ...(bindCredential.startsWith("bd_")
53
+ ? { bind_code: bindCredential }
54
+ : { bind_ticket: bindCredential }),
53
55
  }),
54
56
  signal: AbortSignal.timeout(15000),
55
57
  });
@@ -72,13 +74,13 @@ export function createBindTool() {
72
74
  name: "botcord_bind",
73
75
  label: "Bind Dashboard",
74
76
  description:
75
- "Bind this BotCord agent to a user's web dashboard account using a bind ticket.",
77
+ "Bind this BotCord agent to a user's web dashboard account using a short bind code or bind ticket.",
76
78
  parameters: {
77
79
  type: "object" as const,
78
80
  properties: {
79
81
  bind_ticket: {
80
82
  type: "string" as const,
81
- description: "The bind ticket from the BotCord web dashboard",
83
+ description: "The short bind code or bind ticket from the BotCord web dashboard",
82
84
  },
83
85
  dashboard_url: {
84
86
  type: "string" as const,
@@ -9,7 +9,7 @@ type FollowUpDeliveryResult = {
9
9
  error?: string;
10
10
  };
11
11
 
12
- export type ContactOnlyTransferResult = {
12
+ export type TransferResult = {
13
13
  tx: WalletTransaction;
14
14
  transfer_record_message: FollowUpDeliveryResult;
15
15
  notifications: {
@@ -18,6 +18,7 @@ export type ContactOnlyTransferResult = {
18
18
  };
19
19
  };
20
20
 
21
+
21
22
  function extractTransferMetadata(tx: WalletTransaction): Record<string, unknown> | null {
22
23
  if (!tx.metadata_json) return null;
23
24
  try {
@@ -33,14 +34,12 @@ function formatOptionalLine(label: string, value: string | null | undefined): st
33
34
  return value ? `${label}: ${value}` : null;
34
35
  }
35
36
 
36
- export async function assertTransferPeerIsContact(client: BotCordClient, toAgentId: string): Promise<void> {
37
+ export async function isPeerContact(client: BotCordClient, toAgentId: string): Promise<boolean> {
37
38
  const contacts = await client.listContacts();
38
- const isContact = contacts.some((contact) => contact.contact_agent_id === toAgentId);
39
- if (!isContact) {
40
- throw new Error("Transfer is only allowed between contacts. Please add this agent as a contact first.");
41
- }
39
+ return contacts.some((contact) => contact.contact_agent_id === toAgentId);
42
40
  }
43
41
 
42
+
44
43
  export function buildTransferRecordMessage(tx: WalletTransaction): string {
45
44
  const metadata = extractTransferMetadata(tx);
46
45
  return [
@@ -68,7 +67,7 @@ export function buildTransferNotificationMessage(
68
67
  return `[BotCord Notice] Payment received: ${formatCoinAmount(tx.amount_minor)} from ${tx.from_agent_id} (tx: ${tx.tx_id})`;
69
68
  }
70
69
 
71
- export function formatFollowUpDeliverySummary(result: ContactOnlyTransferResult): string {
70
+ export function formatFollowUpDeliverySummary(result: TransferResult): string {
72
71
  const lines = [
73
72
  `Transfer record message: ${result.transfer_record_message.sent ? "sent" : "failed"}`,
74
73
  `Payer notification: ${result.notifications.payer.sent ? "sent" : "failed"}`,
@@ -121,7 +120,7 @@ async function sendNotification(
121
120
  }
122
121
  }
123
122
 
124
- export async function executeContactOnlyTransfer(
123
+ export async function executeTransfer(
125
124
  client: BotCordClient,
126
125
  params: {
127
126
  to_agent_id: string;
@@ -132,9 +131,7 @@ export async function executeContactOnlyTransfer(
132
131
  metadata?: Record<string, unknown>;
133
132
  idempotency_key?: string;
134
133
  },
135
- ): Promise<ContactOnlyTransferResult> {
136
- await assertTransferPeerIsContact(client, params.to_agent_id);
137
-
134
+ ): Promise<TransferResult> {
138
135
  const tx = await client.createTransfer(params);
139
136
  const [recordMessage, payerNotification, payeeNotification] = await Promise.all([
140
137
  sendRecordMessage(client, tx),
@@ -9,7 +9,7 @@ import {
9
9
  import { BotCordClient } from "../client.js";
10
10
  import { getConfig as getAppConfig } from "../runtime.js";
11
11
  import { formatCoinAmount } from "./coin-format.js";
12
- import { executeContactOnlyTransfer, formatFollowUpDeliverySummary } from "./payment-transfer.js";
12
+ import { executeTransfer, isPeerContact, formatFollowUpDeliverySummary } from "./payment-transfer.js";
13
13
 
14
14
  function sanitizeBalance(summary: any): any {
15
15
  return {
@@ -275,6 +275,10 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
275
275
  type: "string" as const,
276
276
  description: "Pagination cursor — for ledger",
277
277
  },
278
+ confirmed: {
279
+ type: "boolean" as const,
280
+ description: "Set to true to confirm a stranger transfer (recipient not in contacts) — for transfer",
281
+ },
278
282
  limit: {
279
283
  type: "number" as const,
280
284
  description: "Max entries to return — for ledger",
@@ -324,7 +328,15 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
324
328
  case "transfer": {
325
329
  if (!args.to_agent_id) return { error: "to_agent_id is required" };
326
330
  if (!args.amount_minor) return { error: "amount_minor is required" };
327
- const transfer = await executeContactOnlyTransfer(client, {
331
+
332
+ const isContact = await isPeerContact(client, args.to_agent_id);
333
+ if (!isContact && args.confirmed !== true) {
334
+ return {
335
+ result: `\u26a0\ufe0f ${args.to_agent_id} is not in your contacts. This is a stranger transfer of ${formatCoinAmount(args.amount_minor)}. To proceed, call this tool again with confirmed: true. The transfer will create a chat room between you and the recipient.`,
336
+ };
337
+ }
338
+
339
+ const transfer = await executeTransfer(client, {
328
340
  to_agent_id: args.to_agent_id,
329
341
  amount_minor: args.amount_minor,
330
342
  memo: args.memo,