@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,651 @@
1
+ # OpenClaw Plugin 开发指南
2
+
3
+ > 基于 a2hmarket-plugin 实战经验总结
4
+ > 版本:OpenClaw 2026.3.23-2
5
+
6
+ ---
7
+
8
+ ## 目录
9
+
10
+ 1. [Plugin 基础开发](#1-plugin-基础开发)
11
+ 2. [独立 Agent 体:自管理 Session + AI 引擎调用](#2-独立-agent-体自管理-session--ai-引擎调用)
12
+ 3. [Channel 通知能力与飞书富文本卡片](#3-channel-通知能力与飞书富文本卡片)
13
+ 4. [Skill 系统:让飞书指令识别到插件能力](#4-skill-系统让飞书指令识别到插件能力)
14
+ 5. [安装与配置](#5-安装与配置)
15
+ 6. [踩坑经验与最佳实践](#6-踩坑经验与最佳实践)
16
+
17
+ ---
18
+
19
+ ## 1. Plugin 基础开发
20
+
21
+ ### 1.1 文件结构
22
+
23
+ ```
24
+ my-plugin/
25
+ ├── index.ts # 插件入口(必须 export default)
26
+ ├── openclaw.plugin.json # 插件清单
27
+ ├── package.json # 依赖管理
28
+ ├── credentials.json # 凭证(.gitignore)
29
+ ├── skills/ # Skill 定义
30
+ │ └── my-skill/
31
+ │ └── SKILL.md
32
+ └── src/
33
+ ├── tools/ # 工具实现
34
+ └── ... # 业务逻辑
35
+ ```
36
+
37
+ ### 1.2 插件入口 (index.ts)
38
+
39
+ ```typescript
40
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
41
+
42
+ export default {
43
+ id: "my-plugin",
44
+ name: "My Plugin",
45
+ description: "Plugin description",
46
+
47
+ register(api: OpenClawPluginApi) {
48
+ // 注册工具
49
+ api.registerTool({ ... });
50
+
51
+ // 注册后台服务
52
+ api.registerService({ ... });
53
+
54
+ // 注册生命周期 hook
55
+ api.on("after_tool_call", (event) => { ... });
56
+ api.on("message_sending", (event) => { ... });
57
+ },
58
+ };
59
+ ```
60
+
61
+ **关键点:**
62
+ - 不要用 `definePluginEntry()`(report 里描述的,实际不存在),直接 `export default` 一个对象
63
+ - import 路径是 `"openclaw/plugin-sdk"` 或子路径如 `"openclaw/plugin-sdk/gateway-runtime"`
64
+ - 插件目录需要 symlink `node_modules/openclaw` 指向 OpenClaw 安装目录
65
+
66
+ ### 1.3 插件清单 (openclaw.plugin.json)
67
+
68
+ ```json
69
+ {
70
+ "id": "my-plugin",
71
+ "name": "My Plugin",
72
+ "description": "...",
73
+ "version": "1.0.0",
74
+ "skills": ["./skills"],
75
+ "configSchema": {
76
+ "type": "object",
77
+ "properties": {},
78
+ "additionalProperties": false
79
+ }
80
+ }
81
+ ```
82
+
83
+ **注意:**
84
+ - `skills` 使用相对路径 `["./skills"]`,不是 skill 名称
85
+ - 如果需要注册 channel,添加 `"channels": ["my-channel"]`
86
+
87
+ ### 1.4 注册工具 (api.registerTool)
88
+
89
+ ```typescript
90
+ api.registerTool({
91
+ name: "my_tool",
92
+ description: "Tool description for the LLM",
93
+ parameters: {
94
+ type: "object",
95
+ properties: {
96
+ keyword: { type: "string", description: "Search keyword" },
97
+ },
98
+ required: ["keyword"],
99
+ },
100
+ execute: async (params: Record<string, unknown>) => {
101
+ const result = await doSomething(params.keyword);
102
+ return { result: JSON.stringify(result, null, 2) };
103
+ },
104
+ });
105
+ ```
106
+
107
+ **返回格式:** `{ result: string }` — result 为 JSON 字符串
108
+ **错误处理:** 直接 `throw new Error(message)`,框架会自动捕获
109
+
110
+ ### 1.5 注册后台服务 (api.registerService)
111
+
112
+ ```typescript
113
+ api.registerService({
114
+ id: "my-service",
115
+ start: async (ctx) => {
116
+ // ctx.config — OpenClaw 配置
117
+ // ctx.logger — { info, warn, error, debug }
118
+ // ctx.stateDir — 状态存储目录
119
+
120
+ // 启动后台任务...
121
+ ctx.logger.info("service started");
122
+ },
123
+ stop: async (ctx) => {
124
+ // 清理...
125
+ },
126
+ });
127
+ ```
128
+
129
+ **Service Context 类型:**
130
+ ```typescript
131
+ {
132
+ config: OpenClawConfig;
133
+ workspaceDir?: string;
134
+ stateDir: string;
135
+ logger: { info, warn, error, debug };
136
+ }
137
+ ```
138
+
139
+ ### 1.6 生命周期 Hook
140
+
141
+ | Hook | 触发时机 | 用途 |
142
+ |------|---------|------|
143
+ | `after_tool_call` | 工具调用后 | 追踪工具使用、记录状态 |
144
+ | `message_sending` | 消息发送前 | 修改出站消息内容 |
145
+ | `message_sent` | 消息发送后 | 记录、确认 |
146
+ | `inbound_claim` | 入站消息到达 | 拦截/处理消息 |
147
+ | `before_tool_call` | 工具调用前 | 授权检查、拦截 |
148
+
149
+ ```typescript
150
+ api.on("after_tool_call", (event) => {
151
+ const toolName = (event as any)?.toolName;
152
+ const sessionKey = (event as any)?.sessionKey;
153
+ // ...
154
+ });
155
+
156
+ api.on("message_sending", (event) => {
157
+ let content = (event as any)?.content ?? "";
158
+ // 修改内容
159
+ content = content.replace("old", "new");
160
+ return { content };
161
+ });
162
+ ```
163
+
164
+ ---
165
+
166
+ ## 2. 独立 Agent 体:自管理 Session + AI 引擎调用
167
+
168
+ ### 2.1 架构概述
169
+
170
+ a2hmarket 采用的是**独立 Agent 体**模式:插件自己管理消息流、Session 和通知,只借用 OpenClaw 的 AI 推理引擎和工具调用能力。
171
+
172
+ ```
173
+ 外部消息源 (MQTT)
174
+
175
+ Plugin Service (后台运行)
176
+ ↓ 收到消息
177
+ GatewayClient.request("agent", { message, sessionKey, deliver: false })
178
+ ↓ AI 推理 + 工具调用
179
+ GatewayClient.request("agent.wait", { runId })
180
+ ↓ 读取回复
181
+ GatewayClient.request("chat.history", { sessionKey })
182
+
183
+ 自己决定如何投递(MQTT/飞书卡片/...)
184
+ ```
185
+
186
+ ### 2.2 GatewayClient — 调用 AI 引擎
187
+
188
+ ```typescript
189
+ import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
190
+
191
+ const gateway = new GatewayClient({
192
+ url: `ws://127.0.0.1:${port}`,
193
+ token: gatewayAuthToken,
194
+ clientName: "cli" as any,
195
+ mode: "cli" as any,
196
+ scopes: [
197
+ "operator.admin",
198
+ "operator.read",
199
+ "operator.write",
200
+ "operator.approvals",
201
+ "operator.pairing",
202
+ ],
203
+ requestTimeoutMs: 90_000,
204
+ });
205
+ ```
206
+
207
+ **关键参数:**
208
+ - `clientName` 和 `mode` 必须是合法值:`"cli"`, `"webchat"`, `"gateway-client"` 等
209
+ - `scopes` 必须声明,否则请求会报 `missing scope`
210
+ - `token` 从 `openclaw.json` 的 `gateway.auth.token` 读取
211
+ - 不要设 `deviceIdentity: null`(会导致 scopes 被清空)
212
+
213
+ ### 2.3 触发 AI 推理
214
+
215
+ ```typescript
216
+ // 1. 发送消息触发推理
217
+ const resp = await gateway.request<{ runId?: string }>("agent", {
218
+ message: "用户的消息",
219
+ sessionKey: "agent:main:a2hmarket:dm:peer-id",
220
+ idempotencyKey: crypto.randomUUID(), // 必须
221
+ deliver: false, // 不自动投递到 channel
222
+ extraSystemPrompt: "你是 A2H Market 助手...",
223
+ });
224
+
225
+ // 2. 等待推理完成
226
+ const waitRunId = resp?.runId || idempotencyKey;
227
+ await gateway.request("agent.wait", {
228
+ runId: waitRunId,
229
+ timeoutMs: 90_000,
230
+ });
231
+
232
+ // 3. 读取回复
233
+ const history = await gateway.request<{ messages: Array<Record<string, unknown>> }>(
234
+ "chat.history",
235
+ { sessionKey, limit: 20 }
236
+ );
237
+ // 从 messages 中找最后一条 role=assistant 的 text 内容
238
+ ```
239
+
240
+ ### 2.4 等待 Gateway 连接
241
+
242
+ GatewayClient.start() 是异步的,需要等 hello 握手完成:
243
+
244
+ ```typescript
245
+ await new Promise<void>((resolve, reject) => {
246
+ const timeout = setTimeout(() => reject(new Error("timeout")), 15_000);
247
+ gateway["opts"].onHelloOk = (hello: unknown) => {
248
+ clearTimeout(timeout);
249
+ resolve();
250
+ };
251
+ gateway["opts"].onConnectError = (err: Error) => {
252
+ clearTimeout(timeout);
253
+ reject(err);
254
+ };
255
+ gateway.start();
256
+ });
257
+ ```
258
+
259
+ ### 2.5 重要限制
260
+
261
+ **通过 gateway `agent` method 创建的 session 看不到 plugin 注册的工具。**
262
+
263
+ 这是因为 gateway 的 `agent` method 使用标准工具 profile(`tools.profile: "coding"`),不包含 plugin 自定义工具。
264
+
265
+ **解决方案:** 不要依赖 gateway RPC 来让 AI 调用 plugin 工具。改用 **Skill 系统**(见第 4 节),让用户通过飞书/Discord 等 channel 的 Agent session 来调用——这些 session 能看到所有 plugin 注册的工具。
266
+
267
+ ### 2.6 消息回声防护
268
+
269
+ MQTT P2P 消息中,如果 `sender_id === 自己的 agentId`,必须跳过:
270
+
271
+ ```typescript
272
+ if (senderId === this.creds.agentId) {
273
+ this.log.info(`skipping own message (echo): ${messageId}`);
274
+ return;
275
+ }
276
+ ```
277
+
278
+ ---
279
+
280
+ ## 3. Channel 通知能力与飞书富文本卡片
281
+
282
+ ### 3.1 直接调用飞书 API 发送卡片
283
+
284
+ 无需依赖飞书 SDK,直接用 fetch 调飞书 Open API:
285
+
286
+ ```typescript
287
+ // 1. 获取 tenant_access_token
288
+ const tokenResp = await fetch(
289
+ "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
290
+ {
291
+ method: "POST",
292
+ headers: { "Content-Type": "application/json" },
293
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
294
+ }
295
+ );
296
+ const { tenant_access_token } = await tokenResp.json();
297
+
298
+ // 2. 发送 interactive card
299
+ const card = {
300
+ schema: "2.0",
301
+ config: { wide_screen_mode: true },
302
+ header: {
303
+ title: { tag: "plain_text", content: "📩 A2H Market · 收到消息" },
304
+ template: "blue", // blue, green, red, orange, purple, turquoise, yellow, grey
305
+ },
306
+ body: {
307
+ elements: [
308
+ { tag: "markdown", content: "**来自**: `ag_xxxxx`" },
309
+ { tag: "markdown", content: "消息内容..." },
310
+ { tag: "markdown", content: "---\n*Agent: ag_yyyyy*" },
311
+ ],
312
+ },
313
+ };
314
+
315
+ await fetch(
316
+ "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
317
+ {
318
+ method: "POST",
319
+ headers: {
320
+ "Content-Type": "application/json",
321
+ Authorization: `Bearer ${tenant_access_token}`,
322
+ },
323
+ body: JSON.stringify({
324
+ receive_id: "ou_xxxxx", // 用户的 open_id
325
+ msg_type: "interactive",
326
+ content: JSON.stringify(card),
327
+ }),
328
+ }
329
+ );
330
+ ```
331
+
332
+ ### 3.2 通知目标配置
333
+
334
+ 在 `credentials.json` 中配置通知渠道:
335
+
336
+ ```json
337
+ {
338
+ "agent_id": "ag_xxxxx",
339
+ "agent_key": "...",
340
+ "api_url": "https://api.a2hmarket.ai",
341
+ "mqtt_url": "mqtts://...",
342
+ "notify": {
343
+ "channel": "feishu",
344
+ "target": "ou_xxxxx"
345
+ }
346
+ }
347
+ ```
348
+
349
+ 飞书的 appId/appSecret 从 OpenClaw 配置中读取:
350
+
351
+ ```typescript
352
+ const runtime = getA2HRuntime();
353
+ const cfg = runtime.config.loadConfig();
354
+ const feishuCfg = cfg.channels.feishu;
355
+ // feishuCfg.appId, feishuCfg.appSecret
356
+ ```
357
+
358
+ ### 3.3 卡片类型
359
+
360
+ | 类型 | 标题 | 颜色 | 场景 |
361
+ |------|------|------|------|
362
+ | `inbound` | 📩 A2H Market · 收到消息 | 蓝色 | 收到外部消息 |
363
+ | `reply` | 🤖 A2H Market · 已自动回复 | 绿色 | AI 回复了消息 |
364
+ | `approval` | 🔔 A2H Market · 需要确认 | 橙色 | 需要人类审批 |
365
+
366
+ ### 3.4 修改 OpenClaw 出站消息的卡片样式
367
+
368
+ 使用 `message_sending` hook 修改飞书出站消息:
369
+
370
+ ```typescript
371
+ api.on("message_sending", (event) => {
372
+ let content = (event as any)?.content ?? "";
373
+ // 替换卡片标题
374
+ content = content.replace(/^(#+\s*)main\s*$/m, "$1A2H Market");
375
+ // 替换底部信息
376
+ content = content.replace(
377
+ /Agent:\s*main\s*\|\s*Model:.*$/m,
378
+ `我的 Agent ID: ${agentId}`,
379
+ );
380
+ return { content };
381
+ });
382
+ ```
383
+
384
+ ---
385
+
386
+ ## 4. Skill 系统:让飞书指令识别到插件能力
387
+
388
+ ### 4.1 为什么需要 Skill
389
+
390
+ OpenClaw 的 Agent 在各 channel(飞书/Discord)session 中运行时,通过 **Skill 匹配系统**决定是否读取和使用某个 Skill。Skill 是 Markdown 文件 + YAML frontmatter,本质是**提示注入**——Agent 读取后会遵循其中的指令使用对应工具。
391
+
392
+ ### 4.2 Skill 文件 (SKILL.md)
393
+
394
+ ```markdown
395
+ ---
396
+ name: a2hmarket
397
+ description: "A2H Market AI交易助手 — 在AI交易市场中搜索服务、发送消息、管理订单。
398
+ 触发词:a2hmarket、a2h、交易市场、摆摊、逛街、找服务、买服务、卖服务、
399
+ 发帖、搜索市场、agent市场。当用户提到在市场上找东西、买卖服务、联系其他agent时触发。"
400
+ version: 2.0.0
401
+ ---
402
+
403
+ ## 你是 A2H Market 交易助手
404
+
405
+ 当用户提到以下场景时,使用 `a2h_*` 工具:
406
+ - 找服务、搜索市场 → `a2h_works_search`
407
+ - 查看帖子 → `a2h_works_list`
408
+ - 发消息 → `a2h_send`
409
+ - 管理订单 → `a2h_order_*`
410
+ ...
411
+ ```
412
+
413
+ ### 4.3 Skill 触发机制
414
+
415
+ OpenClaw 在每次 Agent session 启动时:
416
+
417
+ 1. 扫描所有 skill 目录,收集 `SKILL.md` 文件
418
+ 2. 将 skill 名称 + 描述注入 system prompt 的 `<available_skills>` 列表
419
+ 3. Agent 收到用户消息后,匹配描述中的关键词
420
+ 4. 匹配到 → Agent 读取对应的 `SKILL.md` → 遵循其中指令调用工具
421
+
422
+ **关键:`description` 字段是触发的核心。** 必须包含足够多的触发词和场景描述,让 LLM 能准确匹配。
423
+
424
+ ### 4.4 Skill 放置位置
425
+
426
+ Skill 在 `openclaw.plugin.json` 的 `"skills": ["./skills"]` 中声明。OpenClaw 会从插件目录的 `skills/` 下扫描。
427
+
428
+ 优先级(从低到高):
429
+ ```
430
+ Plugin 提供的 skills/ ← 最低
431
+ ~/.openclaw/skills/ ← 全局
432
+ ~/.agents/skills/ ← 个人 Agent 级
433
+ <workspace>/.agents/skills/ ← 项目级
434
+ <workspace>/skills/ ← 工作区级(最高)
435
+ ```
436
+
437
+ ### 4.5 Skill vs Command vs Gateway RPC
438
+
439
+ | 方式 | 能否调用 plugin 工具 | 适用场景 |
440
+ |------|---------------------|---------|
441
+ | **Skill(推荐)** | ✅ 能 | 用户在任意 channel 自然语言触发 |
442
+ | Command (`/a2h`) | ✅ 能(但返回文本,不触发工具调用) | 简单命令响应 |
443
+ | Gateway RPC (`agent` method) | ❌ 不能看到 plugin 工具 | 仅适合内置工具 |
444
+
445
+ ---
446
+
447
+ ## 5. 安装与配置
448
+
449
+ ### 5.1 安装插件
450
+
451
+ ```bash
452
+ # 本地开发模式(symlink)
453
+ openclaw plugins install --link /path/to/my-plugin/index.ts
454
+
455
+ # npm 包模式
456
+ openclaw plugins install my-plugin-package
457
+ ```
458
+
459
+ ### 5.2 OpenClaw 配置 (openclaw.json)
460
+
461
+ 安装后需要在 `~/.openclaw/openclaw.json` 中确认以下配置:
462
+
463
+ ```json
464
+ {
465
+ "plugins": {
466
+ "load": {
467
+ "paths": [
468
+ "/absolute/path/to/my-plugin/index.ts"
469
+ ]
470
+ },
471
+ "entries": {
472
+ "my-plugin": {
473
+ "enabled": true
474
+ }
475
+ }
476
+ }
477
+ }
478
+ ```
479
+
480
+ ### 5.3 模块解析 — symlink 依赖
481
+
482
+ 本地 link 的插件无法直接 resolve `openclaw/plugin-sdk`。需要在插件目录创建 symlink:
483
+
484
+ ```bash
485
+ cd my-plugin
486
+ mkdir -p node_modules
487
+ ln -sf /path/to/openclaw/npm/install node_modules/openclaw
488
+ ```
489
+
490
+ **注意:** 如果有多个 OpenClaw 安装(如 homebrew + nvm),确保 symlink 指向 **gateway 实际使用的** 那个安装路径。gateway 作为 LaunchAgent 运行,可能用的不是当前 shell 的 node 路径。
491
+
492
+ 检查 gateway 使用的路径:
493
+ ```bash
494
+ openclaw logs 2>&1 | grep "failed to load" | head -1
495
+ # 看 Require stack 中的路径
496
+ ```
497
+
498
+ ### 5.4 验证安装
499
+
500
+ ```bash
501
+ # 查看插件状态
502
+ openclaw plugins info my-plugin
503
+
504
+ # 期望输出:
505
+ # Status: loaded
506
+ # Tools: my_tool_1, my_tool_2, ...
507
+ # Services: my-service
508
+
509
+ # 查看 skill 是否加载
510
+ openclaw skills check 2>&1 | grep my-skill
511
+ ```
512
+
513
+ ### 5.5 重启 Gateway
514
+
515
+ 配置或代码修改后:
516
+ ```bash
517
+ openclaw gateway restart
518
+ ```
519
+
520
+ ---
521
+
522
+ ## 6. 踩坑经验与最佳实践
523
+
524
+ ### 6.1 MQTT 相关
525
+
526
+ | 问题 | 原因 | 解决 |
527
+ |------|------|------|
528
+ | MQTT 频繁 reconnect (7-10s) | 其他客户端用同一个 clientId,互踢 | 使用独占的 agent 凭证 |
529
+ | 消息收不到 | clientId 与 P2P topic 不匹配 | 必须用 `buildClientId(agentId)` 作为 clientId |
530
+ | 消息重复 | auto-restart 导致多个 listener | `startAccount` 的 promise 不要立即 resolve |
531
+
532
+ ### 6.2 Plugin 加载相关
533
+
534
+ | 问题 | 原因 | 解决 |
535
+ |------|------|------|
536
+ | `Cannot find module 'openclaw/plugin-sdk'` | 插件目录没有 symlink | 创建 `node_modules/openclaw` symlink |
537
+ | `plugin id mismatch` | 入口文件名非 `index.ts` | 改名为 `index.ts` 或忽略(不影响功能) |
538
+ | `ERR_PACKAGE_PATH_NOT_EXPORTED` | SDK 子路径不在 exports map | 换用已导出的子路径 |
539
+
540
+ ### 6.3 可用的 SDK 子路径
541
+
542
+ ```
543
+ openclaw/plugin-sdk # 主入口(类型 + 少量工具)
544
+ openclaw/plugin-sdk/core # createChatChannelPlugin, defineChannelPluginEntry
545
+ openclaw/plugin-sdk/gateway-runtime # GatewayClient
546
+ openclaw/plugin-sdk/channel-inbound # dispatchInboundDirectDmWithRuntime
547
+ openclaw/plugin-sdk/channel-config-helpers
548
+ openclaw/plugin-sdk/account-helpers
549
+ openclaw/plugin-sdk/channel-send-result
550
+ openclaw/plugin-sdk/status-helpers
551
+ openclaw/plugin-sdk/extension-shared
552
+ ```
553
+
554
+ 查看完整列表:
555
+ ```bash
556
+ grep '"./plugin-sdk/' /path/to/openclaw/package.json | sed 's/.*"\(\.\/plugin-sdk\/[^"]*\)".*/\1/'
557
+ ```
558
+
559
+ ### 6.4 Gateway RPC 的 agent method 限制
560
+
561
+ 通过 `gateway.request("agent", ...)` 创建的 session **无法使用 plugin 注册的工具**。这些 session 只能用 OpenClaw 内置工具(如 web_search、read、edit 等)。
562
+
563
+ **解决方案:**
564
+ - 用户交互场景 → 使用 **Skill 系统**,让 channel session 的 Agent 调用 plugin 工具
565
+ - 后台自动处理 → 如果 AI 需要调用 plugin 工具,考虑用 Channel Plugin 的 `dispatchInboundDirectDmWithRuntime`
566
+
567
+ ### 6.5 Channel Plugin vs Service Plugin
568
+
569
+ | 维度 | Channel Plugin | Service Plugin(当前方案) |
570
+ |------|---------------|-------------------------|
571
+ | 注册方式 | `api.registerChannel()` | `api.registerService()` |
572
+ | 消息入站 | `dispatchInboundDirectDmWithRuntime` → 自动触发 Agent | 自定义逻辑 + gateway RPC |
573
+ | Agent 能否用 plugin 工具 | ✅ 能 | ❌ 不能(gateway RPC 限制) |
574
+ | Session 管理 | OpenClaw 管理 | 自己管理 |
575
+ | 通知人类 | 需要额外实现 | 完全自主控制 |
576
+ | 回复投递 | deliver 回调(自动) | 自己控制 |
577
+
578
+ **建议:** 如果需要 AI 调用 plugin 工具来处理入站消息,用 Channel Plugin。如果只需要自定义处理逻辑,用 Service Plugin。
579
+
580
+ ### 6.6 飞书卡片 Token 缓存
581
+
582
+ 飞书 `tenant_access_token` 有效期 2 小时。务必实现缓存,避免每次发卡片都请求新 token:
583
+
584
+ ```typescript
585
+ let tokenCache: { token: string; expiresAt: number } | null = null;
586
+
587
+ async function getToken(appId: string, appSecret: string): Promise<string> {
588
+ if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) {
589
+ return tokenCache.token;
590
+ }
591
+ // ... fetch new token
592
+ tokenCache = { token, expiresAt: Date.now() + expire * 1000 };
593
+ return token;
594
+ }
595
+ ```
596
+
597
+ ### 6.7 Plugin Runtime 能力
598
+
599
+ `api.runtime` 提供的核心能力:
600
+
601
+ ```typescript
602
+ api.runtime.config.loadConfig() // 读取 OpenClaw 配置
603
+ api.runtime.system.enqueueSystemEvent() // 向指定 session 注入事件
604
+ api.runtime.system.requestHeartbeatNow() // 唤醒 session
605
+ api.runtime.channel.routing.* // 路由解析
606
+ api.runtime.channel.session.* // Session 管理
607
+ api.runtime.channel.reply.* // 回复管道
608
+ api.runtime.channel.text.* // 文本处理(分块、Markdown 转换)
609
+ ```
610
+
611
+ ---
612
+
613
+ ## 附:a2hmarket-plugin 完整文件清单
614
+
615
+ ```
616
+ a2hmarket-plugin/
617
+ ├── index.ts # 插件入口:注册工具、Hook、Service
618
+ ├── openclaw.plugin.json # 插件清单
619
+ ├── package.json # 依赖(mqtt)
620
+ ├── credentials.json # A2H Market 凭证 + 通知配置(.gitignore)
621
+ ├── skills/
622
+ │ └── a2hmarket/
623
+ │ ├── SKILL.md # Skill 定义(触发词 + 工具指引)
624
+ │ └── references/
625
+ │ ├── commands.md # 工具参数详细参考
626
+ │ └── inbox.md # 消息处理手册
627
+ ├── src/
628
+ │ ├── agent-service.ts # 核心:MQTT 监听 + Gateway RPC AI 调度 + 通知
629
+ │ ├── feishu-notify.ts # 飞书卡片发送(直接调 API,不依赖 SDK)
630
+ │ ├── runtime.ts # Plugin Runtime 单例
631
+ │ ├── channel-state.ts # LastChannelStore 桥接
632
+ │ ├── last-channel.ts # 用户最后活跃 channel 追踪
633
+ │ ├── reply-bridge.ts # 飞书卡片 → a2hmarket peer 映射
634
+ │ ├── credentials.ts # 凭证加载(插件目录 > ~/.a2hmarket/)
635
+ │ ├── api-client.ts # A2H Market HTTP API 客户端
636
+ │ ├── mqtt-listener.ts # MQTT 长连接监听
637
+ │ ├── mqtt-transport.ts # MQTT 传输层(连接、重连、发布)
638
+ │ ├── mqtt-token.ts # MQTT Token 管理
639
+ │ ├── protocol.ts # A2A 消息协议(信封、签名)
640
+ │ ├── signer.ts # HTTP 签名
641
+ │ ├── oss.ts # OSS 文件上传
642
+ │ └── tools/
643
+ │ ├── status.ts # a2h_status
644
+ │ ├── profile.ts # a2h_profile_get/upload_qrcode/delete_qrcode
645
+ │ ├── file.ts # a2h_file_upload
646
+ │ ├── works.ts # a2h_works_search/list/publish/update/delete
647
+ │ ├── order.ts # a2h_order_create/action/get/list
648
+ │ └── send.ts # a2h_send
649
+ └── docs/
650
+ └── openclaw-plugin-development-guide.md # 本文档
651
+ ```