@zhin.js/adapter-lark 1.1.4 → 1.1.5

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,17 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { getLogger } from '@zhin.js/logger';
4
+ /** WSClient uses this agent only for WSS; discovery/OpenAPI stay unchanged. */
5
+ export function createLarkStreamAgent(proxy) {
6
+ const agent = new Agent({ keepAlive: false });
7
+ agent.createConnection = options => {
8
+ const hostname = String(options.hostname ?? options.host ?? '');
9
+ if (hostname !== proxy.serverName || Number(options.port ?? 443) !== 443) {
10
+ getLogger('lark').warn(`Stream proxy rejected gateway hostname: ${hostname}`);
11
+ throw new Error('Lark Stream proxy gateway identity changed; update fixed upstream before retrying');
12
+ }
13
+ return tls.connect({ ...options, path: undefined, host: '127.0.0.1', port: proxy.port,
14
+ servername: hostname, rejectUnauthorized: true });
15
+ };
16
+ return agent;
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { LarkFetch } from './endpoint.js';
2
+ export interface LarkWebApiProxy {
3
+ readonly port: number;
4
+ }
5
+ export declare function validateLarkWebApiProxy(value: LarkWebApiProxy): void;
6
+ /** Per-endpoint fetch dispatcher; URL/Host/SNI remain the official Feishu identity. */
7
+ export declare function createLarkWebApiTransport(proxy: LarkWebApiProxy): {
8
+ fetch: LarkFetch;
9
+ reopen(): void;
10
+ close(): Promise<void>;
11
+ };
@@ -0,0 +1,48 @@
1
+ import { Agent, fetch as dispatcherFetch } from 'undici';
2
+ import tls from 'node:tls';
3
+ export function validateLarkWebApiProxy(value) {
4
+ if (!value || typeof value !== 'object' || Object.keys(value).some(key => key !== 'port')
5
+ || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535) {
6
+ throw new TypeError('Invalid Lark webApiProxy: only a loopback port is allowed');
7
+ }
8
+ }
9
+ /** Per-endpoint fetch dispatcher; URL/Host/SNI remain the official Feishu identity. */
10
+ export function createLarkWebApiTransport(proxy) {
11
+ validateLarkWebApiProxy(proxy);
12
+ const port = proxy.port;
13
+ const sockets = new Set();
14
+ let active = true;
15
+ let dispatcher;
16
+ const fetch = async (url, init) => {
17
+ const target = new URL(url);
18
+ if (target.origin !== 'https://open.feishu.cn' || target.username || target.password
19
+ || target.hash || !target.pathname.startsWith('/open-apis/')) {
20
+ throw new TypeError('Lark Web API proxy rejected destination identity');
21
+ }
22
+ if (!active)
23
+ throw new Error('Lark Web API proxy transport is stopped');
24
+ dispatcher ??= new Agent({ connect(options, callback) {
25
+ if (options.hostname !== 'open.feishu.cn' || options.protocol !== 'https:' || Number(options.port || 443) !== 443) {
26
+ callback(new Error('Lark Web API proxy rejected destination identity'), null);
27
+ return;
28
+ }
29
+ const socket = tls.connect({ host: '127.0.0.1', port,
30
+ servername: 'open.feishu.cn', rejectUnauthorized: true });
31
+ sockets.add(socket);
32
+ socket.once('close', () => sockets.delete(socket));
33
+ socket.setTimeout(10_000, () => socket.destroy(new Error('Lark Web API TLS connection timeout')));
34
+ const failed = (error) => callback(error, null);
35
+ socket.once('error', failed);
36
+ socket.once('secureConnect', () => { socket.setTimeout(0); socket.removeListener('error', failed); callback(null, socket); });
37
+ } });
38
+ // Pair fetch and its dispatcher from the same Undici version; Node's bundled
39
+ // handler protocol may differ (notably Node 26 versus Undici 6).
40
+ return dispatcherFetch(url, { ...init, redirect: 'error', dispatcher });
41
+ };
42
+ return {
43
+ fetch,
44
+ reopen() { active = true; },
45
+ async close() { active = false; for (const socket of sockets)
46
+ socket.destroy(new Error('Lark Web API proxy transport stopped')); const previous = dispatcher; dispatcher = undefined; await previous?.destroy(); },
47
+ };
48
+ }
package/lib/webhook.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Lark/Feishu webhook HTTP: verification → signature → challenge/event → admit.
3
3
  */
4
+ import type { LarkCardAction } from './cards.js';
4
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
5
6
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
7
  import { type LarkMessage, type ResolvedLarkConfig } from './protocol.js';
@@ -9,6 +10,7 @@ export interface LarkWebhookHandler {
9
10
  readonly isOpen: boolean;
10
11
  admitPlatform(name: string, event: unknown): void;
11
12
  admit(msg: LarkMessage): void;
13
+ admitCardAction?(event: LarkCardAction): void;
12
14
  }
13
15
  export declare function registerLarkWebhookRoutes(http: HttpHost, handler: LarkWebhookHandler): HttpRouteRegistration[];
14
16
  export declare function handleLarkWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: LarkWebhookHandler): Promise<void>;
package/lib/webhook.js CHANGED
@@ -12,8 +12,18 @@ export function registerLarkWebhookRoutes(http, handler) {
12
12
  export async function handleLarkWebhookRequest(request, response, handler) {
13
13
  try {
14
14
  const rawBody = await readTextBody(request);
15
+ let event;
16
+ try {
17
+ event = JSON.parse(rawBody);
18
+ }
19
+ catch {
20
+ response.writeHead(200, { 'Content-Type': 'application/json' });
21
+ response.end(JSON.stringify({ code: 0, msg: 'success' }));
22
+ return;
23
+ }
15
24
  if (handler.config.verificationToken) {
16
- const token = headerValue(request.headers, 'x-lark-request-token');
25
+ const bodyToken = event.header?.token ?? event.token;
26
+ const token = typeof bodyToken === 'string' ? bodyToken : headerValue(request.headers, 'x-lark-request-token');
17
27
  if (!verifyToken(token, handler.config.verificationToken)) {
18
28
  logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid verification token' }));
19
29
  response.writeHead(403, { 'Content-Type': 'text/plain' });
@@ -35,15 +45,6 @@ export async function handleLarkWebhookRequest(request, response, handler) {
35
45
  return;
36
46
  }
37
47
  }
38
- let event;
39
- try {
40
- event = JSON.parse(rawBody);
41
- }
42
- catch {
43
- response.writeHead(200, { 'Content-Type': 'application/json' });
44
- response.end(JSON.stringify({ code: 0, msg: 'success' }));
45
- return;
46
- }
47
48
  if (handler.isOpen) {
48
49
  const eventName = event.type === 'event_callback'
49
50
  ? String(event.event?.type ?? event.type)
@@ -55,6 +56,8 @@ export async function handleLarkWebhookRequest(request, response, handler) {
55
56
  response.end(JSON.stringify({ challenge: event.challenge }));
56
57
  return;
57
58
  }
59
+ if (event.header?.event_type === 'card.action.trigger' && handler.isOpen)
60
+ handler.admitCardAction?.(event.event);
58
61
  if (event.type === 'event_callback' && event.event?.message && handler.isOpen) {
59
62
  const message = {
60
63
  ...event.event.message,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-lark",
3
- "version": "1.1.4",
4
- "description": "Zhin.js Lark/Feishu (飞书) adapter for Plugin Runtime (HTTP webhook)",
3
+ "version": "1.1.5",
4
+ "description": "Zhin.js Lark/Feishu adapter (HTTP webhook and WebSocket long connection)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -34,24 +34,26 @@
34
34
  "directory": "plugins/adapters/lark"
35
35
  },
36
36
  "dependencies": {
37
- "@zhin.js/adapter": "1.1.15",
38
- "@zhin.js/agent-feature": "1.1.2",
39
- "@zhin.js/core": "1.1.39",
37
+ "undici": "6.28.0",
38
+ "@larksuiteoapi/node-sdk": "^1.67.0",
39
+ "@zhin.js/adapter": "1.1.16",
40
+ "@zhin.js/agent-feature": "1.1.3",
41
+ "@zhin.js/core": "1.1.40",
40
42
  "@zhin.js/feature-kit": "1.1.2",
41
- "@zhin.js/host-http": "1.1.2",
42
- "@zhin.js/im-contract": "1.1.2",
43
+ "@zhin.js/host-http": "1.1.3",
44
+ "@zhin.js/im-contract": "1.1.3",
43
45
  "@zhin.js/logger": "1.1.1",
44
- "@zhin.js/skill": "1.1.2",
45
- "@zhin.js/tool": "1.1.2"
46
+ "@zhin.js/skill": "1.1.3",
47
+ "@zhin.js/tool": "1.1.3"
46
48
  },
47
49
  "peerDependencies": {
48
50
  "zod": "^4.0.0",
49
- "@zhin.js/adapter": "^1.1.15",
50
- "@zhin.js/command": "^1.1.3",
51
- "@zhin.js/core": "^1.1.39",
52
- "@zhin.js/host-http": "^1.1.2",
51
+ "@zhin.js/adapter": "^1.1.16",
52
+ "@zhin.js/command": "^1.1.4",
53
+ "@zhin.js/core": "^1.1.40",
54
+ "@zhin.js/host-http": "^1.1.3",
53
55
  "@zhin.js/permission": "^1.1.3",
54
- "zhin.js": "^1.1.4"
56
+ "zhin.js": "^1.1.5"
55
57
  },
56
58
  "peerDependenciesMeta": {
57
59
  "@zhin.js/command": {
@@ -69,7 +71,7 @@
69
71
  "typescript": "^6.0.3",
70
72
  "vitest": "^4.1.10",
71
73
  "zod": "^4.4.3",
72
- "zhin.js": "1.1.4"
74
+ "zhin.js": "1.1.5"
73
75
  },
74
76
  "files": [
75
77
  "adapters",
package/schema.json CHANGED
@@ -89,6 +89,50 @@
89
89
  "id": {
90
90
  "type": "string",
91
91
  "description": "Lark bot name"
92
+ },
93
+ "mode": {
94
+ "type": "string",
95
+ "enum": [
96
+ "webhook",
97
+ "websocket"
98
+ ],
99
+ "default": "webhook"
100
+ },
101
+ "streamProxy": {
102
+ "type": "object",
103
+ "additionalProperties": false,
104
+ "properties": {
105
+ "port": {
106
+ "type": "integer",
107
+ "minimum": 1,
108
+ "maximum": 65535
109
+ },
110
+ "serverName": {
111
+ "type": "string",
112
+ "minLength": 1,
113
+ "pattern": "^[^\\s/:@?#]+$"
114
+ }
115
+ },
116
+ "required": [
117
+ "port",
118
+ "serverName"
119
+ ],
120
+ "description": "WSS故障验收:仅TCP固定loopback,保留TLS身份"
121
+ },
122
+ "webApiProxy": {
123
+ "type": "object",
124
+ "additionalProperties": false,
125
+ "required": [
126
+ "port"
127
+ ],
128
+ "properties": {
129
+ "port": {
130
+ "type": "integer",
131
+ "minimum": 1,
132
+ "maximum": 65535
133
+ }
134
+ },
135
+ "description": "默认关闭;仅飞书出站API的loopback TLS故障代理,保留官方Host/SNI和证书校验,与streamProxy独立"
92
136
  }
93
137
  },
94
138
  "required": [
@@ -102,6 +146,29 @@
102
146
  "type": "string",
103
147
  "default": "",
104
148
  "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
149
+ },
150
+ "mode": {
151
+ "type": "string",
152
+ "enum": [
153
+ "webhook",
154
+ "websocket"
155
+ ],
156
+ "default": "webhook"
157
+ },
158
+ "webApiProxy": {
159
+ "type": "object",
160
+ "additionalProperties": false,
161
+ "required": [
162
+ "port"
163
+ ],
164
+ "properties": {
165
+ "port": {
166
+ "type": "integer",
167
+ "minimum": 1,
168
+ "maximum": 65535
169
+ }
170
+ },
171
+ "description": "默认关闭;仅飞书出站API的loopback TLS故障代理,保留官方Host/SNI和证书校验,与streamProxy独立"
105
172
  }
106
173
  },
107
174
  "required": [
package/src/cards.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ import type { LarkSendBody, LarkWireSegment } from './protocol.js';
3
+
4
+ /** Feishu JSON card v1: ordered native rich text, link and button components. */
5
+ export function formatLarkCard(items: Array<string | LarkWireSegment>): LarkSendBody {
6
+ const elements: unknown[] = [];
7
+ let replyTo: string | undefined;
8
+ for (const item of items) {
9
+ if (typeof item === 'string') { elements.push({ tag: 'div', text: { tag: 'plain_text', content: item } }); continue; }
10
+ const data = item.data ?? {};
11
+ switch (item.type) {
12
+ case 'reply': replyTo = String(data.message_id ?? '') || undefined; break;
13
+ case 'text': elements.push({ tag: 'div', text: { tag: 'plain_text', content: String(data.text ?? data.content ?? '') } }); break;
14
+ case 'markdown': elements.push({ tag: 'div', text: { tag: 'lark_md', content: String(data.content ?? data.text ?? '') } }); break;
15
+ case 'image': {
16
+ const media = data.media as { kind?: string; value?: string } | undefined;
17
+ if (media?.kind !== 'file' || !media.value) throw unsupported();
18
+ elements.push({ tag: 'img', img_key: media.value, alt: { tag: 'plain_text', content: 'Image' } }); break;
19
+ }
20
+ case 'share': {
21
+ const url = String(data.url ?? '');
22
+ if (!/^https?:\/\//.test(url)) throw unsupported();
23
+ elements.push({ tag: 'div', text: { tag: 'plain_text', content: String(data.title ?? url) } });
24
+ if (data.description) elements.push({ tag: 'div', text: { tag: 'plain_text', content: String(data.description) } });
25
+ elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: '打开链接' }, url }] }); break;
26
+ }
27
+ case 'keyboard': {
28
+ const rows = data.rows as Array<Array<{ id?: string; label?: string; payload?: string; url?: string }>> | undefined;
29
+ if (!Array.isArray(rows) || rows.length === 0) throw unsupported();
30
+ for (const row of rows) {
31
+ if (!Array.isArray(row) || !row.length || row.length > 5) throw unsupported();
32
+ const actions = row.map(button => {
33
+ if (!button.label || (!button.payload && !button.url)) throw unsupported();
34
+ return { tag: 'button', text: { tag: 'plain_text', content: button.label },
35
+ ...(button.url ? { url: button.url } : { value: { zhin_payload: button.payload, zhin_button_id: button.id } }) };
36
+ });
37
+ elements.push({ tag: 'action', actions });
38
+ }
39
+ break;
40
+ }
41
+ default: throw unsupported();
42
+ }
43
+ }
44
+ // JSON 1.0 lark_md does not render inline code. Use the SDK's JSON 2.0
45
+ // markdown component for Markdown cards; keep validated v1 action cards.
46
+ const hasMarkdown = items.some(item => typeof item !== 'string' && item.type === 'markdown');
47
+ const hasActions = items.some(item => typeof item !== 'string' && ['share', 'keyboard'].includes(item.type));
48
+ if (hasMarkdown && hasActions) throw new EndpointDeliveryError('unsupported_operation', 'Mixed Markdown and action cards require JSON 2.0 action support', 'not_sent');
49
+ if (hasMarkdown) {
50
+ const bodyElements = elements.map(element => {
51
+ const row = element as { tag: string; text?: { tag: string; content: string } };
52
+ if (row.tag !== 'div') return element;
53
+ const text = row.text!;
54
+ return { tag: 'markdown', content: text.tag === 'lark_md' ? text.content : text.content.replace(/[\\`*_{}[\]()#+.!|>~-]/g, '\\$&') };
55
+ });
56
+ return { msg_type: 'interactive', content: JSON.stringify({ schema: '2.0', body: { elements: bodyElements } }), ...(replyTo ? { replyTo } : {}) };
57
+ }
58
+ return { msg_type: 'interactive', content: JSON.stringify({ config: { wide_screen_mode: true }, elements }), ...(replyTo ? { replyTo } : {}) };
59
+ }
60
+ function unsupported() { return new EndpointDeliveryError('unsupported_operation', 'Unsupported or malformed Lark card segment', 'not_sent'); }
61
+
62
+ export interface LarkCardAction {
63
+ readonly token?: string;
64
+ readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
65
+ readonly open_message_id?: string;
66
+ readonly open_chat_id?: string;
67
+ readonly operator?: { readonly open_id?: string; readonly user_id?: string };
68
+ readonly action?: { readonly tag?: string; readonly value?: { readonly zhin_payload?: unknown; readonly zhin_button_id?: unknown } };
69
+ }