@zhin.js/adapter-line 1.1.4 → 1.1.6
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 +41 -0
- package/README.md +8 -0
- package/lib/endpoint.d.ts +3 -2
- package/lib/endpoint.js +86 -40
- package/lib/protocol.d.ts +7 -1
- package/lib/side-event-dispatch.d.ts +1 -1
- package/lib/side-event-dispatch.js +3 -2
- package/lib/webhook.d.ts +1 -0
- package/lib/webhook.js +16 -8
- package/package.json +15 -15
- package/src/endpoint.ts +76 -45
- package/src/protocol.ts +7 -2
- package/src/side-event-dispatch.ts +4 -3
- package/src/webhook.ts +17 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @zhin.js/adapter-line
|
|
2
2
|
|
|
3
|
+
## 1.1.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [badcc1f]
|
|
8
|
+
- @zhin.js/core@1.1.41
|
|
9
|
+
- @zhin.js/feature-kit@1.1.3
|
|
10
|
+
- @zhin.js/adapter@1.1.17
|
|
11
|
+
- @zhin.js/agent-feature@1.1.4
|
|
12
|
+
- @zhin.js/skill@1.1.4
|
|
13
|
+
- @zhin.js/tool@1.1.4
|
|
14
|
+
- @zhin.js/host-http@1.1.4
|
|
15
|
+
|
|
16
|
+
## 1.1.5
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- b9b3953: Stop synthesizing message IDs after outbound API calls. Missing real platform IDs now report an unknown delivery outcome and must not be retried automatically. DingTalk webhook success responses without a message ID therefore remain unconfirmed under the current receipt contract.
|
|
21
|
+
|
|
22
|
+
LINE reply and push use their actual sentMessages IDs. Reply HTTP 400 errors no longer trigger automatic push fallback: rejected or partially successful replies could otherwise duplicate a message. Locally expired cached reply tokens still select push before a reply request is made.
|
|
23
|
+
|
|
24
|
+
- b9b3953: Deduplicate concurrent and completed LINE webhook event IDs within the endpoint instance, retaining successful admission for 24 hours with a bounded cache. Failed admission remains retryable; stop clears the cache. Document that this does not provide persistent or exactly-once business processing.
|
|
25
|
+
- Updated dependencies [b9b3953]
|
|
26
|
+
- Updated dependencies [b9b3953]
|
|
27
|
+
- Updated dependencies [b9b3953]
|
|
28
|
+
- Updated dependencies [b9b3953]
|
|
29
|
+
- Updated dependencies [b9b3953]
|
|
30
|
+
- Updated dependencies [b9b3953]
|
|
31
|
+
- Updated dependencies [b9b3953]
|
|
32
|
+
- Updated dependencies [b9b3953]
|
|
33
|
+
- Updated dependencies [b9b3953]
|
|
34
|
+
- Updated dependencies [b9b3953]
|
|
35
|
+
- Updated dependencies [b9b3953]
|
|
36
|
+
- @zhin.js/adapter@1.1.16
|
|
37
|
+
- @zhin.js/im-contract@1.1.3
|
|
38
|
+
- @zhin.js/core@1.1.40
|
|
39
|
+
- @zhin.js/host-http@1.1.3
|
|
40
|
+
- @zhin.js/tool@1.1.3
|
|
41
|
+
- @zhin.js/skill@1.1.3
|
|
42
|
+
- @zhin.js/agent-feature@1.1.3
|
|
43
|
+
|
|
3
44
|
## 1.1.4
|
|
4
45
|
|
|
5
46
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -106,3 +106,11 @@ LINE 要求 Webhook URL 以 HTTPS 开头。常见方案:
|
|
|
106
106
|
| 发送 401 | 确认 Channel Access Token 未过期 |
|
|
107
107
|
| 发送 400 | 检查消息格式是否符合 LINE API 规范 |
|
|
108
108
|
| 事件未到达 | Console 中 Webhook 是否已启用、是否关闭 Auto-reply |
|
|
109
|
+
|
|
110
|
+
### Webhook 重放边界
|
|
111
|
+
|
|
112
|
+
实例内按官方 `webhookEventId` 去重:并发重复请求共享同一次 admission;成功后保留 24 小时,失败释放后可重试。缓存最多 10,000 项,满载时拒绝新 admission,由 HTTP 返回 503;不会驱逐正在处理的事件。重复事件不会重新缓存已消费的 replyToken。未提供事件 ID 的旧事件保持原行为。
|
|
113
|
+
|
|
114
|
+
缓存随端点停止清空,不提供跨重启或跨实例去重;业务处理可能在失败前已有副作用,因此重试仍需业务幂等。Core 的会话事件存储幂等不等同于命令处理幂等。持久去重需要 Host 提供带租约、完成状态及未知状态处理的 inbox,再按端点身份与事件 ID 认领,不能只写一条数据库记录后宣称 exactly-once。
|
|
115
|
+
|
|
116
|
+
协议依据:[LINE webhook redelivery](https://developers.line.biz/en/docs/messaging-api/receiving-messages/#webhook-redelivery)。
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Endpoint } from 'zhin.js/adapter';
|
|
2
1
|
/**
|
|
3
2
|
* LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
4
3
|
*/
|
|
5
|
-
import { type EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
4
|
+
import { Endpoint, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
6
5
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
7
6
|
import type { CapabilityId } from 'zhin.js';
|
|
8
7
|
import { type LineEvent, type ResolvedLineConfig } from './protocol.js';
|
|
@@ -39,4 +38,6 @@ export declare class LineEndpoint extends Endpoint<LineClient> {
|
|
|
39
38
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
40
39
|
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
41
40
|
admit(event: LineEvent): void;
|
|
41
|
+
/** Webhook acknowledgement follows actual runtime generation admission. */
|
|
42
|
+
admitAccepted(event: LineEvent): Promise<void>;
|
|
42
43
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
/**
|
|
3
|
+
* LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
4
|
+
*/
|
|
1
5
|
import { Endpoint } from 'zhin.js/adapter';
|
|
2
6
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
3
7
|
import { formatInboundContent, formatOutboundMessages, generateMessageId, isLineLifecycleEvent, isMessageEvent, isValidLineRecipientId, lineInboundConversation, } from './protocol.js';
|
|
@@ -15,6 +19,7 @@ export class LineEndpoint extends Endpoint {
|
|
|
15
19
|
#fetch;
|
|
16
20
|
#routeReleases = [];
|
|
17
21
|
#replyTokenCache = new Map();
|
|
22
|
+
#admissions = new Map();
|
|
18
23
|
#open = false;
|
|
19
24
|
#started = false;
|
|
20
25
|
management = createLineEndpointManagement(this);
|
|
@@ -59,46 +64,79 @@ export class LineEndpoint extends Endpoint {
|
|
|
59
64
|
async stop() {
|
|
60
65
|
this.#open = false;
|
|
61
66
|
this.#replyTokenCache.clear();
|
|
67
|
+
this.#admissions.clear();
|
|
62
68
|
for (const release of this.#routeReleases.splice(0))
|
|
63
69
|
release();
|
|
64
70
|
this.#started = false;
|
|
65
71
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
66
72
|
}
|
|
67
73
|
async send({ conversation, payload }) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
try {
|
|
75
|
+
const messages = formatOutboundMessages(payload);
|
|
76
|
+
if (messages.length === 0) {
|
|
77
|
+
throw new EndpointDeliveryError('invalid_payload', 'No valid LINE messages to send', 'not_sent');
|
|
78
|
+
}
|
|
79
|
+
// LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
|
|
80
|
+
const target = conversation.id;
|
|
81
|
+
const cached = this.#replyTokenCache.get(target);
|
|
82
|
+
if (cached) {
|
|
83
|
+
this.#replyTokenCache.delete(target);
|
|
84
|
+
if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
|
|
79
85
|
return await this.#replyMessage(cached.token, messages);
|
|
80
86
|
}
|
|
81
|
-
catch (error) {
|
|
82
|
-
// replyToken 过期/失效时 LINE 返回 400,回退 push 保证消息不丢
|
|
83
|
-
if (error.status !== 400)
|
|
84
|
-
throw error;
|
|
85
|
-
this.#logger.warn(formatCompact({
|
|
86
|
-
op: 'line_reply_fallback_push',
|
|
87
|
-
endpoint: this.#options.config.id,
|
|
88
|
-
target,
|
|
89
|
-
}));
|
|
90
|
-
}
|
|
91
87
|
}
|
|
88
|
+
if (!isValidLineRecipientId(target)) {
|
|
89
|
+
throw new EndpointDeliveryError('invalid_recipient', `Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`, 'not_sent');
|
|
90
|
+
}
|
|
91
|
+
return await this.#pushMessage(target, messages);
|
|
92
92
|
}
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof EndpointDeliveryError)
|
|
95
|
+
throw error;
|
|
96
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
|
|
95
97
|
}
|
|
96
|
-
return this.#pushMessage(target, messages);
|
|
97
98
|
}
|
|
98
99
|
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
99
100
|
admit(event) {
|
|
100
101
|
if (!this.#open)
|
|
101
102
|
return;
|
|
103
|
+
void this.admitAccepted(event).catch((err) => {
|
|
104
|
+
this.#logger.warn(formatCompact({ op: 'line_gateway_receive_failed', error: err instanceof Error ? err.message : String(err) }));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Webhook acknowledgement follows actual runtime generation admission. */
|
|
108
|
+
async admitAccepted(event) {
|
|
109
|
+
if (!this.#open)
|
|
110
|
+
throw new Error('LINE endpoint is closed');
|
|
111
|
+
const id = event.webhookEventId;
|
|
112
|
+
if (typeof id !== 'string' || !id.trim())
|
|
113
|
+
return this.#dispatchAccepted(event);
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
for (const [key, entry] of this.#admissions)
|
|
116
|
+
if (entry.expires <= now)
|
|
117
|
+
this.#admissions.delete(key);
|
|
118
|
+
const existing = this.#admissions.get(id);
|
|
119
|
+
if (existing)
|
|
120
|
+
return existing.promise;
|
|
121
|
+
// Never evict in-flight admissions: overload returns 503 and permits platform retry.
|
|
122
|
+
if (this.#admissions.size >= 10_000)
|
|
123
|
+
throw new Error('LINE admission dedup capacity exhausted');
|
|
124
|
+
const entry = { promise: Promise.resolve().then(() => this.#dispatchAccepted(event)), expires: Infinity };
|
|
125
|
+
this.#admissions.set(id, entry);
|
|
126
|
+
try {
|
|
127
|
+
await entry.promise;
|
|
128
|
+
if (this.#admissions.get(id) === entry)
|
|
129
|
+
entry.expires = Date.now() + 24 * 60 * 60 * 1000;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (this.#admissions.get(id) === entry)
|
|
133
|
+
this.#admissions.delete(id);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async #dispatchAccepted(event) {
|
|
138
|
+
if (!this.#open)
|
|
139
|
+
throw new Error('LINE endpoint is closed');
|
|
102
140
|
void this.emitPlatform(event.type || 'event', event).catch((error) => {
|
|
103
141
|
this.#logger.warn(formatCompact({
|
|
104
142
|
op: 'line_platform_event_failed',
|
|
@@ -107,14 +145,14 @@ export class LineEndpoint extends Endpoint {
|
|
|
107
145
|
}));
|
|
108
146
|
});
|
|
109
147
|
if (isLineLifecycleEvent(event)) {
|
|
110
|
-
receiveLineSideEvent((name, payload) => this.
|
|
148
|
+
await receiveLineSideEvent((name, payload) => this.emitAccepted(name, payload), String(this.#options.id), this.#options.config.id, event, this.#logger);
|
|
111
149
|
return;
|
|
112
150
|
}
|
|
113
151
|
const conversation = lineInboundConversation(String(this.#options.id), event.source);
|
|
114
152
|
if ('replyToken' in event && typeof event.replyToken === 'string') {
|
|
115
153
|
this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
|
|
116
154
|
}
|
|
117
|
-
|
|
155
|
+
await this.emitAccepted('message.receive', {
|
|
118
156
|
conversation,
|
|
119
157
|
message: { conversation, id: generateMessageId(event) },
|
|
120
158
|
content: formatInboundContent(event),
|
|
@@ -126,12 +164,6 @@ export class LineEndpoint extends Endpoint {
|
|
|
126
164
|
timestamp: event.timestamp,
|
|
127
165
|
...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
|
|
128
166
|
}),
|
|
129
|
-
}).catch((err) => {
|
|
130
|
-
this.#logger.warn(formatCompact({
|
|
131
|
-
op: 'line_gateway_receive_failed',
|
|
132
|
-
target: `${conversation.kind}:${conversation.id}`,
|
|
133
|
-
error: err instanceof Error ? err.message : String(err),
|
|
134
|
-
}));
|
|
135
167
|
});
|
|
136
168
|
}
|
|
137
169
|
async #replyMessage(replyToken, messages) {
|
|
@@ -146,13 +178,10 @@ export class LineEndpoint extends Endpoint {
|
|
|
146
178
|
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
147
179
|
});
|
|
148
180
|
if (!response.ok) {
|
|
149
|
-
|
|
150
|
-
const error = new Error(`LINE Reply API error ${response.status}: ${errorText}`);
|
|
151
|
-
error.status = response.status;
|
|
152
|
-
throw error;
|
|
181
|
+
throw await lineResponseError(response);
|
|
153
182
|
}
|
|
154
183
|
const result = await response.json();
|
|
155
|
-
return result.sentMessages?.[0]?.id
|
|
184
|
+
return requireMessageId(result.sentMessages?.[0]?.id);
|
|
156
185
|
}
|
|
157
186
|
async #pushMessage(to, messages) {
|
|
158
187
|
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
|
|
@@ -166,11 +195,10 @@ export class LineEndpoint extends Endpoint {
|
|
|
166
195
|
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
167
196
|
});
|
|
168
197
|
if (!response.ok) {
|
|
169
|
-
|
|
170
|
-
throw new Error(`LINE Push API error ${response.status}: ${errorText}`);
|
|
198
|
+
throw await lineResponseError(response);
|
|
171
199
|
}
|
|
172
200
|
const result = await response.json();
|
|
173
|
-
return result.sentMessages?.[0]?.id
|
|
201
|
+
return requireMessageId(result.sentMessages?.[0]?.id);
|
|
174
202
|
}
|
|
175
203
|
}
|
|
176
204
|
function createLineEndpointManagement(endpoint) {
|
|
@@ -179,3 +207,21 @@ function createLineEndpointManagement(endpoint) {
|
|
|
179
207
|
listGroupMembers: (groupId) => endpoint.client.getGroupMembers(groupId),
|
|
180
208
|
});
|
|
181
209
|
}
|
|
210
|
+
async function lineResponseError(response) {
|
|
211
|
+
// Error responses may carry sentMessages: a failed request can have partial side effects.
|
|
212
|
+
let body;
|
|
213
|
+
try {
|
|
214
|
+
body = await response.json();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return new EndpointDeliveryError('delivery_unconfirmed', `LINE HTTP ${response.status}`, 'unknown');
|
|
218
|
+
}
|
|
219
|
+
const disposition = Array.isArray(body?.sentMessages) && body.sentMessages.length > 0
|
|
220
|
+
? 'unknown' : response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 409 ? 'rejected' : 'unknown';
|
|
221
|
+
return new EndpointDeliveryError(disposition === 'unknown' ? 'delivery_unconfirmed' : 'platform_rejected', `LINE HTTP ${response.status}`, disposition);
|
|
222
|
+
}
|
|
223
|
+
function requireMessageId(value) {
|
|
224
|
+
if (typeof value === 'string' && value.trim().length > 0)
|
|
225
|
+
return value;
|
|
226
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
|
|
227
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -65,7 +65,13 @@ export interface LinePostbackEvent {
|
|
|
65
65
|
readonly params?: Record<string, string>;
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
-
export
|
|
68
|
+
export interface LineEventDelivery {
|
|
69
|
+
readonly webhookEventId?: string;
|
|
70
|
+
readonly deliveryContext?: {
|
|
71
|
+
readonly isRedelivery?: boolean;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export type LineEvent = LineEventDelivery & (LineMessageEvent | LineFollowEvent | LineUnfollowEvent | LineJoinEvent | LineLeaveEvent | LinePostbackEvent);
|
|
69
75
|
export interface LineSource {
|
|
70
76
|
readonly type: 'user' | 'group' | 'room';
|
|
71
77
|
readonly userId?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
2
2
|
import { type getAdapterLogger } from '@zhin.js/logger';
|
|
3
3
|
import { type LineEvent } from './protocol.js';
|
|
4
|
-
export declare function receiveLineSideEvent(emit: EndpointEventEmitter, endpointKey: string, configId: string, event: LineEvent, logger: ReturnType<typeof getAdapterLogger>):
|
|
4
|
+
export declare function receiveLineSideEvent(emit: EndpointEventEmitter, endpointKey: string, configId: string, event: LineEvent, logger: ReturnType<typeof getAdapterLogger>): Promise<unknown>;
|
|
@@ -17,11 +17,11 @@ function mapLineLifecycleParts(type) {
|
|
|
17
17
|
}
|
|
18
18
|
export function receiveLineSideEvent(emit, endpointKey, configId, event, logger) {
|
|
19
19
|
if (!isLineLifecycleEvent(event))
|
|
20
|
-
return;
|
|
20
|
+
return Promise.resolve();
|
|
21
21
|
const parts = mapLineLifecycleParts(event.type);
|
|
22
22
|
const conversation = lineInboundConversation(endpointKey, event.source);
|
|
23
23
|
const userId = event.source.userId || conversation.id;
|
|
24
|
-
|
|
24
|
+
return emit('notice.receive', buildNotice(event, {
|
|
25
25
|
id: `line:${event.type}:${event.timestamp}:${userId}`,
|
|
26
26
|
clientAdapter: 'line',
|
|
27
27
|
endpointId: configId,
|
|
@@ -37,5 +37,6 @@ export function receiveLineSideEvent(emit, endpointKey, configId, event, logger)
|
|
|
37
37
|
event: event.type,
|
|
38
38
|
error: err instanceof Error ? err.message : String(err),
|
|
39
39
|
}));
|
|
40
|
+
throw err;
|
|
40
41
|
});
|
|
41
42
|
}
|
package/lib/webhook.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface LineWebhookHandler {
|
|
|
8
8
|
readonly config: ResolvedLineConfig;
|
|
9
9
|
readonly isOpen: boolean;
|
|
10
10
|
admit(event: LineEvent): void;
|
|
11
|
+
admitAccepted(event: LineEvent): Promise<void>;
|
|
11
12
|
}
|
|
12
13
|
export declare function registerLineWebhookRoutes(http: HttpHost, handler: LineWebhookHandler): HttpRouteRegistration[];
|
|
13
14
|
export declare function handleLineWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: LineWebhookHandler): Promise<void>;
|
package/lib/webhook.js
CHANGED
|
@@ -30,21 +30,29 @@ export async function handleLineWebhookRequest(request, response, handler) {
|
|
|
30
30
|
body = JSON.parse(rawBody);
|
|
31
31
|
}
|
|
32
32
|
catch {
|
|
33
|
-
response.writeHead(
|
|
34
|
-
response.end(JSON.stringify({ message: '
|
|
33
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
34
|
+
response.end(JSON.stringify({ message: 'Invalid JSON' }));
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
if (!body || !Array.isArray(body.events) || body.events.some(event => !event || typeof event.type !== 'string' || !event.source || !['user', 'group', 'room'].includes(event.source.type) || (event.type === 'message' && !('message' in event && event.message && typeof event.message.id === 'string')))) {
|
|
38
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
39
|
+
response.end(JSON.stringify({ message: 'Invalid events' }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (body.events.length && !handler.isOpen) {
|
|
43
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
44
|
+
response.end(JSON.stringify({ message: 'Endpoint unavailable' }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const event of body.events) {
|
|
48
|
+
await handler.admitAccepted(event);
|
|
41
49
|
}
|
|
42
50
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
43
51
|
response.end(JSON.stringify({ message: 'OK' }));
|
|
44
52
|
}
|
|
45
53
|
catch (error) {
|
|
46
54
|
logger.error('LINE webhook error:', error);
|
|
47
|
-
response.writeHead(
|
|
48
|
-
response.end(JSON.stringify({ message: '
|
|
55
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
56
|
+
response.end(JSON.stringify({ message: 'Admission unavailable' }));
|
|
49
57
|
}
|
|
50
58
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-line",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
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,23 +32,23 @@
|
|
|
32
32
|
"directory": "plugins/adapters/line"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@zhin.js/adapter": "1.1.
|
|
36
|
-
"@zhin.js/agent-feature": "1.1.
|
|
37
|
-
"@zhin.js/core": "1.1.
|
|
38
|
-
"@zhin.js/feature-kit": "1.1.
|
|
39
|
-
"@zhin.js/host-http": "1.1.
|
|
40
|
-
"@zhin.js/im-contract": "1.1.
|
|
35
|
+
"@zhin.js/adapter": "1.1.17",
|
|
36
|
+
"@zhin.js/agent-feature": "1.1.4",
|
|
37
|
+
"@zhin.js/core": "1.1.41",
|
|
38
|
+
"@zhin.js/feature-kit": "1.1.3",
|
|
39
|
+
"@zhin.js/host-http": "1.1.4",
|
|
40
|
+
"@zhin.js/im-contract": "1.1.3",
|
|
41
41
|
"@zhin.js/logger": "1.1.1",
|
|
42
|
-
"@zhin.js/skill": "1.1.
|
|
43
|
-
"@zhin.js/tool": "1.1.
|
|
42
|
+
"@zhin.js/skill": "1.1.4",
|
|
43
|
+
"@zhin.js/tool": "1.1.4"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"zod": "^4.0.0",
|
|
47
|
-
"@zhin.js/adapter": "^1.1.
|
|
48
|
-
"@zhin.js/command": "^1.1.
|
|
49
|
-
"@zhin.js/core": "^1.1.
|
|
50
|
-
"@zhin.js/host-http": "^1.1.
|
|
51
|
-
"zhin.js": "^1.1.
|
|
47
|
+
"@zhin.js/adapter": "^1.1.17",
|
|
48
|
+
"@zhin.js/command": "^1.1.5",
|
|
49
|
+
"@zhin.js/core": "^1.1.41",
|
|
50
|
+
"@zhin.js/host-http": "^1.1.4",
|
|
51
|
+
"zhin.js": "^1.1.6"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@zhin.js/command": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"typescript": "^6.0.3",
|
|
67
67
|
"vitest": "^4.1.10",
|
|
68
68
|
"zod": "^4.4.3",
|
|
69
|
-
"zhin.js": "1.1.
|
|
69
|
+
"zhin.js": "1.1.6"
|
|
70
70
|
},
|
|
71
71
|
"files": [
|
|
72
72
|
"adapters",
|
package/src/endpoint.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
4
4
|
*/
|
|
5
|
-
import { type EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
5
|
+
import { Endpoint, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
6
6
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
7
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
8
|
import type { CapabilityId } from 'zhin.js';
|
|
@@ -57,6 +57,7 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
57
57
|
readonly #fetch: LineFetch;
|
|
58
58
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
59
59
|
#replyTokenCache = new Map<string, { token: string; timestamp: number }>();
|
|
60
|
+
readonly #admissions = new Map<string, { readonly promise: Promise<void>; expires: number }>();
|
|
60
61
|
#open = false;
|
|
61
62
|
#started = false;
|
|
62
63
|
readonly management: EndpointManagement = createLineEndpointManagement(this);
|
|
@@ -106,48 +107,73 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
106
107
|
async stop(): Promise<void> {
|
|
107
108
|
this.#open = false;
|
|
108
109
|
this.#replyTokenCache.clear();
|
|
110
|
+
this.#admissions.clear();
|
|
109
111
|
for (const release of this.#routeReleases.splice(0)) release();
|
|
110
112
|
this.#started = false;
|
|
111
113
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
117
|
+
try {
|
|
118
|
+
const messages = formatOutboundMessages(payload);
|
|
119
|
+
if (messages.length === 0) {
|
|
120
|
+
throw new EndpointDeliveryError('invalid_payload', 'No valid LINE messages to send', 'not_sent');
|
|
121
|
+
}
|
|
122
|
+
// LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
|
|
123
|
+
const target = conversation.id;
|
|
124
|
+
|
|
125
|
+
const cached = this.#replyTokenCache.get(target);
|
|
126
|
+
if (cached) {
|
|
127
|
+
this.#replyTokenCache.delete(target);
|
|
128
|
+
if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
|
|
127
129
|
return await this.#replyMessage(cached.token, messages);
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// replyToken 过期/失效时 LINE 返回 400,回退 push 保证消息不丢
|
|
130
|
-
if ((error as { status?: number }).status !== 400) throw error;
|
|
131
|
-
this.#logger.warn(formatCompact({
|
|
132
|
-
op: 'line_reply_fallback_push',
|
|
133
|
-
endpoint: this.#options.config.id,
|
|
134
|
-
target,
|
|
135
|
-
}));
|
|
136
130
|
}
|
|
137
131
|
}
|
|
138
|
-
}
|
|
139
132
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
133
|
+
if (!isValidLineRecipientId(target)) {
|
|
134
|
+
throw new EndpointDeliveryError('invalid_recipient',
|
|
135
|
+
`Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`, 'not_sent',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return await this.#pushMessage(target, messages);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof EndpointDeliveryError) throw error;
|
|
141
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
|
|
144
142
|
}
|
|
145
|
-
return this.#pushMessage(target, messages);
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
149
146
|
admit(event: LineEvent): void {
|
|
150
147
|
if (!this.#open) return;
|
|
148
|
+
void this.admitAccepted(event).catch((err) => {
|
|
149
|
+
this.#logger.warn(formatCompact({ op: 'line_gateway_receive_failed', error: err instanceof Error ? err.message : String(err) }));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Webhook acknowledgement follows actual runtime generation admission. */
|
|
154
|
+
async admitAccepted(event: LineEvent): Promise<void> {
|
|
155
|
+
if (!this.#open) throw new Error('LINE endpoint is closed');
|
|
156
|
+
const id = event.webhookEventId;
|
|
157
|
+
if (typeof id !== 'string' || !id.trim()) return this.#dispatchAccepted(event);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const [key, entry] of this.#admissions) if (entry.expires <= now) this.#admissions.delete(key);
|
|
160
|
+
const existing = this.#admissions.get(id);
|
|
161
|
+
if (existing) return existing.promise;
|
|
162
|
+
// Never evict in-flight admissions: overload returns 503 and permits platform retry.
|
|
163
|
+
if (this.#admissions.size >= 10_000) throw new Error('LINE admission dedup capacity exhausted');
|
|
164
|
+
const entry = { promise: Promise.resolve().then(() => this.#dispatchAccepted(event)), expires: Infinity };
|
|
165
|
+
this.#admissions.set(id, entry);
|
|
166
|
+
try {
|
|
167
|
+
await entry.promise;
|
|
168
|
+
if (this.#admissions.get(id) === entry) entry.expires = Date.now() + 24 * 60 * 60 * 1000;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (this.#admissions.get(id) === entry) this.#admissions.delete(id);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async #dispatchAccepted(event: LineEvent): Promise<void> {
|
|
176
|
+
if (!this.#open) throw new Error('LINE endpoint is closed');
|
|
151
177
|
void this.emitPlatform(event.type || 'event', event).catch((error) => {
|
|
152
178
|
this.#logger.warn(formatCompact({
|
|
153
179
|
op: 'line_platform_event_failed',
|
|
@@ -156,8 +182,8 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
156
182
|
}));
|
|
157
183
|
});
|
|
158
184
|
if (isLineLifecycleEvent(event)) {
|
|
159
|
-
receiveLineSideEvent(
|
|
160
|
-
(name, payload) => this.
|
|
185
|
+
await receiveLineSideEvent(
|
|
186
|
+
(name, payload) => this.emitAccepted(name, payload),
|
|
161
187
|
String(this.#options.id),
|
|
162
188
|
this.#options.config.id,
|
|
163
189
|
event,
|
|
@@ -169,7 +195,7 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
169
195
|
if ('replyToken' in event && typeof event.replyToken === 'string') {
|
|
170
196
|
this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
|
|
171
197
|
}
|
|
172
|
-
|
|
198
|
+
await this.emitAccepted('message.receive', {
|
|
173
199
|
conversation,
|
|
174
200
|
message: { conversation, id: generateMessageId(event) },
|
|
175
201
|
content: formatInboundContent(event),
|
|
@@ -181,12 +207,6 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
181
207
|
timestamp: event.timestamp,
|
|
182
208
|
...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
|
|
183
209
|
}),
|
|
184
|
-
}).catch((err) => {
|
|
185
|
-
this.#logger.warn(formatCompact({
|
|
186
|
-
op: 'line_gateway_receive_failed',
|
|
187
|
-
target: `${conversation.kind}:${conversation.id}`,
|
|
188
|
-
error: err instanceof Error ? err.message : String(err),
|
|
189
|
-
}));
|
|
190
210
|
});
|
|
191
211
|
}
|
|
192
212
|
|
|
@@ -205,13 +225,10 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
205
225
|
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
206
226
|
});
|
|
207
227
|
if (!response.ok) {
|
|
208
|
-
|
|
209
|
-
const error = new Error(`LINE Reply API error ${response.status}: ${errorText}`) as Error & { status?: number };
|
|
210
|
-
error.status = response.status;
|
|
211
|
-
throw error;
|
|
228
|
+
throw await lineResponseError(response);
|
|
212
229
|
}
|
|
213
230
|
const result = await response.json() as LineApiResponse;
|
|
214
|
-
return result.sentMessages?.[0]?.id
|
|
231
|
+
return requireMessageId(result.sentMessages?.[0]?.id);
|
|
215
232
|
}
|
|
216
233
|
|
|
217
234
|
async #pushMessage(
|
|
@@ -229,11 +246,10 @@ export class LineEndpoint extends Endpoint<LineClient> {
|
|
|
229
246
|
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
230
247
|
});
|
|
231
248
|
if (!response.ok) {
|
|
232
|
-
|
|
233
|
-
throw new Error(`LINE Push API error ${response.status}: ${errorText}`);
|
|
249
|
+
throw await lineResponseError(response);
|
|
234
250
|
}
|
|
235
251
|
const result = await response.json() as LineApiResponse;
|
|
236
|
-
return result.sentMessages?.[0]?.id
|
|
252
|
+
return requireMessageId(result.sentMessages?.[0]?.id);
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
/**
|
|
@@ -249,3 +265,18 @@ function createLineEndpointManagement(endpoint: LineEndpoint): EndpointManagemen
|
|
|
249
265
|
listGroupMembers: (groupId) => endpoint.client.getGroupMembers(groupId),
|
|
250
266
|
});
|
|
251
267
|
}
|
|
268
|
+
|
|
269
|
+
async function lineResponseError(response: Awaited<ReturnType<LineFetch>>): Promise<EndpointDeliveryError> {
|
|
270
|
+
// Error responses may carry sentMessages: a failed request can have partial side effects.
|
|
271
|
+
let body: { sentMessages?: unknown[] };
|
|
272
|
+
try { body = await response.json() as { sentMessages?: unknown[] }; }
|
|
273
|
+
catch { return new EndpointDeliveryError('delivery_unconfirmed', `LINE HTTP ${response.status}`, 'unknown'); }
|
|
274
|
+
const disposition = Array.isArray(body?.sentMessages) && body.sentMessages.length > 0
|
|
275
|
+
? 'unknown' : response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 409 ? 'rejected' : 'unknown';
|
|
276
|
+
return new EndpointDeliveryError(disposition === 'unknown' ? 'delivery_unconfirmed' : 'platform_rejected', `LINE HTTP ${response.status}`, disposition);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function requireMessageId(value: unknown): string {
|
|
280
|
+
if (typeof value === 'string' && value.trim().length > 0) return value;
|
|
281
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
|
|
282
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -80,13 +80,18 @@ export interface LinePostbackEvent {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export
|
|
83
|
+
export interface LineEventDelivery {
|
|
84
|
+
readonly webhookEventId?: string;
|
|
85
|
+
readonly deliveryContext?: { readonly isRedelivery?: boolean };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type LineEvent = LineEventDelivery & (
|
|
84
89
|
| LineMessageEvent
|
|
85
90
|
| LineFollowEvent
|
|
86
91
|
| LineUnfollowEvent
|
|
87
92
|
| LineJoinEvent
|
|
88
93
|
| LineLeaveEvent
|
|
89
|
-
| LinePostbackEvent;
|
|
94
|
+
| LinePostbackEvent);
|
|
90
95
|
|
|
91
96
|
export interface LineSource {
|
|
92
97
|
readonly type: 'user' | 'group' | 'room';
|
|
@@ -28,12 +28,12 @@ export function receiveLineSideEvent(
|
|
|
28
28
|
configId: string,
|
|
29
29
|
event: LineEvent,
|
|
30
30
|
logger: ReturnType<typeof getAdapterLogger>,
|
|
31
|
-
):
|
|
32
|
-
if (!isLineLifecycleEvent(event)) return;
|
|
31
|
+
): Promise<unknown> {
|
|
32
|
+
if (!isLineLifecycleEvent(event)) return Promise.resolve();
|
|
33
33
|
const parts = mapLineLifecycleParts(event.type);
|
|
34
34
|
const conversation = lineInboundConversation(endpointKey, event.source);
|
|
35
35
|
const userId = event.source.userId || conversation.id;
|
|
36
|
-
|
|
36
|
+
return emit('notice.receive', buildNotice(event, {
|
|
37
37
|
id: `line:${event.type}:${event.timestamp}:${userId}`,
|
|
38
38
|
clientAdapter: 'line',
|
|
39
39
|
endpointId: configId,
|
|
@@ -49,5 +49,6 @@ export function receiveLineSideEvent(
|
|
|
49
49
|
event: event.type,
|
|
50
50
|
error: err instanceof Error ? err.message : String(err),
|
|
51
51
|
}));
|
|
52
|
+
throw err;
|
|
52
53
|
});
|
|
53
54
|
}
|
package/src/webhook.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface LineWebhookHandler {
|
|
|
18
18
|
readonly config: ResolvedLineConfig;
|
|
19
19
|
readonly isOpen: boolean;
|
|
20
20
|
admit(event: LineEvent): void;
|
|
21
|
+
admitAccepted(event: LineEvent): Promise<void>;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function registerLineWebhookRoutes(
|
|
@@ -58,22 +59,30 @@ export async function handleLineWebhookRequest(
|
|
|
58
59
|
try {
|
|
59
60
|
body = JSON.parse(rawBody) as LineWebhookBody;
|
|
60
61
|
} catch {
|
|
61
|
-
response.writeHead(
|
|
62
|
-
response.end(JSON.stringify({ message: '
|
|
62
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
63
|
+
response.end(JSON.stringify({ message: 'Invalid JSON' }));
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (!body || !Array.isArray(body.events) || body.events.some(event => !event || typeof event.type !== 'string' || !event.source || !['user', 'group', 'room'].includes(event.source.type) || (event.type === 'message' && !('message' in event && event.message && typeof event.message.id === 'string')))) {
|
|
68
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
69
|
+
response.end(JSON.stringify({ message: 'Invalid events' }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (body.events.length && !handler.isOpen) {
|
|
73
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
74
|
+
response.end(JSON.stringify({ message: 'Endpoint unavailable' }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const event of body.events) {
|
|
78
|
+
await handler.admitAccepted(event);
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
73
82
|
response.end(JSON.stringify({ message: 'OK' }));
|
|
74
83
|
} catch (error) {
|
|
75
84
|
logger.error('LINE webhook error:', error);
|
|
76
|
-
response.writeHead(
|
|
77
|
-
response.end(JSON.stringify({ message: '
|
|
85
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
86
|
+
response.end(JSON.stringify({ message: 'Admission unavailable' }));
|
|
78
87
|
}
|
|
79
88
|
}
|