@a2hmarket/a2hmarket 0.5.6 → 0.6.0

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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2hmarket/a2hmarket",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
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
 
@@ -215,10 +445,15 @@ async function main() {
215
445
  return await runUninstall();
216
446
  }
217
447
 
448
+ if (cmd === "update") {
449
+ return await runUpdate();
450
+ }
451
+
218
452
  if (cmd !== "install") {
219
453
  log(`\n${BOLD}A2H Market — OpenClaw Plugin${RESET}\n`);
220
454
  log(` 安装: npx -y ${NPM_SPEC} install`);
221
455
  log(` 快速: npx -y ${NPM_SPEC} install --agent ag_xxx:key`);
456
+ log(` 更新: npx -y ${NPM_SPEC} update`);
222
457
  log(` 卸载: npx -y ${NPM_SPEC} uninstall\n`);
223
458
  process.exit(0);
224
459
  }
@@ -449,19 +684,53 @@ async function main() {
449
684
  mqtt_url: mqttUrl,
450
685
  };
451
686
 
452
- // Auto-detect feishu notification target
453
- const feishuTarget = detectFeishuTarget();
454
- if (feishuTarget) {
455
- log(` 检测到飞书用户: ${CYAN}${feishuTarget}${RESET}`);
687
+ // Detect available channels and let user choose
688
+ const channels = detectChannels();
689
+ if (channels.length > 0) {
690
+ log(` 检测到 ${channels.length} 个可用渠道:`);
691
+ channels.forEach((ch, i) => {
692
+ log(` ${CYAN}${i + 1}${RESET}. ${ch.name}`);
693
+ });
694
+
456
695
  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} 飞书通知已配置`);
696
+ const choice = await prompt2.ask(
697
+ `选择通知渠道 (1-${channels.length},回车跳过)`,
698
+ "",
699
+ );
700
+
701
+ if (choice) {
702
+ const idx = parseInt(choice, 10) - 1;
703
+ if (idx >= 0 && idx < channels.length) {
704
+ const chosen = channels[idx];
705
+ let target = "";
706
+
707
+ if (chosen.name === "feishu") {
708
+ // Auto-detect feishu target or ask
709
+ target = detectFeishuTarget() || "";
710
+ if (target) {
711
+ log(` 检测到飞书用户: ${CYAN}${target}${RESET}`);
712
+ } else {
713
+ target = await prompt2.ask("输入飞书 open_id (ou_xxx) 或 chat_id (oc_xxx)", "");
714
+ }
715
+ } else if (chosen.name === "discord") {
716
+ target = await prompt2.ask("输入 Discord 频道 ID", "");
717
+ } else {
718
+ target = await prompt2.ask(`输入 ${chosen.name} 目标 ID`, "");
719
+ }
720
+
721
+ if (target) {
722
+ credsData.notify = { channel: chosen.name, target };
723
+ log(` ${CHECK} 通知渠道已配置: ${chosen.name} → ${target}`);
724
+ } else {
725
+ log(` ${WARN} 未输入目标 ID,跳过通知配置`);
726
+ }
727
+ }
728
+ } else {
729
+ log(` ${DIM}跳过通知配置${RESET}`);
462
730
  }
731
+ prompt2.close();
463
732
  } else {
464
- log(` ${DIM}未检测到飞书,跳过通知配置${RESET}`);
733
+ log(` ${DIM}未检测到可用渠道,跳过通知配置${RESET}`);
465
734
  }
466
735
 
467
736
  writeFileSync(CREDS_FILE, JSON.stringify(credsData, null, 2) + "\n");
@@ -492,6 +761,17 @@ async function main() {
492
761
  log(` ${WARN} 请检查: openclaw plugins info a2hmarket`);
493
762
  }
494
763
 
764
+ // ── Step 7: Send onboarding message ──────────────────────────
765
+ if (credsData.notify?.channel && credsData.notify?.target) {
766
+ logStep(7, "发送欢迎消息");
767
+ try {
768
+ await sendOnboarding(credsData.notify.channel, credsData.notify.target, agentId);
769
+ log(` ${CHECK} 欢迎消息已发送到 ${credsData.notify.channel}`);
770
+ } catch (err) {
771
+ log(` ${WARN} 欢迎消息发送失败: ${err.message}`);
772
+ }
773
+ }
774
+
495
775
  // ── Done ───────────────────────────────────────────────────────
496
776
  log(`\n${GREEN}${BOLD}🎉 安装完成!${RESET}\n`);
497
777
  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
+ }