@a2hmarket/a2hmarket 2026.3.19

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,118 @@
1
+ /**
2
+ * Lease control-plane client.
3
+ * Coordinates leader/follower role for multi-instance deployments of the same agentId.
4
+ * Mirrors the Go lease.Client in a2hmarket-cli/internal/lease/client.go.
5
+ */
6
+
7
+ import { createHmac, randomUUID } from "node:crypto";
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { homedir, hostname } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import type { Credentials } from "../api-client.js";
12
+
13
+ const PATH_ACQUIRE = "/agent-service/api/v1/agent-runtime/lease/acquire";
14
+ const PATH_HEARTBEAT = "/agent-service/api/v1/agent-runtime/lease/heartbeat";
15
+
16
+ const INSTANCE_ID_PATH = join(homedir(), ".a2hmarket", "instance_id");
17
+
18
+ export type LeaseRole = "leader" | "follower" | "standalone";
19
+
20
+ export type AcquireResult = {
21
+ role: LeaseRole;
22
+ epoch: number;
23
+ leaseUntil: number;
24
+ leaderInstanceId?: string;
25
+ };
26
+
27
+ export type HeartbeatResult = {
28
+ ok: boolean;
29
+ reason?: string;
30
+ epoch: number;
31
+ leaseUntil: number;
32
+ };
33
+
34
+ // ─── Instance ID persistence ─────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Load the persistent instance ID from disk, or generate and save a new one.
38
+ * Instance ID is stable across gateway restarts on the same machine.
39
+ */
40
+ export function loadOrCreateInstanceId(): string {
41
+ if (existsSync(INSTANCE_ID_PATH)) {
42
+ try {
43
+ const id = readFileSync(INSTANCE_ID_PATH, "utf-8").trim();
44
+ if (id) return id;
45
+ } catch {
46
+ // fall through to generate
47
+ }
48
+ }
49
+ const id = randomUUID().replace(/-/g, "").slice(0, 16);
50
+ mkdirSync(dirname(INSTANCE_ID_PATH), { recursive: true });
51
+ writeFileSync(INSTANCE_ID_PATH, id, "utf-8");
52
+ return id;
53
+ }
54
+
55
+ // ─── HTTP helpers ─────────────────────────────────────────────────────────────
56
+
57
+ function buildHeaders(creds: Credentials, method: string, path: string): Record<string, string> {
58
+ const ts = String(Math.floor(Date.now() / 1000));
59
+ const payload = `${method}&${path}&${creds.agent_id}&${ts}`;
60
+ const sig = createHmac("sha256", creds.agent_key).update(payload).digest("hex");
61
+ return {
62
+ "Content-Type": "application/json",
63
+ "X-Agent-Id": creds.agent_id,
64
+ "X-Timestamp": ts,
65
+ "X-Agent-Signature": sig,
66
+ };
67
+ }
68
+
69
+ async function leasePost<T>(creds: Credentials, path: string, body: unknown): Promise<T> {
70
+ const url = `${creds.api_url}${path}`;
71
+ const headers = buildHeaders(creds, "POST", path);
72
+ const resp = await fetch(url, {
73
+ method: "POST",
74
+ headers,
75
+ body: JSON.stringify(body),
76
+ signal: AbortSignal.timeout(10_000),
77
+ });
78
+ if (!resp.ok) {
79
+ throw new Error(`lease HTTP ${resp.status}`);
80
+ }
81
+ const wrapper = (await resp.json()) as { success: boolean; data?: T; error?: string };
82
+ if (!wrapper.success) {
83
+ throw new Error(`lease server error: ${wrapper.error ?? "unknown"}`);
84
+ }
85
+ return wrapper.data as T;
86
+ }
87
+
88
+ // ─── Public API ───────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Acquire (or forcefully take over) the leader lease for this instance.
92
+ * Pass forceTakeover=true to immediately displace any existing leader.
93
+ */
94
+ export async function acquireLease(
95
+ creds: Credentials,
96
+ instanceId: string,
97
+ clientId: string,
98
+ forceTakeover: boolean,
99
+ ): Promise<AcquireResult> {
100
+ return leasePost<AcquireResult>(creds, PATH_ACQUIRE, {
101
+ instanceId,
102
+ clientId,
103
+ hostname: hostname(),
104
+ forceTakeover,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Send a heartbeat to renew the leader lease.
110
+ * Returns ok=false when the lease has been revoked (another instance took over).
111
+ */
112
+ export async function sendHeartbeat(
113
+ creds: Credentials,
114
+ instanceId: string,
115
+ epoch: number,
116
+ ): Promise<HeartbeatResult> {
117
+ return leasePost<HeartbeatResult>(creds, PATH_HEARTBEAT, { instanceId, epoch });
118
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * MQTT Listener Service — persistent MQTT connection for receiving A2A messages.
3
+ * Replaces the Go binary listener daemon with an in-process openclaw service.
4
+ *
5
+ * Leader election:
6
+ * On startup, acquires the leader lease with forceTakeover=true so the newest
7
+ * instance always wins. The displaced leader detects revocation on its next
8
+ * heartbeat and stops reconnecting.
9
+ *
10
+ * role=leader → clientId GID_agent@@@{agentId}, clean:false, subscribes
11
+ * role=follower → clientId GID_agent@@@{agentId}_rt_{instanceId}, no subscribe
12
+ * standalone → control plane unreachable; behave as leader (best-effort)
13
+ */
14
+
15
+ import mqtt from "mqtt";
16
+ import type { MqttClient } from "mqtt";
17
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
18
+ import { readCredentials } from "../credentials.js";
19
+ import type { Credentials } from "../api-client.js";
20
+ import { fetchMqttToken } from "../api-client.js";
21
+ import { parseEnvelope } from "../a2a-protocol.js";
22
+ import type { Envelope } from "../a2a-protocol.js";
23
+ import { ingestEvent } from "../api/inbox.js";
24
+ import { getCachedSessionKey, hasChannelSession } from "../session-cache.js";
25
+ import {
26
+ loadOrCreateInstanceId,
27
+ acquireLease,
28
+ sendHeartbeat,
29
+ type LeaseRole,
30
+ } from "./lease-client.js";
31
+
32
+ // Reconnect delay sequence (seconds)
33
+ const RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30];
34
+
35
+ const HEARTBEAT_INTERVAL_MS = 15_000;
36
+ const FOLLOWER_POLL_INTERVAL_MS = 20_000;
37
+
38
+ // Module-level singleton client; shared with send.ts via getMqttClient()
39
+ let _client: MqttClient | null = null;
40
+ let _connected = false;
41
+ let _sessionPresent = false;
42
+ let _reconnectAttempts = 0;
43
+
44
+ // Set to true when stop() is called to prevent scheduled reconnects
45
+ let _stopped = false;
46
+
47
+ // Lease state
48
+ let _role: LeaseRole = "standalone";
49
+ let _leaseEpoch = 0;
50
+ let _instanceId = "";
51
+ let _heartbeatTimer: ReturnType<typeof setInterval> | null = null;
52
+ let _followerPollTimer: ReturnType<typeof setInterval> | null = null;
53
+
54
+ export function getMqttClient(): MqttClient | null {
55
+ return _client;
56
+ }
57
+
58
+ export function getListenerStatus(): {
59
+ connected: boolean;
60
+ sessionPresent: boolean;
61
+ reconnectAttempts: number;
62
+ role: LeaseRole;
63
+ instanceId: string;
64
+ } {
65
+ return {
66
+ connected: _connected,
67
+ sessionPresent: _sessionPresent,
68
+ reconnectAttempts: _reconnectAttempts,
69
+ role: _role,
70
+ instanceId: _instanceId,
71
+ };
72
+ }
73
+
74
+ function sleep(ms: number): Promise<void> {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
77
+
78
+ function clearTimers(): void {
79
+ if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; }
80
+ if (_followerPollTimer) { clearInterval(_followerPollTimer); _followerPollTimer = null; }
81
+ }
82
+
83
+ // ─── Incoming message handler ─────────────────────────────────────────────────
84
+
85
+ async function handleIncomingMessage(
86
+ rawPayload: string,
87
+ creds: Credentials,
88
+ api: OpenClawPluginApi,
89
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
90
+ ): Promise<void> {
91
+ const envelope: Envelope | null = parseEnvelope(rawPayload);
92
+ if (!envelope) {
93
+ logger.warn("a2hmarket-mqtt-listener: failed to parse envelope");
94
+ return;
95
+ }
96
+
97
+ if (envelope.protocol !== "a2hmarket-a2a") return;
98
+ if (!envelope.message_id || !envelope.sender_id) return;
99
+
100
+ const event = ingestEvent(envelope);
101
+ if (!event) {
102
+ logger.info(`a2hmarket-mqtt-listener: duplicate message ${envelope.message_id}`);
103
+ return;
104
+ }
105
+
106
+ logger.info(
107
+ `a2hmarket-mqtt-listener: received event_id=${event.event_id} from ${envelope.sender_id}`,
108
+ );
109
+
110
+ const sessionKey = getCachedSessionKey();
111
+ const preview = event.preview || "(no preview)";
112
+ // Be explicit: tell the AI which tool to call and with what args, so it
113
+ // doesn't fall back to trying CLI commands.
114
+ const message = [
115
+ `[A2HMarket 新消息到达]`,
116
+ `来自: ${envelope.sender_id}`,
117
+ `event_id: ${event.event_id}`,
118
+ `摘要: ${preview}`,
119
+ ``,
120
+ `请立即执行:`,
121
+ `1. 调用 inbox_pull 工具读取完整消息内容`,
122
+ `2. 调用 inbox_ack 工具确认消息(传入 eventId: "${event.event_id}")`,
123
+ `3. 根据消息内容决定是否需要用 send_message 工具回复对方`,
124
+ ].join("\n");
125
+
126
+ if (!api.runtime?.subagent?.run) {
127
+ logger.warn(`a2hmarket-mqtt-listener: subagent.run unavailable (openclaw version too old), event_id=${event.event_id} queued in inbox`);
128
+ return;
129
+ }
130
+
131
+ // Only use deliver:true when a specific channel session (e.g. Feishu) is active.
132
+ // With the fallback "agent:main:main" session, deliver:true causes a Feishu error
133
+ // ("requires target") because openclaw doesn't know which user to push to.
134
+ // The subagent still runs and processes the message either way.
135
+ const deliver = hasChannelSession();
136
+
137
+ try {
138
+ await api.runtime.subagent.run({
139
+ sessionKey,
140
+ message,
141
+ deliver,
142
+ idempotencyKey: `a2hmarket-mqtt:${event.event_id}`,
143
+ });
144
+ logger.info(`a2hmarket-mqtt-listener: delivered event_id=${event.event_id}`);
145
+ } catch (err) {
146
+ logger.warn(`a2hmarket-mqtt-listener: subagent.run failed: ${String(err)}`);
147
+ }
148
+ }
149
+
150
+ // ─── Reconnect scheduler ──────────────────────────────────────────────────────
151
+
152
+ async function scheduleReconnect(
153
+ api: OpenClawPluginApi,
154
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
155
+ attempt: number,
156
+ ): Promise<void> {
157
+ if (_stopped) return;
158
+
159
+ const delaySeconds = RECONNECT_DELAYS[Math.min(attempt, RECONNECT_DELAYS.length - 1)];
160
+ logger.info(
161
+ `a2hmarket-mqtt-listener: reconnecting in ${delaySeconds}s (attempt ${attempt + 1})`,
162
+ );
163
+ await sleep(delaySeconds * 1000);
164
+
165
+ if (_stopped) return;
166
+
167
+ const creds = readCredentials();
168
+ if (!creds) {
169
+ logger.warn("a2hmarket-mqtt-listener: no credentials on reconnect, giving up");
170
+ return;
171
+ }
172
+
173
+ try {
174
+ await connectAndSubscribe(creds, api, logger, attempt + 1);
175
+ } catch (err) {
176
+ logger.warn(
177
+ `a2hmarket-mqtt-listener: reconnect attempt ${attempt + 1} failed: ${String(err)}`,
178
+ );
179
+ scheduleReconnect(api, logger, attempt + 1).catch(() => undefined);
180
+ }
181
+ }
182
+
183
+ // ─── MQTT connect ─────────────────────────────────────────────────────────────
184
+
185
+ async function connectAndSubscribe(
186
+ creds: Credentials,
187
+ api: OpenClawPluginApi,
188
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
189
+ attempt = 0,
190
+ ): Promise<void> {
191
+ // clientId depends on role:
192
+ // leader/standalone → base clientId (persistent session, receives messages)
193
+ // follower → instance-unique suffix (no session conflict, no subscribe)
194
+ const isFollower = _role === "follower";
195
+ const clientId = isFollower
196
+ ? `GID_agent@@@${creds.agent_id}_rt_${_instanceId}`
197
+ : `GID_agent@@@${creds.agent_id}`;
198
+
199
+ // Pass the actual clientId so the token matches the connection clientId.
200
+ // Broker validates token.clientId == connection clientId; mismatch causes disconnect.
201
+ const token = await fetchMqttToken(creds, clientId);
202
+ const incomingTopic = `P2P_TOPIC/p2p/GID_agent@@@${creds.agent_id}`;
203
+
204
+ // Clean up any existing client
205
+ if (_client) {
206
+ try { _client.end(true); } catch { /* ignore */ }
207
+ _client = null;
208
+ _connected = false;
209
+ }
210
+
211
+ _reconnectAttempts = attempt;
212
+
213
+ const brokerUrl = `mqtts://${token.instance_id}.mqtt.aliyuncs.com:8883`;
214
+
215
+ const client = mqtt.connect(brokerUrl, {
216
+ clientId,
217
+ username: token.username,
218
+ password: token.password,
219
+ clean: isFollower, // followers use clean session; leader uses persistent
220
+ keepalive: 60, // broker keepalive; token.clientId must match connection clientId
221
+ connectTimeout: 15000,
222
+ reconnectPeriod: 0, // manual reconnect
223
+ rejectUnauthorized: false,
224
+ });
225
+
226
+ _client = client;
227
+
228
+ client.on("connect", (connack) => {
229
+ _connected = true;
230
+ _sessionPresent = connack.sessionPresent ?? false;
231
+ _reconnectAttempts = 0;
232
+
233
+ if (!isFollower) {
234
+ logger.info(
235
+ `a2hmarket-mqtt-listener: connected role=${_role} (sessionPresent=${_sessionPresent}), subscribing to ${incomingTopic}`,
236
+ );
237
+ client.subscribe(incomingTopic, { qos: 1 }, (err) => {
238
+ if (err) {
239
+ logger.warn(`a2hmarket-mqtt-listener: subscribe error: ${err.message}`);
240
+ } else {
241
+ logger.info(`a2hmarket-mqtt-listener: subscribed to ${incomingTopic}`);
242
+ }
243
+ });
244
+ } else {
245
+ logger.info(
246
+ `a2hmarket-mqtt-listener: connected role=follower (standby, not subscribing)`,
247
+ );
248
+ }
249
+ });
250
+
251
+ client.on("message", (_topic, payload) => {
252
+ handleIncomingMessage(payload.toString(), creds, api, logger).catch((err) => {
253
+ logger.warn(`a2hmarket-mqtt-listener: message handler error: ${String(err)}`);
254
+ });
255
+ });
256
+
257
+ client.on("close", () => {
258
+ _connected = false;
259
+ if (!_stopped) {
260
+ logger.info("a2hmarket-mqtt-listener: connection closed, scheduling reconnect");
261
+ scheduleReconnect(api, logger, _reconnectAttempts).catch(() => undefined);
262
+ }
263
+ });
264
+
265
+ client.on("error", (err) => {
266
+ logger.warn(`a2hmarket-mqtt-listener: error: ${err.message}`);
267
+ });
268
+ }
269
+
270
+ // ─── Heartbeat loop (leader only) ────────────────────────────────────────────
271
+
272
+ function startHeartbeat(
273
+ creds: Credentials,
274
+ api: OpenClawPluginApi,
275
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
276
+ ): void {
277
+ clearTimers();
278
+ _heartbeatTimer = setInterval(async () => {
279
+ if (_stopped || _role !== "leader") return;
280
+ try {
281
+ const result = await sendHeartbeat(creds, _instanceId, _leaseEpoch);
282
+ if (!result.ok) {
283
+ // Lease revoked — another instance took over. Stop gracefully.
284
+ logger.warn(
285
+ `a2hmarket-mqtt-listener: lease revoked (reason=${result.reason ?? "unknown"}), stepping down`,
286
+ );
287
+ _stopped = true;
288
+ clearTimers();
289
+ if (_client) {
290
+ _client.unsubscribe(`P2P_TOPIC/p2p/GID_agent@@@${creds.agent_id}`);
291
+ _client.end(true);
292
+ _client = null;
293
+ _connected = false;
294
+ }
295
+ return;
296
+ }
297
+ _leaseEpoch = result.epoch;
298
+ } catch (err) {
299
+ logger.warn(`a2hmarket-mqtt-listener: heartbeat failed: ${String(err)}`);
300
+ }
301
+ }, HEARTBEAT_INTERVAL_MS);
302
+ }
303
+
304
+ // ─── Follower poll loop ───────────────────────────────────────────────────────
305
+
306
+ function startFollowerPoll(
307
+ creds: Credentials,
308
+ api: OpenClawPluginApi,
309
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
310
+ ): void {
311
+ clearTimers();
312
+ const clientId = `GID_agent@@@${creds.agent_id}_rt_${_instanceId}`;
313
+ _followerPollTimer = setInterval(async () => {
314
+ if (_stopped || _role !== "follower") return;
315
+ try {
316
+ const result = await acquireLease(creds, _instanceId, clientId, false);
317
+ if (result.role === "leader") {
318
+ logger.info(
319
+ `a2hmarket-mqtt-listener: promoted to leader (epoch=${result.epoch}), reconnecting`,
320
+ );
321
+ clearTimers();
322
+ _role = "leader";
323
+ _leaseEpoch = result.epoch;
324
+ // Reconnect with base clientId + subscribe
325
+ connectAndSubscribe(creds, api, logger, 0).catch((err) => {
326
+ logger.warn(`a2hmarket-mqtt-listener: reconnect after promotion failed: ${String(err)}`);
327
+ });
328
+ startHeartbeat(creds, api, logger);
329
+ }
330
+ } catch (err) {
331
+ logger.warn(`a2hmarket-mqtt-listener: follower poll failed: ${String(err)}`);
332
+ }
333
+ }, FOLLOWER_POLL_INTERVAL_MS);
334
+ }
335
+
336
+ // ─── Publish helper (used by send.ts) ────────────────────────────────────────
337
+
338
+ export async function publishMessage(
339
+ targetAgentId: string,
340
+ envelope: Envelope,
341
+ ): Promise<void> {
342
+ if (!_client?.connected) {
343
+ throw new Error("mqtt-listener: not connected");
344
+ }
345
+ const topic = `P2P_TOPIC/p2p/GID_agent@@@${targetAgentId}`;
346
+ const payload = JSON.stringify(envelope);
347
+ await new Promise<void>((resolve, reject) => {
348
+ _client!.publish(topic, payload, { qos: 1 }, (err) => {
349
+ if (err) reject(err);
350
+ else resolve();
351
+ });
352
+ });
353
+ }
354
+
355
+ // ─── Service registration ─────────────────────────────────────────────────────
356
+
357
+ export function registerMqttListenerService(api: OpenClawPluginApi): void {
358
+ api.registerService({
359
+ id: "a2hmarket-mqtt-listener",
360
+
361
+ start: (ctx) => {
362
+ const logger = ctx.logger ?? api.logger;
363
+ _stopped = false;
364
+ clearTimers();
365
+
366
+ const creds = readCredentials();
367
+ if (!creds) {
368
+ logger.warn("a2hmarket-mqtt-listener: no credentials found, skipping MQTT connection");
369
+ return;
370
+ }
371
+
372
+ _instanceId = loadOrCreateInstanceId();
373
+ const baseClientId = `GID_agent@@@${creds.agent_id}`;
374
+
375
+ logger.info(`a2hmarket-mqtt-listener: service started, instanceId=${_instanceId}`);
376
+
377
+ // Acquire lease with forceTakeover=true: new instance always displaces the old one.
378
+ acquireLease(creds, _instanceId, baseClientId, true)
379
+ .then((result) => {
380
+ _role = result.role;
381
+ _leaseEpoch = result.epoch;
382
+ logger.info(
383
+ `a2hmarket-mqtt-listener: lease acquired role=${_role} epoch=${_leaseEpoch}`,
384
+ );
385
+
386
+ return connectAndSubscribe(creds, api, logger, 0).then(() => {
387
+ if (_role === "leader") {
388
+ startHeartbeat(creds, api, logger);
389
+ } else if (_role === "follower") {
390
+ startFollowerPoll(creds, api, logger);
391
+ }
392
+ });
393
+ })
394
+ .catch((err) => {
395
+ // Control plane unreachable — fall back to standalone (connect as leader, no heartbeat).
396
+ logger.warn(
397
+ `a2hmarket-mqtt-listener: lease acquire failed (standalone mode): ${String(err)}`,
398
+ );
399
+ _role = "standalone";
400
+ connectAndSubscribe(creds, api, logger, 0).catch((connectErr) => {
401
+ logger.warn(`a2hmarket-mqtt-listener: initial connect failed: ${String(connectErr)}`);
402
+ scheduleReconnect(api, logger, 0).catch(() => undefined);
403
+ });
404
+ });
405
+ },
406
+
407
+ stop: (ctx) => {
408
+ _stopped = true;
409
+ clearTimers();
410
+ const logger = ctx.logger ?? api.logger;
411
+ if (_client) {
412
+ _client.end(true);
413
+ _client = null;
414
+ _connected = false;
415
+ }
416
+ logger.info("a2hmarket-mqtt-listener: service stopped");
417
+ },
418
+ });
419
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * In-process cache for the most recently active openclaw session key.
3
+ * Used by the MQTT listener to route push notifications to the correct session.
4
+ *
5
+ * On first call, bootstraps from sessions.json so gateway restarts don't lose
6
+ * the last active session (Feishu, webchat, or any other channel).
7
+ */
8
+
9
+ import { readFileSync, existsSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ const FALLBACK_SESSION_KEY = "agent:main:main";
14
+ const SESSIONS_PATH = join(homedir(), ".openclaw", "agents", "main", "sessions", "sessions.json");
15
+
16
+ // Patterns that identify channel sessions (non-webchat, non-generic)
17
+ const CHANNEL_KEY_PATTERNS = [":feishu:", ":discord:", ":slack:", ":telegram:"];
18
+
19
+ function isChannelKey(key: string): boolean {
20
+ return CHANNEL_KEY_PATTERNS.some((p) => key.includes(p));
21
+ }
22
+
23
+ /**
24
+ * Read sessions.json and return the most recently updated session key.
25
+ * Returns null if the file doesn't exist or is empty.
26
+ */
27
+ function loadMostRecentSession(): string | null {
28
+ try {
29
+ if (!existsSync(SESSIONS_PATH)) return null;
30
+ const raw = readFileSync(SESSIONS_PATH, "utf-8");
31
+ const store = JSON.parse(raw) as Record<string, { updatedAt?: number }>;
32
+ let bestKey: string | null = null;
33
+ let bestTime = 0;
34
+ for (const [key, meta] of Object.entries(store)) {
35
+ const t = meta?.updatedAt ?? 0;
36
+ if (t > bestTime) {
37
+ bestTime = t;
38
+ bestKey = key;
39
+ }
40
+ }
41
+ return bestKey;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ let cachedSessionKey: string | null = null;
48
+ let bootstrapped = false;
49
+
50
+ function bootstrap(): void {
51
+ if (bootstrapped) return;
52
+ bootstrapped = true;
53
+ const key = loadMostRecentSession();
54
+ if (key) cachedSessionKey = key;
55
+ }
56
+
57
+ /** Update the cached session key to the most recently active session. */
58
+ export function setCachedSessionKey(key: string): void {
59
+ if (!key?.trim()) return;
60
+ cachedSessionKey = key.trim();
61
+ }
62
+
63
+ /** Returns the most recently active session key, or the fallback. */
64
+ export function getCachedSessionKey(): string {
65
+ bootstrap();
66
+ return cachedSessionKey ?? FALLBACK_SESSION_KEY;
67
+ }
68
+
69
+ /** Returns true if the current session is a specific channel (Feishu, Discord, etc.). */
70
+ export function hasChannelSession(): boolean {
71
+ bootstrap();
72
+ return !!cachedSessionKey && isChannelKey(cachedSessionKey);
73
+ }
@@ -0,0 +1,14 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { registerInboxTools } from "./tool-inbox.js";
3
+ import { registerOrderTools } from "./tool-order.js";
4
+ import { registerProfileTool } from "./tool-profile.js";
5
+ import { registerSendTool } from "./tool-send.js";
6
+ import { registerWorksTools } from "./tool-works.js";
7
+
8
+ export function registerAllTools(api: OpenClawPluginApi): void {
9
+ registerInboxTools(api);
10
+ registerSendTool(api);
11
+ registerWorksTools(api);
12
+ registerOrderTools(api);
13
+ registerProfileTool(api);
14
+ }