@zhin.js/adapter-lark 4.0.3 → 5.0.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # @zhin.js/adapter-lark
2
2
 
3
+ ## 5.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
17
+ - 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
18
+ - 6cb6152: 统一消息元素通道(UNI-Channel)落地:
19
+
20
+ - **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
21
+ - **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
22
+ - **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
23
+ - **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
24
+
25
+ - Updated dependencies [cdf64e7]
26
+ - Updated dependencies [2d0a159]
27
+ - Updated dependencies [5691aba]
28
+ - Updated dependencies [078e3f7]
29
+ - Updated dependencies [50497a5]
30
+ - Updated dependencies [9c997b2]
31
+ - Updated dependencies [09d4f25]
32
+ - Updated dependencies [43485a9]
33
+ - Updated dependencies [f0ec5ab]
34
+ - Updated dependencies [3e925d0]
35
+ - Updated dependencies [fa66c4c]
36
+ - Updated dependencies [fa66c4c]
37
+ - Updated dependencies [6cb6152]
38
+ - @zhin.js/command@1.0.3
39
+ - @zhin.js/agent@1.0.6
40
+ - @zhin.js/plugin-runtime@1.1.1
41
+ - @zhin.js/host-http@1.0.3
42
+ - zhin.js@5.0.1
43
+ - @zhin.js/adapter@1.1.1
44
+ - @zhin.js/core@1.4.1
45
+
46
+ ## 5.0.0
47
+
48
+ ### Patch Changes
49
+
50
+ - 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
51
+
52
+ BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
53
+
54
+ - 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
55
+ - Updated dependencies [7db69c1]
56
+ - Updated dependencies [e5c84ed]
57
+ - Updated dependencies [3ea84a0]
58
+ - Updated dependencies [1ddcd70]
59
+ - Updated dependencies [ac9da66]
60
+ - @zhin.js/core@1.4.0
61
+ - @zhin.js/adapter@1.1.0
62
+ - @zhin.js/plugin-runtime@1.1.0
63
+ - @zhin.js/agent@1.0.5
64
+ - @zhin.js/host-http@1.0.2
65
+ - zhin.js@5.0.0
66
+
3
67
  ## 4.0.3
4
68
 
5
69
  ### Patch Changes
package/README.md CHANGED
@@ -36,7 +36,7 @@ pnpm add @zhin.js/adapter-lark
36
36
  3. 启用机器人能力并配置事件订阅 URL:`https://your-domain/lark/webhook`
37
37
  4. Runtime Host(`http`)须已 listen,Webhook 才可达
38
38
 
39
- 必填字段:`appId`、`appSecret`。
39
+ 必填字段(`endpoints[i]`):`name`、`appId`、`appSecret`。
40
40
 
41
41
  ## 最小配置
42
42
 
@@ -44,14 +44,15 @@ pnpm add @zhin.js/adapter-lark
44
44
  # zhin.config.yml(Plugin Runtime)
45
45
  plugins:
46
46
  lark:
47
- name: my-lark-bot
48
- appId: ${LARK_APP_ID}
49
- appSecret: ${LARK_APP_SECRET}
50
47
  webhookPath: /lark/webhook # 可选,默认 /lark/webhook
51
- encryptKey: ${LARK_ENCRYPT_KEY} # 可选
52
- verificationToken: ${LARK_VERIFY_TOKEN} # 可选
53
48
  isFeishu: true # 可选,默认 true
54
49
  # apiBaseUrl: https://open.feishu.cn/open-apis
50
+ endpoints:
51
+ - name: my-lark-bot
52
+ appId: ${LARK_APP_ID}
53
+ appSecret: ${LARK_APP_SECRET}
54
+ # encryptKey: ${LARK_ENCRYPT_KEY} # 可选
55
+ # verificationToken: ${LARK_VERIFY_TOKEN} # 可选
55
56
  ```
56
57
 
57
58
  根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-lark`(`instanceKey: lark`)。
package/adapters/lark.ts CHANGED
@@ -9,18 +9,31 @@ import {
9
9
  resolveLarkConfig,
10
10
  type LarkAdapterConfig,
11
11
  } from '../src/protocol.js';
12
+ import { larkRuntimeStateToken } from '../src/lark-runtime-state.js';
12
13
 
13
14
  export { LarkEndpoint } from '../src/endpoint.js';
14
15
  export type { LarkEndpointOptions, LarkFetch } from '../src/endpoint.js';
15
16
 
16
17
  export default defineAdapter<LarkAdapterConfig>({
17
18
  capabilities: ['inbound', 'outbound'],
19
+ // image 段经 /im/v1/images 物化为 image_key(url 下载后上传);
20
+ // 卡片交互未接入出站通道,交互段降级纯文本。
21
+ segments: {
22
+ outboundMedia: ['url', 'upload'],
23
+ interactive: 'text',
24
+ },
18
25
  create(context) {
26
+ const config = resolveLarkConfig(context.config);
27
+ // 注册到插件运行时状态(lark endpoint list 的"运行中"数据源)
28
+ context.use(larkRuntimeStateToken).endpoints.set(config.name, {
29
+ name: config.name,
30
+ mode: 'webhook',
31
+ });
19
32
  return new LarkEndpoint({
20
33
  id: context.id,
21
34
  gateway: context.use(messageGatewayToken),
22
35
  http: context.use(httpHostToken),
23
- config: resolveLarkConfig(context.config),
36
+ config,
24
37
  });
25
38
  },
26
39
  });
@@ -0,0 +1,3 @@
1
+ import { larkEndpointCommands } from '../../../src/lark-endpoint-commands.js';
2
+
3
+ export default larkEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { larkEndpointCommands } from '../../src/lark-endpoint-commands.js';
2
+
3
+ export default larkEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { larkEndpointCommands } from '../../../src/lark-endpoint-commands.js';
2
+
3
+ export default larkEndpointCommands.remove;
package/lib/endpoint.d.ts CHANGED
@@ -1,12 +1,14 @@
1
- import type { EndpointInstance } from '@zhin.js/adapter';
1
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
5
+ import { resolveMediaBinary } from './media-upload.js';
5
6
  import { type LarkMessage, type ResolvedLarkConfig } from './protocol.js';
6
7
  export type LarkFetch = (url: string, init?: {
7
8
  readonly method?: string;
8
9
  readonly headers?: Record<string, string>;
9
10
  readonly body?: string | FormData;
11
+ readonly signal?: AbortSignal;
10
12
  }) => Promise<{
11
13
  readonly ok: boolean;
12
14
  readonly status: number;
@@ -22,6 +24,14 @@ export interface LarkEndpointOptions {
22
24
  }
23
25
  export declare class LarkEndpoint implements EndpointInstance {
24
26
  #private;
27
+ /**
28
+ * Console 社交面语义端口。Lark OpenAPI 仅接读取类列表能力:
29
+ * - listGroups:GET /im/v1/chats(bot 所在群列表,分页归一为 {group_id: chat_id, name})
30
+ * - listGroupMembers:GET /im/v1/chats/:chat_id/members(open_id 分页,返回平台原生成员形状)
31
+ * 不接的项:listFriends(Lark 无好友概念,contact API 为组织通讯录而非社交关系)、
32
+ * listChannels(无频道/guild 概念)、写操作(审批/踢人/禁言等本轮不在范围内)。
33
+ */
34
+ readonly management: EndpointManagement;
25
35
  constructor(options: LarkEndpointOptions);
26
36
  /** Used by webhook handler. */
27
37
  get isOpen(): boolean;
@@ -34,6 +44,8 @@ export declare class LarkEndpoint implements EndpointInstance {
34
44
  readonly target: string;
35
45
  readonly payload: unknown;
36
46
  }): Promise<string>;
47
+ /** POST /im/v1/images(image_type=message),返回 image_key;失败返回 null。 */
48
+ uploadImage(media: Parameters<typeof resolveMediaBinary>[0]): Promise<string | null>;
37
49
  /** Test / internal: admit a parsed message when open (non-webhook path). */
38
50
  admit(msg: LarkMessage): void;
39
51
  getUserInfo(userId: string, userIdType?: 'open_id' | 'user_id' | 'union_id'): Promise<unknown>;
package/lib/endpoint.js CHANGED
@@ -5,12 +5,39 @@ import { readFile } from 'node:fs/promises';
5
5
  import { basename } from 'node:path';
6
6
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
7
  import { registerLarkAgentEndpoint } from './lark-agent-deps.js';
8
+ import { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
8
9
  import { formatInboundContent, formatOutboundBody, generateMessageId, resolveChatType, resolveSender, resolveTarget, } from './protocol.js';
9
10
  import { registerLarkWebhookRoutes } from './webhook.js';
10
11
  const logger = getLogger('lark');
12
+ /** 出站 HTTP 调用统一 30s 超时。 */
13
+ const OUTBOUND_TIMEOUT_MS = 30_000;
11
14
  export class LarkEndpoint {
12
15
  #options;
13
16
  #fetch;
17
+ /**
18
+ * Console 社交面语义端口。Lark OpenAPI 仅接读取类列表能力:
19
+ * - listGroups:GET /im/v1/chats(bot 所在群列表,分页归一为 {group_id: chat_id, name})
20
+ * - listGroupMembers:GET /im/v1/chats/:chat_id/members(open_id 分页,返回平台原生成员形状)
21
+ * 不接的项:listFriends(Lark 无好友概念,contact API 为组织通讯录而非社交关系)、
22
+ * listChannels(无频道/guild 概念)、写操作(审批/踢人/禁言等本轮不在范围内)。
23
+ */
24
+ management = Object.freeze({
25
+ listGroups: async () => {
26
+ const items = await this.#listAllPages('/im/v1/chats');
27
+ const groups = [];
28
+ for (const item of items) {
29
+ const chatId = typeof item.chat_id === 'string' ? item.chat_id : '';
30
+ if (!chatId)
31
+ continue;
32
+ groups.push({
33
+ group_id: chatId,
34
+ name: typeof item.name === 'string' && item.name ? item.name : chatId,
35
+ });
36
+ }
37
+ return groups;
38
+ },
39
+ listGroupMembers: (groupId) => this.#listAllPages(`/im/v1/chats/${encodeURIComponent(groupId)}/members`, { member_id_type: 'open_id' }),
40
+ });
14
41
  #routeReleases = [];
15
42
  #accessToken = { token: '', expires_in: 0, timestamp: 0 };
16
43
  #refreshPromise = null;
@@ -33,6 +60,15 @@ export class LarkEndpoint {
33
60
  return;
34
61
  this.#started = true;
35
62
  try {
63
+ if (!this.#options.config.encryptKey && !this.#options.config.verificationToken) {
64
+ // encryptKey / verificationToken 都未配置时 webhook 完全无鉴权,任何人可伪造事件
65
+ logger.warn(formatCompact({
66
+ endpoint: this.#options.config.name,
67
+ op: 'webhook',
68
+ ok: false,
69
+ error: 'neither encryptKey nor verificationToken configured; webhook is unauthenticated',
70
+ }));
71
+ }
36
72
  await this.#refreshAccessToken();
37
73
  this.#unregisterAgent = registerLarkAgentEndpoint(this.#options.config.name, this);
38
74
  this.#routeReleases.push(...registerLarkWebhookRoutes(this.#options.http, this));
@@ -64,7 +100,8 @@ export class LarkEndpoint {
64
100
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
65
101
  }
66
102
  async send({ target, payload }) {
67
- const content = formatOutboundBody(payload);
103
+ const materialized = await this.#materializeOutboundMedia(payload);
104
+ const content = formatOutboundBody(materialized);
68
105
  const data = await this.#request('/im/v1/messages', {
69
106
  method: 'POST',
70
107
  params: { receive_id_type: 'chat_id' },
@@ -86,6 +123,55 @@ export class LarkEndpoint {
86
123
  }));
87
124
  return messageId;
88
125
  }
126
+ /**
127
+ * im/v1 消息 image 段只接受 image_key:canonical MediaRef(base64/本地路径/URL)
128
+ * 先经 /im/v1/images 物化;上传失败降级为文本(alt 优先),不阻断发送。
129
+ */
130
+ async #materializeOutboundMedia(payload) {
131
+ if (!Array.isArray(payload))
132
+ return payload;
133
+ return Promise.all(payload.map(async (item) => {
134
+ if (typeof item === 'string' || !item || typeof item !== 'object')
135
+ return item;
136
+ const seg = item;
137
+ if (seg.type !== 'image')
138
+ return item;
139
+ const data = seg.data ?? {};
140
+ const media = readOutboundImageMedia(data);
141
+ if (!media)
142
+ return item;
143
+ const imageKey = await this.uploadImage(media);
144
+ if (!imageKey) {
145
+ const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
146
+ return { type: 'text', data: { text: alt } };
147
+ }
148
+ return { type: 'image', data: { image_key: imageKey } };
149
+ }));
150
+ }
151
+ /** POST /im/v1/images(image_type=message),返回 image_key;失败返回 null。 */
152
+ async uploadImage(media) {
153
+ try {
154
+ await this.#ensureAccessToken();
155
+ const binary = await resolveMediaBinary(media);
156
+ const form = buildImageUploadForm(binary);
157
+ const url = `${this.#options.config.apiBaseUrl}/im/v1/images`;
158
+ const response = await this.#fetch(url, {
159
+ method: 'POST',
160
+ headers: { Authorization: `Bearer ${this.#accessToken.token}` },
161
+ body: form,
162
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
163
+ });
164
+ const data = await response.json();
165
+ if (data.code === 0) {
166
+ return data.data?.image_key || null;
167
+ }
168
+ throw new Error(`Image upload failed: ${data.msg}`);
169
+ }
170
+ catch (error) {
171
+ logger.error('Failed to upload image:', error);
172
+ return null;
173
+ }
174
+ }
89
175
  /** Test / internal: admit a parsed message when open (non-webhook path). */
90
176
  admit(msg) {
91
177
  if (!this.#open)
@@ -99,7 +185,7 @@ export class LarkEndpoint {
99
185
  id: generateMessageId(msg),
100
186
  metadata: Object.freeze({
101
187
  messageType: msg.message_type,
102
- chatType: resolveChatType(msg.chat_id),
188
+ chatType: resolveChatType(msg.chat_id, msg.chat_type),
103
189
  endpoint: this.#options.config.name,
104
190
  }),
105
191
  }).catch((err) => {
@@ -275,6 +361,36 @@ export class LarkEndpoint {
275
361
  return false;
276
362
  }
277
363
  }
364
+ /** 拉取分页列表(items + has_more/page_token);code!==0 时抛错交由 RPC 层透出。 */
365
+ async #listAllPages(path, params = {}) {
366
+ const items = [];
367
+ let pageToken = '';
368
+ do {
369
+ const data = await this.#request(path, {
370
+ method: 'GET',
371
+ params: {
372
+ ...params,
373
+ page_size: 100,
374
+ ...(pageToken ? { page_token: pageToken } : {}),
375
+ },
376
+ });
377
+ if (data.code !== 0) {
378
+ throw new Error(`Lark API ${path} failed: ${data.msg ?? 'unknown'} (${data.code})`);
379
+ }
380
+ const page = data.data ?? {};
381
+ if (Array.isArray(page.items)) {
382
+ for (const item of page.items) {
383
+ if (item !== null && typeof item === 'object') {
384
+ items.push(item);
385
+ }
386
+ }
387
+ }
388
+ pageToken = page.has_more === true && typeof page.page_token === 'string'
389
+ ? page.page_token
390
+ : '';
391
+ } while (pageToken);
392
+ return items;
393
+ }
278
394
  async #request(path, options = {}) {
279
395
  await this.#ensureAccessToken();
280
396
  const { method = 'GET', params = {}, body } = options;
@@ -288,6 +404,7 @@ export class LarkEndpoint {
288
404
  Authorization: `Bearer ${this.#accessToken.token}`,
289
405
  },
290
406
  body: body && method !== 'GET' ? JSON.stringify(body) : undefined,
407
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
291
408
  });
292
409
  if (!response.ok) {
293
410
  const text = await response.text().catch(() => '');
package/lib/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export { formatInboundContent, formatOutboundBody, generateMessageId, headerValu
2
2
  export { getLarkAgentDeps, registerLarkAgentEndpoint, setLarkAgentDeps, type LarkAgentDeps, type LarkAgentEndpoint, } from './lark-agent-deps.js';
3
3
  export { checkLarkPlatformPermit, larkGroupPermitResolver, normalizeLarkSenderForPermit, platformPermit, registerLarkPlatformPermitChecker, } from './platform-permit.js';
4
4
  export { LarkEndpoint, type LarkEndpointOptions, type LarkFetch, } from './endpoint.js';
5
+ export { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, type MediaBinary, } from './media-upload.js';
5
6
  export { registerLarkWebhookRoutes, handleLarkWebhookRequest, type LarkWebhookHandler, } from './webhook.js';
package/lib/index.js CHANGED
@@ -2,4 +2,5 @@ export { formatInboundContent, formatOutboundBody, generateMessageId, headerValu
2
2
  export { getLarkAgentDeps, registerLarkAgentEndpoint, setLarkAgentDeps, } from './lark-agent-deps.js';
3
3
  export { checkLarkPlatformPermit, larkGroupPermitResolver, normalizeLarkSenderForPermit, platformPermit, registerLarkPlatformPermitChecker, } from './platform-permit.js';
4
4
  export { LarkEndpoint, } from './endpoint.js';
5
+ export { buildImageUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
5
6
  export { registerLarkWebhookRoutes, handleLarkWebhookRequest, } from './webhook.js';
@@ -0,0 +1 @@
1
+ export declare const larkEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `lark endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { larkRuntimeStateToken } from './lark-runtime-state.js';
8
+ export const larkEndpointCommands = createEndpointCommands({
9
+ adapterKey: 'lark',
10
+ adapterDisplayName: 'Lark',
11
+ fields: [
12
+ { key: 'appId', required: true, env: true, description: '飞书 App ID' },
13
+ { key: 'appSecret', required: true, env: true, description: '飞书 App Secret' },
14
+ { key: 'encryptKey', env: true, description: '事件加密 key' },
15
+ { key: 'verificationToken', env: true, description: '事件验证 token' },
16
+ ],
17
+ running: (use) => use(larkRuntimeStateToken).endpoints.values(),
18
+ describeEntry: (entry) => `appId: ${String(entry.appId)}`,
19
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const larkRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Lark 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `lark endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const larkRuntimeStateToken = defineEndpointRuntimeStateToken('lark');
@@ -0,0 +1,15 @@
1
+ import { type MediaRef } from '@zhin.js/core';
2
+ export interface MediaBinary {
3
+ readonly data: Buffer;
4
+ readonly mimeType: string;
5
+ readonly fileName: string;
6
+ }
7
+ /** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
8
+ export declare function resolveMediaBinary(media: MediaRef, download?: (url: string) => Promise<Buffer>): Promise<MediaBinary>;
9
+ /** /im/v1/images 的 multipart body(image_type=message 用于消息内图片)。 */
10
+ export declare function buildImageUploadForm(binary: MediaBinary): FormData;
11
+ /**
12
+ * 出站 image 段的媒体引用:已有 image_key/file_key 的视为已物化;
13
+ * 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
14
+ */
15
+ export declare function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Lark 图片上传(im/v1 消息 image 段需要 image_key)。
3
+ * 接口:POST {apiBaseUrl}/im/v1/images(multipart:image_type=message + image)
4
+ * 与 im/v1/messages 同属 IM 接口域(同一 tenant_access_token),不引入额外授权域。
5
+ */
6
+ import { readFile } from 'node:fs/promises';
7
+ import { basename } from 'node:path';
8
+ import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
9
+ const MIME_EXT = {
10
+ 'image/jpeg': 'jpg',
11
+ 'image/png': 'png',
12
+ 'image/gif': 'gif',
13
+ 'image/webp': 'webp',
14
+ 'image/bmp': 'bmp',
15
+ };
16
+ /** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
17
+ export async function resolveMediaBinary(media, download = defaultDownload) {
18
+ const mimeType = media.mime_type ?? 'image/png';
19
+ const ext = MIME_EXT[mimeType] ?? 'png';
20
+ if (media.kind === 'base64') {
21
+ const value = media.value.startsWith('base64://')
22
+ ? media.value.slice('base64://'.length)
23
+ : media.value;
24
+ return { data: Buffer.from(value, 'base64'), mimeType, fileName: `image.${ext}` };
25
+ }
26
+ if (media.kind === 'path') {
27
+ const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
28
+ return { data: await readFile(path), mimeType, fileName: basename(path) };
29
+ }
30
+ return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
31
+ }
32
+ async function defaultDownload(url) {
33
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
34
+ if (!response.ok)
35
+ throw new Error(`download failed: HTTP ${response.status}`);
36
+ return Buffer.from(await response.arrayBuffer());
37
+ }
38
+ /** /im/v1/images 的 multipart body(image_type=message 用于消息内图片)。 */
39
+ export function buildImageUploadForm(binary) {
40
+ // Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
41
+ const bytes = new Uint8Array(binary.data.byteLength);
42
+ bytes.set(binary.data);
43
+ const form = new FormData();
44
+ form.append('image_type', 'message');
45
+ form.append('image', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
46
+ return form;
47
+ }
48
+ /**
49
+ * 出站 image 段的媒体引用:已有 image_key/file_key 的视为已物化;
50
+ * 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
51
+ */
52
+ export function readOutboundImageMedia(data) {
53
+ if (typeof data.image_key === 'string' && data.image_key)
54
+ return undefined;
55
+ if (typeof data.file_key === 'string' && data.file_key)
56
+ return undefined;
57
+ if (typeof data.key === 'string' && data.key)
58
+ return undefined;
59
+ if (isMediaRef(data.media))
60
+ return data.media;
61
+ return mediaRefFromLegacyData(data);
62
+ }
package/lib/protocol.d.ts CHANGED
@@ -36,6 +36,8 @@ export interface LarkMessage {
36
36
  readonly create_time?: string;
37
37
  readonly update_time?: string;
38
38
  readonly chat_id?: string;
39
+ /** 事件携带的会话类型('p2p' | 'group');p2p 的 chat_id 也是 oc_ 开头。 */
40
+ readonly chat_type?: string;
39
41
  readonly sender?: {
40
42
  readonly sender_id?: {
41
43
  readonly user_id?: string;
@@ -93,13 +95,17 @@ export interface LarkSendBody {
93
95
  }
94
96
  export declare function resolveLarkConfig(config?: LarkAdapterConfig): ResolvedLarkConfig;
95
97
  export declare function normalizeWebhookPath(path: string): string;
96
- export declare function resolveChatType(chatId?: string): 'group' | 'private';
98
+ export declare function resolveChatType(chatId?: string, chatType?: string): 'group' | 'private';
97
99
  export declare function resolveTarget(msg: LarkMessage): string;
98
100
  export declare function resolveSender(msg: LarkMessage): string;
99
101
  export declare function generateMessageId(msg: LarkMessage): string;
100
102
  /** Build inbound text for MessageGateway.receive. */
101
103
  export declare function formatInboundContent(msg: LarkMessage): string;
102
- export declare function verifySignature(encryptKey: string, timestamp: string, nonce: string, body: string, signature: string): boolean;
104
+ /** 验签时间戳时效窗口(防重放)。 */
105
+ export declare const LARK_SIGNATURE_MAX_AGE_MS: number;
106
+ export declare function verifySignature(encryptKey: string, timestamp: string, nonce: string, body: string, signature: string, nowMs?: number): boolean;
107
+ /** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
108
+ export declare function verifyToken(token: string, expected: string): boolean;
103
109
  /**
104
110
  * Wire-encode an already-rendered outbound payload into Lark im/v1 message body.
105
111
  * Segment canonicalization is intentionally not done here.
package/lib/protocol.js CHANGED
@@ -38,7 +38,12 @@ export function normalizeWebhookPath(path) {
38
38
  const trimmed = path.trim() || '/lark/webhook';
39
39
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
40
40
  }
41
- export function resolveChatType(chatId) {
41
+ export function resolveChatType(chatId, chatType) {
42
+ // 优先使用事件里的 chat_type:p2p 会话的 chat_id 同样以 oc_ 开头,不能靠前缀判断
43
+ if (chatType === 'group')
44
+ return 'group';
45
+ if (chatType === 'p2p')
46
+ return 'private';
42
47
  return chatId?.startsWith('oc_') ? 'group' : 'private';
43
48
  }
44
49
  export function resolveTarget(msg) {
@@ -86,8 +91,15 @@ export function formatInboundContent(msg) {
86
91
  return '[消息解析失败]';
87
92
  }
88
93
  }
89
- export function verifySignature(encryptKey, timestamp, nonce, body, signature) {
94
+ /** 验签时间戳时效窗口(防重放)。 */
95
+ export const LARK_SIGNATURE_MAX_AGE_MS = 5 * 60_000;
96
+ export function verifySignature(encryptKey, timestamp, nonce, body, signature, nowMs = Date.now()) {
90
97
  try {
98
+ // 时效窗口 ±5min,超出直接拒绝(timestamp 为秒)
99
+ const ts = Number(timestamp);
100
+ if (!Number.isFinite(ts) || Math.abs(nowMs - ts * 1000) > LARK_SIGNATURE_MAX_AGE_MS) {
101
+ return false;
102
+ }
91
103
  const stringToSign = `${timestamp}${nonce}${encryptKey}${body}`;
92
104
  const calculated = createHash('sha256').update(stringToSign).digest('hex');
93
105
  const a = Buffer.from(calculated);
@@ -100,6 +112,19 @@ export function verifySignature(encryptKey, timestamp, nonce, body, signature) {
100
112
  return false;
101
113
  }
102
114
  }
115
+ /** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
116
+ export function verifyToken(token, expected) {
117
+ try {
118
+ const a = Buffer.from(token);
119
+ const b = Buffer.from(expected);
120
+ if (a.length !== b.length)
121
+ return false;
122
+ return timingSafeEqual(a, b);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
103
128
  /**
104
129
  * Wire-encode an already-rendered outbound payload into Lark im/v1 message body.
105
130
  * Segment canonicalization is intentionally not done here.
@@ -147,7 +172,8 @@ export function formatOutboundBody(payload) {
147
172
  media = {
148
173
  msg_type: 'image',
149
174
  content: JSON.stringify({
150
- image_key: data.file_key ?? data.key ?? data.url,
175
+ // image_key 由 endpoint 上传物化写入;file_key/key/url 为旧调用方兼容入口。
176
+ image_key: data.image_key ?? data.file_key ?? data.key ?? data.url,
151
177
  }),
152
178
  };
153
179
  }
package/lib/webhook.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { formatCompact, getLogger } from '@zhin.js/logger';
2
- import { headerValue, readTextBody, verifySignature, } from './protocol.js';
2
+ import { headerValue, readTextBody, verifySignature, verifyToken, } from './protocol.js';
3
3
  const logger = getLogger('lark');
4
4
  export function registerLarkWebhookRoutes(http, handler) {
5
5
  const path = handler.config.webhookPath;
@@ -14,7 +14,7 @@ export async function handleLarkWebhookRequest(request, response, handler) {
14
14
  const rawBody = await readTextBody(request);
15
15
  if (handler.config.verificationToken) {
16
16
  const token = headerValue(request.headers, 'x-lark-request-token');
17
- if (token !== handler.config.verificationToken) {
17
+ if (!verifyToken(token, handler.config.verificationToken)) {
18
18
  logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
19
19
  response.writeHead(403, { 'Content-Type': 'text/plain' });
20
20
  response.end('Forbidden');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-lark",
3
- "version": "4.0.3",
3
+ "version": "5.0.1",
4
4
  "description": "Zhin.js Lark/Feishu (飞书) adapter for Plugin Runtime (HTTP webhook)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -34,20 +34,21 @@
34
34
  "directory": "plugins/adapters/lark"
35
35
  },
36
36
  "dependencies": {
37
- "@zhin.js/adapter": "1.0.1",
38
- "@zhin.js/core": "1.3.5",
39
- "@zhin.js/host-http": "1.0.1",
37
+ "@zhin.js/adapter": "1.1.1",
38
+ "@zhin.js/command": "1.0.3",
39
+ "@zhin.js/core": "1.4.1",
40
+ "@zhin.js/host-http": "1.0.3",
40
41
  "@zhin.js/logger": "1.0.75",
41
- "@zhin.js/plugin-runtime": "1.0.1"
42
+ "@zhin.js/plugin-runtime": "1.1.1"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "zod": "^4.0.0",
45
- "@zhin.js/adapter": "1.0.1",
46
- "@zhin.js/agent": "1.0.4",
47
- "@zhin.js/core": "1.3.5",
48
- "@zhin.js/host-http": "1.0.1",
49
- "@zhin.js/plugin-runtime": "1.0.1",
50
- "zhin.js": "4.1.3"
46
+ "@zhin.js/adapter": "1.1.1",
47
+ "@zhin.js/agent": "1.0.6",
48
+ "@zhin.js/core": "1.4.1",
49
+ "@zhin.js/host-http": "1.0.3",
50
+ "@zhin.js/plugin-runtime": "1.1.1",
51
+ "zhin.js": "5.0.1"
51
52
  },
52
53
  "peerDependenciesMeta": {
53
54
  "zhin.js": {
@@ -65,11 +66,12 @@
65
66
  "typescript": "^6.0.3",
66
67
  "vitest": "^4.1.10",
67
68
  "zod": "^4.4.3",
68
- "@zhin.js/agent": "1.0.4",
69
- "zhin.js": "4.1.3"
69
+ "@zhin.js/agent": "1.0.6",
70
+ "zhin.js": "5.0.1"
70
71
  },
71
72
  "files": [
72
73
  "adapters",
74
+ "commands",
73
75
  "plugin.ts",
74
76
  "schema.json",
75
77
  "src",
@@ -95,6 +97,10 @@
95
97
  {
96
98
  "package": "@zhin.js/adapter",
97
99
  "api": "^1.0.0"
100
+ },
101
+ {
102
+ "package": "@zhin.js/command",
103
+ "api": "^1.0.0"
98
104
  }
99
105
  ],
100
106
  "plugins": []
package/plugin.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
1
2
  import { definePlugin } from '@zhin.js/plugin-runtime';
2
3
  import { registerLarkPlatformPermitChecker } from './src/platform-permit.js';
4
+ import { larkRuntimeStateToken } from './src/lark-runtime-state.js';
3
5
 
4
6
  export default definePlugin({
5
7
  name: 'lark',
6
8
  metadata: {
7
9
  displayName: 'Lark/Feishu (飞书) Adapter',
8
10
  },
9
- setup() {
11
+ setup(context) {
12
+ // 运行中 endpoint 注册表(lark endpoint list 的"运行中"数据源)
13
+ context.resources.provide(larkRuntimeStateToken, createEndpointRuntimeState());
10
14
  return registerLarkPlatformPermitChecker();
11
15
  },
12
16
  });
package/schema.json CHANGED
@@ -3,23 +3,59 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
- "name": {
7
- "type": "string",
8
- "default": "lark-bot"
9
- },
10
- "appId": { "type": "string" },
11
- "appSecret": { "type": "string" },
12
- "encryptKey": { "type": "string" },
13
- "verificationToken": { "type": "string" },
14
6
  "webhookPath": {
15
7
  "type": "string",
16
8
  "default": "/lark/webhook"
17
9
  },
18
- "apiBaseUrl": { "type": "string" },
10
+ "apiBaseUrl": {
11
+ "type": "string"
12
+ },
19
13
  "isFeishu": {
20
14
  "type": "boolean",
21
15
  "default": true
16
+ },
17
+ "endpoints": {
18
+ "type": "array",
19
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
20
+ "items": {
21
+ "type": "object",
22
+ "additionalProperties": true,
23
+ "properties": {
24
+ "name": {
25
+ "type": "string",
26
+ "description": "Lark bot name"
27
+ },
28
+ "appId": {
29
+ "type": "string",
30
+ "description": "Lark app ID"
31
+ },
32
+ "appSecret": {
33
+ "type": "string",
34
+ "description": "Lark app secret"
35
+ },
36
+ "encryptKey": {
37
+ "type": "string",
38
+ "description": "Lark encrypt key"
39
+ },
40
+ "verificationToken": {
41
+ "type": "string",
42
+ "description": "Lark verification token"
43
+ }
44
+ },
45
+ "required": [
46
+ "name",
47
+ "appId",
48
+ "appSecret"
49
+ ]
50
+ }
51
+ },
52
+ "commandPrefix": {
53
+ "type": "string",
54
+ "default": "",
55
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
22
56
  }
23
57
  },
24
- "required": ["appId", "appSecret"]
58
+ "required": [
59
+ "endpoints"
60
+ ]
25
61
  }
package/src/endpoint.ts CHANGED
@@ -3,12 +3,17 @@
3
3
  */
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { basename } from 'node:path';
6
- import type { EndpointInstance } from '@zhin.js/adapter';
6
+ import type { EndpointGroup, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
7
7
  import type { MessageGateway } from '@zhin.js/core/runtime';
8
8
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
9
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
10
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
11
11
  import { registerLarkAgentEndpoint } from './lark-agent-deps.js';
12
+ import {
13
+ buildImageUploadForm,
14
+ readOutboundImageMedia,
15
+ resolveMediaBinary,
16
+ } from './media-upload.js';
12
17
  import {
13
18
  formatInboundContent,
14
19
  formatOutboundBody,
@@ -25,12 +30,16 @@ import { registerLarkWebhookRoutes } from './webhook.js';
25
30
 
26
31
  const logger = getLogger('lark');
27
32
 
33
+ /** 出站 HTTP 调用统一 30s 超时。 */
34
+ const OUTBOUND_TIMEOUT_MS = 30_000;
35
+
28
36
  export type LarkFetch = (
29
37
  url: string,
30
38
  init?: {
31
39
  readonly method?: string;
32
40
  readonly headers?: Record<string, string>;
33
41
  readonly body?: string | FormData;
42
+ readonly signal?: AbortSignal;
34
43
  },
35
44
  ) => Promise<{
36
45
  readonly ok: boolean;
@@ -50,6 +59,32 @@ export interface LarkEndpointOptions {
50
59
  export class LarkEndpoint implements EndpointInstance {
51
60
  readonly #options: LarkEndpointOptions;
52
61
  readonly #fetch: LarkFetch;
62
+ /**
63
+ * Console 社交面语义端口。Lark OpenAPI 仅接读取类列表能力:
64
+ * - listGroups:GET /im/v1/chats(bot 所在群列表,分页归一为 {group_id: chat_id, name})
65
+ * - listGroupMembers:GET /im/v1/chats/:chat_id/members(open_id 分页,返回平台原生成员形状)
66
+ * 不接的项:listFriends(Lark 无好友概念,contact API 为组织通讯录而非社交关系)、
67
+ * listChannels(无频道/guild 概念)、写操作(审批/踢人/禁言等本轮不在范围内)。
68
+ */
69
+ readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
70
+ listGroups: async (): Promise<readonly EndpointGroup[]> => {
71
+ const items = await this.#listAllPages('/im/v1/chats');
72
+ const groups: EndpointGroup[] = [];
73
+ for (const item of items) {
74
+ const chatId = typeof item.chat_id === 'string' ? item.chat_id : '';
75
+ if (!chatId) continue;
76
+ groups.push({
77
+ group_id: chatId,
78
+ name: typeof item.name === 'string' && item.name ? item.name : chatId,
79
+ });
80
+ }
81
+ return groups;
82
+ },
83
+ listGroupMembers: (groupId) => this.#listAllPages(
84
+ `/im/v1/chats/${encodeURIComponent(groupId)}/members`,
85
+ { member_id_type: 'open_id' },
86
+ ),
87
+ });
53
88
  #routeReleases: HttpRouteRegistration[] = [];
54
89
  #accessToken: AccessToken = { token: '', expires_in: 0, timestamp: 0 };
55
90
  #refreshPromise: Promise<string> | null = null;
@@ -75,6 +110,15 @@ export class LarkEndpoint implements EndpointInstance {
75
110
  if (this.#started) return;
76
111
  this.#started = true;
77
112
  try {
113
+ if (!this.#options.config.encryptKey && !this.#options.config.verificationToken) {
114
+ // encryptKey / verificationToken 都未配置时 webhook 完全无鉴权,任何人可伪造事件
115
+ logger.warn(formatCompact({
116
+ endpoint: this.#options.config.name,
117
+ op: 'webhook',
118
+ ok: false,
119
+ error: 'neither encryptKey nor verificationToken configured; webhook is unauthenticated',
120
+ }));
121
+ }
78
122
  await this.#refreshAccessToken();
79
123
  this.#unregisterAgent = registerLarkAgentEndpoint(this.#options.config.name, this);
80
124
  this.#routeReleases.push(...registerLarkWebhookRoutes(this.#options.http, this));
@@ -108,7 +152,8 @@ export class LarkEndpoint implements EndpointInstance {
108
152
  }
109
153
 
110
154
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
111
- const content = formatOutboundBody(payload);
155
+ const materialized = await this.#materializeOutboundMedia(payload);
156
+ const content = formatOutboundBody(materialized);
112
157
  const data = await this.#request('/im/v1/messages', {
113
158
  method: 'POST',
114
159
  params: { receive_id_type: 'chat_id' },
@@ -131,6 +176,52 @@ export class LarkEndpoint implements EndpointInstance {
131
176
  return messageId;
132
177
  }
133
178
 
179
+ /**
180
+ * im/v1 消息 image 段只接受 image_key:canonical MediaRef(base64/本地路径/URL)
181
+ * 先经 /im/v1/images 物化;上传失败降级为文本(alt 优先),不阻断发送。
182
+ */
183
+ async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
184
+ if (!Array.isArray(payload)) return payload;
185
+ return Promise.all(payload.map(async (item) => {
186
+ if (typeof item === 'string' || !item || typeof item !== 'object') return item;
187
+ const seg = item as { type?: unknown; data?: Record<string, unknown> };
188
+ if (seg.type !== 'image') return item;
189
+ const data = seg.data ?? {};
190
+ const media = readOutboundImageMedia(data);
191
+ if (!media) return item;
192
+ const imageKey = await this.uploadImage(media);
193
+ if (!imageKey) {
194
+ const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
195
+ return { type: 'text', data: { text: alt } };
196
+ }
197
+ return { type: 'image', data: { image_key: imageKey } };
198
+ }));
199
+ }
200
+
201
+ /** POST /im/v1/images(image_type=message),返回 image_key;失败返回 null。 */
202
+ async uploadImage(media: Parameters<typeof resolveMediaBinary>[0]): Promise<string | null> {
203
+ try {
204
+ await this.#ensureAccessToken();
205
+ const binary = await resolveMediaBinary(media);
206
+ const form = buildImageUploadForm(binary);
207
+ const url = `${this.#options.config.apiBaseUrl}/im/v1/images`;
208
+ const response = await this.#fetch(url, {
209
+ method: 'POST',
210
+ headers: { Authorization: `Bearer ${this.#accessToken.token}` },
211
+ body: form,
212
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
213
+ });
214
+ const data = await response.json() as LarkApiResponse;
215
+ if (data.code === 0) {
216
+ return (data.data?.image_key as string) || null;
217
+ }
218
+ throw new Error(`Image upload failed: ${data.msg}`);
219
+ } catch (error) {
220
+ logger.error('Failed to upload image:', error);
221
+ return null;
222
+ }
223
+ }
224
+
134
225
  /** Test / internal: admit a parsed message when open (non-webhook path). */
135
226
  admit(msg: LarkMessage): void {
136
227
  if (!this.#open) return;
@@ -143,7 +234,7 @@ export class LarkEndpoint implements EndpointInstance {
143
234
  id: generateMessageId(msg),
144
235
  metadata: Object.freeze({
145
236
  messageType: msg.message_type,
146
- chatType: resolveChatType(msg.chat_id),
237
+ chatType: resolveChatType(msg.chat_id, msg.chat_type),
147
238
  endpoint: this.#options.config.name,
148
239
  }),
149
240
  }).catch((err) => {
@@ -325,6 +416,40 @@ export class LarkEndpoint implements EndpointInstance {
325
416
  }
326
417
  }
327
418
 
419
+ /** 拉取分页列表(items + has_more/page_token);code!==0 时抛错交由 RPC 层透出。 */
420
+ async #listAllPages(
421
+ path: string,
422
+ params: Record<string, string | number> = {},
423
+ ): Promise<Record<string, unknown>[]> {
424
+ const items: Record<string, unknown>[] = [];
425
+ let pageToken = '';
426
+ do {
427
+ const data = await this.#request(path, {
428
+ method: 'GET',
429
+ params: {
430
+ ...params,
431
+ page_size: 100,
432
+ ...(pageToken ? { page_token: pageToken } : {}),
433
+ },
434
+ });
435
+ if (data.code !== 0) {
436
+ throw new Error(`Lark API ${path} failed: ${data.msg ?? 'unknown'} (${data.code})`);
437
+ }
438
+ const page = data.data ?? {};
439
+ if (Array.isArray(page.items)) {
440
+ for (const item of page.items) {
441
+ if (item !== null && typeof item === 'object') {
442
+ items.push(item as Record<string, unknown>);
443
+ }
444
+ }
445
+ }
446
+ pageToken = page.has_more === true && typeof page.page_token === 'string'
447
+ ? page.page_token
448
+ : '';
449
+ } while (pageToken);
450
+ return items;
451
+ }
452
+
328
453
  async #request(
329
454
  path: string,
330
455
  options: {
@@ -349,6 +474,7 @@ export class LarkEndpoint implements EndpointInstance {
349
474
  Authorization: `Bearer ${this.#accessToken.token}`,
350
475
  },
351
476
  body: body && method !== 'GET' ? JSON.stringify(body) : undefined,
477
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
352
478
  });
353
479
  if (!response.ok) {
354
480
  const text = await response.text().catch(() => '');
package/src/index.ts CHANGED
@@ -42,6 +42,13 @@ export {
42
42
  type LarkFetch,
43
43
  } from './endpoint.js';
44
44
 
45
+ export {
46
+ buildImageUploadForm,
47
+ readOutboundImageMedia,
48
+ resolveMediaBinary,
49
+ type MediaBinary,
50
+ } from './media-upload.js';
51
+
45
52
  export {
46
53
  registerLarkWebhookRoutes,
47
54
  handleLarkWebhookRequest,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `lark endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { larkRuntimeStateToken } from './lark-runtime-state.js';
8
+
9
+ export const larkEndpointCommands = createEndpointCommands({
10
+ adapterKey: 'lark',
11
+ adapterDisplayName: 'Lark',
12
+ fields: [
13
+ { key: 'appId', required: true, env: true, description: '飞书 App ID' },
14
+ { key: 'appSecret', required: true, env: true, description: '飞书 App Secret' },
15
+ { key: 'encryptKey', env: true, description: '事件加密 key' },
16
+ { key: 'verificationToken', env: true, description: '事件验证 token' },
17
+ ],
18
+ running: (use) => use(larkRuntimeStateToken).endpoints.values(),
19
+ describeEntry: (entry) => `appId: ${String(entry.appId)}`,
20
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Lark 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `lark endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const larkRuntimeStateToken = defineEndpointRuntimeStateToken('lark');
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Lark 图片上传(im/v1 消息 image 段需要 image_key)。
3
+ * 接口:POST {apiBaseUrl}/im/v1/images(multipart:image_type=message + image)
4
+ * 与 im/v1/messages 同属 IM 接口域(同一 tenant_access_token),不引入额外授权域。
5
+ */
6
+ import { readFile } from 'node:fs/promises';
7
+ import { basename } from 'node:path';
8
+ import { isMediaRef, mediaRefFromLegacyData, type MediaRef } from '@zhin.js/core';
9
+
10
+ export interface MediaBinary {
11
+ readonly data: Buffer;
12
+ readonly mimeType: string;
13
+ readonly fileName: string;
14
+ }
15
+
16
+ const MIME_EXT: Record<string, string> = {
17
+ 'image/jpeg': 'jpg',
18
+ 'image/png': 'png',
19
+ 'image/gif': 'gif',
20
+ 'image/webp': 'webp',
21
+ 'image/bmp': 'bmp',
22
+ };
23
+
24
+ /** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
25
+ export async function resolveMediaBinary(
26
+ media: MediaRef,
27
+ download: (url: string) => Promise<Buffer> = defaultDownload,
28
+ ): Promise<MediaBinary> {
29
+ const mimeType = media.mime_type ?? 'image/png';
30
+ const ext = MIME_EXT[mimeType] ?? 'png';
31
+ if (media.kind === 'base64') {
32
+ const value = media.value.startsWith('base64://')
33
+ ? media.value.slice('base64://'.length)
34
+ : media.value;
35
+ return { data: Buffer.from(value, 'base64'), mimeType, fileName: `image.${ext}` };
36
+ }
37
+ if (media.kind === 'path') {
38
+ const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
39
+ return { data: await readFile(path), mimeType, fileName: basename(path) };
40
+ }
41
+ return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
42
+ }
43
+
44
+ async function defaultDownload(url: string): Promise<Buffer> {
45
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
46
+ if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
47
+ return Buffer.from(await response.arrayBuffer());
48
+ }
49
+
50
+ /** /im/v1/images 的 multipart body(image_type=message 用于消息内图片)。 */
51
+ export function buildImageUploadForm(binary: MediaBinary): FormData {
52
+ // Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
53
+ const bytes = new Uint8Array(binary.data.byteLength);
54
+ bytes.set(binary.data);
55
+ const form = new FormData();
56
+ form.append('image_type', 'message');
57
+ form.append('image', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
58
+ return form;
59
+ }
60
+
61
+ /**
62
+ * 出站 image 段的媒体引用:已有 image_key/file_key 的视为已物化;
63
+ * 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
64
+ */
65
+ export function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined {
66
+ if (typeof data.image_key === 'string' && data.image_key) return undefined;
67
+ if (typeof data.file_key === 'string' && data.file_key) return undefined;
68
+ if (typeof data.key === 'string' && data.key) return undefined;
69
+ if (isMediaRef(data.media)) return data.media;
70
+ return mediaRefFromLegacyData(data);
71
+ }
package/src/protocol.ts CHANGED
@@ -41,6 +41,8 @@ export interface LarkMessage {
41
41
  readonly create_time?: string;
42
42
  readonly update_time?: string;
43
43
  readonly chat_id?: string;
44
+ /** 事件携带的会话类型('p2p' | 'group');p2p 的 chat_id 也是 oc_ 开头。 */
45
+ readonly chat_type?: string;
44
46
  readonly sender?: {
45
47
  readonly sender_id?: {
46
48
  readonly user_id?: string;
@@ -145,7 +147,10 @@ export function normalizeWebhookPath(path: string): string {
145
147
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
146
148
  }
147
149
 
148
- export function resolveChatType(chatId?: string): 'group' | 'private' {
150
+ export function resolveChatType(chatId?: string, chatType?: string): 'group' | 'private' {
151
+ // 优先使用事件里的 chat_type:p2p 会话的 chat_id 同样以 oc_ 开头,不能靠前缀判断
152
+ if (chatType === 'group') return 'group';
153
+ if (chatType === 'p2p') return 'private';
149
154
  return chatId?.startsWith('oc_') ? 'group' : 'private';
150
155
  }
151
156
 
@@ -196,14 +201,23 @@ export function formatInboundContent(msg: LarkMessage): string {
196
201
  }
197
202
  }
198
203
 
204
+ /** 验签时间戳时效窗口(防重放)。 */
205
+ export const LARK_SIGNATURE_MAX_AGE_MS = 5 * 60_000;
206
+
199
207
  export function verifySignature(
200
208
  encryptKey: string,
201
209
  timestamp: string,
202
210
  nonce: string,
203
211
  body: string,
204
212
  signature: string,
213
+ nowMs: number = Date.now(),
205
214
  ): boolean {
206
215
  try {
216
+ // 时效窗口 ±5min,超出直接拒绝(timestamp 为秒)
217
+ const ts = Number(timestamp);
218
+ if (!Number.isFinite(ts) || Math.abs(nowMs - ts * 1000) > LARK_SIGNATURE_MAX_AGE_MS) {
219
+ return false;
220
+ }
207
221
  const stringToSign = `${timestamp}${nonce}${encryptKey}${body}`;
208
222
  const calculated = createHash('sha256').update(stringToSign).digest('hex');
209
223
  const a = Buffer.from(calculated);
@@ -215,6 +229,18 @@ export function verifySignature(
215
229
  }
216
230
  }
217
231
 
232
+ /** timing-safe 比较 webhook verification token(长度不同快速失败)。 */
233
+ export function verifyToken(token: string, expected: string): boolean {
234
+ try {
235
+ const a = Buffer.from(token);
236
+ const b = Buffer.from(expected);
237
+ if (a.length !== b.length) return false;
238
+ return timingSafeEqual(a, b);
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
218
244
  /**
219
245
  * Wire-encode an already-rendered outbound payload into Lark im/v1 message body.
220
246
  * Segment canonicalization is intentionally not done here.
@@ -268,7 +294,8 @@ export function formatOutboundBody(payload: unknown): LarkSendBody {
268
294
  media = {
269
295
  msg_type: 'image',
270
296
  content: JSON.stringify({
271
- image_key: data.file_key ?? data.key ?? data.url,
297
+ // image_key 由 endpoint 上传物化写入;file_key/key/url 为旧调用方兼容入口。
298
+ image_key: data.image_key ?? data.file_key ?? data.key ?? data.url,
272
299
  }),
273
300
  };
274
301
  }
package/src/webhook.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  headerValue,
9
9
  readTextBody,
10
10
  verifySignature,
11
+ verifyToken,
11
12
  type LarkEventBody,
12
13
  type LarkMessage,
13
14
  type ResolvedLarkConfig,
@@ -43,7 +44,7 @@ export async function handleLarkWebhookRequest(
43
44
 
44
45
  if (handler.config.verificationToken) {
45
46
  const token = headerValue(request.headers, 'x-lark-request-token');
46
- if (token !== handler.config.verificationToken) {
47
+ if (!verifyToken(token, handler.config.verificationToken)) {
47
48
  logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
48
49
  response.writeHead(403, { 'Content-Type': 'text/plain' });
49
50
  response.end('Forbidden');