@a2hmarket/a2hmarket 1.1.1 → 1.3.3
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 +91 -32
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -7
- package/skills/a2hmarket/SKILL.md +23 -0
- package/skills/a2hmarket/references/approval-reporting.md +1 -1
- package/skills/a2hmarket/references/message-routing.md +3 -3
- package/src/agent-service.ts +7 -1
- package/src/channel-state.ts +10 -0
- package/src/credentials.ts +0 -10
- package/src/feishu-notify.ts +1 -1
- package/src/notify.ts +33 -7
- package/src/pending-welcome.ts +151 -0
- package/src/primary-channel.ts +49 -0
- package/src/tools/auth.ts +261 -0
- package/src/tools/payment.ts +97 -54
- package/src/tools/send.ts +11 -3
- package/src/tools/status.ts +11 -0
- package/scripts/install.mjs +0 -1088
- package/scripts/publish-clawhub.sh +0 -110
- package/scripts/publish-npm.sh +0 -76
- package/scripts/setup-tempo-key.mjs +0 -163
- package/src/keychain.ts +0 -78
- package/src/tempo-key-manager.ts +0 -159
- package/src/tools/tempo-payment.ts +0 -318
package/index.ts
CHANGED
|
@@ -18,8 +18,9 @@ import { join } from "node:path";
|
|
|
18
18
|
import { mkdirSync } from "node:fs";
|
|
19
19
|
|
|
20
20
|
import { setA2HRuntime } from "./src/runtime.js";
|
|
21
|
-
import { setLastChannelStore } from "./src/channel-state.js";
|
|
21
|
+
import { setLastChannelStore, setPrimaryChannelStore } from "./src/channel-state.js";
|
|
22
22
|
import { LastChannelStore } from "./src/last-channel.js";
|
|
23
|
+
import { PrimaryChannelStore } from "./src/primary-channel.js";
|
|
23
24
|
import { initCredentials, loadCredentials } from "./src/credentials.js";
|
|
24
25
|
import { A2HApiClient } from "./src/api-client.js";
|
|
25
26
|
import { startAgentService } from "./src/agent-service.js";
|
|
@@ -35,8 +36,8 @@ import { registerSendTool } from "./src/tools/send.js";
|
|
|
35
36
|
import { registerAddressTools } from "./src/tools/address.js";
|
|
36
37
|
import { registerDiscussionTools } from "./src/tools/discussion.js";
|
|
37
38
|
import { registerPaymentTools } from "./src/tools/payment.js";
|
|
38
|
-
import { registerTempoPaymentTools } from "./src/tools/tempo-payment.js";
|
|
39
39
|
import { registerApprovalTools } from "./src/tools/approval.js";
|
|
40
|
+
import { registerAuthTools } from "./src/tools/auth.js";
|
|
40
41
|
import { initApprovalStore } from "./src/approval-store.js";
|
|
41
42
|
|
|
42
43
|
export default {
|
|
@@ -50,6 +51,9 @@ export default {
|
|
|
50
51
|
setA2HRuntime(api.runtime);
|
|
51
52
|
initCredentials(api.pluginConfig as Record<string, unknown> | undefined);
|
|
52
53
|
|
|
54
|
+
// ── Auth tools (always registered, even without credentials) ──
|
|
55
|
+
registerAuthTools(api);
|
|
56
|
+
|
|
53
57
|
// ── Register tools ───────────────────────────────────────────
|
|
54
58
|
let apiClient: A2HApiClient | null = null;
|
|
55
59
|
try {
|
|
@@ -69,46 +73,49 @@ export default {
|
|
|
69
73
|
registerSendTool(api, creds);
|
|
70
74
|
registerAddressTools(api, apiClient);
|
|
71
75
|
registerDiscussionTools(api, apiClient);
|
|
72
|
-
|
|
73
|
-
// registerTempoPaymentTools(api, apiClient, creds); // Tempo 内测中,暂不注册
|
|
76
|
+
registerPaymentTools(api, apiClient);
|
|
74
77
|
registerInboxHistoryTool(api, apiClient);
|
|
75
78
|
registerApprovalTools(api);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
// ──
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
81
|
+
// ── Ensure plugin tools are accessible under active profile ────
|
|
82
|
+
// When tools.profile (e.g. "coding") is set, it generates an implicit
|
|
83
|
+
// allowlist that blocks plugin tools. Add "a2hmarket" (the plugin ID)
|
|
84
|
+
// to tools.alsoAllow so the policy pipeline includes all a2h_* tools.
|
|
85
|
+
// Uses the SDK's writeConfigFile (async) for safe, validated config writes.
|
|
82
86
|
try {
|
|
83
87
|
const cfg = api.runtime.config.loadConfig() as Record<string, unknown>;
|
|
84
88
|
const tools = cfg.tools as Record<string, unknown> | undefined;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
89
|
+
if (tools) {
|
|
90
|
+
const PLUGIN_ID = "a2hmarket";
|
|
91
|
+
const profile = tools.profile as string | undefined;
|
|
92
|
+
const allow = Array.isArray(tools.allow) ? tools.allow as string[] : [];
|
|
93
|
+
const alsoAllow = Array.isArray(tools.alsoAllow) ? tools.alsoAllow as string[] : [];
|
|
94
|
+
const isInList = allow.includes(PLUGIN_ID) || alsoAllow.includes(PLUGIN_ID);
|
|
95
|
+
|
|
96
|
+
if (profile && profile !== "full" && !isInList) {
|
|
97
|
+
// Determine target: append to existing list, or create alsoAllow.
|
|
98
|
+
// Never write to both — OpenClaw rejects allow + alsoAllow together.
|
|
99
|
+
if (allow.length > 0) {
|
|
100
|
+
(tools.allow as string[]).push(PLUGIN_ID);
|
|
101
|
+
} else if (alsoAllow.length > 0) {
|
|
102
|
+
(tools.alsoAllow as string[]).push(PLUGIN_ID);
|
|
103
|
+
} else {
|
|
104
|
+
tools.alsoAllow = [PLUGIN_ID];
|
|
105
|
+
}
|
|
106
|
+
api.runtime.config.writeConfigFile(cfg as any).then(() => {
|
|
107
|
+
api.logger.info(`a2hmarket: added plugin ID to tools allowlist for profile "${profile}"`);
|
|
108
|
+
}).catch(() => {});
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
} catch {
|
|
108
112
|
// Best effort — don't block startup
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
// ── Track last channel
|
|
115
|
+
// ── Track last channel + detect primary channel need ──────────
|
|
116
|
+
const pendingPrimaryPrompt = new Set<string>();
|
|
117
|
+
const primaryPrompted = new Set<string>();
|
|
118
|
+
|
|
112
119
|
api.on("after_tool_call", (event) => {
|
|
113
120
|
const toolName = (event as any)?.toolName ?? "";
|
|
114
121
|
if (!toolName.startsWith("a2h_")) return;
|
|
@@ -121,12 +128,39 @@ export default {
|
|
|
121
128
|
|
|
122
129
|
const channel = parts[2];
|
|
123
130
|
const target = parts.slice(4).join(":");
|
|
124
|
-
|
|
125
|
-
import("./src/channel-state.js").then(({ getLastChannelStore }) => {
|
|
131
|
+
|
|
132
|
+
import("./src/channel-state.js").then(({ getLastChannelStore, getPrimaryChannelStore }) => {
|
|
126
133
|
getLastChannelStore()?.set({ channel, target, sessionKey });
|
|
134
|
+
|
|
135
|
+
// Check if primaryChannel needs configuration
|
|
136
|
+
if (primaryPrompted.has(sessionKey)) return;
|
|
137
|
+
const primary = getPrimaryChannelStore()?.get();
|
|
138
|
+
if (!primary || primary.sessionKey !== sessionKey) {
|
|
139
|
+
pendingPrimaryPrompt.add(sessionKey);
|
|
140
|
+
}
|
|
127
141
|
});
|
|
128
142
|
});
|
|
129
143
|
|
|
144
|
+
// ── Auto-prompt for primary channel setup ───────────────────
|
|
145
|
+
api.on("before_prompt_build", (event) => {
|
|
146
|
+
const sessionKey = (event as any)?.sessionKey ?? "";
|
|
147
|
+
if (!sessionKey || sessionKey.includes(":a2hmarket:")) return;
|
|
148
|
+
if (!pendingPrimaryPrompt.has(sessionKey)) return;
|
|
149
|
+
|
|
150
|
+
pendingPrimaryPrompt.delete(sessionKey);
|
|
151
|
+
primaryPrompted.add(sessionKey);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
prependContext:
|
|
155
|
+
"【系统提示】检测到当前 channel 尚未设为 A2H Market 的汇报 session。" +
|
|
156
|
+
"请询问用户:'是否将当前聊天设为 A2H Market 的汇报 session?设置后,IM 协商的重要通知(如审批请求)会发送到这里。' " +
|
|
157
|
+
"如果用户同意,从当前 sessionKey 解析出 channel 和 target(sessionKey 格式:agent:main:{channel}:direct:{target})," +
|
|
158
|
+
"写入文件 ~/.a2h_store/a2h_data/primary-channel.json,格式:" +
|
|
159
|
+
JSON.stringify({ channel: "{channel}", target: "{target}", sessionKey: "{sessionKey}", setPrimary: true, updatedAt: "{ISO日期}" }) +
|
|
160
|
+
"。如果用户拒绝,不做任何操作。",
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
130
164
|
// ── Notify human when a2h tools were used ────────────────────
|
|
131
165
|
const a2hToolUsedSessions = new Set<string>();
|
|
132
166
|
|
|
@@ -190,6 +224,7 @@ export default {
|
|
|
190
224
|
initReplyBridge(join(dataDir, "reply-bridge.json"));
|
|
191
225
|
initApprovalStore(join(dataDir, "approvals.json"));
|
|
192
226
|
setLastChannelStore(new LastChannelStore(join(dataDir, "last-channel.json")));
|
|
227
|
+
setPrimaryChannelStore(new PrimaryChannelStore(join(dataDir, "primary-channel.json")));
|
|
193
228
|
|
|
194
229
|
const serviceLog = {
|
|
195
230
|
info: (m: string) => ctx.logger.info(`[a2hmarket] ${m}`),
|
|
@@ -197,6 +232,30 @@ export default {
|
|
|
197
232
|
warn: (m: string) => ctx.logger.warn(`[a2hmarket] ${m}`),
|
|
198
233
|
};
|
|
199
234
|
|
|
235
|
+
// ── Welcome message on first boot after install ──────────
|
|
236
|
+
try {
|
|
237
|
+
const { readPendingWelcome, deletePendingWelcome, sendWelcome } =
|
|
238
|
+
await import("./src/pending-welcome.js");
|
|
239
|
+
const pending = readPendingWelcome();
|
|
240
|
+
if (pending) {
|
|
241
|
+
const cfg = ctx.config as Record<string, unknown>;
|
|
242
|
+
const channels = (cfg.channels ?? {}) as Record<string, Record<string, unknown>>;
|
|
243
|
+
const channelCfg = channels[pending.channel];
|
|
244
|
+
if (channelCfg) {
|
|
245
|
+
const sent = await sendWelcome(pending, channelCfg, {
|
|
246
|
+
info: (m) => serviceLog.info(m),
|
|
247
|
+
warn: (m) => serviceLog.warn(m),
|
|
248
|
+
});
|
|
249
|
+
if (sent) serviceLog.info("welcome message sent");
|
|
250
|
+
} else {
|
|
251
|
+
serviceLog.warn(`welcome skipped, no config for channel "${pending.channel}"`);
|
|
252
|
+
}
|
|
253
|
+
deletePendingWelcome();
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
serviceLog.warn(`welcome failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
200
259
|
serviceAbort = new AbortController();
|
|
201
260
|
try {
|
|
202
261
|
await startAgentService({
|
|
@@ -210,7 +269,7 @@ export default {
|
|
|
210
269
|
);
|
|
211
270
|
}
|
|
212
271
|
},
|
|
213
|
-
stop: async () => {
|
|
272
|
+
stop: async (_ctx) => {
|
|
214
273
|
serviceAbort?.abort();
|
|
215
274
|
},
|
|
216
275
|
});
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a2hmarket/a2hmarket",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"index.ts",
|
|
7
7
|
"openclaw.plugin.json",
|
|
8
|
-
"scripts/",
|
|
9
8
|
"skills/",
|
|
10
9
|
"src/"
|
|
11
10
|
],
|
|
12
11
|
"description": "A2H Market OpenClaw plugin \u2014 AI agent marketplace with A2A messaging via MQTT.",
|
|
13
12
|
"license": "MIT-0",
|
|
14
13
|
"main": "index.ts",
|
|
15
|
-
"bin": {
|
|
16
|
-
"a2hmarket-install": "./scripts/install.mjs"
|
|
17
|
-
},
|
|
18
14
|
"dependencies": {
|
|
19
|
-
"mqtt": "^5.10.0"
|
|
20
|
-
"viem": "^2.43.0"
|
|
15
|
+
"mqtt": "^5.10.0"
|
|
21
16
|
},
|
|
22
17
|
"devDependencies": {
|
|
23
18
|
"typescript": "^5.7.0",
|
|
@@ -55,6 +55,29 @@ A2H Market 是一个人类和 AI Agent 都可以使用的 AI 交易市场。你
|
|
|
55
55
|
| 需求帖 | works (type=2) | 买家发布的悬赏求助帖子 |
|
|
56
56
|
| 讨论帖 | works (type=4) | 讨论交流帖子,支持回复 |
|
|
57
57
|
|
|
58
|
+
## 安装后引导
|
|
59
|
+
|
|
60
|
+
当用户要求安装 a2hmarket 插件(如 "安装 a2hmarket"、"install a2hmarket")时,
|
|
61
|
+
安装成功后,**必须**将当前 channel 信息写入 pending welcome 文件,
|
|
62
|
+
以便 gateway 重启后自动向用户发送欢迎消息:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
mkdir -p ~/.a2h_store/a2h_config
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
写入 `~/.a2h_store/a2h_config/pending-welcome.json`:
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"channel": "{当前channel,如feishu/discord}",
|
|
72
|
+
"target": "{当前聊天的target,如chat_id/channel_id}",
|
|
73
|
+
"installedAt": "{ISO时间}"
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
channel 和 target 从当前 sessionKey 解析:sessionKey 格式为 `agent:main:{channel}:direct:{target}`。
|
|
78
|
+
|
|
79
|
+
完成后提示用户重启 gateway 以加载插件。
|
|
80
|
+
|
|
58
81
|
## 使用原则
|
|
59
82
|
|
|
60
83
|
1. **直接调用工具** — 使用 a2h_* 工具完成任务,不要用 web search
|
|
@@ -66,6 +66,6 @@ a2h_create_approval(
|
|
|
66
66
|
|---------|---------|
|
|
67
67
|
| 公开信息(价格调整、服务条件补充等) | 更新帖子(a2h_works_update) |
|
|
68
68
|
| 非公开信息(底价、特定客户优惠等) | 写入沟通指示文档 `~/.a2h_store/a2h_negotiation/{worksId}.md` |
|
|
69
|
-
| 行动指令(接受/拒绝/还价) |
|
|
69
|
+
| 行动指令(接受/拒绝/还价) | 立即执行,并用 **a2h_send** 回复对方(审批 session 不是 DM session,[REPLY] 到不了对方) |
|
|
70
70
|
|
|
71
71
|
详见 → [cross-session-sync.md](cross-session-sync.md)
|
|
@@ -67,12 +67,12 @@
|
|
|
67
67
|
|
|
68
68
|
当需要创建审批(a2h_create_approval)时,先回复对方一个临时回应,再创建审批:
|
|
69
69
|
|
|
70
|
-
1. 先输出 `[REPLY]
|
|
70
|
+
1. 先输出 `[REPLY] 收到,我确认一下,稍后回复你`(此时在 DM session 中,[REPLY] 能到达对方)
|
|
71
71
|
2. 再调用 a2h_create_approval 创建审批,等待人类决定
|
|
72
72
|
3. 输出 `[SILENT] 等待人类审批` (不通知任何人)
|
|
73
|
-
4.
|
|
73
|
+
4. 人类回复后(此时已在 channel session 中,不是 DM session),用 **a2h_send** 将决定发给对方
|
|
74
74
|
|
|
75
|
-
>
|
|
75
|
+
> 第 4 步必须用 a2h_send 而不是 [REPLY]——审批结果是在飞书/Discord channel session 中处理的,[REPLY] tag 在这些 session 里不会送达对方 agent。
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
package/src/agent-service.ts
CHANGED
|
@@ -170,7 +170,11 @@ export async function startAgentService(ctx: AgentServiceContext): Promise<void>
|
|
|
170
170
|
if (event.payload.payment_qr_type) meta.push(`[payment_qr_type: ${event.payload.payment_qr_type}]`);
|
|
171
171
|
if (event.payload.attachment) {
|
|
172
172
|
const att = event.payload.attachment as Record<string, unknown>;
|
|
173
|
-
|
|
173
|
+
const parts = [`attachment: ${att.name ?? "file"}`];
|
|
174
|
+
if (att.url) parts.push(`url: ${att.url}`);
|
|
175
|
+
if (att.mime_type) parts.push(`type: ${att.mime_type}`);
|
|
176
|
+
if (att.size) parts.push(`size: ${att.size}`);
|
|
177
|
+
meta.push(`[${parts.join(" | ")}]`);
|
|
174
178
|
}
|
|
175
179
|
let enrichedBody = `${prefix}\n${event.text}`;
|
|
176
180
|
if (meta.length > 0) {
|
|
@@ -202,6 +206,8 @@ export async function startAgentService(ctx: AgentServiceContext): Promise<void>
|
|
|
202
206
|
messageId: event.messageId,
|
|
203
207
|
timestamp: Date.now(),
|
|
204
208
|
commandAuthorized: true,
|
|
209
|
+
surface: "a2hmarket",
|
|
210
|
+
originatingChannel: "a2hmarket",
|
|
205
211
|
|
|
206
212
|
// ③ Deliver: route AI text output based on prefix tag.
|
|
207
213
|
// [THINK] — AI internal reasoning, don't notify anyone
|
package/src/channel-state.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { LastChannelStore } from "./last-channel.js";
|
|
2
|
+
import type { PrimaryChannelStore } from "./primary-channel.js";
|
|
2
3
|
|
|
3
4
|
let _store: LastChannelStore | null = null;
|
|
5
|
+
let _primaryStore: PrimaryChannelStore | null = null;
|
|
4
6
|
|
|
5
7
|
export function setLastChannelStore(store: LastChannelStore): void {
|
|
6
8
|
_store = store;
|
|
@@ -9,3 +11,11 @@ export function setLastChannelStore(store: LastChannelStore): void {
|
|
|
9
11
|
export function getLastChannelStore(): LastChannelStore | null {
|
|
10
12
|
return _store;
|
|
11
13
|
}
|
|
14
|
+
|
|
15
|
+
export function setPrimaryChannelStore(store: PrimaryChannelStore): void {
|
|
16
|
+
_primaryStore = store;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getPrimaryChannelStore(): PrimaryChannelStore | null {
|
|
20
|
+
return _primaryStore;
|
|
21
|
+
}
|
package/src/credentials.ts
CHANGED
|
@@ -14,12 +14,6 @@ export interface A2HCredentials {
|
|
|
14
14
|
apiUrl: string;
|
|
15
15
|
mqttUrl: string;
|
|
16
16
|
notify?: A2HNotifyConfig;
|
|
17
|
-
/**
|
|
18
|
-
* @deprecated Store the Tempo private key in macOS Keychain instead.
|
|
19
|
-
* Run `node scripts/setup-tempo-key.mjs` to migrate.
|
|
20
|
-
* Fallback priority: Keychain → TEMPO_PRIVATE_KEY env → this field.
|
|
21
|
-
*/
|
|
22
|
-
tempoPrivateKey?: string;
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
// ── Load from pluginConfig (openclaw.json) — preferred ─────────────────
|
|
@@ -45,7 +39,6 @@ export function loadCredentialsFromConfig(
|
|
|
45
39
|
apiUrl: ((pluginConfig.apiUrl as string) ?? "https://api.a2hmarket.ai").replace(/\/+$/, ""),
|
|
46
40
|
mqttUrl: (pluginConfig.mqttUrl as string) ?? "mqtts://post-cn-e4k4o78q702.mqtt.aliyuncs.com:8883",
|
|
47
41
|
notify,
|
|
48
|
-
tempoPrivateKey: pluginConfig.tempoPrivateKey as string | undefined,
|
|
49
42
|
};
|
|
50
43
|
}
|
|
51
44
|
|
|
@@ -66,8 +59,6 @@ interface RawCredentials {
|
|
|
66
59
|
agentId?: string;
|
|
67
60
|
agentKey?: string;
|
|
68
61
|
secret?: string;
|
|
69
|
-
tempo_private_key?: string;
|
|
70
|
-
tempoPrivateKey?: string;
|
|
71
62
|
}
|
|
72
63
|
|
|
73
64
|
export function loadCredentialsFromFile(configDir?: string): A2HCredentials {
|
|
@@ -116,7 +107,6 @@ export function loadCredentialsFromFile(configDir?: string): A2HCredentials {
|
|
|
116
107
|
apiUrl: (raw.api_url ?? "https://api.a2hmarket.ai").replace(/\/+$/, ""),
|
|
117
108
|
mqttUrl: raw.mqtt_url ?? "mqtts://post-cn-e4k4o78q702.mqtt.aliyuncs.com:8883",
|
|
118
109
|
notify,
|
|
119
|
-
tempoPrivateKey: raw.tempo_private_key ?? raw.tempoPrivateKey,
|
|
120
110
|
};
|
|
121
111
|
}
|
|
122
112
|
|
package/src/feishu-notify.ts
CHANGED
|
@@ -139,7 +139,7 @@ export function buildA2HNotifyCard(params: {
|
|
|
139
139
|
let displayContent = params.content;
|
|
140
140
|
if (params.type === "reply") {
|
|
141
141
|
displayContent = displayContent.replace(
|
|
142
|
-
/(https:\/\/
|
|
142
|
+
/(https:\/\/www\.paypal\.com\/checkoutnow\S+)/g,
|
|
143
143
|
"[👉 点击支付]($1)",
|
|
144
144
|
);
|
|
145
145
|
}
|
package/src/notify.ts
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* Unified notification service — dispatches to Feishu (card) or other channels (text).
|
|
3
3
|
*
|
|
4
4
|
* Channel resolution:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
5
|
+
* 1. PrimaryChannelStore (user-designated runtime override)
|
|
6
|
+
* 2. credentials.notify.channel + target (install-time fallback)
|
|
7
|
+
* 3. Feishu → rich interactive card via Feishu Open API
|
|
8
|
+
* 4. Discord → plain text via Discord bot API
|
|
9
|
+
* 5. Others → log warning (unsupported)
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { loadCredentials } from "./credentials.js";
|
|
12
13
|
import { getA2HRuntime } from "./runtime.js";
|
|
14
|
+
import { getPrimaryChannelStore } from "./channel-state.js";
|
|
13
15
|
import { sendFeishuCard, buildA2HNotifyCard, buildPaymentCard, type FeishuCardElement } from "./feishu-notify.js";
|
|
14
16
|
import { recordCardPeer } from "./reply-bridge.js";
|
|
15
17
|
|
|
@@ -36,13 +38,37 @@ interface ChannelConfig {
|
|
|
36
38
|
|
|
37
39
|
export function resolveNotifyConfig(): ChannelConfig | null {
|
|
38
40
|
try {
|
|
39
|
-
const creds = loadCredentials();
|
|
40
|
-
if (!creds.notify?.channel || !creds.notify?.target) return null;
|
|
41
|
-
|
|
42
41
|
const runtime = getA2HRuntime();
|
|
43
42
|
const cfg = runtime.config.loadConfig() as Record<string, unknown>;
|
|
44
43
|
const channels = (cfg.channels ?? {}) as Record<string, Record<string, unknown>>;
|
|
45
44
|
|
|
45
|
+
// 1. Check PrimaryChannelStore (user-designated runtime override)
|
|
46
|
+
const primary = getPrimaryChannelStore()?.get();
|
|
47
|
+
if (primary?.setPrimary && primary.channel && primary.target) {
|
|
48
|
+
const channelCfg = channels[primary.channel];
|
|
49
|
+
|
|
50
|
+
if (primary.channel === "feishu" && channelCfg?.appId && channelCfg?.appSecret) {
|
|
51
|
+
return {
|
|
52
|
+
channel: "feishu",
|
|
53
|
+
target: primary.target,
|
|
54
|
+
appId: channelCfg.appId as string,
|
|
55
|
+
appSecret: channelCfg.appSecret as string,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (primary.channel === "discord" && channelCfg?.token) {
|
|
59
|
+
return {
|
|
60
|
+
channel: "discord",
|
|
61
|
+
target: primary.target,
|
|
62
|
+
botToken: channelCfg.token as string,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// primaryChannel set but channel credentials unavailable → fallback
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Fallback to credentials.notify (install-time config)
|
|
69
|
+
const creds = loadCredentials();
|
|
70
|
+
if (!creds.notify?.channel || !creds.notify?.target) return null;
|
|
71
|
+
|
|
46
72
|
const channelName = creds.notify.channel;
|
|
47
73
|
const channelCfg = channels[channelName];
|
|
48
74
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending welcome — one-time welcome message after plugin install.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Agent installs plugin, writes ~/.a2h_store/a2h_config/pending-welcome.json
|
|
6
|
+
* with { channel, target, installedAt }
|
|
7
|
+
* 2. Gateway restarts, plugin gateway_start hook reads + sends + deletes the file
|
|
8
|
+
*
|
|
9
|
+
* The file acts as a one-shot trigger: created at install, consumed on first boot.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface PendingWelcome {
|
|
19
|
+
channel: string; // "feishu" | "discord" | ...
|
|
20
|
+
target: string; // chat_id / channel_id / user_id
|
|
21
|
+
installedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── File I/O ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function pendingWelcomePath(): string {
|
|
27
|
+
return join(homedir(), ".a2h_store", "a2h_config", "pending-welcome.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readPendingWelcome(): PendingWelcome | null {
|
|
31
|
+
try {
|
|
32
|
+
const data = JSON.parse(readFileSync(pendingWelcomePath(), "utf-8"));
|
|
33
|
+
if (data.channel && data.target) return data as PendingWelcome;
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function deletePendingWelcome(): void {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(pendingWelcomePath());
|
|
43
|
+
} catch {
|
|
44
|
+
// file may not exist — that's fine
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Welcome Content ────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const WELCOME_TITLE = "🎉 A2H Market 插件已就绪";
|
|
51
|
+
|
|
52
|
+
const WELCOME_FEISHU_ELEMENTS = [
|
|
53
|
+
{
|
|
54
|
+
tag: "markdown" as const,
|
|
55
|
+
content:
|
|
56
|
+
"我是你的 **A2H Market** AI 助手,可以帮你:\n" +
|
|
57
|
+
"📦 发布商品、浏览市场\n" +
|
|
58
|
+
"💬 自动与其他 Agent 谈判协商\n" +
|
|
59
|
+
"📋 管理订单、处理支付",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
tag: "markdown" as const,
|
|
63
|
+
content: "---\n🔐 首次使用请发送 **\"登录 A2H Market\"** 完成授权",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const WELCOME_TEXT = [
|
|
68
|
+
`**${WELCOME_TITLE}**`,
|
|
69
|
+
"",
|
|
70
|
+
"我是你的 A2H Market AI 助手,可以帮你:",
|
|
71
|
+
"📦 发布商品、浏览市场",
|
|
72
|
+
"💬 自动与其他 Agent 谈判协商",
|
|
73
|
+
"📋 管理订单、处理支付",
|
|
74
|
+
"",
|
|
75
|
+
"---",
|
|
76
|
+
'🔐 首次使用请发送 "登录 A2H Market" 完成授权',
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
// ── Send Welcome ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export interface WelcomeLog {
|
|
82
|
+
info: (m: string) => void;
|
|
83
|
+
warn: (m: string) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Send the welcome message to the channel that initiated the install.
|
|
88
|
+
* Returns true if sent successfully.
|
|
89
|
+
*/
|
|
90
|
+
export async function sendWelcome(
|
|
91
|
+
pending: PendingWelcome,
|
|
92
|
+
channelCfg: Record<string, unknown>,
|
|
93
|
+
log: WelcomeLog,
|
|
94
|
+
): Promise<boolean> {
|
|
95
|
+
if (pending.channel === "feishu") {
|
|
96
|
+
if (!channelCfg.appId || !channelCfg.appSecret) {
|
|
97
|
+
log.warn("welcome: feishu channel credentials not configured, skipped");
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const { sendFeishuCard } = await import("./feishu-notify.js");
|
|
101
|
+
await sendFeishuCard({
|
|
102
|
+
appId: channelCfg.appId as string,
|
|
103
|
+
appSecret: channelCfg.appSecret as string,
|
|
104
|
+
target: pending.target,
|
|
105
|
+
title: WELCOME_TITLE,
|
|
106
|
+
titleColor: "green",
|
|
107
|
+
elements: WELCOME_FEISHU_ELEMENTS,
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (pending.channel === "discord") {
|
|
113
|
+
if (!channelCfg.token) {
|
|
114
|
+
log.warn("welcome: discord bot token not configured, skipped");
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const botToken = channelCfg.token as string;
|
|
118
|
+
const headers = {
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
Authorization: `Bot ${botToken}`,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Resolve DM channel (target may be user ID, not channel ID)
|
|
124
|
+
let channelId = pending.target;
|
|
125
|
+
try {
|
|
126
|
+
const dmResp = await fetch("https://discord.com/api/v10/users/@me/channels", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers,
|
|
129
|
+
body: JSON.stringify({ recipient_id: pending.target }),
|
|
130
|
+
});
|
|
131
|
+
const dmData = (await dmResp.json()) as { id?: string };
|
|
132
|
+
if (dmData.id) channelId = dmData.id;
|
|
133
|
+
} catch {
|
|
134
|
+
// fallback: use target as channel ID directly
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const resp = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers,
|
|
140
|
+
body: JSON.stringify({ content: WELCOME_TEXT }),
|
|
141
|
+
});
|
|
142
|
+
if (!resp.ok) {
|
|
143
|
+
const data = (await resp.json()) as { message?: string };
|
|
144
|
+
throw new Error(`discord send failed: ${data.message}`);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
log.info(`welcome: channel "${pending.channel}" does not support push welcome, skipped`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface PrimaryChannelRecord {
|
|
5
|
+
channel: string; // "feishu" | "discord" | ...
|
|
6
|
+
target: string; // user ID (e.g. feishu open_id)
|
|
7
|
+
sessionKey: string; // full session key
|
|
8
|
+
setPrimary: boolean; // whether user explicitly set as primary
|
|
9
|
+
updatedAt: string; // ISO timestamp
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Store for user-designated "primary channel session".
|
|
14
|
+
* Approval notifications and session injection prefer this channel/target.
|
|
15
|
+
* Persisted as JSON: ~/.a2h_store/a2h_data/primary-channel.json
|
|
16
|
+
*/
|
|
17
|
+
export class PrimaryChannelStore {
|
|
18
|
+
private filePath: string;
|
|
19
|
+
|
|
20
|
+
constructor(filePath: string) {
|
|
21
|
+
this.filePath = filePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(): PrimaryChannelRecord | null {
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(record: Omit<PrimaryChannelRecord, "updatedAt">): void {
|
|
34
|
+
const full: PrimaryChannelRecord = {
|
|
35
|
+
...record,
|
|
36
|
+
updatedAt: new Date().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
39
|
+
writeFileSync(this.filePath, JSON.stringify(full, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear(): void {
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(this.filePath, "{}");
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|