@a2hmarket/a2hmarket 0.5.7 → 0.6.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 CHANGED
@@ -122,7 +122,7 @@ export default {
122
122
  });
123
123
  });
124
124
 
125
- // ── Customize feishu card when a2h tools were used ───────────
125
+ // ── Notify human when a2h tools were used ────────────────────
126
126
  const a2hToolUsedSessions = new Set<string>();
127
127
 
128
128
  api.on("after_tool_call", (event) => {
@@ -143,33 +143,18 @@ export default {
143
143
 
144
144
  const agentId = apiClient?.agentId ?? "";
145
145
 
146
- import("./src/feishu-notify.js").then(({ sendFeishuCard }) => {
147
- const feishuConfig = (() => {
148
- try {
149
- const creds = loadCredentials();
150
- if (!creds.notify || creds.notify.channel !== "feishu") return null;
151
- const cfg = api.runtime.config.loadConfig() as Record<string, unknown>;
152
- const channels = (cfg.channels ?? {}) as Record<string, Record<string, unknown>>;
153
- const f = channels.feishu;
154
- if (!f?.appId || !f?.appSecret) return null;
155
- return { appId: f.appId as string, appSecret: f.appSecret as string, target: creds.notify.target };
156
- } catch { return null; }
157
- })();
158
-
159
- if (!feishuConfig) return;
160
-
161
- sendFeishuCard({
162
- appId: feishuConfig.appId,
163
- appSecret: feishuConfig.appSecret,
164
- target: feishuConfig.target,
165
- title: "🤖 A2H Market · 查询结果",
166
- titleColor: "indigo",
167
- elements: [
146
+ import("./src/notify.js").then(({ notifyCustom }) => {
147
+ notifyCustom(
148
+ "🤖 A2H Market · 查询结果",
149
+ "indigo",
150
+ [
168
151
  { tag: "markdown", content },
169
- { tag: "markdown", content: `---\n*我的 Agent ID: ${agentId}*` },
152
+ { tag: "markdown", content: `---\n*我的A2H Agent: ${agentId}*` },
170
153
  ],
171
- }).catch((err) => {
172
- api.logger.error(`a2hmarket: feishu card override failed: ${err.message}`);
154
+ `**🤖 A2H Market · 查询结果**\n\n${content}\n\n---\n_我的A2H Agent: ${agentId}_`,
155
+ { info: (m) => api.logger.info(m), error: (m) => api.logger.error(m) },
156
+ ).catch((err) => {
157
+ api.logger.error(`a2hmarket: notify failed: ${err.message}`);
173
158
  });
174
159
  });
175
160
 
@@ -6,7 +6,20 @@
6
6
  "skills": ["./skills"],
7
7
  "configSchema": {
8
8
  "type": "object",
9
- "properties": {},
9
+ "properties": {
10
+ "agentId": { "type": "string", "description": "A2H Market agent ID" },
11
+ "agentKey": { "type": "string", "description": "A2H Market agent key (HMAC signing)" },
12
+ "apiUrl": { "type": "string", "description": "A2H Market API base URL" },
13
+ "mqttUrl": { "type": "string", "description": "MQTT broker URL for A2A messaging" },
14
+ "tempoPrivateKey": { "type": "string", "description": "Solana private key for Tempo payments" },
15
+ "notify": {
16
+ "type": "object",
17
+ "properties": {
18
+ "channel": { "type": "string", "description": "Notification channel (feishu, discord, etc.)" },
19
+ "target": { "type": "string", "description": "Target ID for notifications" }
20
+ }
21
+ }
22
+ },
10
23
  "additionalProperties": false
11
24
  }
12
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2hmarket/a2hmarket",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "index.ts",
@@ -85,6 +85,21 @@ function checkOpenclaw() {
85
85
  }
86
86
  }
87
87
 
88
+ function detectChannels() {
89
+ // Detect enabled channels from openclaw.json
90
+ const channels = [];
91
+ try {
92
+ const cfg = JSON.parse(readFileSync(join(OPENCLAW_DIR, "openclaw.json"), "utf-8"));
93
+ const ch = cfg.channels ?? {};
94
+ for (const [name, config] of Object.entries(ch)) {
95
+ if (config?.enabled !== false) {
96
+ channels.push({ name, config });
97
+ }
98
+ }
99
+ } catch {}
100
+ return channels;
101
+ }
102
+
88
103
  function detectFeishuTarget() {
89
104
  try {
90
105
  const output = execSync(
@@ -160,7 +175,222 @@ async function pollForAuth(code) {
160
175
  return { error: "授权超时(5分钟),请重新运行安装" };
161
176
  }
162
177
 
163
- // ── Main ─────────────────────────────────────────────────────────────────
178
+ // ── Feishu Card Helper ───────────────────────────────────────────────────
179
+
180
+ async function getFeishuToken(appId, appSecret) {
181
+ const resp = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
185
+ });
186
+ const data = await resp.json();
187
+ if (data.code !== 0) throw new Error(`feishu token error: code=${data.code}`);
188
+ return data.tenant_access_token;
189
+ }
190
+
191
+ const ONBOARDING_TEXT =
192
+ "🎉 A2H Market · 安装成功\n\n" +
193
+ "我已经进入 A2H 市场了!这边特别热闹,很多人在摆摊出售各种东西,也有很多人在挑选商品和讨价还价。\n\n" +
194
+ "我可以帮你做三件事:\n\n" +
195
+ "🏪 **摆摊** — 帮你在市场上出售商品或服务,也可以直接接别人的悬赏任务赚钱\n\n" +
196
+ "🛍️ **逛街** — 帮你在市场上找东西买,我来搜、来比、来砍价,你拍板就行\n\n" +
197
+ "👀 **逛逛** — 还没想好也没关系!我帮你逛逛市场,看看有什么赚钱机会或者值得买的好东西\n\n" +
198
+ "你是想**摆摊卖东西**、**买点什么**,还是先让我帮你**逛逛**看看有什么机会?";
199
+
200
+ async function sendOnboardingFeishu(appId, appSecret, target, agentId) {
201
+ const token = await getFeishuToken(appId, appSecret);
202
+ const receiveIdType = target.startsWith("oc_") ? "chat_id" : "open_id";
203
+
204
+ const card = {
205
+ schema: "2.0",
206
+ config: { wide_screen_mode: true },
207
+ header: {
208
+ title: { tag: "plain_text", content: "🎉 A2H Market · 安装成功" },
209
+ template: "green",
210
+ },
211
+ body: {
212
+ elements: [
213
+ { tag: "markdown", content: "我已经进入 A2H 市场了!这边特别热闹,很多人在摆摊出售各种东西,也有很多人在挑选商品和讨价还价。" },
214
+ { tag: "markdown", content: "我可以帮你做三件事:" },
215
+ { tag: "markdown", content:
216
+ "🏪 **摆摊** — 帮你在市场上出售商品或服务,也可以直接接别人的悬赏任务赚钱\n\n" +
217
+ "🛍️ **逛街** — 帮你在市场上找东西买,我来搜、来比、来砍价,你拍板就行\n\n" +
218
+ "👀 **逛逛** — 还没想好也没关系!我帮你逛逛市场,看看有什么赚钱机会或者值得买的好东西" },
219
+ { tag: "markdown", content: "你是想**摆摊卖东西**、**买点什么**,还是先让我帮你**逛逛**看看有什么机会?" },
220
+ { tag: "markdown", content: `---\n*我的A2H Agent: ${agentId}*` },
221
+ ],
222
+ },
223
+ };
224
+
225
+ const resp = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
226
+ method: "POST",
227
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
228
+ body: JSON.stringify({ receive_id: target, msg_type: "interactive", content: JSON.stringify(card) }),
229
+ });
230
+ const data = await resp.json();
231
+ if (data.code !== 0) throw new Error(`feishu send error: code=${data.code} msg=${data.msg}`);
232
+ }
233
+
234
+ async function sendOnboardingDiscord(botToken, channelId, agentId) {
235
+ const text = ONBOARDING_TEXT + `\n\n---\n_我的A2H Agent: ${agentId}_`;
236
+ const resp = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json", Authorization: `Bot ${botToken}` },
239
+ body: JSON.stringify({ content: text }),
240
+ });
241
+ const data = await resp.json();
242
+ if (!resp.ok) throw new Error(`discord send error: ${data.code} ${data.message}`);
243
+ }
244
+
245
+ async function sendOnboarding(channel, target, agentId) {
246
+ const cfg = JSON.parse(readFileSync(join(OPENCLAW_DIR, "openclaw.json"), "utf-8"));
247
+ const channelCfg = cfg?.channels?.[channel];
248
+ if (!channelCfg) throw new Error(`channel "${channel}" not found in config`);
249
+
250
+ if (channel === "feishu") {
251
+ if (!channelCfg.appId || !channelCfg.appSecret) throw new Error("feishu appId/appSecret missing");
252
+ await sendOnboardingFeishu(channelCfg.appId, channelCfg.appSecret, target, agentId);
253
+ } else if (channel === "discord") {
254
+ if (!channelCfg.token) throw new Error("discord bot token missing");
255
+ await sendOnboardingDiscord(channelCfg.token, target, agentId);
256
+ } else {
257
+ throw new Error(`unsupported channel: ${channel}`);
258
+ }
259
+ }
260
+
261
+ // ── Update ───────────────────────────────────────────────────────────────
262
+
263
+ async function runUpdate() {
264
+ log(`\n${BOLD}🏪 A2H Market — Update${RESET}\n`);
265
+
266
+ // 1. Check OpenClaw
267
+ logStep(1, "检查环境");
268
+ const version = checkOpenclaw();
269
+ if (!version) {
270
+ log(` ${CROSS} OpenClaw 未安装`);
271
+ process.exit(1);
272
+ }
273
+ log(` ${CHECK} ${version}`);
274
+
275
+ try {
276
+ const info = execSync("openclaw plugins info a2hmarket 2>&1", { encoding: "utf-8" });
277
+ if (!info.includes("a2hmarket")) throw new Error("not found");
278
+ log(` ${CHECK} 插件已安装`);
279
+ } catch {
280
+ log(` ${CROSS} 插件未安装,请先运行: npx -y ${NPM_SPEC} install`);
281
+ process.exit(1);
282
+ }
283
+
284
+ // 2. Check versions
285
+ logStep(2, "检查版本");
286
+ let currentVersion = "unknown";
287
+ try {
288
+ const pluginPkg = join(OPENCLAW_DIR, "extensions", "a2hmarket", "package.json");
289
+ if (existsSync(pluginPkg)) {
290
+ const pkg = JSON.parse(readFileSync(pluginPkg, "utf-8"));
291
+ currentVersion = pkg.version ?? "unknown";
292
+ }
293
+ } catch {}
294
+
295
+ let latestVersion = "unknown";
296
+ try {
297
+ latestVersion = execSync(`npm view ${NPM_SPEC} version 2>/dev/null`, { encoding: "utf-8" }).trim();
298
+ } catch {
299
+ log(` ${CROSS} 无法获取最新版本`);
300
+ process.exit(1);
301
+ }
302
+
303
+ log(` 当前版本: ${CYAN}${currentVersion}${RESET}`);
304
+ log(` 最新版本: ${CYAN}${latestVersion}${RESET}`);
305
+
306
+ if (currentVersion === latestVersion) {
307
+ log(`\n ${CHECK} 已是最新版本,无需更新`);
308
+ process.exit(0);
309
+ }
310
+
311
+ // 3. Update plugin
312
+ logStep(3, "更新插件");
313
+ try {
314
+ log(` 卸载旧版本...`);
315
+ execSync('echo y | openclaw plugins uninstall a2hmarket 2>&1', { encoding: "utf-8", stdio: "pipe" });
316
+ log(` 安装新版本...`);
317
+ execSync(`echo y | openclaw plugins install ${NPM_SPEC} 2>&1`, { encoding: "utf-8", stdio: "pipe" });
318
+ log(` ${CHECK} 更新完成`);
319
+ } catch (err) {
320
+ log(` ${CROSS} 更新失败: ${err.message}`);
321
+ process.exit(1);
322
+ }
323
+
324
+ // 4. Re-link openclaw module
325
+ log(` 配置模块依赖...`);
326
+ try {
327
+ const pluginDir = join(OPENCLAW_DIR, "extensions", "a2hmarket");
328
+ if (existsSync(pluginDir)) {
329
+ let openclawPkg = null;
330
+ try {
331
+ const realBin = execSync("realpath $(which openclaw) 2>/dev/null || readlink -f $(which openclaw) 2>/dev/null", { encoding: "utf-8" }).trim();
332
+ if (realBin) {
333
+ let dir = realBin;
334
+ for (let i = 0; i < 5; i++) {
335
+ dir = join(dir, "..");
336
+ const pj = join(dir, "package.json");
337
+ if (existsSync(pj)) {
338
+ try {
339
+ const pkg = JSON.parse(readFileSync(pj, "utf-8"));
340
+ if (pkg.name === "openclaw") { openclawPkg = dir; break; }
341
+ } catch {}
342
+ }
343
+ }
344
+ }
345
+ } catch {}
346
+
347
+ if (!openclawPkg) {
348
+ for (const p of [
349
+ "/opt/homebrew/lib/node_modules/openclaw",
350
+ "/usr/local/lib/node_modules/openclaw",
351
+ ]) {
352
+ if (existsSync(join(p, "package.json"))) { openclawPkg = p; break; }
353
+ }
354
+ }
355
+
356
+ if (openclawPkg) {
357
+ const symlinkDir = join(pluginDir, "node_modules");
358
+ const symlinkTarget = join(symlinkDir, "openclaw");
359
+ mkdirSync(symlinkDir, { recursive: true });
360
+ try { execSync(`rm -f "${symlinkTarget}"`, { stdio: "pipe" }); } catch {}
361
+ execSync(`ln -sf "${openclawPkg}" "${symlinkTarget}"`, { stdio: "pipe" });
362
+ log(` ${CHECK} openclaw/plugin-sdk 已链接`);
363
+ } else {
364
+ log(` ${WARN} 未找到 openclaw 包路径`);
365
+ }
366
+ }
367
+ } catch (err) {
368
+ log(` ${WARN} 模块链接失败: ${err.message}`);
369
+ }
370
+
371
+ // 5. Restart gateway
372
+ logStep(4, "重启服务");
373
+ try {
374
+ execSync("openclaw gateway restart 2>&1", { encoding: "utf-8", stdio: "pipe" });
375
+ log(` ${CHECK} Gateway 已重启`);
376
+ } catch {
377
+ log(` ${WARN} 请手动执行: openclaw gateway restart`);
378
+ }
379
+
380
+ // 6. Verify
381
+ logStep(5, "验证");
382
+ await new Promise((r) => setTimeout(r, 5000));
383
+ try {
384
+ const info = execSync("openclaw plugins info a2hmarket 2>&1", { encoding: "utf-8" });
385
+ if (info.includes("Status: loaded")) {
386
+ log(` ${CHECK} 插件运行正常`);
387
+ }
388
+ } catch {
389
+ log(` ${WARN} 请检查: openclaw plugins info a2hmarket`);
390
+ }
391
+
392
+ log(`\n${GREEN}${BOLD}🎉 更新完成!${RESET} ${currentVersion} → ${CYAN}${latestVersion}${RESET}\n`);
393
+ }
164
394
 
165
395
  // ── Uninstall ────────────────────────────────────────────────────────────
166
396
 
@@ -194,7 +424,22 @@ async function runUninstall() {
194
424
  }
195
425
  }
196
426
 
197
- // 3. Restart gateway
427
+ // 3. Clean openclaw.json plugin config
428
+ try {
429
+ const configPath = join(OPENCLAW_DIR, "openclaw.json");
430
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
431
+ const entry = cfg?.plugins?.entries?.a2hmarket;
432
+ if (entry) {
433
+ // Reset to minimal — keep enabled:false so openclaw knows it was uninstalled
434
+ cfg.plugins.entries.a2hmarket = { enabled: false };
435
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
436
+ log(` ${CHECK} openclaw.json 已清理`);
437
+ }
438
+ } catch {
439
+ log(` ${WARN} openclaw.json 清理跳过`);
440
+ }
441
+
442
+ // 4. Restart gateway
198
443
  try {
199
444
  execSync("openclaw gateway restart 2>&1", { encoding: "utf-8", stdio: "pipe" });
200
445
  log(` ${CHECK} Gateway 已重启`);
@@ -215,10 +460,15 @@ async function main() {
215
460
  return await runUninstall();
216
461
  }
217
462
 
463
+ if (cmd === "update") {
464
+ return await runUpdate();
465
+ }
466
+
218
467
  if (cmd !== "install") {
219
468
  log(`\n${BOLD}A2H Market — OpenClaw Plugin${RESET}\n`);
220
469
  log(` 安装: npx -y ${NPM_SPEC} install`);
221
470
  log(` 快速: npx -y ${NPM_SPEC} install --agent ag_xxx:key`);
471
+ log(` 更新: npx -y ${NPM_SPEC} update`);
222
472
  log(` 卸载: npx -y ${NPM_SPEC} uninstall\n`);
223
473
  process.exit(0);
224
474
  }
@@ -402,71 +652,123 @@ async function main() {
402
652
  log(` ${WARN} 模块链接失败: ${err.message}`);
403
653
  }
404
654
 
405
- // ── Step 3.6: Ensure a2h tools in alsoAllow ────────────────────
406
- // If tools.alsoAllow exists (whitelist mode), add a2h tools to it.
407
- log(` 配置工具权限...`);
655
+ // ── Step 4: Save credentials & configure openclaw.json ───────
656
+ logStep(4, "保存配置");
657
+ mkdirSync(DATA_DIR, { recursive: true });
658
+
659
+ const credsData = {
660
+ agent_id: agentId,
661
+ agent_key: agentKey,
662
+ api_url: apiUrl,
663
+ mqtt_url: mqttUrl,
664
+ };
665
+
666
+ // Detect available channels and let user choose
667
+ const channels = detectChannels();
668
+ if (channels.length > 0) {
669
+ log(` 检测到 ${channels.length} 个可用渠道:`);
670
+ channels.forEach((ch, i) => {
671
+ log(` ${CYAN}${i + 1}${RESET}. ${ch.name}`);
672
+ });
673
+
674
+ const prompt2 = createPrompt();
675
+ const choice = await prompt2.ask(
676
+ `选择通知渠道 (1-${channels.length},回车跳过)`,
677
+ "",
678
+ );
679
+
680
+ if (choice) {
681
+ const idx = parseInt(choice, 10) - 1;
682
+ if (idx >= 0 && idx < channels.length) {
683
+ const chosen = channels[idx];
684
+ let target = "";
685
+
686
+ if (chosen.name === "feishu") {
687
+ target = detectFeishuTarget() || "";
688
+ if (target) {
689
+ log(` 检测到飞书用户: ${CYAN}${target}${RESET}`);
690
+ } else {
691
+ target = await prompt2.ask("输入飞书 open_id (ou_xxx) 或 chat_id (oc_xxx)", "");
692
+ }
693
+ } else if (chosen.name === "discord") {
694
+ target = await prompt2.ask("输入 Discord 频道 ID", "");
695
+ } else {
696
+ target = await prompt2.ask(`输入 ${chosen.name} 目标 ID`, "");
697
+ }
698
+
699
+ if (target) {
700
+ credsData.notify = { channel: chosen.name, target };
701
+ log(` ${CHECK} 通知渠道已配置: ${chosen.name} → ${target}`);
702
+ } else {
703
+ log(` ${WARN} 未输入目标 ID,跳过通知配置`);
704
+ }
705
+ }
706
+ } else {
707
+ log(` ${DIM}跳过通知配置${RESET}`);
708
+ }
709
+ prompt2.close();
710
+ } else {
711
+ log(` ${DIM}未检测到可用渠道,跳过通知配置${RESET}`);
712
+ }
713
+
714
+ // Save credentials file (fallback for dev mode)
715
+ writeFileSync(CREDS_FILE, JSON.stringify(credsData, null, 2) + "\n");
716
+ log(` ${CHECK} 凭证文件已保存`);
717
+
718
+ // Write credentials into openclaw.json → plugins.entries.a2hmarket
719
+ // This is the primary config path — api.pluginConfig reads from here
720
+ log(` 写入 openclaw.json...`);
408
721
  try {
409
722
  const configPath = join(OPENCLAW_DIR, "openclaw.json");
410
723
  const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
411
- const alsoAllow = cfg?.tools?.alsoAllow;
412
- if (Array.isArray(alsoAllow)) {
724
+
725
+ // Ensure plugins.entries.a2hmarket exists
726
+ if (!cfg.plugins) cfg.plugins = {};
727
+ if (!cfg.plugins.entries) cfg.plugins.entries = {};
728
+ const entry = cfg.plugins.entries.a2hmarket ?? {};
729
+
730
+ // Write credentials into plugin entry
731
+ entry.enabled = true;
732
+ entry.agentId = agentId;
733
+ entry.agentKey = agentKey;
734
+ entry.apiUrl = apiUrl;
735
+ entry.mqttUrl = mqttUrl;
736
+ if (credsData.notify) {
737
+ entry.notify = credsData.notify;
738
+ }
739
+ cfg.plugins.entries.a2hmarket = entry;
740
+
741
+ // Ensure a2h tools in alsoAllow (if whitelist mode is active)
742
+ if (!cfg.tools) cfg.tools = {};
743
+ if (Array.isArray(cfg.tools.alsoAllow)) {
413
744
  const a2hTools = [
414
745
  "a2h_status", "a2h_profile_get", "a2h_profile_upload_qrcode",
415
746
  "a2h_profile_delete_qrcode", "a2h_file_upload",
416
747
  "a2h_works_search", "a2h_works_list", "a2h_works_publish",
417
748
  "a2h_works_update", "a2h_works_delete",
418
749
  "a2h_order_create", "a2h_order_action", "a2h_order_get", "a2h_order_list",
419
- "a2h_send",
750
+ "a2h_send", "a2h_inbox_history",
751
+ "a2h_address_list", "a2h_address_create", "a2h_address_delete", "a2h_address_set_default",
752
+ "a2h_discussion_publish", "a2h_discussion_reply", "a2h_discussion_list",
753
+ "a2h_tempo_pay", "a2h_tempo_balance",
420
754
  ];
421
755
  let added = 0;
422
756
  for (const t of a2hTools) {
423
- if (!alsoAllow.includes(t)) {
424
- alsoAllow.push(t);
757
+ if (!cfg.tools.alsoAllow.includes(t)) {
758
+ cfg.tools.alsoAllow.push(t);
425
759
  added++;
426
760
  }
427
761
  }
428
- if (added > 0) {
429
- writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
430
- log(` ${CHECK} ${added} 个 a2h 工具已添加到工具白名单`);
431
- } else {
432
- log(` ${CHECK} 工具白名单已包含 a2h 工具`);
433
- }
434
- } else {
435
- log(` ${CHECK} 无工具白名单限制`);
762
+ if (added > 0) log(` ${CHECK} ${added} 个工具已添加到白名单`);
436
763
  }
437
- } catch {
438
- log(` ${WARN} 工具权限配置跳过`);
439
- }
440
764
 
441
- // ── Step 4: Save credentials ───────────────────────────────────
442
- logStep(4, "保存配置");
443
- mkdirSync(DATA_DIR, { recursive: true });
444
-
445
- const credsData = {
446
- agent_id: agentId,
447
- agent_key: agentKey,
448
- api_url: apiUrl,
449
- mqtt_url: mqttUrl,
450
- };
451
-
452
- // Auto-detect feishu notification target
453
- const feishuTarget = detectFeishuTarget();
454
- if (feishuTarget) {
455
- log(` 检测到飞书用户: ${CYAN}${feishuTarget}${RESET}`);
456
- const prompt2 = createPrompt();
457
- const useFeishu = await prompt2.ask("启用飞书通知? (Y/n)", "Y");
458
- prompt2.close();
459
- if (useFeishu.toLowerCase() !== "n") {
460
- credsData.notify = { channel: "feishu", target: feishuTarget };
461
- log(` ${CHECK} 飞书通知已配置`);
462
- }
463
- } else {
464
- log(` ${DIM}未检测到飞书,跳过通知配置${RESET}`);
765
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
766
+ log(` ${CHECK} openclaw.json 已更新`);
767
+ } catch (err) {
768
+ log(` ${WARN} openclaw.json 写入失败: ${err.message}`);
769
+ log(` ${DIM}插件将使用凭证文件作为回退${RESET}`);
465
770
  }
466
771
 
467
- writeFileSync(CREDS_FILE, JSON.stringify(credsData, null, 2) + "\n");
468
- log(` ${CHECK} 凭证已保存`);
469
-
470
772
  // ── Step 5: Restart gateway ────────────────────────────────────
471
773
  logStep(5, "启动服务");
472
774
  try {
@@ -492,6 +794,17 @@ async function main() {
492
794
  log(` ${WARN} 请检查: openclaw plugins info a2hmarket`);
493
795
  }
494
796
 
797
+ // ── Step 7: Send onboarding message ──────────────────────────
798
+ if (credsData.notify?.channel && credsData.notify?.target) {
799
+ logStep(7, "发送欢迎消息");
800
+ try {
801
+ await sendOnboarding(credsData.notify.channel, credsData.notify.target, agentId);
802
+ log(` ${CHECK} 欢迎消息已发送到 ${credsData.notify.channel}`);
803
+ } catch (err) {
804
+ log(` ${WARN} 欢迎消息发送失败: ${err.message}`);
805
+ }
806
+ }
807
+
495
808
  // ── Done ───────────────────────────────────────────────────────
496
809
  log(`\n${GREEN}${BOLD}🎉 安装完成!${RESET}\n`);
497
810
  log(` Agent ID: ${CYAN}${agentId}${RESET}`);
@@ -19,8 +19,7 @@ import { MqttTokenClient } from "./mqtt-token.js";
19
19
  import { createSendTransport } from "./mqtt-transport.js";
20
20
  import { buildEnvelope, signEnvelope } from "./protocol.js";
21
21
  import { getA2HRuntime } from "./runtime.js";
22
- import { sendFeishuCard, buildA2HNotifyCard, buildPaymentCard } from "./feishu-notify.js";
23
- import { recordCardPeer } from "./reply-bridge.js";
22
+ import { notifyHuman, notifyPayment, resolveNotifyConfig, type NotifyLog } from "./notify.js";
24
23
 
25
24
  // ── MQTT Send Helper ─────────────────────────────────────────────────────
26
25
 
@@ -46,60 +45,9 @@ async function mqttSendText(
46
45
  }
47
46
  }
48
47
 
49
- // ── Feishu Notification ──────────────────────────────────────────────────
48
+ // ── Re-export notifyHuman for external callers ──────────────────────────
50
49
 
51
- function resolveFeishuConfig(): { appId: string; appSecret: string; target: string } | null {
52
- try {
53
- const creds = loadCredentials();
54
- if (!creds.notify || creds.notify.channel !== "feishu") return null;
55
-
56
- const runtime = getA2HRuntime();
57
- const cfg = runtime.config.loadConfig() as Record<string, unknown>;
58
- const channels = (cfg.channels ?? {}) as Record<string, Record<string, unknown>>;
59
- const feishuCfg = channels.feishu;
60
- if (!feishuCfg?.appId || !feishuCfg?.appSecret) return null;
61
-
62
- return {
63
- appId: feishuCfg.appId as string,
64
- appSecret: feishuCfg.appSecret as string,
65
- target: creds.notify.target,
66
- };
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- export function notifyHumanCard(
73
- type: "inbound" | "reply",
74
- peerId: string,
75
- content: string,
76
- agentId: string,
77
- log?: { info: (m: string) => void; error: (m: string) => void },
78
- ): void {
79
- const feishu = resolveFeishuConfig();
80
- if (!feishu) {
81
- log?.info("feishu not configured, skip notification");
82
- return;
83
- }
84
-
85
- const card = buildA2HNotifyCard({ type, peerId, content, agentId });
86
-
87
- sendFeishuCard({
88
- appId: feishu.appId,
89
- appSecret: feishu.appSecret,
90
- target: feishu.target,
91
- title: card.title,
92
- titleColor: card.titleColor,
93
- elements: card.elements,
94
- })
95
- .then((msgId) => {
96
- log?.info(`feishu card sent: ${msgId}`);
97
- if (msgId) {
98
- recordCardPeer(msgId, peerId, feishu.target);
99
- }
100
- })
101
- .catch((err) => log?.error(`feishu card failed: ${err.message}`));
102
- }
50
+ export { notifyHuman as notifyHumanCard } from "./notify.js";
103
51
 
104
52
  // ── Agent Service ────────────────────────────────────────────────────────
105
53
 
@@ -196,28 +144,17 @@ export async function startAgentService(ctx: AgentServiceContext): Promise<void>
196
144
  }
197
145
 
198
146
  // ④ Custom notification: notify human about the reply
199
- // If reply contains a Stripe checkout URL, send a dedicated payment card
147
+ // If reply contains a Stripe checkout URL, send a dedicated payment notification
200
148
  const paymentUrlMatch = formatted.match(/(https:\/\/checkout\.stripe\.com\S+)/);
201
149
  if (paymentUrlMatch) {
202
- const feishu = resolveFeishuConfig();
203
- if (feishu) {
204
- const paymentCard = buildPaymentCard({
205
- peerId: event.senderId,
206
- orderId: event.messageId ?? "unknown",
207
- paymentUrl: paymentUrlMatch[1],
208
- agentId: creds.agentId,
209
- });
210
- sendFeishuCard({
211
- appId: feishu.appId,
212
- appSecret: feishu.appSecret,
213
- target: feishu.target,
214
- ...paymentCard,
215
- })
216
- .then((msgId) => notifyLog.info(`feishu payment card sent: ${msgId}`))
217
- .catch((err) => notifyLog.error(`feishu payment card failed: ${err.message}`));
218
- }
150
+ notifyPayment({
151
+ peerId: event.senderId,
152
+ orderId: event.messageId ?? "unknown",
153
+ paymentUrl: paymentUrlMatch[1],
154
+ agentId: creds.agentId,
155
+ }, notifyLog);
219
156
  } else {
220
- notifyHumanCard("reply", event.senderId, formatted.slice(0, 500), creds.agentId, notifyLog);
157
+ notifyHuman("reply", event.senderId, formatted.slice(0, 500), creds.agentId, notifyLog);
221
158
  }
222
159
  },
223
160
 
package/src/notify.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Unified notification service — dispatches to Feishu (card) or other channels (text).
3
+ *
4
+ * Channel resolution:
5
+ * 1. Read credentials.notify.channel + target
6
+ * 2. Feishu → rich interactive card via Feishu Open API
7
+ * 3. Discord → plain text via Discord bot API
8
+ * 4. Others → log warning (unsupported)
9
+ */
10
+
11
+ import { loadCredentials } from "./credentials.js";
12
+ import { getA2HRuntime } from "./runtime.js";
13
+ import { sendFeishuCard, buildA2HNotifyCard, buildPaymentCard, type FeishuCardElement } from "./feishu-notify.js";
14
+ import { recordCardPeer } from "./reply-bridge.js";
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────
17
+
18
+ export type NotifyType = "inbound" | "reply" | "approval" | "payment" | "payment_complete";
19
+
20
+ export interface NotifyLog {
21
+ info: (m: string) => void;
22
+ error: (m: string) => void;
23
+ }
24
+
25
+ interface ChannelConfig {
26
+ channel: string;
27
+ target: string;
28
+ // Feishu-specific
29
+ appId?: string;
30
+ appSecret?: string;
31
+ // Discord-specific
32
+ botToken?: string;
33
+ }
34
+
35
+ // ── Channel Config Resolution ────────────────────────────────────────
36
+
37
+ export function resolveNotifyConfig(): ChannelConfig | null {
38
+ try {
39
+ const creds = loadCredentials();
40
+ if (!creds.notify?.channel || !creds.notify?.target) return null;
41
+
42
+ const runtime = getA2HRuntime();
43
+ const cfg = runtime.config.loadConfig() as Record<string, unknown>;
44
+ const channels = (cfg.channels ?? {}) as Record<string, Record<string, unknown>>;
45
+
46
+ const channelName = creds.notify.channel;
47
+ const channelCfg = channels[channelName];
48
+
49
+ if (channelName === "feishu") {
50
+ if (!channelCfg?.appId || !channelCfg?.appSecret) return null;
51
+ return {
52
+ channel: "feishu",
53
+ target: creds.notify.target,
54
+ appId: channelCfg.appId as string,
55
+ appSecret: channelCfg.appSecret as string,
56
+ };
57
+ }
58
+
59
+ if (channelName === "discord") {
60
+ if (!channelCfg?.token) return null;
61
+ return {
62
+ channel: "discord",
63
+ target: creds.notify.target,
64
+ botToken: channelCfg.token as string,
65
+ };
66
+ }
67
+
68
+ // Unknown channel — return basic config, will be handled as text
69
+ return { channel: channelName, target: creds.notify.target };
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // ── Discord Text Sender ──────────────────────────────────────────────
76
+
77
+ async function sendDiscordText(botToken: string, channelId: string, text: string): Promise<string> {
78
+ const resp = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
79
+ method: "POST",
80
+ headers: {
81
+ "Content-Type": "application/json",
82
+ Authorization: `Bot ${botToken}`,
83
+ },
84
+ body: JSON.stringify({ content: text }),
85
+ });
86
+
87
+ const data = (await resp.json()) as { id?: string; message?: string; code?: number };
88
+ if (!resp.ok) {
89
+ throw new Error(`discord send error: ${data.code} ${data.message}`);
90
+ }
91
+ return data.id ?? "";
92
+ }
93
+
94
+ // ── Text Formatters ──────────────────────────────────────────────────
95
+
96
+ function formatNotifyText(type: NotifyType, peerId: string, content: string, agentId?: string): string {
97
+ const headers: Record<NotifyType, string> = {
98
+ inbound: "📩 A2H Market · 收到消息",
99
+ reply: "🤖 A2H Market · 已自动回复",
100
+ approval: "🔔 A2H Market · 需要确认",
101
+ payment: "💳 A2H Market · 待支付",
102
+ payment_complete: "✅ A2H Market · 支付完成",
103
+ };
104
+
105
+ const peerLabel = type === "reply" ? "回复给" : "来自";
106
+ const lines = [
107
+ `**${headers[type]}**`,
108
+ `${peerLabel}: \`${peerId}\``,
109
+ "",
110
+ content,
111
+ ];
112
+ if (agentId) {
113
+ lines.push("", `---`, `_我的A2H Agent: ${agentId}_`);
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+
118
+ function formatPaymentText(params: {
119
+ peerId: string;
120
+ orderId: string;
121
+ amount?: number;
122
+ currency?: string;
123
+ paymentUrl: string;
124
+ agentId?: string;
125
+ }): string {
126
+ const lines = [
127
+ `**💳 A2H Market · 待支付**`,
128
+ `订单: \`${params.orderId}\``,
129
+ `金额: ${params.amount ? (params.amount / 100).toFixed(2) : "?"} ${params.currency?.toUpperCase() ?? "USD"}`,
130
+ `来自: \`${params.peerId}\``,
131
+ "",
132
+ `👉 点击支付: ${params.paymentUrl}`,
133
+ ];
134
+ if (params.agentId) {
135
+ lines.push("", `---`, `_我的A2H Agent: ${params.agentId}_`);
136
+ }
137
+ return lines.join("\n");
138
+ }
139
+
140
+ // ── Public API ───────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Send a notification to the human user via their configured channel.
144
+ */
145
+ export function notifyHuman(
146
+ type: NotifyType,
147
+ peerId: string,
148
+ content: string,
149
+ agentId: string,
150
+ log?: NotifyLog,
151
+ ): void {
152
+ const config = resolveNotifyConfig();
153
+ if (!config) {
154
+ log?.info("notify: no channel configured, skip");
155
+ return;
156
+ }
157
+
158
+ if (config.channel === "feishu" && config.appId && config.appSecret) {
159
+ const card = buildA2HNotifyCard({ type, peerId, content, agentId });
160
+ sendFeishuCard({
161
+ appId: config.appId,
162
+ appSecret: config.appSecret,
163
+ target: config.target,
164
+ title: card.title,
165
+ titleColor: card.titleColor,
166
+ elements: card.elements,
167
+ })
168
+ .then((msgId) => {
169
+ log?.info(`feishu card sent: ${msgId}`);
170
+ if (msgId) recordCardPeer(msgId, peerId, config.target);
171
+ })
172
+ .catch((err) => log?.error(`feishu card failed: ${err.message}`));
173
+ return;
174
+ }
175
+
176
+ if (config.channel === "discord" && config.botToken) {
177
+ const text = formatNotifyText(type, peerId, content, agentId);
178
+ sendDiscordText(config.botToken, config.target, text)
179
+ .then((msgId) => log?.info(`discord message sent: ${msgId}`))
180
+ .catch((err) => log?.error(`discord send failed: ${err.message}`));
181
+ return;
182
+ }
183
+
184
+ log?.info(`notify: unsupported channel "${config.channel}", skip`);
185
+ }
186
+
187
+ /**
188
+ * Send a payment notification card/text to the human.
189
+ */
190
+ export function notifyPayment(
191
+ params: {
192
+ peerId: string;
193
+ orderId: string;
194
+ amount?: number;
195
+ currency?: string;
196
+ paymentUrl: string;
197
+ agentId?: string;
198
+ },
199
+ log?: NotifyLog,
200
+ ): void {
201
+ const config = resolveNotifyConfig();
202
+ if (!config) return;
203
+
204
+ if (config.channel === "feishu" && config.appId && config.appSecret) {
205
+ const card = buildPaymentCard(params);
206
+ sendFeishuCard({
207
+ appId: config.appId,
208
+ appSecret: config.appSecret,
209
+ target: config.target,
210
+ ...card,
211
+ })
212
+ .then((msgId) => log?.info(`feishu payment card sent: ${msgId}`))
213
+ .catch((err) => log?.error(`feishu payment card failed: ${err.message}`));
214
+ return;
215
+ }
216
+
217
+ if (config.channel === "discord" && config.botToken) {
218
+ const text = formatPaymentText(params);
219
+ sendDiscordText(config.botToken, config.target, text)
220
+ .then((msgId) => log?.info(`discord payment message sent: ${msgId}`))
221
+ .catch((err) => log?.error(`discord payment send failed: ${err.message}`));
222
+ return;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Send a custom card (Feishu) or plain text (others) notification.
228
+ * Used by index.ts message_sending hook and onboarding.
229
+ */
230
+ export async function notifyCustom(
231
+ title: string,
232
+ titleColor: string,
233
+ elements: FeishuCardElement[],
234
+ plainText: string,
235
+ log?: NotifyLog,
236
+ ): Promise<void> {
237
+ const config = resolveNotifyConfig();
238
+ if (!config) return;
239
+
240
+ if (config.channel === "feishu" && config.appId && config.appSecret) {
241
+ await sendFeishuCard({
242
+ appId: config.appId,
243
+ appSecret: config.appSecret,
244
+ target: config.target,
245
+ title,
246
+ titleColor,
247
+ elements,
248
+ });
249
+ return;
250
+ }
251
+
252
+ if (config.channel === "discord" && config.botToken) {
253
+ await sendDiscordText(config.botToken, config.target, plainText);
254
+ return;
255
+ }
256
+ }