@a2hmarket/a2hmarket 1.0.11 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 {
@@ -45,11 +46,14 @@ export default {
45
46
  description:
46
47
  "A2H Market — AI agent marketplace with self-managed A2A messaging and human notification.",
47
48
 
48
- register(api: OpenClawPluginApi) {
49
+ async register(api: OpenClawPluginApi) {
49
50
  // ── Init runtime & credentials ───────────────────────────────
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,48 @@ export default {
69
73
  registerSendTool(api, creds);
70
74
  registerAddressTools(api, apiClient);
71
75
  registerDiscussionTools(api, apiClient);
72
- // registerPaymentTools(api, apiClient);
73
- // registerTempoPaymentTools(api, apiClient, creds); // Tempo 内测中,暂不注册
76
+ registerPaymentTools(api, apiClient);
74
77
  registerInboxHistoryTool(api, apiClient);
75
78
  registerApprovalTools(api);
76
79
  }
77
80
 
78
- // ── Auto-fix tools.alsoAllow at startup ──────────────────────
79
- // If openclaw.json has a tools.alsoAllow whitelist (e.g. from feishu plugin),
80
- // ensure a2h tools are included. This handles the case where a2hmarket was
81
- // installed before other plugins that create the whitelist.
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
- const alsoAllow = tools?.alsoAllow;
86
- if (Array.isArray(alsoAllow)) {
87
- const a2hTools = [
88
- "a2h_status", "a2h_profile_get", "a2h_profile_upload_qrcode",
89
- "a2h_profile_delete_qrcode", "a2h_profile_set_default_payment", "a2h_file_upload",
90
- "a2h_works_search", "a2h_works_list", "a2h_works_publish",
91
- "a2h_works_update", "a2h_works_delete",
92
- "a2h_order_create", "a2h_order_action", "a2h_order_get", "a2h_order_list",
93
- "a2h_send", "a2h_inbox_history",
94
- "a2h_address_list", "a2h_address_create", "a2h_address_delete", "a2h_address_set_default",
95
- "a2h_discussion_publish", "a2h_discussion_reply", "a2h_discussion_list",
96
- "a2h_create_approval", "a2h_approval_response", "a2h_approval_list",
97
- // Tempo 内测中,暂不加入 allowlist
98
- // "a2h_tempo_balance", "a2h_tempo_checkout", "a2h_tempo_transfer", "a2h_tempo_confirm",
99
- ];
100
- const missing = a2hTools.filter((t) => !alsoAllow.includes(t));
101
- if (missing.length > 0) {
102
- alsoAllow.push(...missing);
103
- api.runtime.config.writeConfigFile(cfg as any);
104
- api.logger.info(`a2hmarket: added ${missing.length} tools to alsoAllow`);
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
+ await api.runtime.config.writeConfigFile(cfg as any);
107
+ api.logger.info(`a2hmarket: added plugin ID to tools allowlist for profile "${profile}"`);
105
108
  }
106
109
  }
107
110
  } catch {
108
111
  // Best effort — don't block startup
109
112
  }
110
113
 
111
- // ── Track last channel when a2h_* tools are used ─────────────
114
+ // ── Track last channel + detect primary channel need ──────────
115
+ const pendingPrimaryPrompt = new Set<string>();
116
+ const primaryPrompted = new Set<string>();
117
+
112
118
  api.on("after_tool_call", (event) => {
113
119
  const toolName = (event as any)?.toolName ?? "";
114
120
  if (!toolName.startsWith("a2h_")) return;
@@ -121,12 +127,39 @@ export default {
121
127
 
122
128
  const channel = parts[2];
123
129
  const target = parts.slice(4).join(":");
124
- // LastChannelStore is initialized in service start (needs stateDir)
125
- import("./src/channel-state.js").then(({ getLastChannelStore }) => {
130
+
131
+ import("./src/channel-state.js").then(({ getLastChannelStore, getPrimaryChannelStore }) => {
126
132
  getLastChannelStore()?.set({ channel, target, sessionKey });
133
+
134
+ // Check if primaryChannel needs configuration
135
+ if (primaryPrompted.has(sessionKey)) return;
136
+ const primary = getPrimaryChannelStore()?.get();
137
+ if (!primary || primary.sessionKey !== sessionKey) {
138
+ pendingPrimaryPrompt.add(sessionKey);
139
+ }
127
140
  });
128
141
  });
129
142
 
143
+ // ── Auto-prompt for primary channel setup ───────────────────
144
+ api.on("before_prompt_build", (event) => {
145
+ const sessionKey = (event as any)?.sessionKey ?? "";
146
+ if (!sessionKey || sessionKey.includes(":a2hmarket:")) return;
147
+ if (!pendingPrimaryPrompt.has(sessionKey)) return;
148
+
149
+ pendingPrimaryPrompt.delete(sessionKey);
150
+ primaryPrompted.add(sessionKey);
151
+
152
+ return {
153
+ prependContext:
154
+ "【系统提示】检测到当前 channel 尚未设为 A2H Market 的汇报 session。" +
155
+ "请询问用户:'是否将当前聊天设为 A2H Market 的汇报 session?设置后,IM 协商的重要通知(如审批请求)会发送到这里。' " +
156
+ "如果用户同意,从当前 sessionKey 解析出 channel 和 target(sessionKey 格式:agent:main:{channel}:direct:{target})," +
157
+ "写入文件 ~/.a2h_store/a2h_data/primary-channel.json,格式:" +
158
+ JSON.stringify({ channel: "{channel}", target: "{target}", sessionKey: "{sessionKey}", setPrimary: true, updatedAt: "{ISO日期}" }) +
159
+ "。如果用户拒绝,不做任何操作。",
160
+ };
161
+ });
162
+
130
163
  // ── Notify human when a2h tools were used ────────────────────
131
164
  const a2hToolUsedSessions = new Set<string>();
132
165
 
@@ -169,6 +202,40 @@ export default {
169
202
  return { cancel: true };
170
203
  });
171
204
 
205
+ // ── Welcome message on first boot after install ────────────────
206
+ api.on("gateway_start", async () => {
207
+ try {
208
+ const { readPendingWelcome, deletePendingWelcome, sendWelcome } =
209
+ await import("./src/pending-welcome.js");
210
+ const pending = readPendingWelcome();
211
+ if (!pending) return;
212
+
213
+ const cfg = api.runtime.config.loadConfig() as Record<string, unknown>;
214
+ const channels = (cfg.channels ?? {}) as Record<string, Record<string, unknown>>;
215
+ const channelCfg = channels[pending.channel];
216
+ if (!channelCfg) {
217
+ api.logger.warn(
218
+ `a2hmarket: welcome skipped, no config for channel "${pending.channel}"`,
219
+ );
220
+ deletePendingWelcome();
221
+ return;
222
+ }
223
+
224
+ const sent = await sendWelcome(pending, channelCfg, {
225
+ info: (m) => api.logger.info(`a2hmarket: ${m}`),
226
+ warn: (m) => api.logger.warn(`a2hmarket: ${m}`),
227
+ });
228
+ deletePendingWelcome();
229
+ if (sent) {
230
+ api.logger.info("a2hmarket: welcome message sent");
231
+ }
232
+ } catch (err) {
233
+ api.logger.warn(
234
+ `a2hmarket: welcome failed: ${err instanceof Error ? err.message : String(err)}`,
235
+ );
236
+ }
237
+ });
238
+
172
239
  // ── Register agent service ───────────────────────────────────
173
240
  let serviceAbort: AbortController | null = null;
174
241
  api.registerService({
@@ -190,6 +257,7 @@ export default {
190
257
  initReplyBridge(join(dataDir, "reply-bridge.json"));
191
258
  initApprovalStore(join(dataDir, "approvals.json"));
192
259
  setLastChannelStore(new LastChannelStore(join(dataDir, "last-channel.json")));
260
+ setPrimaryChannelStore(new PrimaryChannelStore(join(dataDir, "primary-channel.json")));
193
261
 
194
262
  const serviceLog = {
195
263
  info: (m: string) => ctx.logger.info(`[a2hmarket] ${m}`),
@@ -210,7 +278,7 @@ export default {
210
278
  );
211
279
  }
212
280
  },
213
- stop: async () => {
281
+ stop: async (_ctx) => {
214
282
  serviceAbort?.abort();
215
283
  },
216
284
  });
@@ -2,7 +2,7 @@
2
2
  "id": "a2hmarket",
3
3
  "name": "A2H Market",
4
4
  "description": "A2H Market \u2014 AI agent marketplace with self-managed A2A messaging via MQTT.",
5
- "version": "1.0.11",
5
+ "version": "1.3.2",
6
6
  "hosts": [
7
7
  "openclaw"
8
8
  ],
package/package.json CHANGED
@@ -1,23 +1,18 @@
1
1
  {
2
2
  "name": "@a2hmarket/a2hmarket",
3
- "version": "1.0.11",
3
+ "version": "1.3.2",
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. 人类回复后,根据决定输出 `[REPLY] 最终回复内容`
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
 
@@ -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
- meta.push(`[attachment: ${att.name ?? att.url ?? "file"}]`);
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
@@ -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
+ }
@@ -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
 
@@ -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:\/\/checkout\.stripe\.com\S+)/g,
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. Read credentials.notify.channel + target
6
- * 2. Feishu rich interactive card via Feishu Open API
7
- * 3. Discordplain text via Discord bot API
8
- * 4. Otherslog warning (unsupported)
5
+ * 1. PrimaryChannelStore (user-designated runtime override)
6
+ * 2. credentials.notify.channel + target (install-time fallback)
7
+ * 3. Feishurich interactive card via Feishu Open API
8
+ * 4. Discordplain 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
+ }