agent-messenger 2.19.1 → 2.19.3

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 (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/line/client.d.ts +27 -0
  4. package/dist/src/platforms/line/client.d.ts.map +1 -1
  5. package/dist/src/platforms/line/client.js +94 -7
  6. package/dist/src/platforms/line/client.js.map +1 -1
  7. package/dist/src/platforms/line/listener.d.ts +3 -0
  8. package/dist/src/platforms/line/listener.d.ts.map +1 -1
  9. package/dist/src/platforms/line/listener.js +68 -47
  10. package/dist/src/platforms/line/listener.js.map +1 -1
  11. package/dist/src/platforms/line/types.d.ts +1 -0
  12. package/dist/src/platforms/line/types.d.ts.map +1 -1
  13. package/dist/src/platforms/line/types.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/line/client.test.ts +212 -1
  33. package/src/platforms/line/client.ts +114 -7
  34. package/src/platforms/line/listener.test.ts +164 -31
  35. package/src/platforms/line/listener.ts +66 -51
  36. package/src/platforms/line/types.ts +2 -0
@@ -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')
@@ -136,6 +172,64 @@ describe('LineListener', () => {
136
172
  expect(messages[0].author_id).toBe('u456')
137
173
  expect(messages[0].text).toBe('hello world')
138
174
  expect(messages[0].content_type).toBe('NONE')
175
+ expect(messages[0].content_metadata).toEqual({})
176
+ })
177
+
178
+ it('forwards contentMetadata for non-text messages', async () => {
179
+ const client = createMockLineClient()
180
+ listener = new LineListener(client)
181
+
182
+ const messages: LinePushMessageEvent[] = []
183
+ listener.on('message', (event) => messages.push(event))
184
+
185
+ await listener.start()
186
+ mockInternalClientInstance.simulateMessage({
187
+ isMyMessage: false,
188
+ from: { type: 'USER', id: 'u456' },
189
+ to: { type: 'USER', id: 'u123' },
190
+ text: null,
191
+ raw: {
192
+ id: 'msg010',
193
+ contentType: 'STICKER',
194
+ createdTime: 1700000007000,
195
+ contentMetadata: { STKID: '123', STKPKGID: '456', STKVER: '1' },
196
+ },
197
+ })
198
+ await flush()
199
+
200
+ expect(messages.length).toBe(1)
201
+ expect(messages[0].text).toBeNull()
202
+ expect(messages[0].content_type).toBe('STICKER')
203
+ expect(messages[0].content_metadata).toEqual({ STKID: '123', STKPKGID: '456', STKVER: '1' })
204
+ })
205
+
206
+ it('coerces non-string contentMetadata values to strings', async () => {
207
+ const client = createMockLineClient()
208
+ listener = new LineListener(client)
209
+
210
+ const messages: LinePushMessageEvent[] = []
211
+ listener.on('message', (event) => messages.push(event))
212
+
213
+ await listener.start()
214
+ mockInternalClientInstance.simulateMessage({
215
+ isMyMessage: false,
216
+ from: { type: 'USER', id: 'u456' },
217
+ to: { type: 'USER', id: 'u123' },
218
+ text: null,
219
+ raw: {
220
+ id: 'msg011',
221
+ contentType: 'IMAGE',
222
+ createdTime: 1700000008000,
223
+ contentMetadata: { FILE_SIZE: 2048, PREVIEW_URL: 'https://example.com/p.jpg', EMPTY: null },
224
+ },
225
+ })
226
+ await flush()
227
+
228
+ expect(messages.length).toBe(1)
229
+ expect(messages[0].content_metadata).toEqual({
230
+ FILE_SIZE: '2048',
231
+ PREVIEW_URL: 'https://example.com/p.jpg',
232
+ })
139
233
  })
140
234
 
141
235
  it('uses to.id as chat_id for own messages', async () => {
@@ -153,6 +247,7 @@ describe('LineListener', () => {
153
247
  text: 'sent by me',
154
248
  raw: { id: 'msg002', contentType: 'NONE', createdTime: 1700000001000 },
155
249
  })
250
+ await flush()
156
251
 
157
252
  expect(messages.length).toBe(1)
158
253
  expect(messages[0].chat_id).toBe('u456')
@@ -175,6 +270,7 @@ describe('LineListener', () => {
175
270
  text: 'hi',
176
271
  raw: { id: 'msg003', contentType: 'NONE', createdTime: 1700000002000 },
177
272
  })
273
+ await flush()
178
274
 
179
275
  expect(events.length).toBe(1)
180
276
  expect(events[0].type).toBe('message')
@@ -189,6 +285,7 @@ describe('LineListener', () => {
189
285
 
190
286
  await listener.start()
191
287
  mockInternalClientInstance.simulateEvent({ type: 'NOTIFIED_READ_MESSAGE', revision: 42 })
288
+ await flush()
192
289
 
193
290
  expect(events.length).toBe(1)
194
291
  expect(events[0].type).toBe('NOTIFIED_READ_MESSAGE')
@@ -233,7 +330,7 @@ describe('LineListener', () => {
233
330
  mockLogin.mockImplementation((): Promise<void> => {
234
331
  callCount++
235
332
  if (callCount === 1) return Promise.reject(new Error('network_error'))
236
- mockInternalClientInstance = new MockInternalClient()
333
+ mockInternalClientInstance = new MockEventSource()
237
334
  return Promise.resolve()
238
335
  })
239
336
 
@@ -283,6 +380,7 @@ describe('LineListener', () => {
283
380
  text: 'first',
284
381
  raw: { id: 'msg004', contentType: 'NONE', createdTime: 1700000003000 },
285
382
  })
383
+ await flush()
286
384
 
287
385
  listener.off('message', handler)
288
386
  mockInternalClientInstance.simulateMessage({
@@ -292,6 +390,7 @@ describe('LineListener', () => {
292
390
  text: 'second',
293
391
  raw: { id: 'msg005', contentType: 'NONE', createdTime: 1700000004000 },
294
392
  })
393
+ await flush()
295
394
 
296
395
  expect(messages.length).toBe(1)
297
396
  expect(messages[0].text).toBe('first')
@@ -319,6 +418,7 @@ describe('LineListener', () => {
319
418
  text: 'second',
320
419
  raw: { id: 'msg007', contentType: 'NONE', createdTime: 1700000006000 },
321
420
  })
421
+ await flush()
322
422
 
323
423
  expect(messages.length).toBe(1)
324
424
  expect(messages[0].text).toBe('first')
@@ -338,4 +438,37 @@ describe('LineListener', () => {
338
438
  expect((listener as any).reconnectAttempts).toBe(0)
339
439
  })
340
440
  })
441
+
442
+ describe('event source consumption', () => {
443
+ it('consumes streamEvents and stops pumping after abort', async () => {
444
+ const streamCalls: AbortSignal[] = []
445
+ const client = {
446
+ login: mockLogin,
447
+ getProfile: mockGetProfile,
448
+ streamEvents: (signal: AbortSignal) => {
449
+ streamCalls.push(signal)
450
+ return mockInternalClientInstance.streamEvents(signal)
451
+ },
452
+ } as any
453
+ listener = new LineListener(client)
454
+
455
+ const messages: LinePushMessageEvent[] = []
456
+ listener.on('message', (event) => messages.push(event))
457
+
458
+ await listener.start()
459
+ expect(streamCalls.length).toBe(1)
460
+
461
+ listener.stop()
462
+ mockInternalClientInstance.simulateMessage({
463
+ isMyMessage: false,
464
+ from: { type: 'USER', id: 'u456' },
465
+ to: { type: 'USER', id: 'u123' },
466
+ text: 'after stop',
467
+ raw: { id: 'msg100', contentType: 'NONE', createdTime: 1700000010000 },
468
+ })
469
+ await flush()
470
+
471
+ expect(messages.length).toBe(0)
472
+ })
473
+ })
341
474
  })
@@ -2,7 +2,6 @@ import { EventEmitter } from 'events'
2
2
 
3
3
  import type { LineClient } from './client'
4
4
  import type { LineListenerEventMap, LinePushGenericEvent, LinePushMessageEvent } from './types'
5
- import { LineError } from './types'
6
5
 
7
6
  const RECONNECT_BASE_DELAY = 1_000
8
7
  const RECONNECT_MAX_DELAY = 30_000
@@ -61,59 +60,13 @@ export class LineListener {
61
60
  await this.lineClient.login()
62
61
  if (!this.running) return
63
62
 
64
- const internalClient = (this.lineClient as any).client
65
- if (!internalClient) {
66
- throw new LineError('not_connected', 'Failed to get internal LINE client')
67
- }
68
-
63
+ const profile = await this.lineClient.getProfile()
64
+ if (!this.running) return
69
65
  this.abortController = new AbortController()
70
-
71
- internalClient.on('message', (msg: any) => {
72
- try {
73
- const toType = msg.raw.toType
74
- const isGroupOrRoom = toType === 'GROUP' || toType === 'ROOM' || toType === 0 || toType === 1
75
- const chatId = isGroupOrRoom ? msg.to.id : msg.isMyMessage ? msg.to.id : msg.from.id
76
-
77
- const event: LinePushMessageEvent = {
78
- type: 'message',
79
- chat_id: chatId,
80
- message_id: String(msg.raw.id),
81
- author_id: msg.from.id,
82
- text: msg.text ?? null,
83
- content_type: String(msg.raw.contentType ?? 'NONE'),
84
- sent_at: new Date(Number(msg.raw.createdTime)).toISOString(),
85
- }
86
- this.emitter.emit('message', event)
87
- this.emitter.emit('line_event', { ...event })
88
- } catch (error) {
89
- this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
90
- }
91
- })
92
-
93
- internalClient.on('event', (op: any) => {
94
- const event: LinePushGenericEvent = {
95
- type: String(op.type ?? 'unknown'),
96
- ...(op.revision !== undefined && { revision: String(op.revision) }),
97
- ...(op.createdTime !== undefined && {
98
- created_time: new Date(Number(op.createdTime)).toISOString(),
99
- }),
100
- }
101
- this.emitter.emit('line_event', event)
102
- })
103
-
104
- internalClient.listen({ signal: this.abortController.signal })?.catch((error: unknown) => {
105
- if (!this.running) return
106
- const err = error instanceof Error ? error : new Error(String(error))
107
- if (err.name === 'AbortError') return
108
- this.emitter.emit('error', err)
109
- this.emitter.emit('disconnected')
110
- this.scheduleReconnect()
111
- })
112
-
113
66
  this.reconnectAttempts = 0
114
-
115
- const profile = await internalClient.base.talk.getProfile()
116
67
  this.emitter.emit('connected', { account_id: profile.mid })
68
+
69
+ void this.pump(this.abortController.signal)
117
70
  } catch (error) {
118
71
  this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
119
72
  if (this.running) {
@@ -122,6 +75,59 @@ export class LineListener {
122
75
  }
123
76
  }
124
77
 
78
+ private async pump(signal: AbortSignal): Promise<void> {
79
+ try {
80
+ for await (const event of this.lineClient.streamEvents(signal)) {
81
+ if (event.kind === 'message') {
82
+ this.emitMessage(event.message)
83
+ } else {
84
+ this.emitOperation(event.op)
85
+ }
86
+ }
87
+ } catch (error) {
88
+ if (!this.running || signal.aborted) return
89
+ const err = error instanceof Error ? error : new Error(String(error))
90
+ if (err.name === 'AbortError') return
91
+ this.emitter.emit('error', err)
92
+ this.emitter.emit('disconnected')
93
+ this.scheduleReconnect()
94
+ }
95
+ }
96
+
97
+ private emitMessage(msg: any): void {
98
+ try {
99
+ const toType = msg.raw.toType
100
+ const isGroupOrRoom = toType === 'GROUP' || toType === 'ROOM' || toType === 0 || toType === 1
101
+ const chatId = isGroupOrRoom ? msg.to.id : msg.isMyMessage ? msg.to.id : msg.from.id
102
+
103
+ const event: LinePushMessageEvent = {
104
+ type: 'message',
105
+ chat_id: chatId,
106
+ message_id: String(msg.raw.id),
107
+ author_id: msg.from.id,
108
+ text: msg.text ?? null,
109
+ content_type: String(msg.raw.contentType ?? 'NONE'),
110
+ content_metadata: normalizeContentMetadata(msg.raw.contentMetadata),
111
+ sent_at: new Date(Number(msg.raw.createdTime)).toISOString(),
112
+ }
113
+ this.emitter.emit('message', event)
114
+ this.emitter.emit('line_event', { ...event })
115
+ } catch (error) {
116
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
117
+ }
118
+ }
119
+
120
+ private emitOperation(op: any): void {
121
+ const event: LinePushGenericEvent = {
122
+ type: String(op.type ?? 'unknown'),
123
+ ...(op.revision !== undefined && { revision: String(op.revision) }),
124
+ ...(op.createdTime !== undefined && {
125
+ created_time: new Date(Number(op.createdTime)).toISOString(),
126
+ }),
127
+ }
128
+ this.emitter.emit('line_event', event)
129
+ }
130
+
125
131
  private scheduleReconnect(): void {
126
132
  this.clearTimers()
127
133
  const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts, RECONNECT_MAX_DELAY)
@@ -136,3 +142,12 @@ export class LineListener {
136
142
  }
137
143
  }
138
144
  }
145
+
146
+ function normalizeContentMetadata(raw: unknown): Record<string, string> {
147
+ if (!raw || typeof raw !== 'object') return {}
148
+ const result: Record<string, string> = {}
149
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
150
+ if (value !== null && value !== undefined) result[key] = String(value)
151
+ }
152
+ return result
153
+ }
@@ -138,6 +138,8 @@ export interface LinePushMessageEvent {
138
138
  author_id: string
139
139
  text: string | null
140
140
  content_type: string
141
+ // Raw LINE contentMetadata (sticker IDs, file name/size, media URLs); empty for plain text.
142
+ content_metadata: Record<string, string>
141
143
  sent_at: string
142
144
  }
143
145