@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 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
- const messages = formatOutboundMessages(payload);
69
- if (messages.length === 0) {
70
- throw new Error('No valid LINE messages to send');
71
- }
72
- // LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
73
- const target = conversation.id;
74
- const cached = this.#replyTokenCache.get(target);
75
- if (cached) {
76
- this.#replyTokenCache.delete(target);
77
- if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
78
- try {
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
- if (!isValidLineRecipientId(target)) {
94
- throw new Error(`Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`);
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.emit(name, payload), String(this.#options.id), this.#options.config.id, event, this.#logger);
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
- void this.emit('message.receive', {
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
- const errorText = await response.text();
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 || `reply-${Date.now()}`;
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
- const errorText = await response.text();
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 || `push-${Date.now()}`;
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 type LineEvent = LineMessageEvent | LineFollowEvent | LineUnfollowEvent | LineJoinEvent | LineLeaveEvent | LinePostbackEvent;
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>): void;
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
- void emit('notice.receive', buildNotice(event, {
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(200, { 'Content-Type': 'application/json' });
34
- response.end(JSON.stringify({ message: 'OK' }));
33
+ response.writeHead(400, { 'Content-Type': 'application/json' });
34
+ response.end(JSON.stringify({ message: 'Invalid JSON' }));
35
35
  return;
36
36
  }
37
- if (handler.isOpen && Array.isArray(body.events)) {
38
- for (const event of body.events) {
39
- handler.admit(event);
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(200, { 'Content-Type': 'application/json' });
48
- response.end(JSON.stringify({ message: 'OK' }));
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.4",
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.15",
36
- "@zhin.js/agent-feature": "1.1.2",
37
- "@zhin.js/core": "1.1.39",
38
- "@zhin.js/feature-kit": "1.1.2",
39
- "@zhin.js/host-http": "1.1.2",
40
- "@zhin.js/im-contract": "1.1.2",
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.2",
43
- "@zhin.js/tool": "1.1.2"
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.15",
48
- "@zhin.js/command": "^1.1.3",
49
- "@zhin.js/core": "^1.1.39",
50
- "@zhin.js/host-http": "^1.1.2",
51
- "zhin.js": "^1.1.4"
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.4"
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 { Endpoint } from 'zhin.js/adapter';
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
- const messages = formatOutboundMessages(payload);
116
- if (messages.length === 0) {
117
- throw new Error('No valid LINE messages to send');
118
- }
119
- // LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
120
- const target = conversation.id;
121
-
122
- const cached = this.#replyTokenCache.get(target);
123
- if (cached) {
124
- this.#replyTokenCache.delete(target);
125
- if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
126
- try {
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
- if (!isValidLineRecipientId(target)) {
141
- throw new Error(
142
- `Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`,
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.emit(name, payload),
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
- void this.emit('message.receive', {
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
- const errorText = await response.text();
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 || `reply-${Date.now()}`;
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
- const errorText = await response.text();
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 || `push-${Date.now()}`;
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 type LineEvent =
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
- ): void {
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
- void emit('notice.receive', buildNotice(event, {
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(200, { 'Content-Type': 'application/json' });
62
- response.end(JSON.stringify({ message: 'OK' }));
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 (handler.isOpen && Array.isArray(body.events)) {
67
- for (const event of body.events) {
68
- handler.admit(event);
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(200, { 'Content-Type': 'application/json' });
77
- response.end(JSON.stringify({ message: 'OK' }));
85
+ response.writeHead(503, { 'Content-Type': 'application/json' });
86
+ response.end(JSON.stringify({ message: 'Admission unavailable' }));
78
87
  }
79
88
  }