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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +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 +94 -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 +68 -47
- package/dist/src/platforms/line/listener.js.map +1 -1
- package/dist/src/platforms/line/types.d.ts +1 -0
- package/dist/src/platforms/line/types.d.ts.map +1 -1
- package/dist/src/platforms/line/types.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/line/client.test.ts +212 -1
- package/src/platforms/line/client.ts +114 -7
- package/src/platforms/line/listener.test.ts +164 -31
- package/src/platforms/line/listener.ts +66 -51
- package/src/platforms/line/types.ts +2 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-line
|
|
3
3
|
description: Interact with LINE - send messages, read chats, manage conversations
|
|
4
|
-
version: 2.19.
|
|
4
|
+
version: 2.19.3
|
|
5
5
|
allowed-tools: Bash(agent-line:*)
|
|
6
6
|
metadata:
|
|
7
7
|
openclaw:
|
|
@@ -429,6 +429,7 @@ See the [LINE SDK documentation](https://agent-messenger.dev/docs/sdk/line) for
|
|
|
429
429
|
|
|
430
430
|
- No auto-extraction of credentials (requires interactive login via QR code or email/password)
|
|
431
431
|
- E2EE (Letter Sealing) may prevent reading some message content
|
|
432
|
+
- Sending to chats that **require** E2EE (Letter Sealing) is not supported on auth-token sessions; such sends fail with `e2ee_required`
|
|
432
433
|
- No file upload support yet
|
|
433
434
|
- No sticker or rich message sending (text only)
|
|
434
435
|
- No group creation or management
|
|
@@ -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,216 @@ 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
|
+
it('swallows empty long-poll errors so the connection is not torn down', async () => {
|
|
230
|
+
const client = new LineClient()
|
|
231
|
+
;(client as any).client = {
|
|
232
|
+
base: {
|
|
233
|
+
profile: { mid: 'me' },
|
|
234
|
+
createPolling: () => ({
|
|
235
|
+
async *_listenTalkEvents(opts: { onError?: (e: unknown) => void }) {
|
|
236
|
+
// given: an idle long-poll returns an empty body, then a real op arrives
|
|
237
|
+
opts.onError?.(new Error('Request internal failed: Invalid response buffer <>'))
|
|
238
|
+
yield { type: 'NOTIFIED_READ_MESSAGE' }
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
e2ee: { decryptE2EEMessage: async (m: unknown) => m },
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const events = await collect(client)
|
|
246
|
+
expect(events.map((e) => e.kind)).toEqual(['event'])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('propagates non-empty malformed buffer errors', async () => {
|
|
250
|
+
const client = new LineClient()
|
|
251
|
+
;(client as any).client = {
|
|
252
|
+
base: {
|
|
253
|
+
profile: { mid: 'me' },
|
|
254
|
+
createPolling: () => ({
|
|
255
|
+
async *_listenTalkEvents(opts: { onError?: (e: unknown) => void }) {
|
|
256
|
+
opts.onError?.(new Error('Request internal failed: Invalid response buffer <de ad be ef>'))
|
|
257
|
+
yield undefined as never
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
e2ee: { decryptE2EEMessage: async (m: unknown) => m },
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await expect(collect(client)).rejects.toThrow('Invalid response buffer <de ad be ef>')
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
57
268
|
describe('login() without credentials', () => {
|
|
58
269
|
it('throws LineError when no saved credentials exist', async () => {
|
|
59
270
|
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,46 @@ 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
|
+
// An idle long-poll returning zero bytes is normal, but the vendored Thrift reader
|
|
55
|
+
// throws "Invalid response buffer <>" (empty brackets = empty body). Match only that
|
|
56
|
+
// empty case so the poll loop continues; a non-empty malformed buffer still propagates.
|
|
57
|
+
function isEmptyLongPollError(message: string): boolean {
|
|
58
|
+
return /Invalid response buffer <>/.test(message)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Writes to stderr so partial-result degradation stays visible without
|
|
62
|
+
// corrupting the JSON the CLI prints to stdout.
|
|
63
|
+
function warnDegraded(action: string, error: unknown): void {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
65
|
+
console.warn(`[line] could not ${action}: ${message}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
function mapChatType(rawType: unknown): 'user' | 'group' | 'room' | 'square' {
|
|
33
69
|
if (rawType === 'GROUP' || rawType === 0) return 'group'
|
|
34
70
|
if (rawType === 'ROOM' || rawType === 1) return 'room'
|
|
@@ -209,7 +245,10 @@ export class LineClient {
|
|
|
209
245
|
},
|
|
210
246
|
syncReason: 'INTERNAL',
|
|
211
247
|
}),
|
|
212
|
-
client.fetchJoinedChats().catch(() =>
|
|
248
|
+
client.fetchJoinedChats().catch((error: unknown) => {
|
|
249
|
+
warnDegraded('fetch joined group/room chats', error)
|
|
250
|
+
return []
|
|
251
|
+
}),
|
|
213
252
|
])
|
|
214
253
|
|
|
215
254
|
for (const chat of joinedChats) {
|
|
@@ -239,7 +278,9 @@ export class LineClient {
|
|
|
239
278
|
for (const c of contacts ?? []) {
|
|
240
279
|
nameMap.set(c.mid, { name: c.displayName, type: 'user' })
|
|
241
280
|
}
|
|
242
|
-
} catch {
|
|
281
|
+
} catch (error) {
|
|
282
|
+
warnDegraded('resolve user display names', error)
|
|
283
|
+
}
|
|
243
284
|
}
|
|
244
285
|
|
|
245
286
|
if (groupMids.length > 0) {
|
|
@@ -253,7 +294,9 @@ export class LineClient {
|
|
|
253
294
|
memberCount: memberMids ? Object.keys(memberMids).length : undefined,
|
|
254
295
|
})
|
|
255
296
|
}
|
|
256
|
-
} catch {
|
|
297
|
+
} catch (error) {
|
|
298
|
+
warnDegraded('resolve group/room names', error)
|
|
299
|
+
}
|
|
257
300
|
}
|
|
258
301
|
|
|
259
302
|
for (const box of messageBoxes) {
|
|
@@ -279,13 +322,16 @@ export class LineClient {
|
|
|
279
322
|
const client = this.ensureClient()
|
|
280
323
|
const count = options?.count ?? 20
|
|
281
324
|
|
|
325
|
+
// getPreviousMessagesV2WithRequest pages backward from endMessageId. A
|
|
326
|
+
// messageId:0 sentinel returns nothing; the max int64 id acts as "from the
|
|
327
|
+
// latest message" and works for any chat regardless of message-box position.
|
|
282
328
|
const serverTime = await client.base.talk.getServerTime()
|
|
283
329
|
const rawMessages = await client.base.talk.getPreviousMessagesV2WithRequest({
|
|
284
330
|
request: {
|
|
285
331
|
messageBoxId: chatId,
|
|
286
332
|
endMessageId: {
|
|
287
333
|
deliveredTime: BigInt(serverTime),
|
|
288
|
-
messageId:
|
|
334
|
+
messageId: MAX_MESSAGE_ID,
|
|
289
335
|
},
|
|
290
336
|
messagesCount: count,
|
|
291
337
|
},
|
|
@@ -313,10 +359,22 @@ export class LineClient {
|
|
|
313
359
|
sent = await client.base.talk.sendMessage({ to: chatId, text, e2ee: true })
|
|
314
360
|
} catch (e2eeError) {
|
|
315
361
|
const msg = e2eeError instanceof Error ? e2eeError.message : String(e2eeError)
|
|
316
|
-
if (
|
|
362
|
+
if (!isE2EEUnavailableError(msg)) throw e2eeError
|
|
363
|
+
|
|
364
|
+
try {
|
|
317
365
|
sent = await client.base.talk.sendMessage({ to: chatId, text, e2ee: false })
|
|
318
|
-
}
|
|
319
|
-
|
|
366
|
+
} catch (plainError) {
|
|
367
|
+
const plainMsg = plainError instanceof Error ? plainError.message : String(plainError)
|
|
368
|
+
// Some chats force Letter Sealing and reject plain mode. This session
|
|
369
|
+
// authenticated via auth token and has no local E2EE private key, so it
|
|
370
|
+
// cannot encrypt. Surface a clear error and keep the original cause.
|
|
371
|
+
if (requiresEncryption(plainMsg)) {
|
|
372
|
+
throw new LineError(
|
|
373
|
+
'e2ee_required',
|
|
374
|
+
`This chat requires end-to-end encryption (Letter Sealing), which this auth-token session cannot provide. Original error: ${msg}`,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
throw plainError
|
|
320
378
|
}
|
|
321
379
|
}
|
|
322
380
|
|
|
@@ -331,6 +389,55 @@ export class LineClient {
|
|
|
331
389
|
}
|
|
332
390
|
}
|
|
333
391
|
|
|
392
|
+
// Drives the vendor polling generator (talk.sync()) instead of Client.listen().
|
|
393
|
+
// Client.listen() uses a LEGY HTTP/2 push connection (duplex:'half' streaming
|
|
394
|
+
// fetch) that yields zero bytes under Bun and dies immediately, so no events
|
|
395
|
+
// ever arrive (evex-dev/linejs#117). The upstream "fix" (linejs#134, v2.7.0+)
|
|
396
|
+
// only adds an undici+allowH2 path for Node.js and explicitly skips Bun
|
|
397
|
+
// ("Bun" in globalThis -> return false), falling back to Bun's native fetch,
|
|
398
|
+
// which still can't stream duplex:'half' HTTP/2 (oven-sh/bun#30342, #31881).
|
|
399
|
+
// Polling works on every runtime. Messages are normalized like Client.listen().
|
|
400
|
+
async *streamEvents(signal: AbortSignal): AsyncGenerator<LineRawEvent, void, unknown> {
|
|
401
|
+
const client = this.ensureClient()
|
|
402
|
+
const polling = client.base.createPolling()
|
|
403
|
+
const selfMid = client.base.profile?.mid
|
|
404
|
+
|
|
405
|
+
for await (const op of polling._listenTalkEvents({
|
|
406
|
+
signal,
|
|
407
|
+
onError: (error) => {
|
|
408
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
409
|
+
if (isEmptyLongPollError(message)) return
|
|
410
|
+
throw error instanceof Error ? error : new Error(message)
|
|
411
|
+
},
|
|
412
|
+
})) {
|
|
413
|
+
yield { kind: 'event', op }
|
|
414
|
+
if (op.type === 'SEND_MESSAGE' || op.type === 'RECEIVE_MESSAGE') {
|
|
415
|
+
// A single undecryptable message must not kill the stream: the failing
|
|
416
|
+
// op stays in the sync window and would be re-fetched every poll, causing
|
|
417
|
+
// an endless decrypt-fail -> reconnect loop. Fall back to the raw op and
|
|
418
|
+
// surface the message with null text, since its text is unreadable.
|
|
419
|
+
let raw = op.message
|
|
420
|
+
let decrypted = true
|
|
421
|
+
try {
|
|
422
|
+
raw = await client.base.e2ee.decryptE2EEMessage(op.message)
|
|
423
|
+
} catch {
|
|
424
|
+
raw = op.message
|
|
425
|
+
decrypted = false
|
|
426
|
+
}
|
|
427
|
+
yield {
|
|
428
|
+
kind: 'message',
|
|
429
|
+
message: {
|
|
430
|
+
raw,
|
|
431
|
+
to: { id: raw.to },
|
|
432
|
+
from: { id: raw.from },
|
|
433
|
+
isMyMessage: selfMid === raw.from,
|
|
434
|
+
text: decrypted ? (raw.text ?? null) : null,
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
334
441
|
close(): void {
|
|
335
442
|
this.client = null
|
|
336
443
|
}
|