@a2hmarket/a2hmarket 0.2.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.
@@ -0,0 +1,154 @@
1
+ import { computeHttpSignature } from "./signer.js";
2
+ import type { A2HCredentials } from "./credentials.js";
3
+
4
+ export class PlatformError extends Error {
5
+ code: string;
6
+ httpStatus: number;
7
+ constructor(code: string, message: string, httpStatus: number) {
8
+ super(`A2H API error [${code}]: ${message}`);
9
+ this.name = "PlatformError";
10
+ this.code = code;
11
+ this.httpStatus = httpStatus;
12
+ }
13
+ }
14
+
15
+ interface PlatformResponse {
16
+ code?: string | number;
17
+ message?: string;
18
+ data?: unknown;
19
+ }
20
+
21
+ export class A2HApiClient {
22
+ private creds: A2HCredentials;
23
+
24
+ constructor(creds: A2HCredentials) {
25
+ this.creds = creds;
26
+ }
27
+
28
+ get agentId(): string {
29
+ return this.creds.agentId;
30
+ }
31
+
32
+ get credentials(): A2HCredentials {
33
+ return this.creds;
34
+ }
35
+
36
+ /**
37
+ * GET with signature. apiPath may include query string.
38
+ * signPath is the path used for signing (without query), auto-extracted if omitted.
39
+ */
40
+ async getJSON<T = unknown>(apiPath: string, signPath?: string): Promise<T> {
41
+ return this.doRequest<T>("GET", this.creds.apiUrl, apiPath, signPath);
42
+ }
43
+
44
+ /** POST JSON with signature. */
45
+ async postJSON<T = unknown>(apiPath: string, body?: unknown): Promise<T> {
46
+ return this.doRequest<T>("POST", this.creds.apiUrl, apiPath, undefined, body);
47
+ }
48
+
49
+ /** POST JSON to a different base URL (e.g. OSS service). */
50
+ async postJSONToHost<T = unknown>(
51
+ baseUrl: string,
52
+ apiPath: string,
53
+ signPath: string,
54
+ body?: unknown
55
+ ): Promise<T> {
56
+ return this.doRequest<T>("POST", baseUrl.replace(/\/+$/, ""), apiPath, signPath, body);
57
+ }
58
+
59
+ /** DELETE with signature. */
60
+ async deleteJSON<T = unknown>(apiPath: string): Promise<T> {
61
+ return this.doRequest<T>("DELETE", this.creds.apiUrl, apiPath);
62
+ }
63
+
64
+ /** PUT binary to a pre-signed URL (no business signature). */
65
+ async putBinary(
66
+ uploadUrl: string,
67
+ signedHeaders: Record<string, string>,
68
+ data: Buffer | Uint8Array
69
+ ): Promise<void> {
70
+ const headers: Record<string, string> = { ...signedHeaders };
71
+ const resp = await fetch(uploadUrl, {
72
+ method: "PUT",
73
+ headers,
74
+ body: data as unknown as BodyInit,
75
+ });
76
+ if (!resp.ok) {
77
+ const text = await resp.text().catch(() => "");
78
+ throw new Error(`PUT binary HTTP ${resp.status}: ${text.slice(0, 200)}`);
79
+ }
80
+ }
81
+
82
+ // ─── Internal ────────────────────────────────────────────────────
83
+
84
+ private async doRequest<T>(
85
+ method: string,
86
+ baseUrl: string,
87
+ apiPath: string,
88
+ signPath?: string,
89
+ body?: unknown
90
+ ): Promise<T> {
91
+ // Determine signing path (strip query string)
92
+ const effectiveSignPath =
93
+ signPath ?? (apiPath.includes("?") ? apiPath.slice(0, apiPath.indexOf("?")) : apiPath);
94
+
95
+ const timestamp = String(Math.floor(Date.now() / 1000));
96
+ const signature = computeHttpSignature(
97
+ this.creds.agentKey,
98
+ method,
99
+ effectiveSignPath,
100
+ this.creds.agentId,
101
+ timestamp
102
+ );
103
+
104
+ const url = baseUrl + apiPath;
105
+ const headers: Record<string, string> = {
106
+ "Content-Type": "application/json",
107
+ "X-Agent-Id": this.creds.agentId,
108
+ "X-Timestamp": timestamp,
109
+ "X-Agent-Signature": signature,
110
+ };
111
+
112
+ const init: RequestInit = { method, headers };
113
+ if (method !== "GET" && method !== "HEAD") {
114
+ init.body = JSON.stringify(body ?? {});
115
+ }
116
+
117
+ const resp = await fetch(url, init);
118
+ const rawBody = await resp.text();
119
+
120
+ if (!resp.ok) {
121
+ let pr: PlatformResponse = {};
122
+ try {
123
+ pr = JSON.parse(rawBody);
124
+ } catch {
125
+ // not JSON
126
+ }
127
+ const msg = pr.message || rawBody.slice(0, 200);
128
+ const code = pr.code != null ? String(pr.code) : String(resp.status);
129
+ throw new PlatformError(code, msg, resp.status);
130
+ }
131
+
132
+ // Parse platform wrapper: { code: "200", message: "...", data: T }
133
+ let parsed: PlatformResponse;
134
+ try {
135
+ parsed = JSON.parse(rawBody);
136
+ } catch {
137
+ // Response is not standard platform format, try direct parse
138
+ return JSON.parse(rawBody) as T;
139
+ }
140
+
141
+ // Normalize code (handles both "200" and 200)
142
+ const codeStr = String(parsed.code ?? "").replace(/"/g, "");
143
+ if (codeStr && codeStr !== "200") {
144
+ throw new PlatformError(codeStr, parsed.message ?? "", 0);
145
+ }
146
+
147
+ // Return the data field
148
+ if (parsed.data !== undefined && parsed.data !== null) {
149
+ return parsed.data as T;
150
+ }
151
+
152
+ return undefined as T;
153
+ }
154
+ }
@@ -0,0 +1,11 @@
1
+ import type { LastChannelStore } from "./last-channel.js";
2
+
3
+ let _store: LastChannelStore | null = null;
4
+
5
+ export function setLastChannelStore(store: LastChannelStore): void {
6
+ _store = store;
7
+ }
8
+
9
+ export function getLastChannelStore(): LastChannelStore | null {
10
+ return _store;
11
+ }
@@ -0,0 +1,131 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export interface A2HNotifyConfig {
7
+ channel: string; // e.g. "feishu"
8
+ target: string; // e.g. "ou_xxxxx" (feishu open_id) or chat_id
9
+ }
10
+
11
+ export interface A2HCredentials {
12
+ agentId: string;
13
+ agentKey: string;
14
+ apiUrl: string;
15
+ mqttUrl: string;
16
+ notify?: A2HNotifyConfig;
17
+ }
18
+
19
+ // ── Load from pluginConfig (openclaw.json) — preferred ─────────────────
20
+
21
+ export function loadCredentialsFromConfig(
22
+ pluginConfig?: Record<string, unknown>,
23
+ ): A2HCredentials | null {
24
+ if (!pluginConfig) return null;
25
+
26
+ const agentId = (pluginConfig.agentId as string) ?? "";
27
+ const agentKey = (pluginConfig.agentKey as string) ?? "";
28
+ if (!agentId || !agentKey) return null;
29
+
30
+ const notifyRaw = pluginConfig.notify as Record<string, string> | undefined;
31
+ const notify: A2HNotifyConfig | undefined =
32
+ notifyRaw?.channel && notifyRaw?.target
33
+ ? { channel: notifyRaw.channel, target: notifyRaw.target }
34
+ : undefined;
35
+
36
+ return {
37
+ agentId,
38
+ agentKey,
39
+ apiUrl: ((pluginConfig.apiUrl as string) ?? "https://api.a2hmarket.ai").replace(/\/+$/, ""),
40
+ mqttUrl: (pluginConfig.mqttUrl as string) ?? "mqtts://post-cn-e4k4o78q702.mqtt.aliyuncs.com:8883",
41
+ notify,
42
+ };
43
+ }
44
+
45
+ // ── Load from file — fallback for dev mode ─────────────────────────────
46
+
47
+ const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
48
+ const OPENCLAW_STATE_DIR = join(homedir(), ".openclaw", "a2hmarket");
49
+ const HOME_CONFIG_DIR = join(homedir(), ".a2hmarket");
50
+ const CREDENTIALS_FILE = "credentials.json";
51
+
52
+ interface RawCredentials {
53
+ agent_id?: string;
54
+ agent_key?: string;
55
+ api_url?: string;
56
+ mqtt_url?: string;
57
+ notify?: { channel?: string; target?: string };
58
+ agentId?: string;
59
+ agentKey?: string;
60
+ secret?: string;
61
+ }
62
+
63
+ export function loadCredentialsFromFile(configDir?: string): A2HCredentials {
64
+ let dir: string;
65
+ if (configDir) {
66
+ dir = configDir;
67
+ } else {
68
+ // Priority: stateDir > plugin dir > ~/.a2hmarket/ (legacy)
69
+ const statePath = join(OPENCLAW_STATE_DIR, CREDENTIALS_FILE);
70
+ const pluginPath = join(PLUGIN_DIR, CREDENTIALS_FILE);
71
+ dir = existsSync(statePath)
72
+ ? OPENCLAW_STATE_DIR
73
+ : existsSync(pluginPath)
74
+ ? PLUGIN_DIR
75
+ : HOME_CONFIG_DIR;
76
+ }
77
+ const filePath = join(dir, CREDENTIALS_FILE);
78
+
79
+ let raw: RawCredentials;
80
+ try {
81
+ raw = JSON.parse(readFileSync(filePath, "utf-8"));
82
+ } catch (err) {
83
+ throw new Error(
84
+ `Failed to read credentials from ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
85
+ );
86
+ }
87
+
88
+ const agentId = raw.agent_id ?? raw.agentId ?? "";
89
+ const agentKey = raw.agent_key ?? raw.agentKey ?? raw.secret ?? "";
90
+ if (!agentId || !agentKey) {
91
+ throw new Error(`Invalid credentials in ${filePath}: agent_id and agent_key are required`);
92
+ }
93
+
94
+ const notify: A2HNotifyConfig | undefined =
95
+ raw.notify?.channel && raw.notify?.target
96
+ ? { channel: raw.notify.channel, target: raw.notify.target }
97
+ : undefined;
98
+
99
+ return {
100
+ agentId,
101
+ agentKey,
102
+ apiUrl: (raw.api_url ?? "https://api.a2hmarket.ai").replace(/\/+$/, ""),
103
+ mqttUrl: raw.mqtt_url ?? "mqtts://post-cn-e4k4o78q702.mqtt.aliyuncs.com:8883",
104
+ notify,
105
+ };
106
+ }
107
+
108
+ // ── Unified loader: pluginConfig > file ────────────────────────────────
109
+
110
+ let _cachedCreds: A2HCredentials | null = null;
111
+ let _pluginConfig: Record<string, unknown> | undefined;
112
+
113
+ export function initCredentials(pluginConfig?: Record<string, unknown>): void {
114
+ _pluginConfig = pluginConfig;
115
+ _cachedCreds = null;
116
+ }
117
+
118
+ export function loadCredentials(): A2HCredentials {
119
+ if (_cachedCreds) return _cachedCreds;
120
+
121
+ // Priority: pluginConfig (openclaw.json) > file (dev mode fallback)
122
+ const fromConfig = loadCredentialsFromConfig(_pluginConfig);
123
+ if (fromConfig) {
124
+ _cachedCreds = fromConfig;
125
+ return fromConfig;
126
+ }
127
+
128
+ const fromFile = loadCredentialsFromFile();
129
+ _cachedCreds = fromFile;
130
+ return fromFile;
131
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Feishu notification — send interactive card messages directly via Feishu API.
3
+ *
4
+ * Uses Feishu Open API v2:
5
+ * 1. Get tenant_access_token (cached)
6
+ * 2. Send interactive card message
7
+ *
8
+ * No SDK dependency needed — pure fetch.
9
+ */
10
+
11
+ const FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
12
+ const FEISHU_SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages";
13
+
14
+ let tokenCache: { token: string; expiresAt: number } | null = null;
15
+
16
+ async function getTenantToken(appId: string, appSecret: string): Promise<string> {
17
+ if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) {
18
+ return tokenCache.token;
19
+ }
20
+
21
+ const resp = await fetch(FEISHU_TOKEN_URL, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
25
+ });
26
+
27
+ const data = (await resp.json()) as {
28
+ code: number;
29
+ tenant_access_token: string;
30
+ expire: number;
31
+ };
32
+
33
+ if (data.code !== 0) {
34
+ throw new Error(`feishu token error: code=${data.code}`);
35
+ }
36
+
37
+ tokenCache = {
38
+ token: data.tenant_access_token,
39
+ expiresAt: Date.now() + data.expire * 1000,
40
+ };
41
+
42
+ return tokenCache.token;
43
+ }
44
+
45
+ export interface FeishuCardElement {
46
+ tag: string;
47
+ content?: string;
48
+ text?: { tag: string; content: string };
49
+ actions?: Array<{
50
+ tag: string;
51
+ text: { tag: string; content: string };
52
+ type?: string;
53
+ value?: Record<string, unknown>;
54
+ }>;
55
+ }
56
+
57
+ export interface FeishuNotifyParams {
58
+ appId: string;
59
+ appSecret: string;
60
+ target: string; // open_id (ou_xxx) or chat_id (oc_xxx)
61
+ title: string;
62
+ titleColor?: string; // blue, green, red, orange, purple, turquoise, yellow, grey
63
+ elements: FeishuCardElement[];
64
+ }
65
+
66
+ /**
67
+ * Send a Feishu interactive card message.
68
+ */
69
+ export async function sendFeishuCard(params: FeishuNotifyParams): Promise<string> {
70
+ const token = await getTenantToken(params.appId, params.appSecret);
71
+
72
+ // Determine receive_id_type from target format
73
+ const receiveIdType = params.target.startsWith("oc_") ? "chat_id" : "open_id";
74
+
75
+ const card = {
76
+ schema: "2.0",
77
+ config: { wide_screen_mode: true },
78
+ header: {
79
+ title: { tag: "plain_text", content: params.title },
80
+ template: params.titleColor ?? "blue",
81
+ },
82
+ body: {
83
+ elements: params.elements,
84
+ },
85
+ };
86
+
87
+ const resp = await fetch(`${FEISHU_SEND_URL}?receive_id_type=${receiveIdType}`, {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ Authorization: `Bearer ${token}`,
92
+ },
93
+ body: JSON.stringify({
94
+ receive_id: params.target,
95
+ msg_type: "interactive",
96
+ content: JSON.stringify(card),
97
+ }),
98
+ });
99
+
100
+ const data = (await resp.json()) as {
101
+ code: number;
102
+ msg: string;
103
+ data?: { message_id: string };
104
+ };
105
+
106
+ if (data.code !== 0) {
107
+ throw new Error(`feishu send error: code=${data.code} msg=${data.msg}`);
108
+ }
109
+
110
+ return data.data?.message_id ?? "";
111
+ }
112
+
113
+ /**
114
+ * Build a standard A2H Market notification card.
115
+ */
116
+ export function buildA2HNotifyCard(params: {
117
+ type: "inbound" | "reply" | "approval";
118
+ peerId: string;
119
+ content: string;
120
+ agentId?: string;
121
+ }): { title: string; titleColor: string; elements: FeishuCardElement[] } {
122
+ const titles = {
123
+ inbound: "📩 A2H Market · 收到消息",
124
+ reply: "🤖 A2H Market · 已自动回复",
125
+ approval: "🔔 A2H Market · 需要确认",
126
+ };
127
+
128
+ const colors = {
129
+ inbound: "blue",
130
+ reply: "green",
131
+ approval: "orange",
132
+ };
133
+
134
+ const elements: FeishuCardElement[] = [
135
+ {
136
+ tag: "markdown",
137
+ content: `**来自**: \`${params.peerId}\``,
138
+ },
139
+ {
140
+ tag: "markdown",
141
+ content: params.content,
142
+ },
143
+ ];
144
+
145
+ if (params.agentId) {
146
+ elements.push({
147
+ tag: "markdown",
148
+ content: `---\n*Agent: ${params.agentId}*`,
149
+ });
150
+ }
151
+
152
+ return {
153
+ title: titles[params.type],
154
+ titleColor: colors[params.type],
155
+ elements,
156
+ };
157
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ export interface LastChannelRecord {
5
+ channel: string;
6
+ target: string;
7
+ sessionKey: string;
8
+ updatedAt: string;
9
+ }
10
+
11
+ /**
12
+ * Tracks the last channel + session the user used to interact with a2hmarket.
13
+ * Persisted as a JSON file alongside plugin data.
14
+ */
15
+ export class LastChannelStore {
16
+ private filePath: string;
17
+
18
+ constructor(filePath: string) {
19
+ this.filePath = filePath;
20
+ }
21
+
22
+ get(): LastChannelRecord | null {
23
+ try {
24
+ const raw = readFileSync(this.filePath, "utf-8");
25
+ return JSON.parse(raw);
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ set(record: Omit<LastChannelRecord, "updatedAt">): void {
32
+ const full: LastChannelRecord = {
33
+ ...record,
34
+ updatedAt: new Date().toISOString(),
35
+ };
36
+ mkdirSync(dirname(this.filePath), { recursive: true });
37
+ writeFileSync(this.filePath, JSON.stringify(full, null, 2));
38
+ }
39
+ }
@@ -0,0 +1,125 @@
1
+ import type { A2HCredentials } from "./credentials.js";
2
+ import { MqttTokenClient, buildClientId } from "./mqtt-token.js";
3
+ import { MqttTransport } from "./mqtt-transport.js";
4
+
5
+ export interface A2AEnvelopeEvent {
6
+ senderId: string;
7
+ messageId: string;
8
+ text: string;
9
+ payload: Record<string, unknown>;
10
+ envelope: Record<string, unknown>;
11
+ }
12
+
13
+ type MessageHandler = (event: A2AEnvelopeEvent) => void | Promise<void>;
14
+
15
+ /**
16
+ * MqttListener manages a long-lived MQTT connection that:
17
+ * 1. Subscribes to the agent's incoming P2P topic
18
+ * 2. Parses received A2A envelopes
19
+ * 3. Invokes the registered message handler (for channel dispatch)
20
+ */
21
+ export class MqttListener {
22
+ private creds: A2HCredentials;
23
+ private transport: MqttTransport | null = null;
24
+ private handler: MessageHandler | null = null;
25
+ private log: { info: (m: string) => void; error: (m: string) => void; warn: (m: string) => void };
26
+
27
+ constructor(
28
+ creds: A2HCredentials,
29
+ log?: { info: (m: string) => void; error: (m: string) => void; warn: (m: string) => void },
30
+ ) {
31
+ this.creds = creds;
32
+ this.log = log ?? {
33
+ info: (m) => process.stderr.write(`a2hmarket: ${m}\n`),
34
+ error: (m) => process.stderr.write(`a2hmarket: ${m}\n`),
35
+ warn: (m) => process.stderr.write(`a2hmarket: ${m}\n`),
36
+ };
37
+ }
38
+
39
+ onMessage(handler: MessageHandler): void {
40
+ this.handler = handler;
41
+ }
42
+
43
+ async start(): Promise<void> {
44
+ const tokenClient = new MqttTokenClient(
45
+ this.creds.apiUrl,
46
+ this.creds.agentId,
47
+ this.creds.agentKey,
48
+ );
49
+
50
+ this.transport = new MqttTransport(this.creds.mqttUrl, tokenClient, this.creds.agentId, {
51
+ clientId: buildClientId(this.creds.agentId),
52
+ cleanSession: false,
53
+ });
54
+
55
+ this.transport.onMessage((msg) => {
56
+ this.handleMessage(msg.payload);
57
+ });
58
+
59
+ this.transport.onReconnect(() => {
60
+ // Reconnects are expected when another client uses the same clientId (e.g. mobile app).
61
+ // Log at warn level but don't flood — this is a known RocketMQ P2P clientId constraint.
62
+ });
63
+
64
+ try {
65
+ await this.transport.connect();
66
+ await this.transport.subscribe();
67
+ this.log.info("MQTT listener started");
68
+ } catch (err) {
69
+ this.log.error(
70
+ `MQTT listener start failed: ${err instanceof Error ? err.message : String(err)}`,
71
+ );
72
+ }
73
+ }
74
+
75
+ stop(): void {
76
+ this.transport?.close();
77
+ this.transport = null;
78
+ this.log.info("MQTT listener stopped");
79
+ }
80
+
81
+ isConnected(): boolean {
82
+ return this.transport?.isConnected() ?? false;
83
+ }
84
+
85
+ private handleMessage(raw: string): void {
86
+ try {
87
+ const envelope = JSON.parse(raw);
88
+ const payload = envelope.payload ?? {};
89
+ const senderId: string = envelope.sender_id ?? "";
90
+ const messageId: string =
91
+ envelope.message_id ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
92
+ const text: string = typeof payload.text === "string" ? payload.text : "";
93
+
94
+ // Debug: log sender and target from envelope
95
+ const targetId: string = envelope.target_id ?? "";
96
+ this.log.info(
97
+ `envelope: sender=${senderId} target=${targetId} type=${envelope.message_type ?? "?"} msgId=${messageId}`,
98
+ );
99
+
100
+ if (!senderId) {
101
+ this.log.warn("received message without sender_id, skipping");
102
+ return;
103
+ }
104
+
105
+ // Skip messages sent BY ourselves (echo prevention)
106
+ if (senderId === this.creds.agentId) {
107
+ this.log.info(`skipping own message (echo): ${messageId}`);
108
+ return;
109
+ }
110
+
111
+ const event: A2AEnvelopeEvent = { senderId, messageId, text, payload, envelope };
112
+
113
+ // Invoke handler (async errors are caught below)
114
+ Promise.resolve(this.handler?.(event)).catch((err) => {
115
+ this.log.error(
116
+ `message handler error: ${err instanceof Error ? err.message : String(err)}`,
117
+ );
118
+ });
119
+ } catch (err) {
120
+ this.log.error(
121
+ `failed to parse message: ${err instanceof Error ? err.message : String(err)}`,
122
+ );
123
+ }
124
+ }
125
+ }