@zhin.js/adapter-line 3.0.0 → 3.0.2
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 +54 -0
- package/adapters/line.js +33 -0
- package/adapters/line.ts +13 -1
- package/commands/endpoint/add/[name:string].js +3 -0
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].js +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint.d.ts +13 -1
- package/lib/endpoint.js +74 -5
- package/lib/line-endpoint-commands.d.ts +1 -0
- package/lib/line-endpoint-commands.js +17 -0
- package/lib/line-runtime-state.d.ts +1 -0
- package/lib/line-runtime-state.js +6 -0
- package/package.json +20 -14
- package/plugin.js +14 -0
- package/src/endpoint.ts +87 -7
- package/src/line-endpoint-commands.ts +18 -0
- package/src/line-runtime-state.ts +7 -0
- package/plugin.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# @zhin.js/adapter-line
|
|
2
2
|
|
|
3
|
+
## 3.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d5cd4aa: Publish Plugin Runtime entry points and convention modules as JavaScript so
|
|
8
|
+
installed npm plugins load on Node without TypeScript stripping. Workspace
|
|
9
|
+
development continues to prefer TypeScript sources for local HMR.
|
|
10
|
+
|
|
11
|
+
Remove the unconsumed legacy game hub APIs from game-kit; game navigation and
|
|
12
|
+
records now use ordinary convention commands owned by the game hub plugin.
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [d5cd4aa]
|
|
15
|
+
- @zhin.js/command@1.0.4
|
|
16
|
+
- zhin.js@5.0.2
|
|
17
|
+
- @zhin.js/adapter@1.1.2
|
|
18
|
+
- @zhin.js/core@1.4.2
|
|
19
|
+
- @zhin.js/agent@1.0.7
|
|
20
|
+
|
|
21
|
+
## 3.0.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
|
|
26
|
+
|
|
27
|
+
- **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
|
|
28
|
+
- **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
|
|
29
|
+
- **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
|
|
30
|
+
- **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
|
|
31
|
+
- **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
|
|
32
|
+
- **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
|
|
33
|
+
|
|
34
|
+
- 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(无凭据)暂不接。
|
|
35
|
+
- 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 注明平台无列表面暂不接。
|
|
36
|
+
- Updated dependencies [cdf64e7]
|
|
37
|
+
- Updated dependencies [2d0a159]
|
|
38
|
+
- Updated dependencies [5691aba]
|
|
39
|
+
- Updated dependencies [078e3f7]
|
|
40
|
+
- Updated dependencies [50497a5]
|
|
41
|
+
- Updated dependencies [9c997b2]
|
|
42
|
+
- Updated dependencies [09d4f25]
|
|
43
|
+
- Updated dependencies [43485a9]
|
|
44
|
+
- Updated dependencies [f0ec5ab]
|
|
45
|
+
- Updated dependencies [3e925d0]
|
|
46
|
+
- Updated dependencies [fa66c4c]
|
|
47
|
+
- Updated dependencies [fa66c4c]
|
|
48
|
+
- Updated dependencies [6cb6152]
|
|
49
|
+
- @zhin.js/command@1.0.3
|
|
50
|
+
- @zhin.js/agent@1.0.6
|
|
51
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
52
|
+
- @zhin.js/host-http@1.0.3
|
|
53
|
+
- zhin.js@5.0.1
|
|
54
|
+
- @zhin.js/adapter@1.1.1
|
|
55
|
+
- @zhin.js/core@1.4.1
|
|
56
|
+
|
|
3
57
|
## 3.0.0
|
|
4
58
|
|
|
5
59
|
### Patch Changes
|
package/adapters/line.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Convention entry: discover `adapters/line.ts` → defineAdapter.
|
|
4
|
+
*/
|
|
5
|
+
import { defineAdapter } from '@zhin.js/adapter';
|
|
6
|
+
import { messageGatewayToken } from '@zhin.js/core/runtime';
|
|
7
|
+
import { httpHostToken } from '@zhin.js/host-http';
|
|
8
|
+
import { LineEndpoint } from "../lib/endpoint.js";
|
|
9
|
+
import { resolveLineConfig, } from "../lib/protocol.js";
|
|
10
|
+
import { lineRuntimeStateToken } from "../lib/line-runtime-state.js";
|
|
11
|
+
export { LineEndpoint } from "../lib/endpoint.js";
|
|
12
|
+
export default defineAdapter({
|
|
13
|
+
capabilities: ['inbound', 'outbound'],
|
|
14
|
+
// LINE Messaging API 媒体消息仅消费远程 URL;无按钮交互面,交互段降级纯文本。
|
|
15
|
+
segments: {
|
|
16
|
+
outboundMedia: ['url'],
|
|
17
|
+
interactive: 'text',
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
const config = resolveLineConfig(context.config);
|
|
21
|
+
// 注册到插件运行时状态(line endpoint list 的"运行中"数据源)
|
|
22
|
+
context.use(lineRuntimeStateToken).endpoints.set(config.name, {
|
|
23
|
+
name: config.name,
|
|
24
|
+
mode: 'webhook',
|
|
25
|
+
});
|
|
26
|
+
return new LineEndpoint({
|
|
27
|
+
id: context.id,
|
|
28
|
+
gateway: context.use(messageGatewayToken),
|
|
29
|
+
http: context.use(httpHostToken),
|
|
30
|
+
config,
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
});
|
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
|
|
35
|
+
config,
|
|
24
36
|
});
|
|
25
37
|
},
|
|
26
38
|
});
|
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
|
|
72
|
-
if (
|
|
76
|
+
const cached = this.#replyTokenCache.get(target);
|
|
77
|
+
if (cached) {
|
|
73
78
|
this.#replyTokenCache.delete(target);
|
|
74
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.0.2",
|
|
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.
|
|
36
|
-
"@zhin.js/
|
|
37
|
-
"@zhin.js/
|
|
35
|
+
"@zhin.js/adapter": "1.1.2",
|
|
36
|
+
"@zhin.js/command": "1.0.4",
|
|
37
|
+
"@zhin.js/core": "1.4.2",
|
|
38
|
+
"@zhin.js/host-http": "1.0.3",
|
|
38
39
|
"@zhin.js/logger": "1.0.75",
|
|
39
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
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.
|
|
44
|
-
"@zhin.js/agent": "1.0.
|
|
45
|
-
"@zhin.js/core": "1.4.
|
|
46
|
-
"@zhin.js/host-http": "1.0.
|
|
47
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
48
|
-
"zhin.js": "5.0.
|
|
44
|
+
"@zhin.js/adapter": "1.1.2",
|
|
45
|
+
"@zhin.js/agent": "1.0.7",
|
|
46
|
+
"@zhin.js/core": "1.4.2",
|
|
47
|
+
"@zhin.js/host-http": "1.0.3",
|
|
48
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
49
|
+
"zhin.js": "5.0.2"
|
|
49
50
|
},
|
|
50
51
|
"peerDependenciesMeta": {
|
|
51
52
|
"zhin.js": {
|
|
@@ -63,11 +64,12 @@
|
|
|
63
64
|
"typescript": "^6.0.3",
|
|
64
65
|
"vitest": "^4.1.10",
|
|
65
66
|
"zod": "^4.4.3",
|
|
66
|
-
"@zhin.js/agent": "1.0.
|
|
67
|
+
"@zhin.js/agent": "1.0.7"
|
|
67
68
|
},
|
|
68
69
|
"files": [
|
|
69
70
|
"adapters",
|
|
70
|
-
"
|
|
71
|
+
"commands",
|
|
72
|
+
"plugin.js",
|
|
71
73
|
"schema.json",
|
|
72
74
|
"src",
|
|
73
75
|
"lib",
|
|
@@ -85,13 +87,17 @@
|
|
|
85
87
|
"zhin": {
|
|
86
88
|
"protocol": 1,
|
|
87
89
|
"type": "plugin",
|
|
88
|
-
"entry": "./plugin.
|
|
90
|
+
"entry": "./plugin.js",
|
|
89
91
|
"engine": "^1.0.0",
|
|
90
92
|
"runtime": "trusted",
|
|
91
93
|
"features": [
|
|
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.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
3
|
+
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { lineRuntimeStateToken } from "./lib/line-runtime-state.js";
|
|
5
|
+
export default definePlugin({
|
|
6
|
+
name: 'line',
|
|
7
|
+
metadata: {
|
|
8
|
+
displayName: 'LINE Messaging API Adapter',
|
|
9
|
+
},
|
|
10
|
+
setup(context) {
|
|
11
|
+
// 运行中 endpoint 注册表(line endpoint list 的"运行中"数据源)
|
|
12
|
+
context.resources.provide(lineRuntimeStateToken, createEndpointRuntimeState());
|
|
13
|
+
},
|
|
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
|
|
120
|
-
if (
|
|
126
|
+
const cached = this.#replyTokenCache.get(target);
|
|
127
|
+
if (cached) {
|
|
121
128
|
this.#replyTokenCache.delete(target);
|
|
122
|
-
|
|
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
|
-
|
|
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');
|