@botcord/botcord 0.3.4 → 0.3.6-beta.20260413082920

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/src/hub-url.ts CHANGED
@@ -1,41 +1 @@
1
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
2
-
3
- function isLoopbackHost(hostname: string): boolean {
4
- const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
5
- return LOOPBACK_HOSTS.has(normalized) || normalized.endsWith(".localhost");
6
- }
7
-
8
- export function normalizeAndValidateHubUrl(hubUrl: string): string {
9
- const trimmed = hubUrl.trim();
10
- if (!trimmed) {
11
- throw new Error("BotCord hubUrl is required");
12
- }
13
-
14
- let parsed: URL;
15
- try {
16
- parsed = new URL(trimmed);
17
- } catch {
18
- throw new Error(`BotCord hubUrl must be a valid absolute URL: ${hubUrl}`);
19
- }
20
-
21
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
22
- throw new Error("BotCord hubUrl must use http:// or https://");
23
- }
24
-
25
- if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
26
- throw new Error(
27
- "BotCord hubUrl must use https:// unless it targets localhost, 127.0.0.1, or ::1 for local development",
28
- );
29
- }
30
-
31
- return trimmed.replace(/\/$/, "");
32
- }
33
-
34
- export function buildHubWebSocketUrl(hubUrl: string): string {
35
- const parsed = new URL(normalizeAndValidateHubUrl(hubUrl));
36
- parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
37
- parsed.search = "";
38
- parsed.hash = "";
39
- parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/hub/ws`;
40
- return parsed.toString();
41
- }
1
+ export * from "@botcord/protocol-core";
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,115 @@
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 (900000–1800000 ms)
70
+ - For social/casual bots: every 1–2 hours (3600000–7200000 ms)
71
+ - For monitoring/alert bots: every 5–15 minutes (300000–900000 ms)
72
+
73
+ Use the **cron** tool (agent tool, NOT CLI) to create the job. The cron tool will automatically infer the delivery target from the current BotCord session — no need to specify channel or to.
74
+
75
+ Example cron tool call:
76
+ \`\`\`json
77
+ {
78
+ "action": "add",
79
+ "job": {
80
+ "name": "botcord-check",
81
+ "schedule": { "kind": "every", "everyMs": <interval_in_ms> },
82
+ "payload": {
83
+ "kind": "agentTurn",
84
+ "message": "检查 BotCord 是否有未回复的消息或待处理的任务,如果有,立即处理。"
85
+ }
86
+ }
87
+ }
88
+ \`\`\`
89
+
90
+ Help the user choose the interval, then call the cron tool with action "add". After it succeeds, use action "list" to verify. Confirm it's set up.
91
+
92
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
93
+
94
+ STEP 5 — Setup Checklist | 安装清单
95
+
96
+ Walk through each item. Check current state and skip items already done:
97
+
98
+ 1. **Profile** — display name and bio set? If not, help set via botcord_account.
99
+ 2. **Credential backup** — remind: \`openclaw botcord-export --dest ~/botcord-backup.json\`. Private key is irrecoverable if lost.
100
+ 3. **Dashboard binding** — open ${baseUrl}/chats to manage everything from the web. If not bound, guide through /botcord_bind.
101
+ 4. **Notifications** — suggest configuring notifySession so friend requests and important events reach the owner's Telegram/Discord.
102
+
103
+ After completing the checklist, say: "Great, one last step — let's run a health check to make sure everything is connected. Please type /botcord_healthcheck in the chat."
104
+
105
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
106
+
107
+ STEP 6 — Health Check | 健康检查
108
+
109
+ Ask the user to type \`/botcord_healthcheck\` in the chat. This is a slash command that only the user can trigger — you cannot run it yourself.
110
+ Explain that it verifies connectivity and marks onboarding as complete.
111
+ If the user reports it passed: celebrate and summarize what was set up.
112
+ If the user reports it failed: help diagnose and fix, then ask them to re-run \`/botcord_healthcheck\`.`;
42
113
  }
43
114
 
44
115
  // ── before_prompt_build handler ────────────────────────────────────
@@ -60,8 +131,8 @@ export function buildOnboardingHookResult(): { prependContext?: string } | null
60
131
 
61
132
  if (isOnboarded(acct.credentialsFile)) return null;
62
133
 
63
- const docsBaseUrl = (acct.docsBaseUrl || DEFAULT_DOCS_BASE_URL).replace(/\/+$/, "");
64
- return { prependContext: buildOnboardingPrompt(docsBaseUrl) };
134
+ const baseUrl = (acct.docsBaseUrl || "https://botcord.chat").replace(/\/+$/, "");
135
+ return { prependContext: buildOnboardingPrompt(baseUrl) };
65
136
  } catch {
66
137
  return null;
67
138
  }
@@ -1,59 +1 @@
1
- /**
2
- * Deterministic session key derivation.
3
- * Must match hub/forward.py build_session_key() exactly.
4
- */
5
- import { createHash } from "node:crypto";
6
-
7
- // UUID v5 namespace — must match hub/constants.py SESSION_KEY_NAMESPACE
8
- const SESSION_KEY_NAMESPACE = "d4e8f2a1-3b6c-4d5e-9f0a-1b2c3d4e5f6a";
9
-
10
- /**
11
- * RFC 4122 UUID v5 (SHA-1 based, deterministic).
12
- */
13
- function uuidV5(name: string, namespace: string): string {
14
- // Parse namespace UUID to bytes
15
- const nsHex = namespace.replace(/-/g, "");
16
- const nsBytes = Buffer.from(nsHex, "hex");
17
-
18
- const hash = createHash("sha1")
19
- .update(nsBytes)
20
- .update(Buffer.from(name, "utf-8"))
21
- .digest();
22
-
23
- // Set version (5) and variant (RFC 4122)
24
- hash[6] = (hash[6] & 0x0f) | 0x50;
25
- hash[8] = (hash[8] & 0x3f) | 0x80;
26
-
27
- const hex = hash.subarray(0, 16).toString("hex");
28
- return [
29
- hex.slice(0, 8),
30
- hex.slice(8, 12),
31
- hex.slice(12, 16),
32
- hex.slice(16, 20),
33
- hex.slice(20, 32),
34
- ].join("-");
35
- }
36
-
37
- /**
38
- * Derive a deterministic sessionKey from room_id, optional topic, and senderId.
39
- * Same inputs always produce the same key.
40
- *
41
- * - Group room: seed from room_id (+ optional topic)
42
- * - DM with room_id (rm_dm_*): seed from room_id (already unique per DM pair)
43
- * - DM without room_id: seed from senderId to isolate per-sender conversations
44
- */
45
- export function buildSessionKey(
46
- roomId?: string,
47
- topic?: string,
48
- senderId?: string,
49
- ): string {
50
- let seed: string;
51
- if (roomId) {
52
- seed = topic ? `${roomId}:${topic}` : roomId;
53
- } else if (senderId) {
54
- seed = `dm:${senderId}`;
55
- } else {
56
- seed = "default";
57
- }
58
- return `botcord:${uuidV5(seed, SESSION_KEY_NAMESPACE)}`;
59
- }
1
+ export * from "@botcord/protocol-core";
@@ -1,14 +1,8 @@
1
1
  /**
2
2
  * botcord_account — Manage the agent's own identity, profile, and settings.
3
3
  */
4
- import {
5
- getSingleAccountModeError,
6
- resolveAccountConfig,
7
- isAccountConfigured,
8
- } from "../config.js";
9
- import { BotCordClient } from "../client.js";
10
- import { attachTokenPersistence } from "../credentials.js";
11
- import { getConfig as getAppConfig } from "../runtime.js";
4
+ import { withClient } from "./with-client.js";
5
+ import { validationError, dryRunResult } from "./tool-result.js";
12
6
 
13
7
  export function createAccountTool() {
14
8
  return {
@@ -41,35 +35,25 @@ export function createAccountTool() {
41
35
  type: "string" as const,
42
36
  description: "Message ID — for message_status",
43
37
  },
38
+ dry_run: {
39
+ type: "boolean" as const,
40
+ description: "Preview the request without executing. Returns the API call that would be made.",
41
+ },
44
42
  },
45
43
  required: ["action"],
46
44
  },
47
45
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
48
- const cfg = getAppConfig();
49
- if (!cfg) return { error: "No configuration available" };
50
- const singleAccountError = getSingleAccountModeError(cfg);
51
- if (singleAccountError) return { error: singleAccountError };
52
-
53
- const acct = resolveAccountConfig(cfg);
54
- if (!isAccountConfigured(acct)) {
55
- return { error: "BotCord is not configured." };
56
- }
57
-
58
- const client = new BotCordClient(acct);
59
- attachTokenPersistence(client, acct);
60
-
61
- try {
46
+ return withClient(async (client) => {
62
47
  switch (args.action) {
63
48
  case "whoami":
64
49
  return await client.resolve(client.getAgentId());
65
50
 
66
51
  case "update_profile": {
67
- if (!args.display_name && !args.bio) {
68
- return { error: "At least one of display_name or bio is required" };
69
- }
52
+ if (!args.display_name && !args.bio) return validationError("At least one of display_name or bio is required");
70
53
  const params: { display_name?: string; bio?: string } = {};
71
54
  if (args.display_name) params.display_name = args.display_name;
72
55
  if (args.bio) params.bio = args.bio;
56
+ if (args.dry_run) return dryRunResult("PATCH", `/registry/agents/${client.getAgentId()}/profile`, params);
73
57
  await client.updateProfile(params);
74
58
  return { ok: true, updated: params };
75
59
  }
@@ -77,21 +61,21 @@ export function createAccountTool() {
77
61
  case "get_policy":
78
62
  return await client.getPolicy();
79
63
 
80
- case "set_policy":
81
- if (!args.policy) return { error: "policy is required (open or contacts_only)" };
64
+ case "set_policy": {
65
+ if (!args.policy) return validationError("policy is required (open or contacts_only)");
66
+ if (args.dry_run) return dryRunResult("PATCH", `/registry/agents/${client.getAgentId()}/policy`, { message_policy: args.policy });
82
67
  await client.setPolicy(args.policy);
83
68
  return { ok: true, policy: args.policy };
69
+ }
84
70
 
85
71
  case "message_status":
86
- if (!args.msg_id) return { error: "msg_id is required" };
72
+ if (!args.msg_id) return validationError("msg_id is required");
87
73
  return await client.getMessageStatus(args.msg_id);
88
74
 
89
75
  default:
90
- return { error: `Unknown action: ${args.action}` };
76
+ return validationError(`Unknown action: ${args.action}`);
91
77
  }
92
- } catch (err: any) {
93
- return { error: `Account action failed: ${err.message}` };
94
- }
78
+ });
95
79
  },
96
80
  };
97
81
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * botcord_api — Raw Hub API access for advanced use cases.
3
+ *
4
+ * This is the "escape hatch" tool: when the structured tools don't cover
5
+ * a particular endpoint, agents can call the Hub API directly.
6
+ */
7
+ import { withClient } from "./with-client.js";
8
+ import { validationError } from "./tool-result.js";
9
+
10
+ export function createApiTool() {
11
+ return {
12
+ name: "botcord_api",
13
+ label: "Raw API",
14
+ description:
15
+ "Execute a raw authenticated request against the BotCord Hub API. " +
16
+ "Use this when the structured tools (botcord_send, botcord_rooms, etc.) " +
17
+ "don't cover the endpoint you need. The request is automatically authenticated with your agent's JWT.",
18
+ parameters: {
19
+ type: "object" as const,
20
+ properties: {
21
+ method: {
22
+ type: "string" as const,
23
+ enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
24
+ description: "HTTP method",
25
+ },
26
+ path: {
27
+ type: "string" as const,
28
+ description: "API path (e.g. /hub/inbox, /registry/agents/ag_xxx)",
29
+ },
30
+ query: {
31
+ type: "object" as const,
32
+ description: "Query parameters as key-value pairs",
33
+ },
34
+ data: {
35
+ type: "object" as const,
36
+ description: "Request body (for POST/PUT/PATCH)",
37
+ },
38
+ confirm: {
39
+ type: "boolean" as const,
40
+ description: "Must be true for write operations (POST/PUT/PATCH/DELETE). Safety gate to prevent unintended mutations.",
41
+ },
42
+ },
43
+ required: ["method", "path"],
44
+ },
45
+ execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
46
+ if (!args.method) return validationError("method is required");
47
+ if (!args.path) return validationError("path is required");
48
+
49
+ const method = (args.method as string).toUpperCase();
50
+ const path = args.path as string;
51
+
52
+ // Validate path to prevent SSRF / path traversal.
53
+ const ALLOWED_PREFIXES = ["/hub/", "/registry/", "/wallet/", "/subscriptions/", "/app/"];
54
+
55
+ // Reject absolute URLs (scheme://...) — path must be relative to Hub
56
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path)) {
57
+ return validationError(
58
+ "Absolute URLs are not allowed — provide a path like /hub/inbox",
59
+ "Path traversal and arbitrary URLs are not allowed.",
60
+ );
61
+ }
62
+
63
+ // Reject query strings embedded in path — callers must use the query field.
64
+ // URL normalization strips ?… from path, so silently accepting them would
65
+ // drop parameters the caller intended to send.
66
+ if (path.includes("?")) {
67
+ return validationError(
68
+ "Query strings in path are not allowed — use the query parameter instead",
69
+ 'e.g. path: "/hub/search", query: { q: "deploy" }',
70
+ );
71
+ }
72
+
73
+ // Resolve against dummy base to normalize percent-encoded traversal
74
+ // (e.g. /%2e%2e/ → /../ → resolved away by URL constructor)
75
+ let resolvedPath: string;
76
+ try {
77
+ resolvedPath = new URL(path, "http://localhost").pathname;
78
+ } catch {
79
+ return validationError("Invalid path", "Could not parse the provided path as a URL.");
80
+ }
81
+ const normalized = resolvedPath.replace(/\/+/g, "/"); // collapse duplicate slashes
82
+ if (normalized.includes("..") || !ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) {
83
+ return validationError(
84
+ `path must start with one of: ${ALLOWED_PREFIXES.join(", ")}`,
85
+ "Path traversal and arbitrary URLs are not allowed.",
86
+ );
87
+ }
88
+
89
+ // Write operations require explicit confirmation via confirm param
90
+ if (method !== "GET" && !args.confirm) {
91
+ return {
92
+ ok: false,
93
+ error: {
94
+ type: "validation" as const,
95
+ code: "confirmation_required",
96
+ message: `${method} ${path} is a write operation — set confirm: true to proceed`,
97
+ hint: "Raw API write operations bypass structured tool safeguards. Review the request carefully before confirming.",
98
+ },
99
+ };
100
+ }
101
+
102
+ return withClient(async (client) => {
103
+ // Use the normalized path so the request matches what was validated
104
+ const result = await client.request(method, normalized, {
105
+ body: args.data,
106
+ query: args.query,
107
+ });
108
+ return { response: result };
109
+ });
110
+ },
111
+ };
112
+ }
package/src/tools/bind.ts CHANGED
@@ -1,39 +1,20 @@
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
6
6
  */
7
- import {
8
- getSingleAccountModeError,
9
- resolveAccountConfig,
10
- isAccountConfigured,
11
- } from "../config.js";
12
- import { BotCordClient } from "../client.js";
13
- import { attachTokenPersistence } from "../credentials.js";
14
- import { getConfig as getAppConfig } from "../runtime.js";
7
+ import { withClient } from "./with-client.js";
8
+ import { validationError } from "./tool-result.js";
9
+ import { HubApiError } from "../client.js";
15
10
 
16
11
  /**
17
12
  * Shared bind logic used by both the tool and the command.
18
13
  */
19
14
  export async function executeBind(
20
15
  bindCredential: string,
21
- _dashboardUrl?: string,
22
- ): Promise<{ ok: true; [key: string]: unknown } | { error: string }> {
23
- const cfg = getAppConfig();
24
- if (!cfg) return { error: "No configuration available" };
25
- const singleAccountError = getSingleAccountModeError(cfg);
26
- if (singleAccountError) return { error: singleAccountError };
27
-
28
- const acct = resolveAccountConfig(cfg);
29
- if (!isAccountConfigured(acct)) {
30
- return { error: "BotCord is not configured." };
31
- }
32
-
33
- const client = new BotCordClient(acct);
34
- attachTokenPersistence(client, acct);
35
-
36
- try {
16
+ ) {
17
+ return withClient(async (client) => {
37
18
  const agentToken = await client.ensureToken();
38
19
  const agentId = client.getAgentId();
39
20
 
@@ -59,14 +40,12 @@ export async function executeBind(
59
40
  const body = await res.json().catch(() => null);
60
41
 
61
42
  if (!res.ok) {
62
- const msg = body?.error || body?.message || res.statusText;
63
- return { error: `Dashboard bind failed (${res.status}): ${msg}` };
43
+ const msg = body?.error || body?.detail || body?.message || res.statusText;
44
+ throw new HubApiError(res.status, JSON.stringify({ detail: msg }), "/api/users/me/agents/bind");
64
45
  }
65
46
 
66
47
  return { ok: true, ...body };
67
- } catch (err: any) {
68
- return { error: `Bind failed: ${err.message}` };
69
- }
48
+ });
70
49
  }
71
50
 
72
51
  export function createBindTool() {
@@ -82,18 +61,14 @@ export function createBindTool() {
82
61
  type: "string" as const,
83
62
  description: "The short bind code or bind ticket from the BotCord web dashboard",
84
63
  },
85
- dashboard_url: {
86
- type: "string" as const,
87
- description: "Dashboard base URL (unused, bind endpoint is resolved from Hub URL)",
88
- },
89
64
  },
90
65
  required: ["bind_ticket"],
91
66
  },
92
67
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
93
68
  if (!args.bind_ticket) {
94
- return { error: "bind_ticket is required" };
69
+ return validationError("bind_ticket is required");
95
70
  }
96
- return executeBind(args.bind_ticket, args.dashboard_url);
71
+ return executeBind(args.bind_ticket);
97
72
  },
98
73
  };
99
74
  }
@@ -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
+ }