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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +51 -1
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/line/client.d.ts +27 -0
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/client.js +85 -7
- package/dist/src/platforms/line/client.js.map +1 -1
- package/dist/src/platforms/line/listener.d.ts +3 -0
- package/dist/src/platforms/line/listener.d.ts.map +1 -1
- package/dist/src/platforms/line/listener.js +57 -47
- package/dist/src/platforms/line/listener.js.map +1 -1
- package/docs/content/docs/cli/line.mdx +10 -9
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +2 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/kakaotalk/client.test.ts +39 -0
- package/src/platforms/kakaotalk/client.ts +59 -1
- package/src/platforms/line/client.test.ts +174 -1
- package/src/platforms/line/client.ts +105 -7
- package/src/platforms/line/listener.test.ts +106 -31
- package/src/platforms/line/listener.ts +56 -51
- package/src/platforms/webex/commands/auth.test.ts +8 -2
- package/src/platforms/webex/commands/member.test.ts +29 -27
- package/src/platforms/webex/commands/message.test.ts +36 -38
- package/src/platforms/webex/commands/snapshot.test.ts +25 -26
- package/src/platforms/webex/commands/space.test.ts +31 -29
- package/src/platforms/webex/commands/whoami.test.ts +3 -1
- package/src/platforms/webex/credential-manager.test.ts +3 -0
- package/src/platforms/whatsapp/commands/auth.test.ts +14 -20
- package/src/platforms/whatsapp/commands/chat.test.ts +17 -24
- 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:
|
|
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 (
|
|
355
|
+
if (!isE2EEUnavailableError(msg)) throw e2eeError
|
|
356
|
+
|
|
357
|
+
try {
|
|
317
358
|
sent = await client.base.talk.sendMessage({ to: chatId, text, e2ee: false })
|
|
318
|
-
}
|
|
319
|
-
|
|
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',
|
|
7
|
+
const mockGetProfile = mock(() => Promise.resolve({ mid: 'u123', display_name: 'Test User' }))
|
|
7
8
|
|
|
8
|
-
let mockInternalClientInstance:
|
|
9
|
+
let mockInternalClientInstance: MockEventSource
|
|
9
10
|
|
|
10
|
-
class
|
|
11
|
-
|
|
12
|
-
private
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
31
|
-
this.
|
|
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.
|
|
71
|
+
this.push({ kind: 'event', op: op as never })
|
|
36
72
|
}
|
|
37
73
|
|
|
38
74
|
simulateListenError(error: Error): void {
|
|
39
|
-
this.
|
|
75
|
+
this.fail(error)
|
|
40
76
|
}
|
|
77
|
+
}
|
|
41
78
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
103
|
+
mockInternalClientInstance = new MockEventSource()
|
|
69
104
|
return Promise.resolve()
|
|
70
105
|
})
|
|
71
106
|
mockGetProfile.mockReset()
|
|
72
|
-
mockGetProfile.mockResolvedValue({ mid: 'u123',
|
|
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
|
|
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
|
})
|