@botcord/botcord 0.3.6 → 0.3.7

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.
@@ -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
@@ -4,44 +4,26 @@
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
- ): Promise<{ ok: true; [key: string]: unknown } | { error: string }> {
22
- const cfg = getAppConfig();
23
- if (!cfg) return { error: "No configuration available" };
24
- const singleAccountError = getSingleAccountModeError(cfg);
25
- if (singleAccountError) return { error: singleAccountError };
26
-
27
- const acct = resolveAccountConfig(cfg);
28
- if (!isAccountConfigured(acct)) {
29
- return { error: "BotCord is not configured." };
30
- }
31
-
32
- const client = new BotCordClient(acct);
33
- attachTokenPersistence(client, acct);
34
-
35
- try {
16
+ ) {
17
+ return withClient(async (client) => {
36
18
  const agentToken = await client.ensureToken();
37
19
  const agentId = client.getAgentId();
38
20
 
39
21
  const resolved = (await client.resolve(agentId)) as Record<string, unknown>;
40
22
  const displayName = (resolved.display_name as string) || agentId;
41
23
 
42
- const hubUrl = client.getHubUrl();
24
+ const baseUrl = client.getHubUrl().replace(/\/+$/, "");
43
25
 
44
- const res = await fetch(`${hubUrl}/api/users/me/agents/bind`, {
26
+ const res = await fetch(`${baseUrl}/api/users/me/agents/bind`, {
45
27
  method: "POST",
46
28
  headers: { "Content-Type": "application/json" },
47
29
  body: JSON.stringify({
@@ -59,13 +41,11 @@ export async function executeBind(
59
41
 
60
42
  if (!res.ok) {
61
43
  const msg = body?.error || body?.detail || body?.message || res.statusText;
62
- return { error: `Bind failed (${res.status}): ${msg}` };
44
+ throw new HubApiError(res.status, JSON.stringify({ detail: msg }), "/api/users/me/agents/bind");
63
45
  }
64
46
 
65
47
  return { ok: true, ...body };
66
- } catch (err: any) {
67
- return { error: `Bind failed: ${err.message}` };
68
- }
48
+ });
69
49
  }
70
50
 
71
51
  export function createBindTool() {
@@ -86,7 +66,7 @@ export function createBindTool() {
86
66
  },
87
67
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
88
68
  if (!args.bind_ticket) {
89
- return { error: "bind_ticket is required" };
69
+ return validationError("bind_ticket is required");
90
70
  }
91
71
  return executeBind(args.bind_ticket);
92
72
  },
@@ -1,14 +1,8 @@
1
1
  /**
2
2
  * botcord_contacts — Manage social relationships: contacts, requests, blocks.
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 createContactsTool() {
14
8
  return {
@@ -56,69 +50,66 @@ export function createContactsTool() {
56
50
  enum: ["pending", "accepted", "rejected"],
57
51
  description: "Filter by state — for received_requests, sent_requests",
58
52
  },
53
+ dry_run: {
54
+ type: "boolean" as const,
55
+ description: "Preview the request without executing. Returns the API call that would be made.",
56
+ },
59
57
  },
60
58
  required: ["action"],
61
59
  },
62
60
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
63
- const cfg = getAppConfig();
64
- if (!cfg) return { error: "No configuration available" };
65
- const singleAccountError = getSingleAccountModeError(cfg);
66
- if (singleAccountError) return { error: singleAccountError };
67
-
68
- const acct = resolveAccountConfig(cfg);
69
- if (!isAccountConfigured(acct)) {
70
- return { error: "BotCord is not configured." };
71
- }
72
-
73
- const client = new BotCordClient(acct);
74
- attachTokenPersistence(client, acct);
75
-
76
- try {
61
+ return withClient(async (client) => {
77
62
  switch (args.action) {
78
63
  case "list":
79
- return await client.listContacts();
64
+ return { contacts: await client.listContacts() };
80
65
 
81
66
  case "remove":
82
- if (!args.agent_id) return { error: "agent_id is required" };
67
+ if (!args.agent_id) return validationError("agent_id is required");
68
+ if (args.dry_run) return dryRunResult("DELETE", `/registry/agents/{self}/contacts/${args.agent_id}`);
83
69
  await client.removeContact(args.agent_id);
84
70
  return { ok: true, removed: args.agent_id };
85
71
 
86
72
  case "send_request":
87
- if (!args.agent_id) return { error: "agent_id is required" };
73
+ if (!args.agent_id) return validationError("agent_id is required");
74
+ if (args.dry_run) return dryRunResult("POST", "/hub/send", { to: args.agent_id, type: "contact_request", payload: args.message ? { text: args.message } : {} }, { note: "The actual body is a signed envelope (JCS + Ed25519), not the raw fields shown here." });
88
75
  await client.sendContactRequest(args.agent_id, args.message);
89
76
  return { ok: true, sent_to: args.agent_id };
90
77
 
91
78
  case "received_requests":
92
- return await client.listReceivedRequests(args.state);
79
+ return { requests: await client.listReceivedRequests(args.state) };
93
80
 
94
81
  case "sent_requests":
95
- return await client.listSentRequests(args.state);
82
+ return { requests: await client.listSentRequests(args.state) };
96
83
 
97
84
  case "accept_request":
98
- if (!args.request_id) return { error: "request_id is required" };
85
+ if (!args.request_id) return validationError("request_id is required");
86
+ if (args.dry_run) return dryRunResult("POST", `/registry/agents/{self}/contact-requests/${args.request_id}/accept`);
99
87
  await client.acceptRequest(args.request_id);
100
88
  return { ok: true, accepted: args.request_id };
101
89
 
102
90
  case "reject_request":
103
- if (!args.request_id) return { error: "request_id is required" };
91
+ if (!args.request_id) return validationError("request_id is required");
92
+ if (args.dry_run) return dryRunResult("POST", `/registry/agents/{self}/contact-requests/${args.request_id}/reject`);
104
93
  await client.rejectRequest(args.request_id);
105
94
  return { ok: true, rejected: args.request_id };
106
95
 
107
96
  case "block":
108
- if (!args.agent_id) return { error: "agent_id is required" };
97
+ if (!args.agent_id) return validationError("agent_id is required");
98
+ if (args.dry_run) return dryRunResult("POST", `/registry/agents/{self}/blocks`, { blocked_agent_id: args.agent_id });
109
99
  await client.blockAgent(args.agent_id);
110
100
  return { ok: true, blocked: args.agent_id };
111
101
 
112
102
  case "unblock":
113
- if (!args.agent_id) return { error: "agent_id is required" };
103
+ if (!args.agent_id) return validationError("agent_id is required");
104
+ if (args.dry_run) return dryRunResult("DELETE", `/registry/agents/{self}/blocks/${args.agent_id}`);
114
105
  await client.unblockAgent(args.agent_id);
115
106
  return { ok: true, unblocked: args.agent_id };
116
107
 
117
108
  case "list_blocks":
118
- return await client.listBlocks();
109
+ return { blocks: await client.listBlocks() };
119
110
 
120
111
  case "redeem_invite": {
121
- if (!args.invite_code) return { error: "invite_code is required" };
112
+ if (!args.invite_code) return validationError("invite_code is required");
122
113
  // Extract code from full URL if needed (e.g. .../invites/iv_xxx/redeem or /i/iv_xxx)
123
114
  const raw = args.invite_code as string;
124
115
  const match = raw.match(/\b(iv_[a-zA-Z0-9]+)/);
@@ -127,11 +118,9 @@ export function createContactsTool() {
127
118
  }
128
119
 
129
120
  default:
130
- return { error: `Unknown action: ${args.action}` };
121
+ return validationError(`Unknown action: ${args.action}`);
131
122
  }
132
- } catch (err: any) {
133
- return { error: `Contact action failed: ${err.message}` };
134
- }
123
+ });
135
124
  },
136
125
  };
137
126
  }
@@ -1,14 +1,8 @@
1
1
  /**
2
2
  * botcord_directory — Read-only queries: resolve agents, discover rooms, message history.
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 } from "./tool-result.js";
12
6
 
13
7
  export function createDirectoryTool() {
14
8
  return {
@@ -63,30 +57,17 @@ export function createDirectoryTool() {
63
57
  required: ["action"],
64
58
  },
65
59
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
66
- const cfg = getAppConfig();
67
- if (!cfg) return { error: "No configuration available" };
68
- const singleAccountError = getSingleAccountModeError(cfg);
69
- if (singleAccountError) return { error: singleAccountError };
70
-
71
- const acct = resolveAccountConfig(cfg);
72
- if (!isAccountConfigured(acct)) {
73
- return { error: "BotCord is not configured." };
74
- }
75
-
76
- const client = new BotCordClient(acct);
77
- attachTokenPersistence(client, acct);
78
-
79
- try {
60
+ return withClient(async (client) => {
80
61
  switch (args.action) {
81
62
  case "resolve":
82
- if (!args.agent_id) return { error: "agent_id is required" };
63
+ if (!args.agent_id) return validationError("agent_id is required");
83
64
  return await client.resolve(args.agent_id);
84
65
 
85
66
  case "discover_rooms":
86
67
  return await client.discoverPublicRooms(args.room_name);
87
68
 
88
69
  case "history":
89
- return await client.getHistory({
70
+ return { history: await client.getHistory({
90
71
  peer: args.peer,
91
72
  roomId: args.room_id,
92
73
  topic: args.topic,
@@ -94,14 +75,12 @@ export function createDirectoryTool() {
94
75
  before: args.before,
95
76
  after: args.after,
96
77
  limit: args.limit || 20,
97
- });
78
+ }) };
98
79
 
99
80
  default:
100
- return { error: `Unknown action: ${args.action}` };
81
+ return validationError(`Unknown action: ${args.action}`);
101
82
  }
102
- } catch (err: any) {
103
- return { error: `Directory action failed: ${err.message}` };
104
- }
83
+ });
105
84
  },
106
85
  };
107
86
  }
@@ -3,15 +3,9 @@
3
3
  */
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { basename } from "node:path";
6
- import { lookup } from "node:dns";
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";
6
+ import { withClient } from "./with-client.js";
7
+ import { validationError, dryRunResult } from "./tool-result.js";
8
+ import type { BotCordClient } from "../client.js";
15
9
  import type { MessageAttachment } from "../types.js";
16
10
 
17
11
  /** Extract clean filename from a URL, stripping query string and hash. */
@@ -127,23 +121,30 @@ export function createMessagingTool() {
127
121
  items: { type: "string" as const },
128
122
  description: "URLs of already-hosted files to attach to the message",
129
123
  },
124
+ dry_run: {
125
+ type: "boolean" as const,
126
+ description: "Preview the request without sending. Returns the API call that would be made.",
127
+ },
130
128
  },
131
129
  required: ["to", "text"],
132
130
  },
133
131
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
134
- const cfg = getAppConfig();
135
- if (!cfg) return { error: "No configuration available" };
136
- const singleAccountError = getSingleAccountModeError(cfg);
137
- if (singleAccountError) return { error: singleAccountError };
138
-
139
- const acct = resolveAccountConfig(cfg);
140
- if (!isAccountConfigured(acct)) {
141
- return { error: "BotCord is not configured. Set hubUrl, agentId, keyId, and privateKey." };
132
+ if (args.dry_run) {
133
+ const msgType = args.type || "message";
134
+ const body: Record<string, unknown> = { to: args.to, text: args.text, type: msgType };
135
+ if (args.topic) body.topic = args.topic;
136
+ if (args.goal) body.goal = args.goal;
137
+ if (args.reply_to) body.reply_to = args.reply_to;
138
+ if (args.mentions) body.mentions = args.mentions;
139
+ if (args.file_paths) body.file_paths = args.file_paths;
140
+ if (args.file_urls) body.file_urls = args.file_urls;
141
+ const notes: string[] = [];
142
+ if (args.file_paths?.length) notes.push("Local files will be uploaded via POST /hub/upload before sending.");
143
+ notes.push("The actual body is a signed envelope (JCS + Ed25519), not the raw fields shown here.");
144
+ return dryRunResult("POST", "/hub/send", body, { note: notes.join(" ") });
142
145
  }
143
146
 
144
- try {
145
- const client = new BotCordClient(acct);
146
- attachTokenPersistence(client, acct);
147
+ return withClient(async (client) => {
147
148
  const msgType = args.type || "message";
148
149
 
149
150
  // Collect attachments from both file_paths (upload first) and file_urls
@@ -182,9 +183,7 @@ export function createMessagingTool() {
182
183
  attachments: finalAttachments,
183
184
  });
184
185
  return { ok: true, hub_msg_id: result.hub_msg_id, to: args.to, type: msgType, attachments: finalAttachments };
185
- } catch (err: any) {
186
- return { error: `Failed to send: ${err.message}` };
187
- }
186
+ });
188
187
  },
189
188
  };
190
189
  }
@@ -212,28 +211,14 @@ export function createUploadTool() {
212
211
  required: ["file_paths"],
213
212
  },
214
213
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
215
- const cfg = getAppConfig();
216
- if (!cfg) return { error: "No configuration available" };
217
- const singleAccountError = getSingleAccountModeError(cfg);
218
- if (singleAccountError) return { error: singleAccountError };
219
-
220
- const acct = resolveAccountConfig(cfg);
221
- if (!isAccountConfigured(acct)) {
222
- return { error: "BotCord is not configured. Set hubUrl, agentId, keyId, and privateKey." };
223
- }
224
-
225
214
  if (!args.file_paths || args.file_paths.length === 0) {
226
- return { error: "file_paths is required and must not be empty" };
215
+ return validationError("file_paths is required and must not be empty");
227
216
  }
228
217
 
229
- try {
230
- const client = new BotCordClient(acct);
231
- attachTokenPersistence(client, acct);
218
+ return withClient(async (client) => {
232
219
  const uploaded = await uploadLocalFiles(client, args.file_paths);
233
220
  return { ok: true, files: uploaded };
234
- } catch (err: any) {
235
- return { error: `Upload failed: ${err.message}` };
236
- }
221
+ });
237
222
  },
238
223
  };
239
224
  }