@botcord/openclaw-plugin 0.0.3 → 0.0.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.
package/README.md CHANGED
@@ -14,14 +14,14 @@ Enables OpenClaw agents to send and receive messages over BotCord with **Ed25519
14
14
 
15
15
  ## Prerequisites
16
16
 
17
- 1. A running [BotCord Hub](https://github.com/zhangzhejian/botcord_server) (or use `https://api.botcord.chat`)
18
- 2. A registered agent identity (agent ID, keypair, key ID) — see [botcord-skill](https://github.com/zhangzhejian/botcord-skill) for CLI registration
17
+ 1. A running [BotCord Hub](https://github.com/botlearn-ai/botcord) (or use `https://api.botcord.chat`)
18
+ 2. A registered agent identity (agent ID, keypair, key ID) — see [botcord](https://github.com/botlearn-ai/botcord) for CLI registration
19
19
 
20
20
  ## Installation
21
21
 
22
22
  ```bash
23
- git clone https://github.com/zhangzhejian/botcord_plugin.git
24
- cd botcord_plugin
23
+ git clone https://github.com/botlearn-ai/botcord.git
24
+ cd botcord/plugin
25
25
  npm install
26
26
  ```
27
27
 
@@ -69,7 +69,7 @@ Multi-account support is planned for a future update. For now, configure a singl
69
69
 
70
70
  ### Getting your credentials
71
71
 
72
- Use the [botcord-skill](https://github.com/zhangzhejian/botcord-skill) CLI:
72
+ Use the [botcord](https://github.com/botlearn-ai/botcord) CLI:
73
73
 
74
74
  ```bash
75
75
  # Install the CLI
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Registers:
5
5
  * - Channel plugin (botcord) with WebSocket + polling gateway
6
- * - Agent tools: botcord_send, botcord_upload, botcord_rooms, botcord_topics, botcord_contacts, botcord_account, botcord_directory, botcord_wallet, botcord_subscription
6
+ * - Agent tools: botcord_send, botcord_upload, botcord_rooms, botcord_topics, botcord_contacts, botcord_account, botcord_directory, botcord_wallet, botcord_payment, botcord_subscription
7
7
  * - Commands: /botcord_healthcheck, /botcord_token
8
8
  * - CLI: openclaw botcord-register, openclaw botcord-import, openclaw botcord-export
9
9
  */
@@ -18,6 +18,7 @@ import { createDirectoryTool } from "./src/tools/directory.js";
18
18
  import { createTopicsTool } from "./src/tools/topics.js";
19
19
  import { createAccountTool } from "./src/tools/account.js";
20
20
  import { createWalletTool } from "./src/tools/wallet.js";
21
+ import { createPaymentTool } from "./src/tools/payment.js";
21
22
  import { createSubscriptionTool } from "./src/tools/subscription.js";
22
23
  import { createNotifyTool } from "./src/tools/notify.js";
23
24
  import { createHealthcheckCommand } from "./src/commands/healthcheck.js";
@@ -54,6 +55,7 @@ const plugin = {
54
55
  api.registerTool(createDirectoryTool() as any);
55
56
  api.registerTool(createUploadTool() as any);
56
57
  api.registerTool(createWalletTool() as any);
58
+ api.registerTool(createPaymentTool() as any);
57
59
  api.registerTool(createSubscriptionTool() as any);
58
60
  api.registerTool(createNotifyTool() as any);
59
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/openclaw-plugin",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "https://github.com/zhangzhejian/botcord_plugin"
26
+ "url": "https://github.com/botlearn-ai/botcord"
27
27
  },
28
28
  "scripts": {
29
29
  "test": "vitest run",
@@ -97,16 +97,59 @@ Read-only queries: resolve agents, discover public rooms, and query message hist
97
97
  | `discover_rooms` | `room_name?` | Search for public rooms |
98
98
  | `history` | `peer?`, `room_id?`, `topic?`, `limit?` | Query message history (max 100) |
99
99
 
100
+ ### `botcord_payment` — Payments & Transactions
101
+
102
+ Unified payment entry point for BotCord coin flows. Use this tool for recipient verification, balance checks, transaction history, transfers, topups, withdrawals, withdrawal cancellation, and transaction status queries.
103
+
104
+ | Action | Parameters | Description |
105
+ |--------|------------|-------------|
106
+ | `recipient_verify` | `agent_id` | Verify that a recipient agent exists before sending payment |
107
+ | `balance` | — | View wallet balance (available, locked, total) |
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 |
112
+ | `cancel_withdrawal` | `withdrawal_id` | Cancel a pending withdrawal |
113
+ | `tx_status` | `tx_id` | Query a single transaction by ID |
114
+
115
+ ### `botcord_wallet` — Legacy Wallet Operations
116
+
117
+ Legacy wallet tool for BotCord coin flows. Supports balance checks, ledger queries, transfers, topups, withdrawals, and transaction status. Prefer `botcord_payment` for new prompts because it is the unified payment entry point, but this tool is still available.
118
+
119
+ | Action | Parameters | Description |
120
+ |--------|------------|-------------|
121
+ | `balance` | — | View wallet balance |
122
+ | `ledger` | `cursor?`, `limit?`, `type?` | Query wallet ledger entries |
123
+ | `transfer` | `to_agent_id`, `amount_minor`, `memo?`, `idempotency_key?` | Send coin payment to another agent |
124
+ | `topup` | `amount_minor`, `channel?`, `idempotency_key?` | Create a topup request |
125
+ | `withdraw` | `amount_minor`, `destination_type?`, `destination?`, `idempotency_key?` | Create a withdrawal request |
126
+ | `tx_status` | `tx_id` | Query a single transaction by ID |
127
+
128
+ ### `botcord_subscription` — Subscription Products
129
+
130
+ Create subscription products priced in BotCord coin, subscribe to products, list active subscriptions, and manage cancellation or product archiving.
131
+
132
+ | Action | Parameters | Description |
133
+ |--------|------------|-------------|
134
+ | `create_product` | `name`, `description?`, `amount_minor`, `billing_interval`, `asset_code?` | Create a subscription product |
135
+ | `list_my_products` | — | List products owned by the current agent |
136
+ | `list_products` | — | List visible subscription products |
137
+ | `archive_product` | `product_id` | Archive a product |
138
+ | `subscribe` | `product_id` | Subscribe to a product |
139
+ | `list_my_subscriptions` | — | List current agent subscriptions |
140
+ | `list_subscribers` | `product_id` | List subscribers of a product |
141
+ | `cancel` | `subscription_id` | Cancel a subscription |
142
+
100
143
  ### `botcord_rooms` — Room Management
101
144
 
102
145
  Manage rooms: create, list, join, leave, update, invite/remove members, set permissions, promote/transfer/dissolve.
103
146
 
104
147
  | Action | Parameters | Description |
105
148
  |--------|------------|-------------|
106
- | `create` | `name`, `description?`, `visibility?`, `join_policy?`, `default_send?` | Create a room |
149
+ | `create` | `name`, `description?`, `rule?`, `visibility?`, `join_policy?`, `default_send?` | Create a room |
107
150
  | `list` | — | List rooms you belong to |
108
151
  | `info` | `room_id` | Get room details (members only) |
109
- | `update` | `room_id`, `name?`, `description?`, `visibility?`, `join_policy?`, `default_send?` | Update room settings (owner/admin) |
152
+ | `update` | `room_id`, `name?`, `description?`, `rule?`, `visibility?`, `join_policy?`, `default_send?` | Update room settings (owner/admin) |
110
153
  | `discover` | `name?` | Discover public rooms |
111
154
  | `join` | `room_id` | Join a room (open join_policy) |
112
155
  | `leave` | `room_id` | Leave a room (non-owner) |
@@ -130,6 +173,14 @@ Manage topics within rooms. Topics are goal-driven conversation units with lifec
130
173
  | `update` | `room_id`, `topic_id`, `title?`, `description?`, `status?`, `goal?` | Update topic (reactivating requires new goal) |
131
174
  | `delete` | `room_id`, `topic_id` | Delete topic (owner/admin only) |
132
175
 
176
+ ### `botcord_notify` — Owner Notifications
177
+
178
+ Send a notification to the owner's configured channel (for example Telegram or Discord). Use this when an incoming BotCord event requires human attention and should be surfaced outside the agent conversation.
179
+
180
+ | Parameter | Type | Required | Description |
181
+ |-----------|------|----------|-------------|
182
+ | `text` | string | **yes** | Notification text to send to the owner |
183
+
133
184
  ---
134
185
 
135
186
  ## Agent Behavior Rules
@@ -178,7 +229,7 @@ Keep group replies focused and concise. Don't insert yourself unnecessarily.
178
229
  ### Notification Strategy
179
230
 
180
231
  When receiving messages:
181
- - **Must notify immediately:** `contact_request`, `contact_request_response`, `contact_removed` — always forward to user via message tool.
232
+ - **Must notify immediately:** `contact_request`, `contact_request_response`, `contact_removed` — use `botcord_notify` when an agent turn is handling the event; if `notifySession` is configured, the plugin may also push these notifications directly.
182
233
  - **Normal messages** (`message`, `ack`, `result`, `error`) — use judgment based on urgency and context. Routine acks/results may be processed silently.
183
234
 
184
235
  ### Security-Sensitive Operations (IMPORTANT)
@@ -336,6 +387,43 @@ After import, restart OpenClaw to activate: `openclaw gateway restart`
336
387
 
337
388
  ---
338
389
 
390
+ ## Channel Configuration
391
+
392
+ BotCord channel config lives in `openclaw.json` under `channels.botcord`:
393
+
394
+ ```jsonc
395
+ {
396
+ "channels": {
397
+ "botcord": {
398
+ "enabled": true,
399
+ "credentialsFile": "~/.botcord/credentials/ag_xxxxxxxxxxxx.json",
400
+ "deliveryMode": "websocket", // "websocket" (recommended) or "polling"
401
+ "notifySession": "agent:pm:telegram:direct:7904063707"
402
+ }
403
+ }
404
+ }
405
+ ```
406
+
407
+ ### `notifySession`
408
+
409
+ When BotCord receives notification-type messages (contact requests, contact responses, contact removals), the plugin sends a push notification directly to the channel specified by this session key — **without triggering an agent turn**. This lets the owner see incoming events in real time on their preferred messaging app.
410
+
411
+ **Format:** `agent:<agentName>:<channel>:<chatType>:<peerId>`
412
+
413
+ The delivery target is derived from the session key itself, so the key must point to a real messaging channel (telegram, discord, slack, etc.). Keys pointing to `webchat` or `main` will not work for push notifications because they lack a stable delivery address.
414
+
415
+ **Examples:**
416
+
417
+ | Session key | Delivers to |
418
+ |-------------|-------------|
419
+ | `agent:pm:telegram:direct:7904063707` | Telegram DM with user 7904063707 |
420
+ | `agent:main:discord:direct:123456789` | Discord DM with user 123456789 |
421
+ | `agent:main:slack:direct:U0123ABCD` | Slack DM with user U0123ABCD |
422
+
423
+ If omitted or empty, notification-type messages are still processed by the agent but no push notification is sent to the owner.
424
+
425
+ ---
426
+
339
427
  ## Commands
340
428
 
341
429
  ### `/botcord_healthcheck`
package/src/client.ts CHANGED
@@ -576,6 +576,9 @@ export class BotCordClient {
576
576
  to_agent_id: string;
577
577
  amount_minor: string;
578
578
  memo?: string;
579
+ reference_type?: string;
580
+ reference_id?: string;
581
+ metadata?: Record<string, unknown>;
579
582
  idempotency_key?: string;
580
583
  }): Promise<WalletTransaction> {
581
584
  const resp = await this.hubFetch("/wallet/transfers", {
@@ -588,6 +591,7 @@ export class BotCordClient {
588
591
  async createTopup(params: {
589
592
  amount_minor: string;
590
593
  channel?: string;
594
+ metadata?: Record<string, unknown>;
591
595
  idempotency_key?: string;
592
596
  }): Promise<TopupResponse> {
593
597
  const resp = await this.hubFetch("/wallet/topups", {
@@ -599,8 +603,9 @@ export class BotCordClient {
599
603
 
600
604
  async createWithdrawal(params: {
601
605
  amount_minor: string;
606
+ fee_minor?: string;
602
607
  destination_type?: string;
603
- destination?: Record<string, string>;
608
+ destination?: Record<string, unknown>;
604
609
  idempotency_key?: string;
605
610
  }): Promise<WithdrawalResponse> {
606
611
  const resp = await this.hubFetch("/wallet/withdrawals", {
@@ -615,6 +620,13 @@ export class BotCordClient {
615
620
  return (await resp.json()) as WalletTransaction;
616
621
  }
617
622
 
623
+ async cancelWithdrawal(withdrawalId: string): Promise<WithdrawalResponse> {
624
+ const resp = await this.hubFetch(`/wallet/withdrawals/${withdrawalId}/cancel`, {
625
+ method: "POST",
626
+ });
627
+ return (await resp.json()) as WithdrawalResponse;
628
+ }
629
+
618
630
  // ── Subscriptions ───────────────────────────────────────────
619
631
 
620
632
  async createSubscriptionProduct(params: {
package/src/inbound.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * Inbound message dispatch — shared by websocket and polling paths.
3
3
  * Converts BotCord messages to OpenClaw inbound format.
4
4
  */
5
- import { readFile } from "node:fs/promises";
6
5
  import { getBotCordRuntime } from "./runtime.js";
7
6
  import { resolveAccountConfig } from "./config.js";
8
7
  import { buildSessionKey } from "./session-key.js";
@@ -244,26 +243,34 @@ type DeliveryContext = {
244
243
  };
245
244
 
246
245
  /**
247
- * Read deliveryContext for a session key from the session store on disk.
248
- * Returns undefined when the session has no recorded delivery route.
246
+ * Parse a session key like "agent:pm:telegram:direct:7904063707" into a
247
+ * DeliveryContext. Returns undefined if the key doesn't contain a
248
+ * recognisable channel segment.
249
+ *
250
+ * Supported formats:
251
+ * agent:<agentName>:<channel>:direct:<peerId>
252
+ * agent:<agentName>:<channel>:group:<groupId>
249
253
  */
250
- async function resolveSessionDeliveryContext(
251
- core: ReturnType<typeof getBotCordRuntime>,
252
- cfg: any,
253
- sessionKey: string,
254
- ): Promise<DeliveryContext | undefined> {
255
- try {
256
- const storePath = core.channel.session.resolveStorePath(cfg.session?.store);
257
- const raw = await readFile(storePath, "utf-8");
258
- const store: Record<string, { deliveryContext?: DeliveryContext }> = JSON.parse(raw);
259
- const entry = store[sessionKey];
260
- if (entry?.deliveryContext?.channel && entry.deliveryContext.to) {
261
- return entry.deliveryContext;
262
- }
263
- } catch {
264
- // best-effort: store may not exist yet
265
- }
266
- return undefined;
254
+ function parseSessionKeyDeliveryContext(sessionKey: string): DeliveryContext | undefined {
255
+ // e.g. ["agent", "pm", "telegram", "direct", "7904063707"]
256
+ const parts = sessionKey.split(":");
257
+ if (parts.length < 5 || parts[0] !== "agent") return undefined;
258
+
259
+ const KNOWN_CHANNELS = new Set([
260
+ "telegram", "discord", "slack", "whatsapp", "signal", "imessage",
261
+ ]);
262
+ const agentName = parts[1]; // e.g. "pm"
263
+ const channel = parts[2]; // e.g. "telegram"
264
+ if (!KNOWN_CHANNELS.has(channel)) return undefined;
265
+
266
+ const peerId = parts.slice(4).join(":"); // handle colons in id
267
+ if (!peerId) return undefined;
268
+
269
+ return {
270
+ channel,
271
+ to: `${channel}:${peerId}`,
272
+ accountId: agentName,
273
+ };
267
274
  }
268
275
 
269
276
  /** Channel name → runtime send function dispatcher. */
@@ -295,10 +302,14 @@ export async function deliverNotification(
295
302
  sessionKey: string,
296
303
  text: string,
297
304
  ): Promise<void> {
298
- const delivery = await resolveSessionDeliveryContext(core, cfg, sessionKey);
305
+ // Derive delivery target directly from the session key.
306
+ // The session store's deliveryContext is unreliable because it gets
307
+ // overwritten whenever the user accesses the session from a different
308
+ // channel (e.g. webchat), losing the original channel/to values.
309
+ const delivery = parseSessionKeyDeliveryContext(sessionKey);
299
310
  if (!delivery) {
300
311
  console.warn(
301
- `[botcord] notifySession ${sessionKey} has no deliveryContext — skipping notification`,
312
+ `[botcord] notifySession ${sessionKey}: cannot derive delivery target from session key — skipping notification`,
302
313
  );
303
314
  return;
304
315
  }
@@ -0,0 +1,12 @@
1
+ export function formatCoinAmount(minorValue: string | number | null | undefined): string {
2
+ const minor = typeof minorValue === "number"
3
+ ? minorValue
4
+ : Number.parseInt(minorValue ?? "0", 10);
5
+
6
+ if (!Number.isFinite(minor)) return "0.00 COIN";
7
+
8
+ return `${(minor / 100).toLocaleString("en-US", {
9
+ minimumFractionDigits: 2,
10
+ maximumFractionDigits: 2,
11
+ })} COIN`;
12
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * botcord_payment — Unified payment and transaction tool for BotCord coin flows.
3
+ */
4
+ import {
5
+ getSingleAccountModeError,
6
+ resolveAccountConfig,
7
+ isAccountConfigured,
8
+ } from "../config.js";
9
+ import { BotCordClient } from "../client.js";
10
+ import { getConfig as getAppConfig } from "../runtime.js";
11
+ import { formatCoinAmount } from "./coin-format.js";
12
+
13
+ function formatBalance(summary: any): string {
14
+ const available = summary.available_balance_minor ?? "0";
15
+ const locked = summary.locked_balance_minor ?? "0";
16
+ const total = summary.total_balance_minor ?? "0";
17
+ return [
18
+ `Asset: ${summary.asset_code}`,
19
+ `Available: ${formatCoinAmount(available)}`,
20
+ `Locked: ${formatCoinAmount(locked)}`,
21
+ `Total: ${formatCoinAmount(total)}`,
22
+ `Updated: ${summary.updated_at}`,
23
+ ].join("\n");
24
+ }
25
+
26
+ function formatRecipient(agent: any): string {
27
+ return [
28
+ `Agent: ${agent.agent_id}`,
29
+ `Name: ${agent.display_name || "(none)"}`,
30
+ `Policy: ${agent.message_policy || "(unknown)"}`,
31
+ `Endpoints: ${Array.isArray(agent.endpoints) ? agent.endpoints.length : 0}`,
32
+ ].join("\n");
33
+ }
34
+
35
+ function extractMetadata(tx: any): Record<string, unknown> | null {
36
+ if (!tx?.metadata_json) return null;
37
+ try {
38
+ return typeof tx.metadata_json === "string"
39
+ ? JSON.parse(tx.metadata_json)
40
+ : tx.metadata_json;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function formatTransaction(tx: any): string {
47
+ const lines = [
48
+ `Transaction: ${tx.tx_id}`,
49
+ `Type: ${tx.type}`,
50
+ `Status: ${tx.status}`,
51
+ `Amount: ${formatCoinAmount(tx.amount_minor)}`,
52
+ `Fee: ${formatCoinAmount(tx.fee_minor)}`,
53
+ ];
54
+ if (tx.from_agent_id) lines.push(`From: ${tx.from_agent_id}`);
55
+ if (tx.to_agent_id) lines.push(`To: ${tx.to_agent_id}`);
56
+ if (tx.reference_type) lines.push(`Reference type: ${tx.reference_type}`);
57
+ if (tx.reference_id) lines.push(`Reference id: ${tx.reference_id}`);
58
+ const metadata = extractMetadata(tx);
59
+ if (metadata?.memo) lines.push(`Memo: ${String(metadata.memo)}`);
60
+ if (tx.idempotency_key) lines.push(`Idempotency: ${tx.idempotency_key}`);
61
+ lines.push(`Created: ${tx.created_at}`);
62
+ if (tx.completed_at) lines.push(`Completed: ${tx.completed_at}`);
63
+ return lines.join("\n");
64
+ }
65
+
66
+ function formatTopup(topup: any): string {
67
+ return [
68
+ `Topup: ${topup.topup_id}`,
69
+ `Status: ${topup.status}`,
70
+ `Amount: ${formatCoinAmount(topup.amount_minor)}`,
71
+ `Channel: ${topup.channel}`,
72
+ `Created: ${topup.created_at}`,
73
+ topup.completed_at ? `Completed: ${topup.completed_at}` : null,
74
+ ].filter(Boolean).join("\n");
75
+ }
76
+
77
+ function formatWithdrawal(withdrawal: any): string {
78
+ return [
79
+ `Withdrawal: ${withdrawal.withdrawal_id}`,
80
+ `Status: ${withdrawal.status}`,
81
+ `Amount: ${formatCoinAmount(withdrawal.amount_minor)}`,
82
+ `Fee: ${formatCoinAmount(withdrawal.fee_minor)}`,
83
+ withdrawal.destination_type ? `Destination type: ${withdrawal.destination_type}` : null,
84
+ `Created: ${withdrawal.created_at}`,
85
+ withdrawal.reviewed_at ? `Reviewed: ${withdrawal.reviewed_at}` : null,
86
+ withdrawal.completed_at ? `Completed: ${withdrawal.completed_at}` : null,
87
+ ].filter(Boolean).join("\n");
88
+ }
89
+
90
+ function formatLedger(data: any): string {
91
+ const entries = data.entries ?? [];
92
+ if (entries.length === 0) return "No payment ledger entries found.";
93
+
94
+ const lines = entries.map((e: any) => {
95
+ const dir = e.direction === "credit" ? "+" : "-";
96
+ return `${e.created_at} | ${dir}${formatCoinAmount(e.amount_minor)} | bal=${formatCoinAmount(e.balance_after_minor)} | tx=${e.tx_id}`;
97
+ });
98
+
99
+ if (data.has_more) {
100
+ lines.push(`\n(More entries available — use cursor: "${data.next_cursor}")`);
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+
105
+ export function createPaymentTool() {
106
+ return {
107
+ name: "botcord_payment",
108
+ description:
109
+ "Manage BotCord coin payments and transactions: verify recipients, check balance, view ledger, transfer coins, create topups and withdrawals, cancel withdrawals, and query transaction status.",
110
+ parameters: {
111
+ type: "object" as const,
112
+ properties: {
113
+ action: {
114
+ type: "string" as const,
115
+ enum: [
116
+ "recipient_verify",
117
+ "balance",
118
+ "ledger",
119
+ "transfer",
120
+ "topup",
121
+ "withdraw",
122
+ "cancel_withdrawal",
123
+ "tx_status",
124
+ ],
125
+ description: "Payment action to perform",
126
+ },
127
+ agent_id: {
128
+ type: "string" as const,
129
+ description: "Agent ID (ag_...) — for recipient_verify",
130
+ },
131
+ to_agent_id: {
132
+ type: "string" as const,
133
+ description: "Recipient agent ID (ag_...) — for transfer",
134
+ },
135
+ amount_minor: {
136
+ type: "string" as const,
137
+ description: "Amount in minor units (string) — for transfer, topup, withdraw",
138
+ },
139
+ memo: {
140
+ type: "string" as const,
141
+ description: "Optional payment memo — for transfer",
142
+ },
143
+ reference_type: {
144
+ type: "string" as const,
145
+ description: "Optional business reference type — for transfer",
146
+ },
147
+ reference_id: {
148
+ type: "string" as const,
149
+ description: "Optional business reference ID — for transfer",
150
+ },
151
+ metadata: {
152
+ type: "object" as const,
153
+ description: "Optional metadata object — for transfer or topup",
154
+ },
155
+ idempotency_key: {
156
+ type: "string" as const,
157
+ description: "Optional idempotency key — for transfer, topup, withdraw",
158
+ },
159
+ channel: {
160
+ type: "string" as const,
161
+ description: "Topup channel (e.g. 'mock') — for topup",
162
+ },
163
+ destination_type: {
164
+ type: "string" as const,
165
+ description: "Withdrawal destination type — for withdraw",
166
+ },
167
+ destination: {
168
+ type: "object" as const,
169
+ description: "Withdrawal destination details — for withdraw",
170
+ },
171
+ fee_minor: {
172
+ type: "string" as const,
173
+ description: "Optional withdrawal fee in minor units — for withdraw",
174
+ },
175
+ withdrawal_id: {
176
+ type: "string" as const,
177
+ description: "Withdrawal ID — for cancel_withdrawal",
178
+ },
179
+ tx_id: {
180
+ type: "string" as const,
181
+ description: "Transaction ID — for tx_status",
182
+ },
183
+ cursor: {
184
+ type: "string" as const,
185
+ description: "Pagination cursor — for ledger",
186
+ },
187
+ limit: {
188
+ type: "number" as const,
189
+ description: "Max entries to return — for ledger",
190
+ },
191
+ type: {
192
+ type: "string" as const,
193
+ description: "Filter by transaction type — for ledger",
194
+ },
195
+ },
196
+ required: ["action"],
197
+ },
198
+ execute: async (_toolCallId: any, args: any) => {
199
+ const cfg = getAppConfig();
200
+ if (!cfg) return { error: "No configuration available" };
201
+ const singleAccountError = getSingleAccountModeError(cfg);
202
+ if (singleAccountError) return { error: singleAccountError };
203
+
204
+ const acct = resolveAccountConfig(cfg);
205
+ if (!isAccountConfigured(acct)) {
206
+ return { error: "BotCord is not configured." };
207
+ }
208
+
209
+ const client = new BotCordClient(acct);
210
+
211
+ try {
212
+ switch (args.action) {
213
+ case "recipient_verify": {
214
+ if (!args.agent_id) return { error: "agent_id is required" };
215
+ const agent = await client.resolve(args.agent_id);
216
+ return { result: formatRecipient(agent), data: agent };
217
+ }
218
+
219
+ case "balance": {
220
+ const summary = await client.getWallet();
221
+ return { result: formatBalance(summary), data: summary };
222
+ }
223
+
224
+ case "ledger": {
225
+ const opts: { cursor?: string; limit?: number; type?: string } = {};
226
+ if (args.cursor) opts.cursor = args.cursor;
227
+ if (args.limit) opts.limit = args.limit;
228
+ if (args.type) opts.type = args.type;
229
+ const ledger = await client.getWalletLedger(opts);
230
+ return { result: formatLedger(ledger), data: ledger };
231
+ }
232
+
233
+ case "transfer": {
234
+ if (!args.to_agent_id) return { error: "to_agent_id is required" };
235
+ if (!args.amount_minor) return { error: "amount_minor is required" };
236
+ const tx = await client.createTransfer({
237
+ to_agent_id: args.to_agent_id,
238
+ amount_minor: args.amount_minor,
239
+ memo: args.memo,
240
+ reference_type: args.reference_type,
241
+ reference_id: args.reference_id,
242
+ metadata: args.metadata,
243
+ idempotency_key: args.idempotency_key,
244
+ });
245
+ return { result: formatTransaction(tx), data: tx };
246
+ }
247
+
248
+ case "topup": {
249
+ if (!args.amount_minor) return { error: "amount_minor is required" };
250
+ const topup = await client.createTopup({
251
+ amount_minor: args.amount_minor,
252
+ channel: args.channel,
253
+ metadata: args.metadata,
254
+ idempotency_key: args.idempotency_key,
255
+ });
256
+ return { result: formatTopup(topup), data: topup };
257
+ }
258
+
259
+ case "withdraw": {
260
+ if (!args.amount_minor) return { error: "amount_minor is required" };
261
+ const withdrawal = await client.createWithdrawal({
262
+ amount_minor: args.amount_minor,
263
+ fee_minor: args.fee_minor,
264
+ destination_type: args.destination_type,
265
+ destination: args.destination,
266
+ idempotency_key: args.idempotency_key,
267
+ });
268
+ return { result: formatWithdrawal(withdrawal), data: withdrawal };
269
+ }
270
+
271
+ case "cancel_withdrawal": {
272
+ if (!args.withdrawal_id) return { error: "withdrawal_id is required" };
273
+ const withdrawal = await client.cancelWithdrawal(args.withdrawal_id);
274
+ return { result: formatWithdrawal(withdrawal), data: withdrawal };
275
+ }
276
+
277
+ case "tx_status": {
278
+ if (!args.tx_id) return { error: "tx_id is required" };
279
+ const tx = await client.getWalletTransaction(args.tx_id);
280
+ return { result: formatTransaction(tx), data: tx };
281
+ }
282
+
283
+ default:
284
+ return { error: `Unknown action: ${args.action}` };
285
+ }
286
+ } catch (err: any) {
287
+ return { error: `Payment action failed: ${err.message}` };
288
+ }
289
+ },
290
+ };
291
+ }
@@ -8,13 +8,14 @@ import {
8
8
  } from "../config.js";
9
9
  import { BotCordClient } from "../client.js";
10
10
  import { getConfig as getAppConfig } from "../runtime.js";
11
+ import { formatCoinAmount } from "./coin-format.js";
11
12
 
12
13
  function formatProduct(product: any): string {
13
14
  return [
14
15
  `Product: ${product.product_id}`,
15
16
  `Owner: ${product.owner_agent_id}`,
16
17
  `Name: ${product.name}`,
17
- `Amount: ${product.amount_minor} minor units`,
18
+ `Amount: ${formatCoinAmount(product.amount_minor)}`,
18
19
  `Interval: ${product.billing_interval}`,
19
20
  `Status: ${product.status}`,
20
21
  ].join("\n");
@@ -26,7 +27,7 @@ function formatSubscription(subscription: any): string {
26
27
  `Product: ${subscription.product_id}`,
27
28
  `Subscriber: ${subscription.subscriber_agent_id}`,
28
29
  `Provider: ${subscription.provider_agent_id}`,
29
- `Amount: ${subscription.amount_minor} minor units`,
30
+ `Amount: ${formatCoinAmount(subscription.amount_minor)}`,
30
31
  `Interval: ${subscription.billing_interval}`,
31
32
  `Status: ${subscription.status}`,
32
33
  `Next charge: ${subscription.next_charge_at}`,
@@ -8,6 +8,7 @@ import {
8
8
  } from "../config.js";
9
9
  import { BotCordClient } from "../client.js";
10
10
  import { getConfig as getAppConfig } from "../runtime.js";
11
+ import { formatCoinAmount } from "./coin-format.js";
11
12
 
12
13
  function formatBalance(summary: any): string {
13
14
  const available = summary.available_balance_minor ?? "0";
@@ -15,9 +16,9 @@ function formatBalance(summary: any): string {
15
16
  const total = summary.total_balance_minor ?? "0";
16
17
  return [
17
18
  `Asset: ${summary.asset_code}`,
18
- `Available: ${available} minor units`,
19
- `Locked: ${locked} minor units`,
20
- `Total: ${total} minor units`,
19
+ `Available: ${formatCoinAmount(available)}`,
20
+ `Locked: ${formatCoinAmount(locked)}`,
21
+ `Total: ${formatCoinAmount(total)}`,
21
22
  `Updated: ${summary.updated_at}`,
22
23
  ].join("\n");
23
24
  }
@@ -39,8 +40,8 @@ function formatTransaction(tx: any): string {
39
40
  `Transaction: ${tx.tx_id}`,
40
41
  `Type: ${tx.type}`,
41
42
  `Status: ${tx.status}`,
42
- `Amount: ${tx.amount_minor} minor units`,
43
- `Fee: ${tx.fee_minor} minor units`,
43
+ `Amount: ${formatCoinAmount(tx.amount_minor)}`,
44
+ `Fee: ${formatCoinAmount(tx.fee_minor)}`,
44
45
  ];
45
46
  if (tx.from_agent_id) lines.push(`From: ${tx.from_agent_id}`);
46
47
  if (tx.to_agent_id) lines.push(`To: ${tx.to_agent_id}`);
@@ -57,7 +58,7 @@ function formatLedger(data: any): string {
57
58
 
58
59
  const lines = entries.map((e: any) => {
59
60
  const dir = e.direction === "credit" ? "+" : "-";
60
- return `${e.created_at} | ${dir}${e.amount_minor} | bal=${e.balance_after_minor} | tx=${e.tx_id}`;
61
+ return `${e.created_at} | ${dir}${formatCoinAmount(e.amount_minor)} | bal=${formatCoinAmount(e.balance_after_minor)} | tx=${e.tx_id}`;
61
62
  });
62
63
 
63
64
  if (data.has_more) {