@botcord/openclaw-plugin 0.0.2
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 +161 -0
- package/index.ts +64 -0
- package/openclaw.plugin.json +55 -0
- package/package.json +61 -0
- package/skills/botcord/SKILL.md +322 -0
- package/src/channel.ts +446 -0
- package/src/client.ts +616 -0
- package/src/commands/healthcheck.ts +143 -0
- package/src/commands/register.ts +302 -0
- package/src/commands/token.ts +43 -0
- package/src/config.ts +92 -0
- package/src/credentials.ts +113 -0
- package/src/crypto.ts +155 -0
- package/src/inbound.ts +305 -0
- package/src/poller.ts +70 -0
- package/src/runtime.ts +25 -0
- package/src/session-key.ts +59 -0
- package/src/tools/account.ts +94 -0
- package/src/tools/contacts.ts +120 -0
- package/src/tools/directory.ts +89 -0
- package/src/tools/messaging.ts +234 -0
- package/src/tools/notify.ts +55 -0
- package/src/tools/rooms.ts +177 -0
- package/src/tools/topics.ts +106 -0
- package/src/tools/wallet.ts +208 -0
- package/src/topic-tracker.ts +204 -0
- package/src/types.ts +203 -0
- package/src/ws-client.ts +187 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* botcord_rooms — Room lifecycle and membership management.
|
|
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
|
+
|
|
12
|
+
export function createRoomsTool() {
|
|
13
|
+
return {
|
|
14
|
+
name: "botcord_rooms",
|
|
15
|
+
description:
|
|
16
|
+
"Manage BotCord rooms: create, list, join, leave, update, invite/remove members, " +
|
|
17
|
+
"set permissions, promote/transfer/dissolve, discover public rooms.",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object" as const,
|
|
20
|
+
properties: {
|
|
21
|
+
action: {
|
|
22
|
+
type: "string" as const,
|
|
23
|
+
enum: [
|
|
24
|
+
"create", "list", "info", "update", "discover",
|
|
25
|
+
"join", "leave", "dissolve",
|
|
26
|
+
"members", "invite", "remove_member",
|
|
27
|
+
"promote", "transfer", "permissions",
|
|
28
|
+
],
|
|
29
|
+
description: "Room action to perform",
|
|
30
|
+
},
|
|
31
|
+
room_id: {
|
|
32
|
+
type: "string" as const,
|
|
33
|
+
description: "Room ID (rm_...)",
|
|
34
|
+
},
|
|
35
|
+
name: {
|
|
36
|
+
type: "string" as const,
|
|
37
|
+
description: "Room name — for create, update, discover",
|
|
38
|
+
},
|
|
39
|
+
description: {
|
|
40
|
+
type: "string" as const,
|
|
41
|
+
description: "Room description — for create, update",
|
|
42
|
+
},
|
|
43
|
+
visibility: {
|
|
44
|
+
type: "string" as const,
|
|
45
|
+
enum: ["private", "public"],
|
|
46
|
+
description: "Room visibility — for create, update",
|
|
47
|
+
},
|
|
48
|
+
join_policy: {
|
|
49
|
+
type: "string" as const,
|
|
50
|
+
enum: ["invite_only", "open"],
|
|
51
|
+
description: "Join policy — for create, update",
|
|
52
|
+
},
|
|
53
|
+
default_send: {
|
|
54
|
+
type: "boolean" as const,
|
|
55
|
+
description: "Whether all members can post — for create, update",
|
|
56
|
+
},
|
|
57
|
+
agent_id: {
|
|
58
|
+
type: "string" as const,
|
|
59
|
+
description: "Agent ID — for invite, remove_member, promote, transfer, permissions",
|
|
60
|
+
},
|
|
61
|
+
role: {
|
|
62
|
+
type: "string" as const,
|
|
63
|
+
enum: ["admin", "member"],
|
|
64
|
+
description: "Target role — for promote",
|
|
65
|
+
},
|
|
66
|
+
can_send: {
|
|
67
|
+
type: "boolean" as const,
|
|
68
|
+
description: "Send permission override — for permissions",
|
|
69
|
+
},
|
|
70
|
+
can_invite: {
|
|
71
|
+
type: "boolean" as const,
|
|
72
|
+
description: "Invite permission override — for permissions",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
required: ["action"],
|
|
76
|
+
},
|
|
77
|
+
execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
|
|
78
|
+
const cfg = getAppConfig();
|
|
79
|
+
if (!cfg) return { error: "No configuration available" };
|
|
80
|
+
const singleAccountError = getSingleAccountModeError(cfg);
|
|
81
|
+
if (singleAccountError) return { error: singleAccountError };
|
|
82
|
+
|
|
83
|
+
const acct = resolveAccountConfig(cfg);
|
|
84
|
+
if (!isAccountConfigured(acct)) {
|
|
85
|
+
return { error: "BotCord is not configured." };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const client = new BotCordClient(acct);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
switch (args.action) {
|
|
92
|
+
case "create":
|
|
93
|
+
if (!args.name) return { error: "name is required" };
|
|
94
|
+
return await client.createRoom({
|
|
95
|
+
name: args.name,
|
|
96
|
+
description: args.description,
|
|
97
|
+
visibility: args.visibility || "private",
|
|
98
|
+
join_policy: args.join_policy,
|
|
99
|
+
default_send: args.default_send,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
case "list":
|
|
103
|
+
return await client.listMyRooms();
|
|
104
|
+
|
|
105
|
+
case "info":
|
|
106
|
+
if (!args.room_id) return { error: "room_id is required" };
|
|
107
|
+
return await client.getRoomInfo(args.room_id);
|
|
108
|
+
|
|
109
|
+
case "update":
|
|
110
|
+
if (!args.room_id) return { error: "room_id is required" };
|
|
111
|
+
return await client.updateRoom(args.room_id, {
|
|
112
|
+
name: args.name,
|
|
113
|
+
description: args.description,
|
|
114
|
+
visibility: args.visibility,
|
|
115
|
+
join_policy: args.join_policy,
|
|
116
|
+
default_send: args.default_send,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
case "discover":
|
|
120
|
+
return await client.discoverRooms(args.name);
|
|
121
|
+
|
|
122
|
+
case "join":
|
|
123
|
+
if (!args.room_id) return { error: "room_id is required" };
|
|
124
|
+
await client.joinRoom(args.room_id);
|
|
125
|
+
return { ok: true, joined: args.room_id };
|
|
126
|
+
|
|
127
|
+
case "leave":
|
|
128
|
+
if (!args.room_id) return { error: "room_id is required" };
|
|
129
|
+
await client.leaveRoom(args.room_id);
|
|
130
|
+
return { ok: true, left: args.room_id };
|
|
131
|
+
|
|
132
|
+
case "dissolve":
|
|
133
|
+
if (!args.room_id) return { error: "room_id is required" };
|
|
134
|
+
await client.dissolveRoom(args.room_id);
|
|
135
|
+
return { ok: true, dissolved: args.room_id };
|
|
136
|
+
|
|
137
|
+
case "members":
|
|
138
|
+
if (!args.room_id) return { error: "room_id is required" };
|
|
139
|
+
return await client.getRoomMembers(args.room_id);
|
|
140
|
+
|
|
141
|
+
case "invite":
|
|
142
|
+
if (!args.room_id || !args.agent_id) return { error: "room_id and agent_id are required" };
|
|
143
|
+
await client.inviteToRoom(args.room_id, args.agent_id);
|
|
144
|
+
return { ok: true, invited: args.agent_id, room: args.room_id };
|
|
145
|
+
|
|
146
|
+
case "remove_member":
|
|
147
|
+
if (!args.room_id || !args.agent_id) return { error: "room_id and agent_id are required" };
|
|
148
|
+
await client.removeMember(args.room_id, args.agent_id);
|
|
149
|
+
return { ok: true, removed: args.agent_id, room: args.room_id };
|
|
150
|
+
|
|
151
|
+
case "promote":
|
|
152
|
+
if (!args.room_id || !args.agent_id) return { error: "room_id and agent_id are required" };
|
|
153
|
+
await client.promoteMember(args.room_id, args.agent_id, args.role || "admin");
|
|
154
|
+
return { ok: true, promoted: args.agent_id, role: args.role || "admin", room: args.room_id };
|
|
155
|
+
|
|
156
|
+
case "transfer":
|
|
157
|
+
if (!args.room_id || !args.agent_id) return { error: "room_id and agent_id are required" };
|
|
158
|
+
await client.transferOwnership(args.room_id, args.agent_id);
|
|
159
|
+
return { ok: true, new_owner: args.agent_id, room: args.room_id };
|
|
160
|
+
|
|
161
|
+
case "permissions":
|
|
162
|
+
if (!args.room_id || !args.agent_id) return { error: "room_id and agent_id are required" };
|
|
163
|
+
await client.setMemberPermissions(args.room_id, args.agent_id, {
|
|
164
|
+
can_send: args.can_send,
|
|
165
|
+
can_invite: args.can_invite,
|
|
166
|
+
});
|
|
167
|
+
return { ok: true, agent: args.agent_id, room: args.room_id };
|
|
168
|
+
|
|
169
|
+
default:
|
|
170
|
+
return { error: `Unknown action: ${args.action}` };
|
|
171
|
+
}
|
|
172
|
+
} catch (err: any) {
|
|
173
|
+
return { error: `Room action failed: ${err.message}` };
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* botcord_topics — Topic lifecycle management within rooms.
|
|
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
|
+
|
|
12
|
+
export function createTopicsTool() {
|
|
13
|
+
return {
|
|
14
|
+
name: "botcord_topics",
|
|
15
|
+
description:
|
|
16
|
+
"Manage topics within BotCord rooms. Topics are goal-driven conversation units " +
|
|
17
|
+
"with lifecycle states: open → completed/failed/expired.",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object" as const,
|
|
20
|
+
properties: {
|
|
21
|
+
action: {
|
|
22
|
+
type: "string" as const,
|
|
23
|
+
enum: ["create", "list", "get", "update", "delete"],
|
|
24
|
+
description: "Topic action to perform",
|
|
25
|
+
},
|
|
26
|
+
room_id: {
|
|
27
|
+
type: "string" as const,
|
|
28
|
+
description: "Room ID (rm_...) — required for all actions",
|
|
29
|
+
},
|
|
30
|
+
topic_id: {
|
|
31
|
+
type: "string" as const,
|
|
32
|
+
description: "Topic ID (tp_...) — for get, update, delete",
|
|
33
|
+
},
|
|
34
|
+
title: {
|
|
35
|
+
type: "string" as const,
|
|
36
|
+
description: "Topic title — for create, update",
|
|
37
|
+
},
|
|
38
|
+
description: {
|
|
39
|
+
type: "string" as const,
|
|
40
|
+
description: "Topic description — for create, update",
|
|
41
|
+
},
|
|
42
|
+
goal: {
|
|
43
|
+
type: "string" as const,
|
|
44
|
+
description: "Topic goal — declares the conversation's purpose. Required to reactivate a closed topic",
|
|
45
|
+
},
|
|
46
|
+
status: {
|
|
47
|
+
type: "string" as const,
|
|
48
|
+
enum: ["open", "completed", "failed", "expired"],
|
|
49
|
+
description: "Topic status — for list (filter) or update (transition)",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["action", "room_id"],
|
|
53
|
+
},
|
|
54
|
+
execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
|
|
55
|
+
const cfg = getAppConfig();
|
|
56
|
+
if (!cfg) return { error: "No configuration available" };
|
|
57
|
+
const singleAccountError = getSingleAccountModeError(cfg);
|
|
58
|
+
if (singleAccountError) return { error: singleAccountError };
|
|
59
|
+
|
|
60
|
+
const acct = resolveAccountConfig(cfg);
|
|
61
|
+
if (!isAccountConfigured(acct)) {
|
|
62
|
+
return { error: "BotCord is not configured." };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const client = new BotCordClient(acct);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
switch (args.action) {
|
|
69
|
+
case "create":
|
|
70
|
+
if (!args.title) return { error: "title is required" };
|
|
71
|
+
return await client.createTopic(args.room_id, {
|
|
72
|
+
title: args.title,
|
|
73
|
+
description: args.description,
|
|
74
|
+
goal: args.goal,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
case "list":
|
|
78
|
+
return await client.listTopics(args.room_id, args.status);
|
|
79
|
+
|
|
80
|
+
case "get":
|
|
81
|
+
if (!args.topic_id) return { error: "topic_id is required" };
|
|
82
|
+
return await client.getTopic(args.room_id, args.topic_id);
|
|
83
|
+
|
|
84
|
+
case "update":
|
|
85
|
+
if (!args.topic_id) return { error: "topic_id is required" };
|
|
86
|
+
return await client.updateTopic(args.room_id, args.topic_id, {
|
|
87
|
+
title: args.title,
|
|
88
|
+
description: args.description,
|
|
89
|
+
status: args.status,
|
|
90
|
+
goal: args.goal,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
case "delete":
|
|
94
|
+
if (!args.topic_id) return { error: "topic_id is required" };
|
|
95
|
+
await client.deleteTopic(args.room_id, args.topic_id);
|
|
96
|
+
return { ok: true, deleted: args.topic_id, room: args.room_id };
|
|
97
|
+
|
|
98
|
+
default:
|
|
99
|
+
return { error: `Unknown action: ${args.action}` };
|
|
100
|
+
}
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
return { error: `Topic action failed: ${err.message}` };
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* botcord_wallet — Manage the agent's coin wallet: balance, ledger, transfers, topups, withdrawals.
|
|
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
|
+
|
|
12
|
+
function formatBalance(summary: any): string {
|
|
13
|
+
const available = summary.available_balance_minor ?? "0";
|
|
14
|
+
const locked = summary.locked_balance_minor ?? "0";
|
|
15
|
+
const total = summary.total_balance_minor ?? "0";
|
|
16
|
+
return [
|
|
17
|
+
`Asset: ${summary.asset_code}`,
|
|
18
|
+
`Available: ${available} minor units`,
|
|
19
|
+
`Locked: ${locked} minor units`,
|
|
20
|
+
`Total: ${total} minor units`,
|
|
21
|
+
`Updated: ${summary.updated_at}`,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractMemo(tx: any): string | null {
|
|
26
|
+
if (!tx.metadata_json) return null;
|
|
27
|
+
try {
|
|
28
|
+
const meta = typeof tx.metadata_json === "string"
|
|
29
|
+
? JSON.parse(tx.metadata_json)
|
|
30
|
+
: tx.metadata_json;
|
|
31
|
+
return meta?.memo ?? null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatTransaction(tx: any): string {
|
|
38
|
+
const lines = [
|
|
39
|
+
`Transaction: ${tx.tx_id}`,
|
|
40
|
+
`Type: ${tx.type}`,
|
|
41
|
+
`Status: ${tx.status}`,
|
|
42
|
+
`Amount: ${tx.amount_minor} minor units`,
|
|
43
|
+
`Fee: ${tx.fee_minor} minor units`,
|
|
44
|
+
];
|
|
45
|
+
if (tx.from_agent_id) lines.push(`From: ${tx.from_agent_id}`);
|
|
46
|
+
if (tx.to_agent_id) lines.push(`To: ${tx.to_agent_id}`);
|
|
47
|
+
const memo = extractMemo(tx);
|
|
48
|
+
if (memo) lines.push(`Memo: ${memo}`);
|
|
49
|
+
lines.push(`Created: ${tx.created_at}`);
|
|
50
|
+
if (tx.completed_at) lines.push(`Completed: ${tx.completed_at}`);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatLedger(data: any): string {
|
|
55
|
+
const entries = data.entries ?? [];
|
|
56
|
+
if (entries.length === 0) return "No ledger entries found.";
|
|
57
|
+
|
|
58
|
+
const lines = entries.map((e: any) => {
|
|
59
|
+
const dir = e.direction === "credit" ? "+" : "-";
|
|
60
|
+
return `${e.created_at} | ${dir}${e.amount_minor} | bal=${e.balance_after_minor} | tx=${e.tx_id}`;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (data.has_more) {
|
|
64
|
+
lines.push(`\n(More entries available — use cursor: "${data.next_cursor}")`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createWalletTool() {
|
|
70
|
+
return {
|
|
71
|
+
name: "botcord_wallet",
|
|
72
|
+
description:
|
|
73
|
+
"Manage your BotCord coin wallet: check balance, view ledger, transfer coins, request topup/withdrawal, check transaction status.",
|
|
74
|
+
parameters: {
|
|
75
|
+
type: "object" as const,
|
|
76
|
+
properties: {
|
|
77
|
+
action: {
|
|
78
|
+
type: "string" as const,
|
|
79
|
+
enum: ["balance", "ledger", "transfer", "topup", "withdraw", "tx_status"],
|
|
80
|
+
description: "Wallet action to perform",
|
|
81
|
+
},
|
|
82
|
+
to_agent_id: {
|
|
83
|
+
type: "string" as const,
|
|
84
|
+
description: "Recipient agent ID (ag_...) — for transfer",
|
|
85
|
+
},
|
|
86
|
+
amount_minor: {
|
|
87
|
+
type: "string" as const,
|
|
88
|
+
description: "Amount in minor units (string) — for transfer, topup, withdraw",
|
|
89
|
+
},
|
|
90
|
+
memo: {
|
|
91
|
+
type: "string" as const,
|
|
92
|
+
description: "Optional memo — for transfer",
|
|
93
|
+
},
|
|
94
|
+
idempotency_key: {
|
|
95
|
+
type: "string" as const,
|
|
96
|
+
description: "Optional idempotency key (UUID) — for transfer, withdraw",
|
|
97
|
+
},
|
|
98
|
+
channel: {
|
|
99
|
+
type: "string" as const,
|
|
100
|
+
description: "Topup channel (e.g. 'mock') — for topup",
|
|
101
|
+
},
|
|
102
|
+
destination_type: {
|
|
103
|
+
type: "string" as const,
|
|
104
|
+
description: "Withdrawal destination type — for withdraw",
|
|
105
|
+
},
|
|
106
|
+
destination: {
|
|
107
|
+
type: "object" as const,
|
|
108
|
+
description: "Withdrawal destination details — for withdraw",
|
|
109
|
+
},
|
|
110
|
+
tx_id: {
|
|
111
|
+
type: "string" as const,
|
|
112
|
+
description: "Transaction ID — for tx_status",
|
|
113
|
+
},
|
|
114
|
+
cursor: {
|
|
115
|
+
type: "string" as const,
|
|
116
|
+
description: "Pagination cursor — for ledger",
|
|
117
|
+
},
|
|
118
|
+
limit: {
|
|
119
|
+
type: "number" as const,
|
|
120
|
+
description: "Max entries to return — for ledger",
|
|
121
|
+
},
|
|
122
|
+
type: {
|
|
123
|
+
type: "string" as const,
|
|
124
|
+
description: "Filter by transaction type — for ledger",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: ["action"],
|
|
128
|
+
},
|
|
129
|
+
execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
|
|
130
|
+
const cfg = getAppConfig();
|
|
131
|
+
if (!cfg) return { error: "No configuration available" };
|
|
132
|
+
const singleAccountError = getSingleAccountModeError(cfg);
|
|
133
|
+
if (singleAccountError) return { error: singleAccountError };
|
|
134
|
+
|
|
135
|
+
const acct = resolveAccountConfig(cfg);
|
|
136
|
+
if (!isAccountConfigured(acct)) {
|
|
137
|
+
return { error: "BotCord is not configured." };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const client = new BotCordClient(acct);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
switch (args.action) {
|
|
144
|
+
case "balance": {
|
|
145
|
+
const summary = await client.getWallet();
|
|
146
|
+
return { result: formatBalance(summary), data: summary };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case "ledger": {
|
|
150
|
+
const opts: { cursor?: string; limit?: number; type?: string } = {};
|
|
151
|
+
if (args.cursor) opts.cursor = args.cursor;
|
|
152
|
+
if (args.limit) opts.limit = args.limit;
|
|
153
|
+
if (args.type) opts.type = args.type;
|
|
154
|
+
const ledger = await client.getWalletLedger(opts);
|
|
155
|
+
return { result: formatLedger(ledger), data: ledger };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case "transfer": {
|
|
159
|
+
if (!args.to_agent_id) return { error: "to_agent_id is required" };
|
|
160
|
+
if (!args.amount_minor) return { error: "amount_minor is required" };
|
|
161
|
+
const tx = await client.createTransfer({
|
|
162
|
+
to_agent_id: args.to_agent_id,
|
|
163
|
+
amount_minor: args.amount_minor,
|
|
164
|
+
memo: args.memo,
|
|
165
|
+
idempotency_key: args.idempotency_key,
|
|
166
|
+
});
|
|
167
|
+
return { result: formatTransaction(tx), data: tx };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "topup": {
|
|
171
|
+
if (!args.amount_minor) return { error: "amount_minor is required" };
|
|
172
|
+
const topup = await client.createTopup({
|
|
173
|
+
amount_minor: args.amount_minor,
|
|
174
|
+
channel: args.channel,
|
|
175
|
+
idempotency_key: args.idempotency_key,
|
|
176
|
+
});
|
|
177
|
+
return { result: `Topup request created: ${JSON.stringify(topup)}`, data: topup };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "withdraw": {
|
|
181
|
+
if (!args.amount_minor) return { error: "amount_minor is required" };
|
|
182
|
+
const withdrawal = await client.createWithdrawal({
|
|
183
|
+
amount_minor: args.amount_minor,
|
|
184
|
+
destination_type: args.destination_type,
|
|
185
|
+
destination: args.destination,
|
|
186
|
+
idempotency_key: args.idempotency_key,
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
result: `Withdrawal request created: ${JSON.stringify(withdrawal)}`,
|
|
190
|
+
data: withdrawal,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case "tx_status": {
|
|
195
|
+
if (!args.tx_id) return { error: "tx_id is required" };
|
|
196
|
+
const tx = await client.getWalletTransaction(args.tx_id);
|
|
197
|
+
return { result: formatTransaction(tx), data: tx };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
default:
|
|
201
|
+
return { error: `Unknown action: ${args.action}` };
|
|
202
|
+
}
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
return { error: `Wallet action failed: ${err.message}` };
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopicTracker — Agent-side Topic lifecycle state management.
|
|
3
|
+
*
|
|
4
|
+
* Implements the decision tree from the Topic lifecycle design doc:
|
|
5
|
+
* - No topic → one-way notification, don't auto-reply
|
|
6
|
+
* - Has topic + open → auto-reply OK
|
|
7
|
+
* - Has topic + terminated + has goal → reactivate to open, auto-reply OK
|
|
8
|
+
* - Has topic + terminated + no goal → don't auto-reply
|
|
9
|
+
* - type: result → mark completed
|
|
10
|
+
* - type: error → mark failed
|
|
11
|
+
* - TTL expiration → mark expired
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TopicState = "open" | "completed" | "failed" | "expired";
|
|
15
|
+
|
|
16
|
+
export interface TopicInfo {
|
|
17
|
+
state: TopicState;
|
|
18
|
+
goal?: string;
|
|
19
|
+
lastActivityAt: number; // timestamp ms
|
|
20
|
+
ttlMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HandleIncomingParams {
|
|
24
|
+
topic?: string | null;
|
|
25
|
+
goal?: string | null;
|
|
26
|
+
type: string; // message type: "message", "ack", "result", "error", etc.
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HandleIncomingResult {
|
|
30
|
+
shouldReply: boolean;
|
|
31
|
+
reason: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class TopicTracker {
|
|
35
|
+
private topics = new Map<string, TopicInfo>();
|
|
36
|
+
private defaultTtlMs: number;
|
|
37
|
+
|
|
38
|
+
constructor(options?: { defaultTtlMs?: number }) {
|
|
39
|
+
this.defaultTtlMs = options?.defaultTtlMs ?? 3_600_000; // 1 hour default
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process an incoming message and return whether the agent should auto-reply.
|
|
44
|
+
*
|
|
45
|
+
* Decision tree:
|
|
46
|
+
* - No topic → one-way notification, don't auto-reply
|
|
47
|
+
* - type: "result" → mark completed, don't auto-reply (termination signal)
|
|
48
|
+
* - type: "error" → mark failed, don't auto-reply (termination signal)
|
|
49
|
+
* - Topic unseen → create as open, auto-reply
|
|
50
|
+
* - Topic open → update activity, auto-reply
|
|
51
|
+
* - Topic terminated (completed/failed/expired):
|
|
52
|
+
* - Message has goal → reactivate to open, auto-reply
|
|
53
|
+
* - Message has no goal → don't auto-reply
|
|
54
|
+
*/
|
|
55
|
+
handleIncoming(params: HandleIncomingParams): HandleIncomingResult {
|
|
56
|
+
const { topic, goal, type } = params;
|
|
57
|
+
|
|
58
|
+
// No topic → one-way notification
|
|
59
|
+
if (!topic) {
|
|
60
|
+
return { shouldReply: false, reason: "no topic — treated as one-way notification" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const existing = this.topics.get(topic);
|
|
65
|
+
|
|
66
|
+
// Check if existing topic has expired by TTL
|
|
67
|
+
if (existing && existing.state === "open") {
|
|
68
|
+
if (now - existing.lastActivityAt > existing.ttlMs) {
|
|
69
|
+
existing.state = "expired";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Termination signals: result or error
|
|
74
|
+
if (type === "result") {
|
|
75
|
+
this.upsertTopic(topic, "completed", goal ?? existing?.goal, now);
|
|
76
|
+
return { shouldReply: false, reason: "type is result — topic marked completed" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (type === "error") {
|
|
80
|
+
this.upsertTopic(topic, "failed", goal ?? existing?.goal, now);
|
|
81
|
+
return { shouldReply: false, reason: "type is error — topic marked failed" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Topic not seen before → create as open
|
|
85
|
+
if (!existing) {
|
|
86
|
+
this.upsertTopic(topic, "open", goal ?? undefined, now);
|
|
87
|
+
return { shouldReply: true, reason: "new topic created as open" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Topic is open → update activity, auto-reply OK
|
|
91
|
+
if (existing.state === "open") {
|
|
92
|
+
existing.lastActivityAt = now;
|
|
93
|
+
if (goal) existing.goal = goal;
|
|
94
|
+
return { shouldReply: true, reason: "topic is open — auto-reply allowed" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Topic is terminated (completed / failed / expired)
|
|
98
|
+
if (goal) {
|
|
99
|
+
// Reactivate with new goal
|
|
100
|
+
existing.state = "open";
|
|
101
|
+
existing.goal = goal;
|
|
102
|
+
existing.lastActivityAt = now;
|
|
103
|
+
return { shouldReply: true, reason: "terminated topic reactivated with new goal" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Terminated + no goal → don't auto-reply
|
|
107
|
+
return {
|
|
108
|
+
shouldReply: false,
|
|
109
|
+
reason: `topic is ${existing.state} and message has no goal — not auto-replying`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get current state of a topic, checking TTL expiration. */
|
|
114
|
+
getState(topicKey: string): TopicState | undefined {
|
|
115
|
+
const info = this.topics.get(topicKey);
|
|
116
|
+
if (!info) return undefined;
|
|
117
|
+
|
|
118
|
+
// Check TTL expiration for open topics
|
|
119
|
+
if (info.state === "open" && Date.now() - info.lastActivityAt > info.ttlMs) {
|
|
120
|
+
info.state = "expired";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return info.state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Get full topic info. */
|
|
127
|
+
getTopicInfo(topicKey: string): TopicInfo | undefined {
|
|
128
|
+
const info = this.topics.get(topicKey);
|
|
129
|
+
if (!info) return undefined;
|
|
130
|
+
|
|
131
|
+
// Check TTL expiration for open topics
|
|
132
|
+
if (info.state === "open" && Date.now() - info.lastActivityAt > info.ttlMs) {
|
|
133
|
+
info.state = "expired";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ...info };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mark a topic as completed (e.g., when sending a result message). */
|
|
140
|
+
markCompleted(topicKey: string): void {
|
|
141
|
+
const info = this.topics.get(topicKey);
|
|
142
|
+
if (info) {
|
|
143
|
+
info.state = "completed";
|
|
144
|
+
info.lastActivityAt = Date.now();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Mark a topic as failed (e.g., when sending an error message). */
|
|
149
|
+
markFailed(topicKey: string): void {
|
|
150
|
+
const info = this.topics.get(topicKey);
|
|
151
|
+
if (info) {
|
|
152
|
+
info.state = "failed";
|
|
153
|
+
info.lastActivityAt = Date.now();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Clean up expired topics. Returns the count of topics removed. */
|
|
158
|
+
sweepExpired(): number {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
let count = 0;
|
|
161
|
+
|
|
162
|
+
for (const [key, info] of this.topics) {
|
|
163
|
+
// Check TTL expiration for open topics
|
|
164
|
+
if (info.state === "open" && now - info.lastActivityAt > info.ttlMs) {
|
|
165
|
+
info.state = "expired";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (info.state === "expired") {
|
|
169
|
+
this.topics.delete(key);
|
|
170
|
+
count++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return count;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Return the number of tracked topics. */
|
|
178
|
+
get size(): number {
|
|
179
|
+
return this.topics.size;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Internal helpers ────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
private upsertTopic(
|
|
185
|
+
key: string,
|
|
186
|
+
state: TopicState,
|
|
187
|
+
goal: string | undefined,
|
|
188
|
+
now: number,
|
|
189
|
+
): void {
|
|
190
|
+
const existing = this.topics.get(key);
|
|
191
|
+
if (existing) {
|
|
192
|
+
existing.state = state;
|
|
193
|
+
existing.lastActivityAt = now;
|
|
194
|
+
if (goal !== undefined) existing.goal = goal;
|
|
195
|
+
} else {
|
|
196
|
+
this.topics.set(key, {
|
|
197
|
+
state,
|
|
198
|
+
goal,
|
|
199
|
+
lastActivityAt: now,
|
|
200
|
+
ttlMs: this.defaultTtlMs,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|