agent-messenger 2.19.0 → 2.19.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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  4. package/dist/src/platforms/kakaotalk/client.js +51 -1
  5. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  6. package/dist/src/platforms/line/client.d.ts +27 -0
  7. package/dist/src/platforms/line/client.d.ts.map +1 -1
  8. package/dist/src/platforms/line/client.js +85 -7
  9. package/dist/src/platforms/line/client.js.map +1 -1
  10. package/dist/src/platforms/line/listener.d.ts +3 -0
  11. package/dist/src/platforms/line/listener.d.ts.map +1 -1
  12. package/dist/src/platforms/line/listener.js +57 -47
  13. package/dist/src/platforms/line/listener.js.map +1 -1
  14. package/docs/content/docs/cli/line.mdx +10 -9
  15. package/package.json +1 -1
  16. package/skills/agent-channeltalk/SKILL.md +1 -1
  17. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  18. package/skills/agent-discord/SKILL.md +1 -1
  19. package/skills/agent-discordbot/SKILL.md +1 -1
  20. package/skills/agent-instagram/SKILL.md +1 -1
  21. package/skills/agent-kakaotalk/SKILL.md +1 -1
  22. package/skills/agent-line/SKILL.md +2 -1
  23. package/skills/agent-slack/SKILL.md +1 -1
  24. package/skills/agent-slackbot/SKILL.md +1 -1
  25. package/skills/agent-teams/SKILL.md +1 -1
  26. package/skills/agent-telegram/SKILL.md +1 -1
  27. package/skills/agent-telegrambot/SKILL.md +1 -1
  28. package/skills/agent-webex/SKILL.md +1 -1
  29. package/skills/agent-wechatbot/SKILL.md +1 -1
  30. package/skills/agent-whatsapp/SKILL.md +1 -1
  31. package/skills/agent-whatsappbot/SKILL.md +1 -1
  32. package/src/platforms/kakaotalk/client.test.ts +39 -0
  33. package/src/platforms/kakaotalk/client.ts +59 -1
  34. package/src/platforms/line/client.test.ts +174 -1
  35. package/src/platforms/line/client.ts +105 -7
  36. package/src/platforms/line/listener.test.ts +106 -31
  37. package/src/platforms/line/listener.ts +56 -51
  38. package/src/platforms/webex/commands/auth.test.ts +8 -2
  39. package/src/platforms/webex/commands/member.test.ts +29 -27
  40. package/src/platforms/webex/commands/message.test.ts +36 -38
  41. package/src/platforms/webex/commands/snapshot.test.ts +25 -26
  42. package/src/platforms/webex/commands/space.test.ts +31 -29
  43. package/src/platforms/webex/commands/whoami.test.ts +3 -1
  44. package/src/platforms/webex/credential-manager.test.ts +3 -0
  45. package/src/platforms/whatsapp/commands/auth.test.ts +14 -20
  46. package/src/platforms/whatsapp/commands/chat.test.ts +17 -24
  47. package/src/platforms/whatsapp/commands/message.test.ts +31 -41
@@ -1,6 +1,7 @@
1
- import { describe, expect, it } from 'bun:test'
1
+ import { describe, expect, it, mock } from 'bun:test'
2
2
 
3
3
  import { LineClient } from './client'
4
+ import type { LineRawEvent } from './client'
4
5
  import { LineError } from './types'
5
6
  import type { LineChat, LineDevice, LineLoginResult, LineMessage, LineSendResult } from './types'
6
7
 
@@ -54,6 +55,178 @@ describe('LineClient', () => {
54
55
  })
55
56
  })
56
57
 
58
+ describe('getMessages()', () => {
59
+ function clientWithTalk(talk: Record<string, unknown>): LineClient {
60
+ const client = new LineClient()
61
+ ;(client as any).client = { base: { talk } }
62
+ return client
63
+ }
64
+
65
+ it('returns empty when the chat has no messages', async () => {
66
+ const client = clientWithTalk({
67
+ getServerTime: async () => 1700000000000,
68
+ getPreviousMessagesV2WithRequest: async () => [],
69
+ })
70
+ expect(await client.getMessages('chat1')).toEqual([])
71
+ })
72
+
73
+ it('queries from the latest message regardless of message-box position', async () => {
74
+ let request: any
75
+ const client = clientWithTalk({
76
+ getServerTime: async () => 1700000000000,
77
+ getPreviousMessagesV2WithRequest: async (args: any) => {
78
+ request = args.request
79
+ return [{ id: '30', from: 'u1', text: 'c', contentType: 'NONE', createdTime: 3 }]
80
+ },
81
+ })
82
+
83
+ const result = await client.getMessages('chat-not-in-top-boxes', { count: 10 })
84
+ expect(request.messageBoxId).toBe('chat-not-in-top-boxes')
85
+ expect(request.endMessageId.messageId).toBe(9223372036854775807n)
86
+ expect(result.map((m) => m.message_id)).toEqual(['30'])
87
+ })
88
+
89
+ it('maps vendor fields and preserves order/count from the request', async () => {
90
+ const raw = [
91
+ { id: '30', from: 'u1', text: 'c', contentType: 'NONE', createdTime: 1700000003000 },
92
+ { id: '20', from: 'u1', text: 'b', contentType: 'NONE', createdTime: 1700000002000 },
93
+ ]
94
+ let requestedCount: number | undefined
95
+ const client = clientWithTalk({
96
+ getServerTime: async () => 1700000000000,
97
+ getPreviousMessagesV2WithRequest: async (args: any) => {
98
+ requestedCount = args.request.messagesCount
99
+ return raw
100
+ },
101
+ })
102
+
103
+ const result = await client.getMessages('chat1', { count: 2 })
104
+ expect(requestedCount).toBe(2)
105
+ expect(result.map((m) => m.message_id)).toEqual(['30', '20'])
106
+ expect(result.map((m) => m.text)).toEqual(['c', 'b'])
107
+ })
108
+ })
109
+
110
+ describe('sendMessage()', () => {
111
+ function clientWithTalk(talk: Record<string, unknown>): LineClient {
112
+ const client = new LineClient()
113
+ ;(client as any).client = { base: { talk } }
114
+ return client
115
+ }
116
+
117
+ it('sends with E2EE when available', async () => {
118
+ const sendMessage = mock(async () => ({ id: '1', createdTime: 1700000000000 }))
119
+ const client = clientWithTalk({ sendMessage })
120
+ const result = await client.sendMessage('chat1', 'hi')
121
+
122
+ expect(result.success).toBe(true)
123
+ expect(result.message_id).toBe('1')
124
+ expect(sendMessage.mock.calls[0][0]).toMatchObject({ e2ee: true })
125
+ })
126
+
127
+ it('falls back to plain text when E2EE key is unavailable', async () => {
128
+ let call = 0
129
+ const sendMessage = mock(async () => {
130
+ call++
131
+ if (call === 1) throw new Error('E2EE Key has not been saved')
132
+ return { id: '2', createdTime: 1700000001000 }
133
+ })
134
+ const client = clientWithTalk({ sendMessage })
135
+ const result = await client.sendMessage('chat1', 'hi')
136
+
137
+ expect(result.message_id).toBe('2')
138
+ expect(sendMessage.mock.calls[1][0]).toMatchObject({ e2ee: false })
139
+ })
140
+
141
+ it('throws e2ee_required when the chat mandates encryption and plain is rejected', async () => {
142
+ const sendMessage = mock(async (args: { e2ee: boolean }) => {
143
+ if (args.e2ee) throw new Error('E2EE Key has not been saved')
144
+ throw new Error('{"code":"E2EE_RETRY_ENCRYPT","reason":"can not send using plain mode"}')
145
+ })
146
+ const client = clientWithTalk({ sendMessage })
147
+
148
+ await expect(client.sendMessage('chat1', 'hi')).rejects.toMatchObject({ code: 'e2ee_required' })
149
+ })
150
+
151
+ it('rethrows non-E2EE send errors unchanged', async () => {
152
+ const sendMessage = mock(async () => {
153
+ throw new Error('rate limited')
154
+ })
155
+ const client = clientWithTalk({ sendMessage })
156
+
157
+ await expect(client.sendMessage('chat1', 'hi')).rejects.toMatchObject({ code: 'send_message_failed' })
158
+ })
159
+ })
160
+
161
+ describe('streamEvents()', () => {
162
+ function clientWithStream(ops: unknown[], decrypt: (m: unknown) => Promise<unknown>): LineClient {
163
+ const client = new LineClient()
164
+ ;(client as any).client = {
165
+ base: {
166
+ profile: { mid: 'me' },
167
+ createPolling: () => ({
168
+ // eslint-disable-next-line require-yield
169
+ async *_listenTalkEvents() {
170
+ for (const op of ops) yield op
171
+ },
172
+ }),
173
+ e2ee: { decryptE2EEMessage: decrypt },
174
+ },
175
+ }
176
+ return client
177
+ }
178
+
179
+ async function collect(client: LineClient): Promise<LineRawEvent[]> {
180
+ const out: LineRawEvent[] = []
181
+ for await (const e of client.streamEvents(new AbortController().signal)) out.push(e)
182
+ return out
183
+ }
184
+
185
+ it('decrypts messages and emits event + message', async () => {
186
+ const op = { type: 'RECEIVE_MESSAGE', message: { id: '1', from: 'u1', to: 'me' } }
187
+ const client = clientWithStream([op], async () => ({ id: '1', from: 'u1', to: 'me', text: 'hello' }))
188
+
189
+ const events = await collect(client)
190
+ expect(events.map((e) => e.kind)).toEqual(['event', 'message'])
191
+ const msg = events[1] as Extract<LineRawEvent, { kind: 'message' }>
192
+ expect(msg.message.text).toBe('hello')
193
+ })
194
+
195
+ it('keeps streaming when E2EE decryption fails (no reconnect loop)', async () => {
196
+ const ops = [
197
+ { type: 'RECEIVE_MESSAGE', message: { id: '1', from: 'u1', to: 'me', text: 'plain-on-raw' } },
198
+ { type: 'NOTIFIED_READ_MESSAGE' },
199
+ ]
200
+ const client = clientWithStream(ops, async () => {
201
+ throw new Error('E2EE decrypt failed')
202
+ })
203
+
204
+ const events = await collect(client)
205
+ expect(events.map((e) => e.kind)).toEqual(['event', 'message', 'event'])
206
+ const msg = events[1] as Extract<LineRawEvent, { kind: 'message' }>
207
+ expect(msg.message.from.id).toBe('u1')
208
+ expect(msg.message.text).toBeNull()
209
+ })
210
+
211
+ it('propagates polling errors so the listener can reconnect', async () => {
212
+ const client = new LineClient()
213
+ ;(client as any).client = {
214
+ base: {
215
+ profile: { mid: 'me' },
216
+ createPolling: () => ({
217
+ async *_listenTalkEvents(opts: { onError?: (e: unknown) => void }) {
218
+ opts.onError?.(new Error('sync failed'))
219
+ yield undefined as never
220
+ },
221
+ }),
222
+ e2ee: { decryptE2EEMessage: async (m: unknown) => m },
223
+ },
224
+ }
225
+
226
+ await expect(collect(client)).rejects.toThrow('sync failed')
227
+ })
228
+ })
229
+
57
230
  describe('login() without credentials', () => {
58
231
  it('throws LineError when no saved credentials exist', async () => {
59
232
  const { LineCredentialManager } = require('./credential-manager')
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ import type { Operation as LineOperation } from '@jsr/evex__linejs-types'
5
+
4
6
  import { getConfigDir } from '@/shared/utils/config-dir'
5
7
  import { FileStorage } from '@/vendor/linejs/base/storage/mod.js'
6
8
  import {
@@ -23,12 +25,39 @@ import type {
23
25
  } from './types'
24
26
  import { LineError } from './types'
25
27
 
28
+ export interface LineRawMessage {
29
+ raw: { id: unknown; contentType?: unknown; createdTime?: unknown; toType?: unknown; to?: unknown; from?: unknown }
30
+ to: { id: unknown }
31
+ from: { id: unknown }
32
+ isMyMessage: boolean
33
+ text: string | null
34
+ }
35
+
36
+ export type LineRawEvent = { kind: 'message'; message: LineRawMessage } | { kind: 'event'; op: LineOperation }
37
+
38
+ const MAX_MESSAGE_ID = 9223372036854775807n
39
+
26
40
  function wrapError(error: unknown, code: string): LineError {
27
41
  if (error instanceof LineError) return error
28
42
  const message = error instanceof Error ? error.message : String(error)
29
43
  return new LineError(code, message)
30
44
  }
31
45
 
46
+ function isE2EEUnavailableError(message: string): boolean {
47
+ return /E2EE|e2ee|KeyNotFound|saveE2EE/.test(message)
48
+ }
49
+
50
+ function requiresEncryption(message: string): boolean {
51
+ return /RETRY_ENCRYPT|can not send using plain mode|cannot send using plain mode/i.test(message)
52
+ }
53
+
54
+ // Writes to stderr so partial-result degradation stays visible without
55
+ // corrupting the JSON the CLI prints to stdout.
56
+ function warnDegraded(action: string, error: unknown): void {
57
+ const message = error instanceof Error ? error.message : String(error)
58
+ console.warn(`[line] could not ${action}: ${message}`)
59
+ }
60
+
32
61
  function mapChatType(rawType: unknown): 'user' | 'group' | 'room' | 'square' {
33
62
  if (rawType === 'GROUP' || rawType === 0) return 'group'
34
63
  if (rawType === 'ROOM' || rawType === 1) return 'room'
@@ -209,7 +238,10 @@ export class LineClient {
209
238
  },
210
239
  syncReason: 'INTERNAL',
211
240
  }),
212
- client.fetchJoinedChats().catch(() => []),
241
+ client.fetchJoinedChats().catch((error: unknown) => {
242
+ warnDegraded('fetch joined group/room chats', error)
243
+ return []
244
+ }),
213
245
  ])
214
246
 
215
247
  for (const chat of joinedChats) {
@@ -239,7 +271,9 @@ export class LineClient {
239
271
  for (const c of contacts ?? []) {
240
272
  nameMap.set(c.mid, { name: c.displayName, type: 'user' })
241
273
  }
242
- } catch {}
274
+ } catch (error) {
275
+ warnDegraded('resolve user display names', error)
276
+ }
243
277
  }
244
278
 
245
279
  if (groupMids.length > 0) {
@@ -253,7 +287,9 @@ export class LineClient {
253
287
  memberCount: memberMids ? Object.keys(memberMids).length : undefined,
254
288
  })
255
289
  }
256
- } catch {}
290
+ } catch (error) {
291
+ warnDegraded('resolve group/room names', error)
292
+ }
257
293
  }
258
294
 
259
295
  for (const box of messageBoxes) {
@@ -279,13 +315,16 @@ export class LineClient {
279
315
  const client = this.ensureClient()
280
316
  const count = options?.count ?? 20
281
317
 
318
+ // getPreviousMessagesV2WithRequest pages backward from endMessageId. A
319
+ // messageId:0 sentinel returns nothing; the max int64 id acts as "from the
320
+ // latest message" and works for any chat regardless of message-box position.
282
321
  const serverTime = await client.base.talk.getServerTime()
283
322
  const rawMessages = await client.base.talk.getPreviousMessagesV2WithRequest({
284
323
  request: {
285
324
  messageBoxId: chatId,
286
325
  endMessageId: {
287
326
  deliveredTime: BigInt(serverTime),
288
- messageId: BigInt(0),
327
+ messageId: MAX_MESSAGE_ID,
289
328
  },
290
329
  messagesCount: count,
291
330
  },
@@ -313,10 +352,22 @@ export class LineClient {
313
352
  sent = await client.base.talk.sendMessage({ to: chatId, text, e2ee: true })
314
353
  } catch (e2eeError) {
315
354
  const msg = e2eeError instanceof Error ? e2eeError.message : String(e2eeError)
316
- if (msg.includes('E2EE') || msg.includes('e2ee') || msg.includes('KeyNotFound') || msg.includes('saveE2EE')) {
355
+ if (!isE2EEUnavailableError(msg)) throw e2eeError
356
+
357
+ try {
317
358
  sent = await client.base.talk.sendMessage({ to: chatId, text, e2ee: false })
318
- } else {
319
- throw e2eeError
359
+ } catch (plainError) {
360
+ const plainMsg = plainError instanceof Error ? plainError.message : String(plainError)
361
+ // Some chats force Letter Sealing and reject plain mode. This session
362
+ // authenticated via auth token and has no local E2EE private key, so it
363
+ // cannot encrypt. Surface a clear error and keep the original cause.
364
+ if (requiresEncryption(plainMsg)) {
365
+ throw new LineError(
366
+ 'e2ee_required',
367
+ `This chat requires end-to-end encryption (Letter Sealing), which this auth-token session cannot provide. Original error: ${msg}`,
368
+ )
369
+ }
370
+ throw plainError
320
371
  }
321
372
  }
322
373
 
@@ -331,6 +382,53 @@ export class LineClient {
331
382
  }
332
383
  }
333
384
 
385
+ // Drives the vendor polling generator (talk.sync()) instead of Client.listen().
386
+ // Client.listen() uses a LEGY HTTP/2 push connection (duplex:'half' streaming
387
+ // fetch) that yields zero bytes under Bun and dies immediately, so no events
388
+ // ever arrive (evex-dev/linejs#117). The upstream "fix" (linejs#134, v2.7.0+)
389
+ // only adds an undici+allowH2 path for Node.js and explicitly skips Bun
390
+ // ("Bun" in globalThis -> return false), falling back to Bun's native fetch,
391
+ // which still can't stream duplex:'half' HTTP/2 (oven-sh/bun#30342, #31881).
392
+ // Polling works on every runtime. Messages are normalized like Client.listen().
393
+ async *streamEvents(signal: AbortSignal): AsyncGenerator<LineRawEvent, void, unknown> {
394
+ const client = this.ensureClient()
395
+ const polling = client.base.createPolling()
396
+ const selfMid = client.base.profile?.mid
397
+
398
+ for await (const op of polling._listenTalkEvents({
399
+ signal,
400
+ onError: (error) => {
401
+ throw error instanceof Error ? error : new Error(String(error))
402
+ },
403
+ })) {
404
+ yield { kind: 'event', op }
405
+ if (op.type === 'SEND_MESSAGE' || op.type === 'RECEIVE_MESSAGE') {
406
+ // A single undecryptable message must not kill the stream: the failing
407
+ // op stays in the sync window and would be re-fetched every poll, causing
408
+ // an endless decrypt-fail -> reconnect loop. Fall back to the raw op and
409
+ // surface the message with null text, since its text is unreadable.
410
+ let raw = op.message
411
+ let decrypted = true
412
+ try {
413
+ raw = await client.base.e2ee.decryptE2EEMessage(op.message)
414
+ } catch {
415
+ raw = op.message
416
+ decrypted = false
417
+ }
418
+ yield {
419
+ kind: 'message',
420
+ message: {
421
+ raw,
422
+ to: { id: raw.to },
423
+ from: { id: raw.from },
424
+ isMyMessage: selfMid === raw.from,
425
+ text: decrypted ? (raw.text ?? null) : null,
426
+ },
427
+ }
428
+ }
429
+ }
430
+ }
431
+
334
432
  close(): void {
335
433
  this.client = null
336
434
  }
@@ -1,60 +1,95 @@
1
1
  import { afterEach, describe, expect, mock, it } from 'bun:test'
2
2
 
3
+ import type { LineRawEvent } from '@/platforms/line/client'
3
4
  import { LineListener } from '@/platforms/line/listener'
4
5
  import type { LinePushGenericEvent, LinePushMessageEvent } from '@/platforms/line/types'
5
6
 
6
- const mockGetProfile = mock(() => Promise.resolve({ mid: 'u123', displayName: 'Test User' }))
7
+ const mockGetProfile = mock(() => Promise.resolve({ mid: 'u123', display_name: 'Test User' }))
7
8
 
8
- let mockInternalClientInstance: MockInternalClient
9
+ let mockInternalClientInstance: MockEventSource
9
10
 
10
- class MockInternalClient {
11
- handlers: Record<string, ((...args: any[]) => void)[]> = {}
12
- private listenReject: ((e: Error) => void) | null = null
11
+ class MockEventSource {
12
+ private queue: LineRawEvent[] = []
13
+ private resolveNext: ((value: IteratorResult<LineRawEvent>) => void) | null = null
14
+ private streamError: Error | null = null
15
+ private done = false
13
16
 
14
- on(event: string, handler: (...args: any[]) => void): void {
15
- if (!this.handlers[event]) this.handlers[event] = []
16
- this.handlers[event].push(handler)
17
- }
17
+ async *streamEvents(signal: AbortSignal): AsyncGenerator<LineRawEvent, void, unknown> {
18
+ signal.addEventListener('abort', () => {
19
+ const err = new Error('The operation was aborted')
20
+ err.name = 'AbortError'
21
+ this.fail(err)
22
+ })
18
23
 
19
- listen(opts: { signal?: AbortSignal } = {}): Promise<void> {
20
- return new Promise((_resolve, reject) => {
21
- this.listenReject = reject
22
- opts.signal?.addEventListener('abort', () => {
23
- const err = new Error('The operation was aborted')
24
- err.name = 'AbortError'
25
- reject(err)
24
+ while (!this.done) {
25
+ if (this.streamError) {
26
+ const err = this.streamError
27
+ this.streamError = null
28
+ throw err
29
+ }
30
+ if (this.queue.length > 0) {
31
+ yield this.queue.shift()!
32
+ continue
33
+ }
34
+ const next = await new Promise<IteratorResult<LineRawEvent>>((resolve) => {
35
+ this.resolveNext = resolve
26
36
  })
27
- })
37
+ if (next.done) return
38
+ if (this.streamError) {
39
+ const err = this.streamError
40
+ this.streamError = null
41
+ throw err
42
+ }
43
+ yield next.value
44
+ }
45
+ }
46
+
47
+ private push(event: LineRawEvent): void {
48
+ if (this.resolveNext) {
49
+ const resolve = this.resolveNext
50
+ this.resolveNext = null
51
+ resolve({ value: event, done: false })
52
+ } else {
53
+ this.queue.push(event)
54
+ }
55
+ }
56
+
57
+ private fail(error: Error): void {
58
+ this.streamError = error
59
+ if (this.resolveNext) {
60
+ const resolve = this.resolveNext
61
+ this.resolveNext = null
62
+ resolve({ value: undefined as never, done: false })
63
+ }
28
64
  }
29
65
 
30
- simulateMessage(msg: unknown): void {
31
- this.handlers['message']?.forEach((h) => h(msg))
66
+ simulateMessage(message: unknown): void {
67
+ this.push({ kind: 'message', message: message as never })
32
68
  }
33
69
 
34
70
  simulateEvent(op: unknown): void {
35
- this.handlers['event']?.forEach((h) => h(op))
71
+ this.push({ kind: 'event', op: op as never })
36
72
  }
37
73
 
38
74
  simulateListenError(error: Error): void {
39
- this.listenReject?.(error)
75
+ this.fail(error)
40
76
  }
77
+ }
41
78
 
42
- base = {
43
- talk: { getProfile: mockGetProfile },
44
- }
79
+ function flush(): Promise<void> {
80
+ return new Promise((resolve) => setTimeout(resolve, 0))
45
81
  }
46
82
 
47
83
  const mockLogin = mock((): Promise<void> => {
48
- mockInternalClientInstance = new MockInternalClient()
84
+ mockInternalClientInstance = new MockEventSource()
49
85
  return Promise.resolve()
50
86
  })
51
87
 
52
88
  function createMockLineClient() {
53
89
  return {
54
90
  login: mockLogin,
55
- get client() {
56
- return mockInternalClientInstance
57
- },
91
+ getProfile: mockGetProfile,
92
+ streamEvents: (signal: AbortSignal) => mockInternalClientInstance.streamEvents(signal),
58
93
  } as any
59
94
  }
60
95
 
@@ -65,11 +100,11 @@ describe('LineListener', () => {
65
100
  listener?.stop()
66
101
  mockLogin.mockReset()
67
102
  mockLogin.mockImplementation((): Promise<void> => {
68
- mockInternalClientInstance = new MockInternalClient()
103
+ mockInternalClientInstance = new MockEventSource()
69
104
  return Promise.resolve()
70
105
  })
71
106
  mockGetProfile.mockReset()
72
- mockGetProfile.mockResolvedValue({ mid: 'u123', displayName: 'Test User' })
107
+ mockGetProfile.mockResolvedValue({ mid: 'u123', display_name: 'Test User' })
73
108
  })
74
109
 
75
110
  describe('start', () => {
@@ -128,6 +163,7 @@ describe('LineListener', () => {
128
163
  createdTime: 1700000000000,
129
164
  },
130
165
  })
166
+ await flush()
131
167
 
132
168
  expect(messages.length).toBe(1)
133
169
  expect(messages[0].type).toBe('message')
@@ -153,6 +189,7 @@ describe('LineListener', () => {
153
189
  text: 'sent by me',
154
190
  raw: { id: 'msg002', contentType: 'NONE', createdTime: 1700000001000 },
155
191
  })
192
+ await flush()
156
193
 
157
194
  expect(messages.length).toBe(1)
158
195
  expect(messages[0].chat_id).toBe('u456')
@@ -175,6 +212,7 @@ describe('LineListener', () => {
175
212
  text: 'hi',
176
213
  raw: { id: 'msg003', contentType: 'NONE', createdTime: 1700000002000 },
177
214
  })
215
+ await flush()
178
216
 
179
217
  expect(events.length).toBe(1)
180
218
  expect(events[0].type).toBe('message')
@@ -189,6 +227,7 @@ describe('LineListener', () => {
189
227
 
190
228
  await listener.start()
191
229
  mockInternalClientInstance.simulateEvent({ type: 'NOTIFIED_READ_MESSAGE', revision: 42 })
230
+ await flush()
192
231
 
193
232
  expect(events.length).toBe(1)
194
233
  expect(events[0].type).toBe('NOTIFIED_READ_MESSAGE')
@@ -233,7 +272,7 @@ describe('LineListener', () => {
233
272
  mockLogin.mockImplementation((): Promise<void> => {
234
273
  callCount++
235
274
  if (callCount === 1) return Promise.reject(new Error('network_error'))
236
- mockInternalClientInstance = new MockInternalClient()
275
+ mockInternalClientInstance = new MockEventSource()
237
276
  return Promise.resolve()
238
277
  })
239
278
 
@@ -283,6 +322,7 @@ describe('LineListener', () => {
283
322
  text: 'first',
284
323
  raw: { id: 'msg004', contentType: 'NONE', createdTime: 1700000003000 },
285
324
  })
325
+ await flush()
286
326
 
287
327
  listener.off('message', handler)
288
328
  mockInternalClientInstance.simulateMessage({
@@ -292,6 +332,7 @@ describe('LineListener', () => {
292
332
  text: 'second',
293
333
  raw: { id: 'msg005', contentType: 'NONE', createdTime: 1700000004000 },
294
334
  })
335
+ await flush()
295
336
 
296
337
  expect(messages.length).toBe(1)
297
338
  expect(messages[0].text).toBe('first')
@@ -319,6 +360,7 @@ describe('LineListener', () => {
319
360
  text: 'second',
320
361
  raw: { id: 'msg007', contentType: 'NONE', createdTime: 1700000006000 },
321
362
  })
363
+ await flush()
322
364
 
323
365
  expect(messages.length).toBe(1)
324
366
  expect(messages[0].text).toBe('first')
@@ -338,4 +380,37 @@ describe('LineListener', () => {
338
380
  expect((listener as any).reconnectAttempts).toBe(0)
339
381
  })
340
382
  })
383
+
384
+ describe('event source consumption', () => {
385
+ it('consumes streamEvents and stops pumping after abort', async () => {
386
+ const streamCalls: AbortSignal[] = []
387
+ const client = {
388
+ login: mockLogin,
389
+ getProfile: mockGetProfile,
390
+ streamEvents: (signal: AbortSignal) => {
391
+ streamCalls.push(signal)
392
+ return mockInternalClientInstance.streamEvents(signal)
393
+ },
394
+ } as any
395
+ listener = new LineListener(client)
396
+
397
+ const messages: LinePushMessageEvent[] = []
398
+ listener.on('message', (event) => messages.push(event))
399
+
400
+ await listener.start()
401
+ expect(streamCalls.length).toBe(1)
402
+
403
+ listener.stop()
404
+ mockInternalClientInstance.simulateMessage({
405
+ isMyMessage: false,
406
+ from: { type: 'USER', id: 'u456' },
407
+ to: { type: 'USER', id: 'u123' },
408
+ text: 'after stop',
409
+ raw: { id: 'msg100', contentType: 'NONE', createdTime: 1700000010000 },
410
+ })
411
+ await flush()
412
+
413
+ expect(messages.length).toBe(0)
414
+ })
415
+ })
341
416
  })