@a2hmarket/a2hmarket 0.7.9 → 0.8.1
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/index.ts +5 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/install.mjs +1 -1
- package/src/agent-service.ts +2 -0
- package/src/approval-store.ts +109 -0
- package/src/notify.ts +54 -0
- package/src/tools/approval.ts +205 -0
package/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ import { registerAddressTools } from "./src/tools/address.js";
|
|
|
36
36
|
import { registerDiscussionTools } from "./src/tools/discussion.js";
|
|
37
37
|
import { registerPaymentTools } from "./src/tools/payment.js";
|
|
38
38
|
import { registerTempoPaymentTools } from "./src/tools/tempo-payment.js";
|
|
39
|
+
import { registerApprovalTools } from "./src/tools/approval.js";
|
|
40
|
+
import { initApprovalStore } from "./src/approval-store.js";
|
|
39
41
|
|
|
40
42
|
export default {
|
|
41
43
|
id: "a2hmarket",
|
|
@@ -71,6 +73,7 @@ export default {
|
|
|
71
73
|
// registerPaymentTools(api, apiClient);
|
|
72
74
|
// registerTempoPaymentTools(api, apiClient, creds);
|
|
73
75
|
registerInboxHistoryTool(api, apiClient);
|
|
76
|
+
registerApprovalTools(api);
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// ── Auto-fix tools.alsoAllow at startup ──────────────────────
|
|
@@ -91,6 +94,7 @@ export default {
|
|
|
91
94
|
"a2h_send", "a2h_inbox_history",
|
|
92
95
|
"a2h_address_list", "a2h_address_create", "a2h_address_delete", "a2h_address_set_default",
|
|
93
96
|
"a2h_discussion_publish", "a2h_discussion_reply", "a2h_discussion_list",
|
|
97
|
+
"a2h_create_approval", "a2h_approval_response", "a2h_approval_list",
|
|
94
98
|
];
|
|
95
99
|
const missing = a2hTools.filter((t) => !alsoAllow.includes(t));
|
|
96
100
|
if (missing.length > 0) {
|
|
@@ -171,6 +175,7 @@ export default {
|
|
|
171
175
|
|
|
172
176
|
// Initialize stores with stateDir paths
|
|
173
177
|
initReplyBridge(join(dataDir, "reply-bridge.json"));
|
|
178
|
+
initApprovalStore(join(dataDir, "approvals.json"));
|
|
174
179
|
setLastChannelStore(new LastChannelStore(join(dataDir, "last-channel.json")));
|
|
175
180
|
|
|
176
181
|
const serviceLog = {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/scripts/install.mjs
CHANGED
|
@@ -765,7 +765,7 @@ async function main() {
|
|
|
765
765
|
if (target) {
|
|
766
766
|
log(` Detected Feishu user: ${CYAN}${target}${RESET}`);
|
|
767
767
|
} else {
|
|
768
|
-
|
|
768
|
+
log(` ${WARN} Could not auto-detect Feishu user. Notification will be configured after first Feishu message.`);
|
|
769
769
|
}
|
|
770
770
|
} else if (chosen.name === "discord") {
|
|
771
771
|
target = await prompt2.ask("Enter Discord channel ID", "");
|
package/src/agent-service.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { createSendTransport } from "./mqtt-transport.js";
|
|
|
20
20
|
import { buildEnvelope, signEnvelope } from "./protocol.js";
|
|
21
21
|
import { getA2HRuntime } from "./runtime.js";
|
|
22
22
|
import { notifyHuman, notifyPayment, resolveNotifyConfig, type NotifyLog } from "./notify.js";
|
|
23
|
+
import { setApprovalConfig } from "./tools/approval.js";
|
|
23
24
|
|
|
24
25
|
// ── MQTT Send Helper ─────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -64,6 +65,7 @@ export interface AgentServiceContext {
|
|
|
64
65
|
export async function startAgentService(ctx: AgentServiceContext): Promise<void> {
|
|
65
66
|
const creds = loadCredentials();
|
|
66
67
|
const runtime = getA2HRuntime();
|
|
68
|
+
setApprovalConfig(ctx.cfg);
|
|
67
69
|
|
|
68
70
|
const listener = new MqttListener(creds, {
|
|
69
71
|
info: (m) => ctx.log.info(`[mqtt] ${m}`),
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/approval-store.ts
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface Approval {
|
|
6
|
+
id: string;
|
|
7
|
+
peerId: string;
|
|
8
|
+
sessionKey: string;
|
|
9
|
+
question: string;
|
|
10
|
+
context?: string;
|
|
11
|
+
options: string[];
|
|
12
|
+
status: "pending" | "approved" | "rejected" | "custom";
|
|
13
|
+
response?: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
resolvedAt?: number;
|
|
16
|
+
notified: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
let storePath: string | null = null;
|
|
22
|
+
let approvals: Map<string, Approval> = new Map();
|
|
23
|
+
|
|
24
|
+
export function initApprovalStore(filePath: string): void {
|
|
25
|
+
storePath = filePath;
|
|
26
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
29
|
+
if (Array.isArray(data)) {
|
|
30
|
+
for (const a of data) {
|
|
31
|
+
if (a?.id) approvals.set(a.id, a);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// No persisted data yet
|
|
36
|
+
}
|
|
37
|
+
evict();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function persist(): void {
|
|
41
|
+
if (!storePath) return;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(storePath, JSON.stringify([...approvals.values()], null, 2));
|
|
44
|
+
} catch {
|
|
45
|
+
// Best effort
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function evict(): void {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
for (const [id, a] of approvals) {
|
|
52
|
+
if (now - a.createdAt > MAX_AGE_MS) approvals.delete(id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function generateId(): string {
|
|
57
|
+
return `apr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createApproval(params: {
|
|
61
|
+
peerId: string;
|
|
62
|
+
sessionKey: string;
|
|
63
|
+
question: string;
|
|
64
|
+
context?: string;
|
|
65
|
+
options?: string[];
|
|
66
|
+
}): Approval {
|
|
67
|
+
evict();
|
|
68
|
+
const approval: Approval = {
|
|
69
|
+
id: generateId(),
|
|
70
|
+
peerId: params.peerId,
|
|
71
|
+
sessionKey: params.sessionKey,
|
|
72
|
+
question: params.question,
|
|
73
|
+
context: params.context,
|
|
74
|
+
options: params.options ?? [],
|
|
75
|
+
status: "pending",
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
notified: false,
|
|
78
|
+
};
|
|
79
|
+
approvals.set(approval.id, approval);
|
|
80
|
+
persist();
|
|
81
|
+
return approval;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getApproval(id: string): Approval | null {
|
|
85
|
+
return approvals.get(id) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveApproval(id: string, status: "approved" | "rejected" | "custom", response: string): Approval | null {
|
|
89
|
+
const approval = approvals.get(id);
|
|
90
|
+
if (!approval || approval.status !== "pending") return null;
|
|
91
|
+
approval.status = status;
|
|
92
|
+
approval.response = response;
|
|
93
|
+
approval.resolvedAt = Date.now();
|
|
94
|
+
persist();
|
|
95
|
+
return approval;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function markNotified(id: string): void {
|
|
99
|
+
const approval = approvals.get(id);
|
|
100
|
+
if (approval) {
|
|
101
|
+
approval.notified = true;
|
|
102
|
+
persist();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function listPending(): Approval[] {
|
|
107
|
+
evict();
|
|
108
|
+
return [...approvals.values()].filter(a => a.status === "pending");
|
|
109
|
+
}
|
package/src/notify.ts
CHANGED
|
@@ -254,3 +254,57 @@ export async function notifyCustom(
|
|
|
254
254
|
return;
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Send an approval request notification to the human.
|
|
260
|
+
* Content is AI-generated — question, context, and options are all composed by the AI.
|
|
261
|
+
*/
|
|
262
|
+
export function notifyApproval(
|
|
263
|
+
approval: { id: string; peerId: string; question: string; context?: string; options: string[] },
|
|
264
|
+
agentId: string,
|
|
265
|
+
log?: NotifyLog,
|
|
266
|
+
): void {
|
|
267
|
+
const config = resolveNotifyConfig();
|
|
268
|
+
if (!config) {
|
|
269
|
+
log?.info("notify: no channel configured, skip approval notification");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (config.channel === "feishu" && config.appId && config.appSecret) {
|
|
274
|
+
const elements: FeishuCardElement[] = [
|
|
275
|
+
{ tag: "markdown", content: `**From:** \`${approval.peerId}\`` },
|
|
276
|
+
{ tag: "markdown", content: approval.question },
|
|
277
|
+
];
|
|
278
|
+
if (approval.context) {
|
|
279
|
+
elements.push({ tag: "markdown", content: `📝 ${approval.context}` });
|
|
280
|
+
}
|
|
281
|
+
if (approval.options.length > 0) {
|
|
282
|
+
elements.push({ tag: "markdown", content: `**Options:** ${approval.options.join(" / ")}` });
|
|
283
|
+
}
|
|
284
|
+
elements.push({ tag: "markdown", content: `---\n*Approval ID: ${approval.id}*\n*我的A2H Agent: ${agentId}*` });
|
|
285
|
+
|
|
286
|
+
sendFeishuCard({
|
|
287
|
+
appId: config.appId,
|
|
288
|
+
appSecret: config.appSecret,
|
|
289
|
+
target: config.target,
|
|
290
|
+
title: "🔔 A2H Market · Approval Required",
|
|
291
|
+
titleColor: "orange",
|
|
292
|
+
elements,
|
|
293
|
+
})
|
|
294
|
+
.then((msgId) => log?.info(`approval card sent: ${msgId}`))
|
|
295
|
+
.catch((err) => log?.error(`approval card failed: ${err.message}`));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (config.channel === "discord" && config.botToken) {
|
|
300
|
+
const contextBlock = approval.context ? `\n\n📝 ${approval.context}` : "";
|
|
301
|
+
const optionsBlock = approval.options.length > 0 ? `\n\n**Options:** ${approval.options.join(" / ")}` : "";
|
|
302
|
+
const text = `**🔔 A2H Market · Approval Required**\n\nFrom: \`${approval.peerId}\`\n\n${approval.question}${contextBlock}${optionsBlock}\n\n---\n_Approval ID: ${approval.id}_`;
|
|
303
|
+
sendDiscordText(config.botToken, config.target, text)
|
|
304
|
+
.then((msgId) => log?.info(`approval discord sent: ${msgId}`))
|
|
305
|
+
.catch((err) => log?.error(`approval discord failed: ${err.message}`));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
log?.info(`notify: unsupported channel "${config.channel}" for approval`);
|
|
310
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/tools/approval.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import {
|
|
4
|
+
dispatchInboundDirectDmWithRuntime,
|
|
5
|
+
} from "openclaw/plugin-sdk/channel-inbound";
|
|
6
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
|
+
import { createApproval, resolveApproval, getApproval, markNotified, listPending } from "../approval-store.js";
|
|
8
|
+
import { notifyApproval, type NotifyLog } from "../notify.js";
|
|
9
|
+
import { loadCredentials } from "../credentials.js";
|
|
10
|
+
import { getA2HRuntime } from "../runtime.js";
|
|
11
|
+
|
|
12
|
+
let _cfg: OpenClawConfig | null = null;
|
|
13
|
+
|
|
14
|
+
export function setApprovalConfig(cfg: OpenClawConfig): void {
|
|
15
|
+
_cfg = cfg;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerApprovalTools(api: OpenClawPluginApi) {
|
|
19
|
+
const notifyLog: NotifyLog = {
|
|
20
|
+
info: (m) => api.logger.info(`[approval] ${m}`),
|
|
21
|
+
error: (m) => api.logger.error(`[approval] ${m}`),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
api.registerTool({
|
|
25
|
+
name: "a2h_create_approval",
|
|
26
|
+
description:
|
|
27
|
+
"Create a human approval request. Use when you need the human to confirm a decision " +
|
|
28
|
+
"(e.g., accept/reject a price, confirm payment, authorize an action). " +
|
|
29
|
+
"Include a clear question, relevant context summary, and suggested options. " +
|
|
30
|
+
"The human will be notified and their response will be delivered back to this session. " +
|
|
31
|
+
"After calling this tool, WAIT — do not reply to the counterparty until you receive the approval result.",
|
|
32
|
+
parameters: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
peer_id: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Counterparty agent ID this approval relates to",
|
|
38
|
+
},
|
|
39
|
+
question: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Clear question for the human (e.g., '对方报价500元,是否接受?')",
|
|
42
|
+
},
|
|
43
|
+
context: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Brief context summary for the human. Include counterparty nickname if known, and summarize the last 2-3 negotiation messages so the human understands the situation.",
|
|
46
|
+
},
|
|
47
|
+
options: {
|
|
48
|
+
type: "array",
|
|
49
|
+
items: { type: "string" },
|
|
50
|
+
description: "Suggested response options for the human (e.g., ['接受', '拒绝', '还价']). Leave empty for free-text response.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
required: ["peer_id", "question"],
|
|
54
|
+
},
|
|
55
|
+
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
56
|
+
const peerId = params.peer_id as string;
|
|
57
|
+
const question = params.question as string;
|
|
58
|
+
const context = params.context as string | undefined;
|
|
59
|
+
const options = (params.options as string[]) ?? [];
|
|
60
|
+
|
|
61
|
+
if (!peerId) throw new Error("peer_id is required");
|
|
62
|
+
if (!question) throw new Error("question is required");
|
|
63
|
+
|
|
64
|
+
const sessionKey = `agent:main:a2hmarket:direct:${peerId}`;
|
|
65
|
+
const approval = createApproval({ peerId, sessionKey, question, context, options });
|
|
66
|
+
|
|
67
|
+
const creds = loadCredentials();
|
|
68
|
+
notifyApproval(approval, creds.agentId, notifyLog);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
result: JSON.stringify({
|
|
72
|
+
approval_id: approval.id,
|
|
73
|
+
status: "pending",
|
|
74
|
+
message: "Approval request sent to human. Wait for their response — it will be delivered to this session automatically.",
|
|
75
|
+
}, null, 2),
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
api.registerTool({
|
|
81
|
+
name: "a2h_approval_response",
|
|
82
|
+
description:
|
|
83
|
+
"Respond to a pending A2H Market approval request on behalf of the human. " +
|
|
84
|
+
"Use this when the human tells you their decision about a pending approval. " +
|
|
85
|
+
"The approval ID is shown in the notification card. " +
|
|
86
|
+
"The response will be automatically delivered to the trading session that requested it.",
|
|
87
|
+
parameters: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
approval_id: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Approval ID (shown in the notification card, format: apr_xxx)",
|
|
93
|
+
},
|
|
94
|
+
decision: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "Human's decision: 'approve'/'accept', 'reject'/'decline', or custom text (e.g., counter-offer '还价到450元')",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
required: ["approval_id", "decision"],
|
|
100
|
+
},
|
|
101
|
+
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
102
|
+
const approvalId = params.approval_id as string;
|
|
103
|
+
const decision = params.decision as string;
|
|
104
|
+
|
|
105
|
+
if (!approvalId) throw new Error("approval_id is required");
|
|
106
|
+
if (!decision) throw new Error("decision is required");
|
|
107
|
+
|
|
108
|
+
const existing = getApproval(approvalId);
|
|
109
|
+
if (!existing) {
|
|
110
|
+
throw new Error(`Approval ${approvalId} not found or expired`);
|
|
111
|
+
}
|
|
112
|
+
if (existing.status !== "pending") {
|
|
113
|
+
throw new Error(`Approval ${approvalId} already resolved: ${existing.status}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const status = decision === "approve" || decision === "accept" || decision === "接受"
|
|
117
|
+
? "approved" as const
|
|
118
|
+
: decision === "reject" || decision === "decline" || decision === "拒绝"
|
|
119
|
+
? "rejected" as const
|
|
120
|
+
: "custom" as const;
|
|
121
|
+
|
|
122
|
+
const resolved = resolveApproval(approvalId, status, decision);
|
|
123
|
+
if (!resolved) {
|
|
124
|
+
throw new Error(`Failed to resolve approval ${approvalId}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (_cfg) {
|
|
128
|
+
const runtime = getA2HRuntime();
|
|
129
|
+
const creds = loadCredentials();
|
|
130
|
+
const resultMessage = [
|
|
131
|
+
`[Human Approval Result]`,
|
|
132
|
+
`Approval: ${resolved.id}`,
|
|
133
|
+
`Decision: ${decision}`,
|
|
134
|
+
`Original question: ${resolved.question}`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await dispatchInboundDirectDmWithRuntime({
|
|
139
|
+
cfg: _cfg,
|
|
140
|
+
runtime,
|
|
141
|
+
channel: "a2hmarket",
|
|
142
|
+
channelLabel: "A2H Market",
|
|
143
|
+
accountId: "default",
|
|
144
|
+
peer: { kind: "direct", id: resolved.peerId },
|
|
145
|
+
senderId: "system",
|
|
146
|
+
senderAddress: "a2hmarket:system",
|
|
147
|
+
recipientAddress: `a2hmarket:${creds.agentId}`,
|
|
148
|
+
conversationLabel: resolved.peerId,
|
|
149
|
+
rawBody: resultMessage,
|
|
150
|
+
messageId: `approval_result_${resolved.id}`,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
commandAuthorized: true,
|
|
153
|
+
deliver: async () => {},
|
|
154
|
+
onRecordError: (err) => {
|
|
155
|
+
notifyLog.error(`approval dispatch record error: ${err instanceof Error ? err.message : String(err)}`);
|
|
156
|
+
},
|
|
157
|
+
onDispatchError: (err, info) => {
|
|
158
|
+
notifyLog.error(`approval dispatch ${info.kind} error: ${err instanceof Error ? err.message : String(err)}`);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
markNotified(resolved.id);
|
|
162
|
+
notifyLog.info(`approval ${resolved.id} result injected into DM session for ${resolved.peerId}`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
notifyLog.error(`approval dispatch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
notifyLog.error("approval: config not set, cannot dispatch result to DM session");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
result: JSON.stringify({
|
|
172
|
+
approval_id: resolved.id,
|
|
173
|
+
status: resolved.status,
|
|
174
|
+
decision,
|
|
175
|
+
delivered: resolved.notified,
|
|
176
|
+
}, null, 2),
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
api.registerTool({
|
|
182
|
+
name: "a2h_approval_list",
|
|
183
|
+
description: "List pending A2H Market approval requests awaiting human response.",
|
|
184
|
+
parameters: {
|
|
185
|
+
type: "object",
|
|
186
|
+
properties: {},
|
|
187
|
+
},
|
|
188
|
+
execute: async () => {
|
|
189
|
+
const pending = listPending();
|
|
190
|
+
return {
|
|
191
|
+
result: JSON.stringify({
|
|
192
|
+
count: pending.length,
|
|
193
|
+
approvals: pending.map(a => ({
|
|
194
|
+
id: a.id,
|
|
195
|
+
peerId: a.peerId,
|
|
196
|
+
question: a.question,
|
|
197
|
+
context: a.context,
|
|
198
|
+
options: a.options,
|
|
199
|
+
createdAt: new Date(a.createdAt).toISOString(),
|
|
200
|
+
})),
|
|
201
|
+
}, null, 2),
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|