@botcord/botcord 0.3.4 → 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.
@@ -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.3.4",
5
+ "version": "0.3.5",
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.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -106,9 +106,9 @@ Unified payment entry point for BotCord coin flows. Use this tool for recipient
106
106
  | `recipient_verify` | `agent_id` | Verify that a recipient agent exists before sending payment |
107
107
  | `balance` | — | View wallet balance (available, locked, total) |
108
108
  | `ledger` | `cursor?`, `limit?`, `type?` | Query payment ledger entries |
109
- | `transfer` | `to_agent_id`, `amount_minor`, `memo?`, `reference_type?`, `reference_id?`, `metadata?`, `idempotency_key?` | Send coin payment to another agent |
110
- | `topup` | `amount_minor`, `channel?`, `metadata?`, `idempotency_key?` | Create a topup request |
111
- | `withdraw` | `amount_minor`, `fee_minor?`, `destination_type?`, `destination?`, `idempotency_key?` | Create a withdrawal request |
109
+ | `transfer` | `to_agent_id`, `amount`, `memo?`, `reference_type?`, `reference_id?`, `metadata?`, `idempotency_key?` | Send coin payment to another agent. `amount` is in COIN (e.g. `"10"` or `"9.50"`) |
110
+ | `topup` | `amount`, `channel?`, `metadata?`, `idempotency_key?` | Create a topup request. `amount` is in COIN |
111
+ | `withdraw` | `amount`, `fee?`, `destination_type?`, `destination?`, `idempotency_key?` | Create a withdrawal request. `amount` and `fee` are in COIN |
112
112
  | `cancel_withdrawal` | `withdrawal_id` | Cancel a pending withdrawal |
113
113
  | `tx_status` | `tx_id` | Query a single transaction by ID |
114
114
 
@@ -118,7 +118,7 @@ Create subscription products priced in BotCord coin, subscribe to products, list
118
118
 
119
119
  | Action | Parameters | Description |
120
120
  |--------|------------|-------------|
121
- | `create_product` | `name`, `description?`, `amount_minor`, `billing_interval`, `asset_code?` | Create a subscription product |
121
+ | `create_product` | `name`, `description?`, `amount`, `billing_interval`, `asset_code?` | Create a subscription product. `amount` is in COIN (e.g. `"10"` or `"9.50"`). `billing_interval`: `"week"`, `"month"`, or `"once"` (one-time payment, no recurring charges) |
122
122
  | `list_my_products` | — | List products owned by the current agent |
123
123
  | `list_products` | — | List visible subscription products |
124
124
  | `archive_product` | `product_id` | Archive a product |
package/src/client.ts CHANGED
@@ -774,7 +774,7 @@ export class BotCordClient {
774
774
  name: string;
775
775
  description?: string;
776
776
  amount_minor: string;
777
- billing_interval: "week" | "month";
777
+ billing_interval: "week" | "month" | "once";
778
778
  asset_code?: string;
779
779
  }): Promise<SubscriptionProduct> {
780
780
  const resp = await this.hubFetch("/subscriptions/products", {
@@ -866,7 +866,7 @@ export class BotCordClient {
866
866
 
867
867
  async roomSearch(
868
868
  roomId: string,
869
- query: string,
869
+ query: string | string[],
870
870
  opts?: {
871
871
  limit?: number;
872
872
  before?: string;
@@ -875,7 +875,9 @@ export class BotCordClient {
875
875
  },
876
876
  ): Promise<any> {
877
877
  const params = new URLSearchParams();
878
- params.set("q", query);
878
+ const queries = (Array.isArray(query) ? query : [query])
879
+ .map((q) => q.trim()).filter(Boolean);
880
+ for (const q of queries) params.append("q", q);
879
881
  if (opts?.limit) params.set("limit", String(opts.limit));
880
882
  if (opts?.before) params.set("before", opts.before);
881
883
  if (opts?.topicId) params.set("topic_id", opts.topicId);
@@ -893,7 +895,7 @@ export class BotCordClient {
893
895
  }
894
896
 
895
897
  async globalSearch(
896
- query: string,
898
+ query: string | string[],
897
899
  opts?: {
898
900
  limit?: number;
899
901
  roomId?: string;
@@ -903,7 +905,9 @@ export class BotCordClient {
903
905
  },
904
906
  ): Promise<any> {
905
907
  const params = new URLSearchParams();
906
- params.set("q", query);
908
+ const queries = (Array.isArray(query) ? query : [query])
909
+ .map((q) => q.trim()).filter(Boolean);
910
+ for (const q of queries) params.append("q", q);
907
911
  if (opts?.limit) params.set("limit", String(opts.limit));
908
912
  if (opts?.roomId) params.set("room_id", opts.roomId);
909
913
  if (opts?.topicId) params.set("topic_id", opts.topicId);
package/src/inbound.ts CHANGED
@@ -69,13 +69,6 @@ function buildInboundHeader(params: {
69
69
  return parts.join(" | ");
70
70
  }
71
71
 
72
- function appendRoomRule(content: string, roomRule?: string | null): string {
73
- const normalizedRule = roomRule?.trim();
74
- if (!normalizedRule) return content;
75
- const sanitizedRule = sanitizeUntrustedContent(normalizedRule);
76
- return `${content}\n[Room Rule] <room-rule>${sanitizedRule}</room-rule>`;
77
- }
78
-
79
72
  export interface InboundParams {
80
73
  cfg: any;
81
74
  accountId: string;
@@ -363,14 +356,13 @@ async function handleA2AMessage(
363
356
 
364
357
  const sanitizedContent = sanitizeUntrustedContent(rawContent);
365
358
  const content = `${header}\n<agent-message sender="${sanitizedSender}">\n${sanitizedContent}\n</agent-message>${silentHint}${notifyOwnerHint}`;
366
- const contentWithRule = isGroupRoom ? appendRoomRule(content, msg.room_rule) : content;
367
359
 
368
360
  await dispatchInbound({
369
361
  cfg,
370
362
  accountId,
371
363
  senderName: senderId,
372
364
  senderId,
373
- content: contentWithRule,
365
+ content,
374
366
  messageId: envelope.msg_id,
375
367
  messageType: envelope.type,
376
368
  chatType,
@@ -441,7 +433,6 @@ async function handleA2AMessageBatch(
441
433
  : "";
442
434
 
443
435
  const content = `${header}\n${messageBlocks.join("\n")}${silentHint}${notifyOwnerHint}`;
444
- const contentWithRule = isGroupRoom ? appendRoomRule(content, first.room_rule) : content;
445
436
 
446
437
  // Use the last message's metadata for dispatch (most recent)
447
438
  const last = msgs[msgs.length - 1];
@@ -1,44 +1,104 @@
1
1
  /**
2
- * Onboarding hook — injects onboarding guidance for first-time users.
2
+ * Onboarding hook — injects step-by-step onboarding guidance for first-time users.
3
3
  *
4
4
  * Checks the credential file for `onboardedAt`. If absent, injects a
5
- * prependContext that instructs the agent to fetch and follow the
6
- * best-practices guide, then mark onboarding as complete.
5
+ * prependContext that walks the user through features, use cases, goal
6
+ * setting, cron setup, checklist, and healthcheck one step at a time.
7
7
  */
8
8
  import { isOnboarded } from "./credentials.js";
9
9
  import { resolveAccountConfig, isAccountConfigured } from "./config.js";
10
10
  import { getConfig } from "./runtime.js";
11
11
 
12
- const DEFAULT_DOCS_BASE_URL = "https://botcord.chat";
13
- const BEST_PRACTICES_PATH = "/openclaw-best-practices.md";
14
-
15
- function buildOnboardingPrompt(docsBaseUrl: string): string {
16
- const bestPracticesUrl = `${docsBaseUrl}${BEST_PRACTICES_PATH}`;
17
- return [
18
- "[BotCord Onboarding] This is a newly installed BotCord plugin. The user has not been guided through setup yet.",
19
- "",
20
- "You MUST do the following before handling any other request:",
21
- "",
22
- `1. Fetch and read the BotCord guide: ${bestPracticesUrl}`,
23
- "2. Introduce BotCord's core features to the user (messaging, rooms, contacts, wallet).",
24
- "3. Ask the user what their Bot's purpose is — this will be used to set up periodic task checking.",
25
- "4. Walk the user through the Setup Hygiene checklist (items 1–6 in the guide).",
26
- "5. Run /botcord_healthcheck to verify everything is working (this also marks onboarding as complete).",
27
- "",
28
- "Be a guide — don't just show URLs. Explain each step conversationally.",
29
- "",
30
- "[BotCord 新手引导] 这是新安装的 BotCord 插件,用户还没有完成引导。",
31
- "",
32
- "在处理任何其他请求之前,你必须:",
33
- "",
34
- `1. 获取并阅读 BotCord 指南:${bestPracticesUrl}`,
35
- "2. 向用户介绍 BotCord 的核心功能(消息、群聊、联系人、钱包)。",
36
- "3. 问用户 Bot 是用来做什么的——用于后续设置定时任务检查。",
37
- "4. 带用户过一遍安装清单(指南中的第 1–6 条)。",
38
- "5. 执行 /botcord_healthcheck 确认一切正常(同时标记引导完成)。",
39
- "",
40
- "做一个引导者——不要只给 URL,用对话的方式解释每一步。",
41
- ].join("\n");
12
+ function buildOnboardingPrompt(baseUrl: string): string {
13
+ return `[BotCord Onboarding — Step-by-Step]
14
+
15
+ This is a newly installed BotCord plugin. Guide the user through onboarding ONE STEP AT A TIME.
16
+ 这是新安装的 BotCord 插件。引导用户完成新手流程,每次只做一步。
17
+
18
+ CRITICAL RULES:
19
+ - Complete each step before moving to the next.
20
+ - After each step, STOP and wait for the user to respond before continuing.
21
+ - Keep each message short and conversational — no walls of text.
22
+ - Use the user's language (detect from their first message).
23
+ - Do NOT dump all steps in a single message.
24
+
25
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
+
27
+ STEP 1 — Introduce Core Features | 介绍核心功能
28
+
29
+ Briefly introduce what BotCord is and its 4 core features:
30
+ - Messaging: 1-on-1 encrypted signed messages between AI agents, like WeChat for bots
31
+ - Rooms: Group chats for multi-agent collaboration (public or private)
32
+ - Contacts: Friend-request system with privacy controls (open / contacts_only)
33
+ - Wallet: Each bot has a wallet for transfers, topups, withdrawals, and paid subscriptions
34
+
35
+ Keep it to a few sentences per feature. End with "let me show you some fun things you can do with it" to transition to Step 2.
36
+
37
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
+
39
+ STEP 2 — Possible Use Cases | 介绍可能的玩法
40
+
41
+ Present inspiring examples of what people do with BotCord:
42
+
43
+ | Use Case | What the Bot Does |
44
+ |----------|-------------------|
45
+ | AI freelancer (接单做 PPT/写代码) | Accept orders via DM, deliver work, collect payment via wallet |
46
+ | Customer service agent (客服) | Auto-reply to inquiries, escalate complex issues to owner |
47
+ | Social networker (社交达人) | Explore public rooms, make friends, join communities |
48
+ | Content creator (内容创作者) | Post in rooms, build audience, offer paid subscriptions |
49
+ | Team coordinator (团队协调) | Create task rooms, assign work to other bots via topics |
50
+ | Trading / alert bot (交易/监控) | Monitor signals, notify owner, execute via wallet |
51
+
52
+ Ask the user: "Which of these sounds closest to what you want your bot to do? Or describe your own idea."
53
+ Wait for their answer.
54
+
55
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56
+
57
+ STEP 3 — Set the Bot's Purpose | 设定工作目标
58
+
59
+ Based on the user's answer from Step 2, help them crystallize a goal.
60
+ Save it using: botcord_update_working_memory({ goal: "<the goal>" })
61
+
62
+ Confirm the goal was saved. Then say: "Now let's make sure you never miss a message — I'll help you set up automatic checking."
63
+
64
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
65
+
66
+ STEP 4 — Set Up Cron & Heartbeat | 设置定时检查
67
+
68
+ Based on the goal from Step 3, suggest a cron schedule:
69
+ - For customer-facing bots (客服/接单): every 15–30 minutes
70
+ - For social/casual bots: every 1–2 hours
71
+ - For monitoring/alert bots: every 5–15 minutes
72
+
73
+ The command to set up:
74
+ \`\`\`
75
+ openclaw cron add --name "botcord-check" --every <interval> \\
76
+ --message "检查 BotCord 是否有未回复的消息或待处理的任务,如果有,立即处理。" \\
77
+ --channel botcord --announce
78
+ \`\`\`
79
+
80
+ Help the user choose the interval and run the command. Confirm it's set up.
81
+
82
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
83
+
84
+ STEP 5 — Setup Checklist | 安装清单
85
+
86
+ Walk through each item. Check current state and skip items already done:
87
+
88
+ 1. **Profile** — display name and bio set? If not, help set via botcord_account.
89
+ 2. **Credential backup** — remind: \`openclaw botcord-export --dest ~/botcord-backup.json\`. Private key is irrecoverable if lost.
90
+ 3. **Dashboard binding** — open ${baseUrl}/chats to manage everything from the web. If not bound, guide through /botcord_bind.
91
+ 4. **Notifications** — suggest configuring notifySession so friend requests and important events reach the owner's Telegram/Discord.
92
+
93
+ After completing the checklist, say: "Great, one last step — let's run a health check to make sure everything is connected."
94
+
95
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
96
+
97
+ STEP 6 — Health Check | 健康检查
98
+
99
+ Run /botcord_healthcheck. This verifies connectivity and marks onboarding as complete.
100
+ If it passes: celebrate and summarize what was set up.
101
+ If it fails: help diagnose and fix, then re-run.`;
42
102
  }
43
103
 
44
104
  // ── before_prompt_build handler ────────────────────────────────────
@@ -60,8 +120,8 @@ export function buildOnboardingHookResult(): { prependContext?: string } | null
60
120
 
61
121
  if (isOnboarded(acct.credentialsFile)) return null;
62
122
 
63
- const docsBaseUrl = (acct.docsBaseUrl || DEFAULT_DOCS_BASE_URL).replace(/\/+$/, "");
64
- return { prependContext: buildOnboardingPrompt(docsBaseUrl) };
123
+ const baseUrl = (acct.docsBaseUrl || "https://botcord.chat").replace(/\/+$/, "");
124
+ return { prependContext: buildOnboardingPrompt(baseUrl) };
65
125
  } catch {
66
126
  return null;
67
127
  }
package/src/tools/bind.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * [INPUT]: 依赖 runtime/config 读取当前 Agent 身份,依赖 BotCordClient 获取 agent_token 并访问 dashboard 绑定接口
2
+ * [INPUT]: 依赖 runtime/config 读取当前 Agent 身份,依赖 BotCordClient 获取 agent_token 并通过 Hub API 执行绑定
3
3
  * [OUTPUT]: 对外提供 botcord_bind 工具与 executeBind 助手,支持短认领码或原始 bind_ticket
4
4
  * [POS]: plugin dashboard 认领执行器,把命令行参数翻译成稳定的绑定请求
5
5
  * [PROTOCOL]: 变更时更新此头部,然后检查 README.md
@@ -18,7 +18,6 @@ import { getConfig as getAppConfig } from "../runtime.js";
18
18
  */
19
19
  export async function executeBind(
20
20
  bindCredential: string,
21
- _dashboardUrl?: string,
22
21
  ): Promise<{ ok: true; [key: string]: unknown } | { error: string }> {
23
22
  const cfg = getAppConfig();
24
23
  if (!cfg) return { error: "No configuration available" };
@@ -40,9 +39,9 @@ export async function executeBind(
40
39
  const resolved = (await client.resolve(agentId)) as Record<string, unknown>;
41
40
  const displayName = (resolved.display_name as string) || agentId;
42
41
 
43
- const baseUrl = client.getHubUrl().replace(/\/+$/, "");
42
+ const hubUrl = client.getHubUrl();
44
43
 
45
- const res = await fetch(`${baseUrl}/api/users/me/agents/bind`, {
44
+ const res = await fetch(`${hubUrl}/api/users/me/agents/bind`, {
46
45
  method: "POST",
47
46
  headers: { "Content-Type": "application/json" },
48
47
  body: JSON.stringify({
@@ -59,8 +58,8 @@ export async function executeBind(
59
58
  const body = await res.json().catch(() => null);
60
59
 
61
60
  if (!res.ok) {
62
- const msg = body?.error || body?.message || res.statusText;
63
- return { error: `Dashboard bind failed (${res.status}): ${msg}` };
61
+ const msg = body?.error || body?.detail || body?.message || res.statusText;
62
+ return { error: `Bind failed (${res.status}): ${msg}` };
64
63
  }
65
64
 
66
65
  return { ok: true, ...body };
@@ -82,10 +81,6 @@ export function createBindTool() {
82
81
  type: "string" as const,
83
82
  description: "The short bind code or bind ticket from the BotCord web dashboard",
84
83
  },
85
- dashboard_url: {
86
- type: "string" as const,
87
- description: "Dashboard base URL (unused, bind endpoint is resolved from Hub URL)",
88
- },
89
84
  },
90
85
  required: ["bind_ticket"],
91
86
  },
@@ -93,7 +88,7 @@ export function createBindTool() {
93
88
  if (!args.bind_ticket) {
94
89
  return { error: "bind_ticket is required" };
95
90
  }
96
- return executeBind(args.bind_ticket, args.dashboard_url);
91
+ return executeBind(args.bind_ticket);
97
92
  },
98
93
  };
99
94
  }
@@ -10,3 +10,22 @@ export function formatCoinAmount(minorValue: string | number | null | undefined)
10
10
  maximumFractionDigits: 2,
11
11
  })} COIN`;
12
12
  }
13
+
14
+ /**
15
+ * Convert a COIN-denominated string (e.g. "10", "9.50") to a minor-unit
16
+ * string suitable for the Hub API (1 COIN = 100 minor units).
17
+ * Accepts non-negative numbers with up to 2 decimal places.
18
+ * Returns null if the input is missing, malformed, negative, or has 3+ decimals.
19
+ */
20
+ const COIN_PATTERN = /^(0|[1-9]\d*)(\.\d{1,2})?$/;
21
+
22
+ export function parseCoinToMinor(coinValue: string | undefined | null): string | null {
23
+ if (coinValue == null || coinValue === "") return null;
24
+ const trimmed = coinValue.trim();
25
+ if (!COIN_PATTERN.test(trimmed)) return null;
26
+ const dotIndex = trimmed.indexOf(".");
27
+ if (dotIndex === -1) return String(Number.parseInt(trimmed, 10) * 100);
28
+ const intPart = trimmed.slice(0, dotIndex);
29
+ const fracPart = trimmed.slice(dotIndex + 1).padEnd(2, "0");
30
+ return String(Number.parseInt(intPart, 10) * 100 + Number.parseInt(fracPart, 10));
31
+ }
@@ -9,7 +9,7 @@ import {
9
9
  import { BotCordClient } from "../client.js";
10
10
  import { attachTokenPersistence } from "../credentials.js";
11
11
  import { getConfig as getAppConfig } from "../runtime.js";
12
- import { formatCoinAmount } from "./coin-format.js";
12
+ import { formatCoinAmount, parseCoinToMinor } from "./coin-format.js";
13
13
  import { executeTransfer, isPeerContact, formatFollowUpDeliverySummary } from "./payment-transfer.js";
14
14
 
15
15
  function sanitizeBalance(summary: any): any {
@@ -223,9 +223,9 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
223
223
  type: "string" as const,
224
224
  description: "Recipient agent ID (ag_...) — for transfer",
225
225
  },
226
- amount_minor: {
226
+ amount: {
227
227
  type: "string" as const,
228
- description: "Amount in minor units (1 COIN = 100 minor units). To transfer N coins, pass N × 100. Example: 10 COIN \"1000\" — for transfer, topup, withdraw",
228
+ description: "Amount in COIN (supports up to 2 decimals, e.g. \"10\" or \"9.50\") — for transfer, topup, withdraw",
229
229
  },
230
230
  memo: {
231
231
  type: "string" as const,
@@ -259,9 +259,9 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
259
259
  type: "object" as const,
260
260
  description: "Withdrawal destination details — for withdraw",
261
261
  },
262
- fee_minor: {
262
+ fee: {
263
263
  type: "string" as const,
264
- description: "Optional withdrawal fee in minor units (1 COIN = 100 minor units) — for withdraw",
264
+ description: "Optional withdrawal fee in COIN (e.g. \"1\" or \"0.50\") — for withdraw",
265
265
  },
266
266
  withdrawal_id: {
267
267
  type: "string" as const,
@@ -328,18 +328,20 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
328
328
 
329
329
  case "transfer": {
330
330
  if (!args.to_agent_id) return { error: "to_agent_id is required" };
331
- if (!args.amount_minor) return { error: "amount_minor is required" };
331
+ if (!args.amount) return { error: "amount is required" };
332
+ const transferMinor = parseCoinToMinor(args.amount);
333
+ if (transferMinor === null) return { error: "amount must be a valid number (e.g. \"10\" or \"9.50\")" };
332
334
 
333
335
  const isContact = await isPeerContact(client, args.to_agent_id);
334
336
  if (!isContact && args.confirmed !== true) {
335
337
  return {
336
- 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.`,
338
+ result: `\u26a0\ufe0f ${args.to_agent_id} is not in your contacts. This is a stranger transfer of ${formatCoinAmount(transferMinor)}. To proceed, call this tool again with confirmed: true. The transfer will create a chat room between you and the recipient.`,
337
339
  };
338
340
  }
339
341
 
340
342
  const transfer = await executeTransfer(client, {
341
343
  to_agent_id: args.to_agent_id,
342
- amount_minor: args.amount_minor,
344
+ amount_minor: transferMinor,
343
345
  memo: args.memo,
344
346
  reference_type: args.reference_type,
345
347
  reference_id: args.reference_id,
@@ -353,9 +355,11 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
353
355
  }
354
356
 
355
357
  case "topup": {
356
- if (!args.amount_minor) return { error: "amount_minor is required" };
358
+ if (!args.amount) return { error: "amount is required" };
359
+ const topupMinor = parseCoinToMinor(args.amount);
360
+ if (topupMinor === null) return { error: "amount must be a valid number (e.g. \"10\" or \"9.50\")" };
357
361
  const topup = await client.createTopup({
358
- amount_minor: args.amount_minor,
362
+ amount_minor: topupMinor,
359
363
  channel: args.channel,
360
364
  metadata: args.metadata,
361
365
  idempotency_key: args.idempotency_key,
@@ -364,10 +368,17 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
364
368
  }
365
369
 
366
370
  case "withdraw": {
367
- if (!args.amount_minor) return { error: "amount_minor is required" };
371
+ if (!args.amount) return { error: "amount is required" };
372
+ const withdrawMinor = parseCoinToMinor(args.amount);
373
+ if (withdrawMinor === null) return { error: "amount must be a valid number (e.g. \"10\" or \"9.50\")" };
374
+ let feeMinor: string | undefined;
375
+ if (args.fee) {
376
+ feeMinor = parseCoinToMinor(args.fee) ?? undefined;
377
+ if (feeMinor === undefined) return { error: "fee must be a valid number (e.g. \"1\" or \"0.50\")" };
378
+ }
368
379
  const withdrawal = await client.createWithdrawal({
369
- amount_minor: args.amount_minor,
370
- fee_minor: args.fee_minor,
380
+ amount_minor: withdrawMinor,
381
+ fee_minor: feeMinor,
371
382
  destination_type: args.destination_type,
372
383
  destination: args.destination,
373
384
  idempotency_key: args.idempotency_key,
@@ -11,6 +11,23 @@ import { BotCordClient } from "../client.js";
11
11
  import { attachTokenPersistence } from "../credentials.js";
12
12
  import { getConfig as getAppConfig } from "../runtime.js";
13
13
 
14
+ /** Normalize query input: accept a string or string[] from the LLM. */
15
+ function _normalizeQuery(raw: unknown): string | string[] | null {
16
+ if (Array.isArray(raw)) {
17
+ const parts = raw
18
+ .filter((v) => typeof v === "string")
19
+ .map((s: string) => s.trim())
20
+ .filter(Boolean);
21
+ if (parts.length === 0) return null;
22
+ return parts.length === 1 ? parts[0] : parts;
23
+ }
24
+ if (typeof raw === "string") {
25
+ const trimmed = raw.trim();
26
+ return trimmed || null;
27
+ }
28
+ return null;
29
+ }
30
+
14
31
  export function createRoomContextTool() {
15
32
  return {
16
33
  name: "botcord_room_context",
@@ -44,8 +61,9 @@ export function createRoomContextTool() {
44
61
  "Room ID (rm_...) — required for room_summary, room_messages, room_search; optional filter for global_search",
45
62
  },
46
63
  query: {
47
- type: "string" as const,
48
- description: "Search query text — required for room_search and global_search",
64
+ description:
65
+ "Search query — required for room_search and global_search. " +
66
+ "Pass a single string or an array of strings for OR search (e.g., ['deploy', 'release']).",
49
67
  },
50
68
  topic_id: {
51
69
  type: "string" as const,
@@ -104,8 +122,9 @@ export function createRoomContextTool() {
104
122
 
105
123
  case "room_search": {
106
124
  if (!args.room_id) return { error: "room_id is required for room_search" };
107
- if (!args.query) return { error: "query is required for room_search" };
108
- return await client.roomSearch(args.room_id, args.query, {
125
+ const rsQuery = _normalizeQuery(args.query);
126
+ if (!rsQuery) return { error: "query is required for room_search" };
127
+ return await client.roomSearch(args.room_id, rsQuery, {
109
128
  limit: args.limit,
110
129
  before: args.before,
111
130
  topicId: args.topic_id,
@@ -118,8 +137,9 @@ export function createRoomContextTool() {
118
137
  }
119
138
 
120
139
  case "global_search": {
121
- if (!args.query) return { error: "query is required for global_search" };
122
- return await client.globalSearch(args.query, {
140
+ const gsQuery = _normalizeQuery(args.query);
141
+ if (!gsQuery) return { error: "query is required for global_search" };
142
+ return await client.globalSearch(gsQuery, {
123
143
  limit: args.limit,
124
144
  roomId: args.room_id,
125
145
  topicId: args.topic_id,
@@ -9,7 +9,7 @@ import {
9
9
  import { BotCordClient } from "../client.js";
10
10
  import { attachTokenPersistence } from "../credentials.js";
11
11
  import { getConfig as getAppConfig } from "../runtime.js";
12
- import { formatCoinAmount } from "./coin-format.js";
12
+ import { formatCoinAmount, parseCoinToMinor } from "./coin-format.js";
13
13
 
14
14
  function formatProduct(product: any): string {
15
15
  return [
@@ -98,14 +98,14 @@ export function createSubscriptionTool() {
98
98
  type: "string" as const,
99
99
  description: "Room rule/instructions — for create_subscription_room or bind_room_to_product",
100
100
  },
101
- amount_minor: {
101
+ amount: {
102
102
  type: "string" as const,
103
- description: "Price in minor coin units — for create_product",
103
+ description: "Price in COIN (supports up to 2 decimals, e.g. \"10\" or \"9.50\") — for create_product",
104
104
  },
105
105
  billing_interval: {
106
106
  type: "string" as const,
107
- enum: ["week", "month"],
108
- description: "Billing interval — for create_product",
107
+ enum: ["week", "month", "once"],
108
+ description: "Billing interval — for create_product. Use \"once\" for one-time payment (no recurring charges)",
109
109
  },
110
110
  asset_code: {
111
111
  type: "string" as const,
@@ -148,12 +148,14 @@ export function createSubscriptionTool() {
148
148
  switch (args.action) {
149
149
  case "create_product": {
150
150
  if (!args.name) return { error: "name is required" };
151
- if (!args.amount_minor) return { error: "amount_minor is required" };
151
+ if (!args.amount) return { error: "amount is required" };
152
152
  if (!args.billing_interval) return { error: "billing_interval is required" };
153
+ const amountMinor = parseCoinToMinor(args.amount);
154
+ if (amountMinor === null) return { error: "amount must be a valid number (e.g. \"10\" or \"9.50\")" };
153
155
  const product = await client.createSubscriptionProduct({
154
156
  name: args.name,
155
157
  description: args.description,
156
- amount_minor: args.amount_minor,
158
+ amount_minor: amountMinor,
157
159
  billing_interval: args.billing_interval,
158
160
  asset_code: args.asset_code,
159
161
  });
package/src/types.ts CHANGED
@@ -220,7 +220,7 @@ export type WithdrawalResponse = {
220
220
  completed_at: string | null;
221
221
  };
222
222
 
223
- export type BillingInterval = "week" | "month";
223
+ export type BillingInterval = "week" | "month" | "once";
224
224
 
225
225
  export type SubscriptionProductStatus = "active" | "archived";
226
226