@zhin.js/adapter-line 3.0.0 → 3.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,41 @@
1
1
  # @zhin.js/adapter-line
2
2
 
3
+ ## 3.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
+ - Updated dependencies [cdf64e7]
19
+ - Updated dependencies [2d0a159]
20
+ - Updated dependencies [5691aba]
21
+ - Updated dependencies [078e3f7]
22
+ - Updated dependencies [50497a5]
23
+ - Updated dependencies [9c997b2]
24
+ - Updated dependencies [09d4f25]
25
+ - Updated dependencies [43485a9]
26
+ - Updated dependencies [f0ec5ab]
27
+ - Updated dependencies [3e925d0]
28
+ - Updated dependencies [fa66c4c]
29
+ - Updated dependencies [fa66c4c]
30
+ - Updated dependencies [6cb6152]
31
+ - @zhin.js/command@1.0.3
32
+ - @zhin.js/agent@1.0.6
33
+ - @zhin.js/plugin-runtime@1.1.1
34
+ - @zhin.js/host-http@1.0.3
35
+ - zhin.js@5.0.1
36
+ - @zhin.js/adapter@1.1.1
37
+ - @zhin.js/core@1.4.1
38
+
3
39
  ## 3.0.0
4
40
 
5
41
  ### Patch Changes
package/adapters/line.ts CHANGED
@@ -9,18 +9,30 @@ import {
9
9
  resolveLineConfig,
10
10
  type LineAdapterConfig,
11
11
  } from '../src/protocol.js';
12
+ import { lineRuntimeStateToken } from '../src/line-runtime-state.js';
12
13
 
13
14
  export { LineEndpoint } from '../src/endpoint.js';
14
15
  export type { LineEndpointOptions, LineFetch } from '../src/endpoint.js';
15
16
 
16
17
  export default defineAdapter<LineAdapterConfig>({
17
18
  capabilities: ['inbound', 'outbound'],
19
+ // LINE Messaging API 媒体消息仅消费远程 URL;无按钮交互面,交互段降级纯文本。
20
+ segments: {
21
+ outboundMedia: ['url'],
22
+ interactive: 'text',
23
+ },
18
24
  create(context) {
25
+ const config = resolveLineConfig(context.config);
26
+ // 注册到插件运行时状态(line endpoint list 的"运行中"数据源)
27
+ context.use(lineRuntimeStateToken).endpoints.set(config.name, {
28
+ name: config.name,
29
+ mode: 'webhook',
30
+ });
19
31
  return new LineEndpoint({
20
32
  id: context.id,
21
33
  gateway: context.use(messageGatewayToken),
22
34
  http: context.use(httpHostToken),
23
- config: resolveLineConfig(context.config),
35
+ config,
24
36
  });
25
37
  },
26
38
  });
@@ -0,0 +1,3 @@
1
+ import { lineEndpointCommands } from '../../../src/line-endpoint-commands.js';
2
+
3
+ export default lineEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { lineEndpointCommands } from '../../src/line-endpoint-commands.js';
2
+
3
+ export default lineEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { lineEndpointCommands } from '../../../src/line-endpoint-commands.js';
2
+
3
+ export default lineEndpointCommands.remove;
package/lib/endpoint.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
3
3
  */
4
- import type { EndpointInstance } from '@zhin.js/adapter';
4
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
5
5
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
6
  import type { HttpHost } from '@zhin.js/host-http';
7
7
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
@@ -10,6 +10,7 @@ export type LineFetch = (url: string, init?: {
10
10
  readonly method?: string;
11
11
  readonly headers?: Record<string, string>;
12
12
  readonly body?: string;
13
+ readonly signal?: AbortSignal;
13
14
  }) => Promise<{
14
15
  readonly ok: boolean;
15
16
  readonly status: number;
@@ -25,6 +26,7 @@ export interface LineEndpointOptions {
25
26
  }
26
27
  export declare class LineEndpoint implements EndpointInstance {
27
28
  #private;
29
+ readonly management: EndpointManagement;
28
30
  constructor(options: LineEndpointOptions);
29
31
  /** Used by webhook handler. */
30
32
  get isOpen(): boolean;
@@ -43,4 +45,14 @@ export declare class LineEndpoint implements EndpointInstance {
43
45
  }): Promise<string>;
44
46
  /** Test / internal: admit a parsed event when open (non-webhook path). */
45
47
  admit(event: LineEvent): void;
48
+ /**
49
+ * group/room 成员列表:Bot API 无群列表,只能按已知 groupId/roomId 拉成员。
50
+ * members/ids 分页(next continuation token)后逐个取 profile 归一 nickname;
51
+ * 单个 profile 失败(用户已退群等)时回退 userId 占位,不拖垮整批。
52
+ */
53
+ getGroupMembers(groupId: string): Promise<LineGroupMember[]>;
54
+ }
55
+ export interface LineGroupMember {
56
+ readonly user_id: string;
57
+ readonly nickname: string;
46
58
  }
package/lib/endpoint.js CHANGED
@@ -3,6 +3,10 @@ import { registerLineAgentEndpoint } from './line-agent-deps.js';
3
3
  import { formatInboundContent, formatOutboundMessages, generateMessageId, isMessageEvent, isValidLineRecipientId, resolveChannel, } from './protocol.js';
4
4
  import { registerLineWebhookRoutes } from './webhook.js';
5
5
  const logger = getLogger('line');
6
+ /** LINE replyToken 有效期短,过期后 reply 必 400;缓存带时间戳,超时弃用改走 push。 */
7
+ const REPLY_TOKEN_TTL_MS = 60_000;
8
+ /** 出站 HTTP 调用统一 30s 超时。 */
9
+ const OUTBOUND_TIMEOUT_MS = 30_000;
6
10
  export class LineEndpoint {
7
11
  #options;
8
12
  #fetch;
@@ -11,6 +15,7 @@ export class LineEndpoint {
11
15
  #open = false;
12
16
  #started = false;
13
17
  #unregisterAgent;
18
+ management = createLineEndpointManagement(this);
14
19
  constructor(options) {
15
20
  this.#options = options;
16
21
  this.#fetch = options.fetch ?? globalThis.fetch;
@@ -68,10 +73,24 @@ export class LineEndpoint {
68
73
  if (messages.length === 0) {
69
74
  throw new Error('No valid LINE messages to send');
70
75
  }
71
- const replyToken = this.#replyTokenCache.get(target);
72
- if (replyToken) {
76
+ const cached = this.#replyTokenCache.get(target);
77
+ if (cached) {
73
78
  this.#replyTokenCache.delete(target);
74
- return this.#replyMessage(replyToken, messages);
79
+ if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
80
+ try {
81
+ return await this.#replyMessage(cached.token, messages);
82
+ }
83
+ catch (error) {
84
+ // replyToken 过期/失效时 LINE 返回 400,回退 push 保证消息不丢
85
+ if (error.status !== 400)
86
+ throw error;
87
+ logger.warn(formatCompact({
88
+ op: 'line_reply_fallback_push',
89
+ endpoint: this.#options.config.name,
90
+ target,
91
+ }));
92
+ }
93
+ }
75
94
  }
76
95
  if (!isValidLineRecipientId(target)) {
77
96
  throw new Error(`Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`);
@@ -84,7 +103,7 @@ export class LineEndpoint {
84
103
  return;
85
104
  const { channelId } = resolveChannel(event.source);
86
105
  if ('replyToken' in event && typeof event.replyToken === 'string') {
87
- this.#replyTokenCache.set(channelId, event.replyToken);
106
+ this.#replyTokenCache.set(channelId, { token: event.replyToken, timestamp: Date.now() });
88
107
  }
89
108
  void this.#options.gateway.receive({
90
109
  adapter: this.#options.id,
@@ -116,10 +135,13 @@ export class LineEndpoint {
116
135
  Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
117
136
  },
118
137
  body: JSON.stringify({ replyToken, messages }),
138
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
119
139
  });
120
140
  if (!response.ok) {
121
141
  const errorText = await response.text();
122
- throw new Error(`LINE Reply API error ${response.status}: ${errorText}`);
142
+ const error = new Error(`LINE Reply API error ${response.status}: ${errorText}`);
143
+ error.status = response.status;
144
+ throw error;
123
145
  }
124
146
  const result = await response.json();
125
147
  return result.sentMessages?.[0]?.id || `reply-${Date.now()}`;
@@ -133,6 +155,7 @@ export class LineEndpoint {
133
155
  Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
134
156
  },
135
157
  body: JSON.stringify({ to, messages }),
158
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
136
159
  });
137
160
  if (!response.ok) {
138
161
  const errorText = await response.text();
@@ -141,4 +164,50 @@ export class LineEndpoint {
141
164
  const result = await response.json();
142
165
  return result.sentMessages?.[0]?.id || `push-${Date.now()}`;
143
166
  }
167
+ /**
168
+ * group/room 成员列表:Bot API 无群列表,只能按已知 groupId/roomId 拉成员。
169
+ * members/ids 分页(next continuation token)后逐个取 profile 归一 nickname;
170
+ * 单个 profile 失败(用户已退群等)时回退 userId 占位,不拖垮整批。
171
+ */
172
+ async getGroupMembers(groupId) {
173
+ const kind = groupId.startsWith('R') ? 'room' : 'group';
174
+ const memberIds = [];
175
+ let start;
176
+ do {
177
+ const query = start ? `?start=${encodeURIComponent(start)}` : '';
178
+ const data = await this.#get(`${this.#options.config.apiBaseUrl}/v2/bot/${kind}/${encodeURIComponent(groupId)}/members/ids${query}`);
179
+ for (const id of data.memberIds ?? []) {
180
+ if (typeof id === 'string' && id)
181
+ memberIds.push(id);
182
+ }
183
+ start = data.next || undefined;
184
+ } while (start);
185
+ return Promise.all(memberIds.map(async (userId) => {
186
+ try {
187
+ const profile = await this.#get(`${this.#options.config.apiBaseUrl}/v2/bot/${kind}/${encodeURIComponent(groupId)}/member/${encodeURIComponent(userId)}`);
188
+ return { user_id: userId, nickname: String(profile.displayName ?? userId) };
189
+ }
190
+ catch {
191
+ return { user_id: userId, nickname: userId };
192
+ }
193
+ }));
194
+ }
195
+ async #get(url) {
196
+ const response = await this.#fetch(url, {
197
+ method: 'GET',
198
+ headers: { Authorization: `Bearer ${this.#options.config.channelAccessToken}` },
199
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
200
+ });
201
+ if (!response.ok) {
202
+ const errorText = await response.text();
203
+ throw new Error(`LINE API error ${response.status}: ${errorText}`);
204
+ }
205
+ return response.json();
206
+ }
207
+ }
208
+ function createLineEndpointManagement(endpoint) {
209
+ return Object.freeze({
210
+ // listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
211
+ listGroupMembers: (groupId) => endpoint.getGroupMembers(groupId),
212
+ });
144
213
  }
@@ -0,0 +1 @@
1
+ export declare const lineEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `line 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 { lineRuntimeStateToken } from './line-runtime-state.js';
8
+ export const lineEndpointCommands = createEndpointCommands({
9
+ adapterKey: 'line',
10
+ adapterDisplayName: 'LINE',
11
+ fields: [
12
+ { key: 'channelSecret', required: true, env: true, description: 'LINE channel secret' },
13
+ { key: 'channelAccessToken', required: true, env: true, description: 'LINE channel access token' },
14
+ ],
15
+ running: (use) => use(lineRuntimeStateToken).endpoints.values(),
16
+ describeEntry: (entry) => `channelAccessToken: ${String(entry.channelAccessToken)}`,
17
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const lineRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * LINE 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `line endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const lineRuntimeStateToken = defineEndpointRuntimeStateToken('line');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-line",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Zhin.js LINE Messaging API adapter for Plugin Runtime (HTTP webhook)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -32,20 +32,21 @@
32
32
  "directory": "plugins/adapters/line"
33
33
  },
34
34
  "dependencies": {
35
- "@zhin.js/adapter": "1.1.0",
36
- "@zhin.js/core": "1.4.0",
37
- "@zhin.js/host-http": "1.0.2",
35
+ "@zhin.js/adapter": "1.1.1",
36
+ "@zhin.js/command": "1.0.3",
37
+ "@zhin.js/core": "1.4.1",
38
+ "@zhin.js/host-http": "1.0.3",
38
39
  "@zhin.js/logger": "1.0.75",
39
- "@zhin.js/plugin-runtime": "1.1.0"
40
+ "@zhin.js/plugin-runtime": "1.1.1"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "zod": "^4.0.0",
43
- "@zhin.js/adapter": "1.1.0",
44
- "@zhin.js/agent": "1.0.5",
45
- "@zhin.js/core": "1.4.0",
46
- "@zhin.js/host-http": "1.0.2",
47
- "@zhin.js/plugin-runtime": "1.1.0",
48
- "zhin.js": "5.0.0"
44
+ "@zhin.js/adapter": "1.1.1",
45
+ "@zhin.js/agent": "1.0.6",
46
+ "@zhin.js/core": "1.4.1",
47
+ "@zhin.js/host-http": "1.0.3",
48
+ "@zhin.js/plugin-runtime": "1.1.1",
49
+ "zhin.js": "5.0.1"
49
50
  },
50
51
  "peerDependenciesMeta": {
51
52
  "zhin.js": {
@@ -63,10 +64,11 @@
63
64
  "typescript": "^6.0.3",
64
65
  "vitest": "^4.1.10",
65
66
  "zod": "^4.4.3",
66
- "@zhin.js/agent": "1.0.5"
67
+ "@zhin.js/agent": "1.0.6"
67
68
  },
68
69
  "files": [
69
70
  "adapters",
71
+ "commands",
70
72
  "plugin.ts",
71
73
  "schema.json",
72
74
  "src",
@@ -92,6 +94,10 @@
92
94
  {
93
95
  "package": "@zhin.js/adapter",
94
96
  "api": "^1.0.0"
97
+ },
98
+ {
99
+ "package": "@zhin.js/command",
100
+ "api": "^1.0.0"
95
101
  }
96
102
  ],
97
103
  "plugins": []
package/plugin.ts CHANGED
@@ -1,8 +1,14 @@
1
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
1
2
  import { definePlugin } from '@zhin.js/plugin-runtime';
3
+ import { lineRuntimeStateToken } from './src/line-runtime-state.js';
2
4
 
3
5
  export default definePlugin({
4
6
  name: 'line',
5
7
  metadata: {
6
8
  displayName: 'LINE Messaging API Adapter',
7
9
  },
10
+ setup(context) {
11
+ // 运行中 endpoint 注册表(line endpoint list 的"运行中"数据源)
12
+ context.resources.provide(lineRuntimeStateToken, createEndpointRuntimeState());
13
+ },
8
14
  });
package/src/endpoint.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
3
3
  */
4
- import type { EndpointInstance } from '@zhin.js/adapter';
4
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
5
5
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
6
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
7
7
  import { formatCompact, getLogger } from '@zhin.js/logger';
@@ -22,12 +22,18 @@ import { registerLineWebhookRoutes } from './webhook.js';
22
22
 
23
23
  const logger = getLogger('line');
24
24
 
25
+ /** LINE replyToken 有效期短,过期后 reply 必 400;缓存带时间戳,超时弃用改走 push。 */
26
+ const REPLY_TOKEN_TTL_MS = 60_000;
27
+ /** 出站 HTTP 调用统一 30s 超时。 */
28
+ const OUTBOUND_TIMEOUT_MS = 30_000;
29
+
25
30
  export type LineFetch = (
26
31
  url: string,
27
32
  init?: {
28
33
  readonly method?: string;
29
34
  readonly headers?: Record<string, string>;
30
35
  readonly body?: string;
36
+ readonly signal?: AbortSignal;
31
37
  },
32
38
  ) => Promise<{
33
39
  readonly ok: boolean;
@@ -48,10 +54,11 @@ export class LineEndpoint implements EndpointInstance {
48
54
  readonly #options: LineEndpointOptions;
49
55
  readonly #fetch: LineFetch;
50
56
  #routeReleases: HttpRouteRegistration[] = [];
51
- #replyTokenCache = new Map<string, string>();
57
+ #replyTokenCache = new Map<string, { token: string; timestamp: number }>();
52
58
  #open = false;
53
59
  #started = false;
54
60
  #unregisterAgent?: () => void;
61
+ readonly management: EndpointManagement = createLineEndpointManagement(this);
55
62
 
56
63
  constructor(options: LineEndpointOptions) {
57
64
  this.#options = options;
@@ -116,10 +123,22 @@ export class LineEndpoint implements EndpointInstance {
116
123
  throw new Error('No valid LINE messages to send');
117
124
  }
118
125
 
119
- const replyToken = this.#replyTokenCache.get(target);
120
- if (replyToken) {
126
+ const cached = this.#replyTokenCache.get(target);
127
+ if (cached) {
121
128
  this.#replyTokenCache.delete(target);
122
- return this.#replyMessage(replyToken, messages);
129
+ if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
130
+ try {
131
+ return await this.#replyMessage(cached.token, messages);
132
+ } catch (error) {
133
+ // replyToken 过期/失效时 LINE 返回 400,回退 push 保证消息不丢
134
+ if ((error as { status?: number }).status !== 400) throw error;
135
+ logger.warn(formatCompact({
136
+ op: 'line_reply_fallback_push',
137
+ endpoint: this.#options.config.name,
138
+ target,
139
+ }));
140
+ }
141
+ }
123
142
  }
124
143
 
125
144
  if (!isValidLineRecipientId(target)) {
@@ -135,7 +154,7 @@ export class LineEndpoint implements EndpointInstance {
135
154
  if (!this.#open) return;
136
155
  const { channelId } = resolveChannel(event.source);
137
156
  if ('replyToken' in event && typeof event.replyToken === 'string') {
138
- this.#replyTokenCache.set(channelId, event.replyToken);
157
+ this.#replyTokenCache.set(channelId, { token: event.replyToken, timestamp: Date.now() });
139
158
  }
140
159
  void this.#options.gateway.receive({
141
160
  adapter: this.#options.id,
@@ -171,10 +190,13 @@ export class LineEndpoint implements EndpointInstance {
171
190
  Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
172
191
  },
173
192
  body: JSON.stringify({ replyToken, messages }),
193
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
174
194
  });
175
195
  if (!response.ok) {
176
196
  const errorText = await response.text();
177
- throw new Error(`LINE Reply API error ${response.status}: ${errorText}`);
197
+ const error = new Error(`LINE Reply API error ${response.status}: ${errorText}`) as Error & { status?: number };
198
+ error.status = response.status;
199
+ throw error;
178
200
  }
179
201
  const result = await response.json() as LineApiResponse;
180
202
  return result.sentMessages?.[0]?.id || `reply-${Date.now()}`;
@@ -192,6 +214,7 @@ export class LineEndpoint implements EndpointInstance {
192
214
  Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
193
215
  },
194
216
  body: JSON.stringify({ to, messages }),
217
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
195
218
  });
196
219
  if (!response.ok) {
197
220
  const errorText = await response.text();
@@ -200,4 +223,61 @@ export class LineEndpoint implements EndpointInstance {
200
223
  const result = await response.json() as LineApiResponse;
201
224
  return result.sentMessages?.[0]?.id || `push-${Date.now()}`;
202
225
  }
226
+
227
+ /**
228
+ * group/room 成员列表:Bot API 无群列表,只能按已知 groupId/roomId 拉成员。
229
+ * members/ids 分页(next continuation token)后逐个取 profile 归一 nickname;
230
+ * 单个 profile 失败(用户已退群等)时回退 userId 占位,不拖垮整批。
231
+ */
232
+ async getGroupMembers(groupId: string): Promise<LineGroupMember[]> {
233
+ const kind = groupId.startsWith('R') ? 'room' : 'group';
234
+ const memberIds: string[] = [];
235
+ let start: string | undefined;
236
+ do {
237
+ const query = start ? `?start=${encodeURIComponent(start)}` : '';
238
+ const data = await this.#get(
239
+ `${this.#options.config.apiBaseUrl}/v2/bot/${kind}/${encodeURIComponent(groupId)}/members/ids${query}`,
240
+ ) as { memberIds?: string[]; next?: string };
241
+ for (const id of data.memberIds ?? []) {
242
+ if (typeof id === 'string' && id) memberIds.push(id);
243
+ }
244
+ start = data.next || undefined;
245
+ } while (start);
246
+
247
+ return Promise.all(memberIds.map(async (userId) => {
248
+ try {
249
+ const profile = await this.#get(
250
+ `${this.#options.config.apiBaseUrl}/v2/bot/${kind}/${encodeURIComponent(groupId)}/member/${encodeURIComponent(userId)}`,
251
+ ) as { displayName?: string };
252
+ return { user_id: userId, nickname: String(profile.displayName ?? userId) };
253
+ } catch {
254
+ return { user_id: userId, nickname: userId };
255
+ }
256
+ }));
257
+ }
258
+
259
+ async #get(url: string): Promise<unknown> {
260
+ const response = await this.#fetch(url, {
261
+ method: 'GET',
262
+ headers: { Authorization: `Bearer ${this.#options.config.channelAccessToken}` },
263
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
264
+ });
265
+ if (!response.ok) {
266
+ const errorText = await response.text();
267
+ throw new Error(`LINE API error ${response.status}: ${errorText}`);
268
+ }
269
+ return response.json();
270
+ }
271
+ }
272
+
273
+ export interface LineGroupMember {
274
+ readonly user_id: string;
275
+ readonly nickname: string;
276
+ }
277
+
278
+ function createLineEndpointManagement(endpoint: LineEndpoint): EndpointManagement {
279
+ return Object.freeze<EndpointManagement>({
280
+ // listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
281
+ listGroupMembers: (groupId) => endpoint.getGroupMembers(groupId),
282
+ });
203
283
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `line 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 { lineRuntimeStateToken } from './line-runtime-state.js';
8
+
9
+ export const lineEndpointCommands = createEndpointCommands({
10
+ adapterKey: 'line',
11
+ adapterDisplayName: 'LINE',
12
+ fields: [
13
+ { key: 'channelSecret', required: true, env: true, description: 'LINE channel secret' },
14
+ { key: 'channelAccessToken', required: true, env: true, description: 'LINE channel access token' },
15
+ ],
16
+ running: (use) => use(lineRuntimeStateToken).endpoints.values(),
17
+ describeEntry: (entry) => `channelAccessToken: ${String(entry.channelAccessToken)}`,
18
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * LINE 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `line endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const lineRuntimeStateToken = defineEndpointRuntimeStateToken('line');