agent-messenger 2.19.3 → 2.19.5
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/README.md +8 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/line/client.d.ts +6 -1
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/client.js +65 -12
- package/dist/src/platforms/line/client.js.map +1 -1
- package/dist/src/platforms/line/index.d.ts +1 -1
- package/dist/src/platforms/line/index.d.ts.map +1 -1
- package/dist/src/platforms/line/index.js.map +1 -1
- package/dist/src/platforms/line/listener.d.ts.map +1 -1
- package/dist/src/platforms/line/listener.js +24 -4
- package/dist/src/platforms/line/listener.js.map +1 -1
- package/dist/src/platforms/line/types.d.ts +13 -0
- package/dist/src/platforms/line/types.d.ts.map +1 -1
- package/dist/src/platforms/line/types.js +6 -0
- package/dist/src/platforms/line/types.js.map +1 -1
- package/dist/src/vendor/linejs/_dist/client/login.d.ts +2 -1
- package/dist/src/vendor/linejs/client/login.js +3 -2
- package/dist/src/vendor/linejs/client/login.test.ts +11 -0
- 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 +1 -1
- package/skills/agent-line/references/common-patterns.md +9 -3
- 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 +36 -0
- package/src/platforms/line/client.ts +79 -13
- package/src/platforms/line/index.test.ts +10 -0
- package/src/platforms/line/index.ts +1 -0
- package/src/platforms/line/listener.test.ts +59 -0
- package/src/platforms/line/listener.ts +26 -6
- package/src/platforms/line/types.test.ts +17 -0
- package/src/platforms/line/types.ts +13 -0
- package/src/platforms/slack/commands/auth.test.ts +16 -6
- package/src/platforms/slack/token-extractor.test.ts +34 -7
- package/src/vendor/linejs/_dist/client/login.d.ts +2 -1
- package/src/vendor/linejs/client/login.js +3 -2
- package/src/vendor/linejs/client/login.test.ts +11 -0
|
@@ -71,8 +71,8 @@ MESSAGES=$(agent-line message list "$CHAT_ID" -n 50)
|
|
|
71
71
|
MSG_COUNT=$(echo "$MESSAGES" | jq 'length')
|
|
72
72
|
echo "Found $MSG_COUNT messages"
|
|
73
73
|
|
|
74
|
-
# Show messages
|
|
75
|
-
echo "$MESSAGES" | jq -r '.[] | "\(.author_id): \(.text // "[non-text]")"'
|
|
74
|
+
# Show messages; encrypted Letter Sealing messages may include decryption_error.
|
|
75
|
+
echo "$MESSAGES" | jq -r '.[] | "\(.author_id): \(.text // .decryption_error.message // "[non-text]")"'
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
**When to use**: Context gathering, summarizing conversations, catching up on missed messages.
|
|
@@ -130,7 +130,9 @@ listener.on('connected', (info) => {
|
|
|
130
130
|
})
|
|
131
131
|
|
|
132
132
|
listener.on('message', (event) => {
|
|
133
|
-
|
|
133
|
+
// event.decryption_error?: { code: 'missing_e2ee_key' | 'decrypt_failed'; message: string }
|
|
134
|
+
const content = event.text ?? event.decryption_error?.message ?? '[non-text]'
|
|
135
|
+
console.log(`[${event.chat_id}] ${event.author_id}: ${content}`)
|
|
134
136
|
})
|
|
135
137
|
|
|
136
138
|
listener.on('error', (error) => {
|
|
@@ -152,6 +154,10 @@ await listener.start()
|
|
|
152
154
|
|
|
153
155
|
**Features**: Auto-reconnects with exponential backoff, typed events, AbortController-based clean shutdown.
|
|
154
156
|
|
|
157
|
+
**E2EE note**: For LINE Letter Sealing messages that cannot be decrypted in the current session, `text` stays `null` and `decryption_error` explains whether E2EE key material is missing or decryption failed.
|
|
158
|
+
|
|
159
|
+
Message listener payloads include `decryption_error` when encrypted content is present but unavailable. Check `event.decryption_error.code` for `missing_e2ee_key` or `decrypt_failed` before treating `text: null` as a non-text message.
|
|
160
|
+
|
|
155
161
|
## Pattern 5: Get User Profile
|
|
156
162
|
|
|
157
163
|
**Use case**: Retrieve your own LINE profile information
|
|
@@ -105,6 +105,30 @@ describe('LineClient', () => {
|
|
|
105
105
|
expect(result.map((m) => m.message_id)).toEqual(['30', '20'])
|
|
106
106
|
expect(result.map((m) => m.text)).toEqual(['c', 'b'])
|
|
107
107
|
})
|
|
108
|
+
|
|
109
|
+
it('marks encrypted chunk messages without text as missing E2EE keys', async () => {
|
|
110
|
+
const client = clientWithTalk({
|
|
111
|
+
getServerTime: async () => 1700000000000,
|
|
112
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
113
|
+
{
|
|
114
|
+
id: '40',
|
|
115
|
+
from: 'u1',
|
|
116
|
+
text: null,
|
|
117
|
+
contentType: 'NONE',
|
|
118
|
+
createdTime: 1700000004000,
|
|
119
|
+
chunks: ['a', 'b'],
|
|
120
|
+
metadata: { e2eeMark: '2', e2eeVersion: '2' },
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const result = await client.getMessages('chat1', { count: 1 })
|
|
126
|
+
expect(result[0].text).toBeNull()
|
|
127
|
+
expect(result[0].decryption_error).toEqual({
|
|
128
|
+
code: 'missing_e2ee_key',
|
|
129
|
+
message: 'LINE message is encrypted with Letter Sealing, but this session has no saved E2EE key material.',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
108
132
|
})
|
|
109
133
|
|
|
110
134
|
describe('sendMessage()', () => {
|
|
@@ -206,6 +230,18 @@ describe('LineClient', () => {
|
|
|
206
230
|
const msg = events[1] as Extract<LineRawEvent, { kind: 'message' }>
|
|
207
231
|
expect(msg.message.from.id).toBe('u1')
|
|
208
232
|
expect(msg.message.text).toBeNull()
|
|
233
|
+
expect(msg.message.decryption_error).toEqual({ code: 'decrypt_failed', message: 'E2EE decrypt failed' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('marks missing E2EE key failures explicitly', async () => {
|
|
237
|
+
const op = { type: 'RECEIVE_MESSAGE', message: { id: '1', from: 'u1', to: 'me' } }
|
|
238
|
+
const client = clientWithStream([op], async () => {
|
|
239
|
+
throw new Error('NoE2EEKey: E2EE Key has not been saved')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const events = await collect(client)
|
|
243
|
+
const msg = events[1] as Extract<LineRawEvent, { kind: 'message' }>
|
|
244
|
+
expect(msg.message.decryption_error?.code).toBe('missing_e2ee_key')
|
|
209
245
|
})
|
|
210
246
|
|
|
211
247
|
it('propagates polling errors so the listener can reconnect', async () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs'
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { Operation as LineOperation } from '@jsr/evex__linejs-types'
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
LineAccountCredentials,
|
|
18
18
|
LineChat,
|
|
19
19
|
LineDevice,
|
|
20
|
+
LineDecryptionError,
|
|
20
21
|
LineFriend,
|
|
21
22
|
LineLoginResult,
|
|
22
23
|
LineMessage,
|
|
@@ -26,11 +27,23 @@ import type {
|
|
|
26
27
|
import { LineError } from './types'
|
|
27
28
|
|
|
28
29
|
export interface LineRawMessage {
|
|
29
|
-
raw: {
|
|
30
|
+
raw: {
|
|
31
|
+
id: unknown
|
|
32
|
+
contentType?: unknown
|
|
33
|
+
contentMetadata?: unknown
|
|
34
|
+
createdTime?: unknown
|
|
35
|
+
toType?: unknown
|
|
36
|
+
to?: unknown
|
|
37
|
+
from?: unknown
|
|
38
|
+
text?: unknown
|
|
39
|
+
chunks?: unknown
|
|
40
|
+
metadata?: unknown
|
|
41
|
+
}
|
|
30
42
|
to: { id: unknown }
|
|
31
43
|
from: { id: unknown }
|
|
32
44
|
isMyMessage: boolean
|
|
33
45
|
text: string | null
|
|
46
|
+
decryption_error?: LineDecryptionError
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export type LineRawEvent = { kind: 'message'; message: LineRawMessage } | { kind: 'event'; op: LineOperation }
|
|
@@ -79,7 +92,14 @@ function getDefaultDevice(): LineDevice {
|
|
|
79
92
|
function createStorage(accountId?: string): FileStorage {
|
|
80
93
|
const dir = join(getConfigDir(), 'line-storage')
|
|
81
94
|
mkdirSync(dir, { recursive: true })
|
|
82
|
-
|
|
95
|
+
const defaultPath = join(dir, 'default.json')
|
|
96
|
+
if (!accountId) return new FileStorage(defaultPath)
|
|
97
|
+
|
|
98
|
+
const accountPath = join(dir, `${accountId}.json`)
|
|
99
|
+
if (!existsSync(accountPath) && existsSync(defaultPath)) {
|
|
100
|
+
copyFileSync(defaultPath, accountPath)
|
|
101
|
+
}
|
|
102
|
+
return new FileStorage(accountPath)
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
export class LineClient {
|
|
@@ -110,6 +130,7 @@ export class LineClient {
|
|
|
110
130
|
this.client = client
|
|
111
131
|
|
|
112
132
|
const profile = await client.base.talk.getProfile()
|
|
133
|
+
createStorage(profile.mid)
|
|
113
134
|
const now = new Date().toISOString()
|
|
114
135
|
|
|
115
136
|
await this.credManager.setAccount({
|
|
@@ -146,6 +167,7 @@ export class LineClient {
|
|
|
146
167
|
{
|
|
147
168
|
email: options.email,
|
|
148
169
|
password: options.password,
|
|
170
|
+
e2ee: true,
|
|
149
171
|
onPincodeRequest: (pin) => options.onPincode(pin),
|
|
150
172
|
},
|
|
151
173
|
{ device, storage },
|
|
@@ -154,6 +176,7 @@ export class LineClient {
|
|
|
154
176
|
this.client = client
|
|
155
177
|
|
|
156
178
|
const profile = await client.base.talk.getProfile()
|
|
179
|
+
createStorage(profile.mid)
|
|
157
180
|
const now = new Date().toISOString()
|
|
158
181
|
|
|
159
182
|
await this.credManager.setAccount({
|
|
@@ -188,7 +211,7 @@ export class LineClient {
|
|
|
188
211
|
}
|
|
189
212
|
|
|
190
213
|
const device: LineDevice = creds.device ?? getDefaultDevice()
|
|
191
|
-
const storage = createStorage()
|
|
214
|
+
const storage = createStorage(creds.account_id)
|
|
192
215
|
|
|
193
216
|
this.client = await linejsLoginWithAuthToken(creds.auth_token, { device, storage })
|
|
194
217
|
return this
|
|
@@ -337,14 +360,18 @@ export class LineClient {
|
|
|
337
360
|
},
|
|
338
361
|
})
|
|
339
362
|
|
|
340
|
-
return (rawMessages ?? []).map((msg) =>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
363
|
+
return (rawMessages ?? []).map((msg) => {
|
|
364
|
+
const decryptionError = getUndecryptableMessageError(msg)
|
|
365
|
+
return {
|
|
366
|
+
message_id: String(msg.id),
|
|
367
|
+
chat_id: chatId,
|
|
368
|
+
author_id: String(msg.from ?? ''),
|
|
369
|
+
text: msg.text || null,
|
|
370
|
+
...(decryptionError && { decryption_error: decryptionError }),
|
|
371
|
+
content_type: String(msg.contentType ?? 'NONE'),
|
|
372
|
+
sent_at: new Date(Number(msg.createdTime)).toISOString(),
|
|
373
|
+
}
|
|
374
|
+
})
|
|
348
375
|
} catch (error) {
|
|
349
376
|
throw wrapError(error, 'get_messages_failed')
|
|
350
377
|
}
|
|
@@ -418,12 +445,15 @@ export class LineClient {
|
|
|
418
445
|
// surface the message with null text, since its text is unreadable.
|
|
419
446
|
let raw = op.message
|
|
420
447
|
let decrypted = true
|
|
448
|
+
let decryptionError: LineDecryptionError | undefined
|
|
421
449
|
try {
|
|
422
450
|
raw = await client.base.e2ee.decryptE2EEMessage(op.message)
|
|
423
|
-
} catch {
|
|
451
|
+
} catch (error) {
|
|
424
452
|
raw = op.message
|
|
425
453
|
decrypted = false
|
|
454
|
+
decryptionError = getDecryptionError(error)
|
|
426
455
|
}
|
|
456
|
+
decryptionError ??= getUndecryptableMessageError(raw)
|
|
427
457
|
yield {
|
|
428
458
|
kind: 'message',
|
|
429
459
|
message: {
|
|
@@ -432,6 +462,7 @@ export class LineClient {
|
|
|
432
462
|
from: { id: raw.from },
|
|
433
463
|
isMyMessage: selfMid === raw.from,
|
|
434
464
|
text: decrypted ? (raw.text ?? null) : null,
|
|
465
|
+
...(decryptionError && { decryption_error: decryptionError }),
|
|
435
466
|
},
|
|
436
467
|
}
|
|
437
468
|
}
|
|
@@ -449,3 +480,38 @@ export class LineClient {
|
|
|
449
480
|
return this.client
|
|
450
481
|
}
|
|
451
482
|
}
|
|
483
|
+
|
|
484
|
+
function getUndecryptableMessageError(raw: unknown): LineDecryptionError | undefined {
|
|
485
|
+
if (!isEncryptedChunkMessage(raw) || hasPlainText(raw)) return undefined
|
|
486
|
+
return {
|
|
487
|
+
code: 'missing_e2ee_key',
|
|
488
|
+
message: 'LINE message is encrypted with Letter Sealing, but this session has no saved E2EE key material.',
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function getDecryptionError(error: unknown): LineDecryptionError {
|
|
493
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
494
|
+
return {
|
|
495
|
+
code: /NoE2EEKey|E2EE Key has not been saved|saveE2EE/i.test(message) ? 'missing_e2ee_key' : 'decrypt_failed',
|
|
496
|
+
message,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function hasPlainText(raw: unknown): boolean {
|
|
501
|
+
if (!raw || typeof raw !== 'object') return false
|
|
502
|
+
const text = (raw as { text?: unknown }).text
|
|
503
|
+
return typeof text === 'string' && text.length > 0
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function isEncryptedChunkMessage(raw: unknown): boolean {
|
|
507
|
+
if (!raw || typeof raw !== 'object') return false
|
|
508
|
+
const message = raw as { chunks?: unknown; contentMetadata?: unknown; metadata?: unknown }
|
|
509
|
+
if (!Array.isArray(message.chunks) || message.chunks.length === 0) return false
|
|
510
|
+
return hasE2EEMetadata(message.contentMetadata) || hasE2EEMetadata(message.metadata)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function hasE2EEMetadata(raw: unknown): boolean {
|
|
514
|
+
if (!raw || typeof raw !== 'object') return false
|
|
515
|
+
const metadata = raw as { e2eeMark?: unknown; e2eeVersion?: unknown }
|
|
516
|
+
return metadata.e2eeMark !== undefined || metadata.e2eeVersion !== undefined
|
|
517
|
+
}
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
LineMessageSchema,
|
|
12
12
|
LineSendResultSchema,
|
|
13
13
|
} from '@/platforms/line/index'
|
|
14
|
+
import type { LineDecryptionError } from '@/platforms/line/index'
|
|
15
|
+
|
|
16
|
+
const lineDecryptionError: LineDecryptionError = {
|
|
17
|
+
code: 'missing_e2ee_key',
|
|
18
|
+
message: 'E2EE key material is missing',
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
it('LineClient is exported from barrel', () => {
|
|
16
22
|
expect(typeof LineClient).toBe('function')
|
|
@@ -47,3 +53,7 @@ it('LineAccountCredentialsSchema is exported from barrel', () => {
|
|
|
47
53
|
it('LineConfigSchema is exported from barrel', () => {
|
|
48
54
|
expect(typeof LineConfigSchema.parse).toBe('function')
|
|
49
55
|
})
|
|
56
|
+
|
|
57
|
+
it('LineDecryptionError type is exported from barrel', () => {
|
|
58
|
+
expect(lineDecryptionError.code).toBe('missing_e2ee_key')
|
|
59
|
+
})
|
|
@@ -203,6 +203,65 @@ describe('LineListener', () => {
|
|
|
203
203
|
expect(messages[0].content_metadata).toEqual({ STKID: '123', STKPKGID: '456', STKVER: '1' })
|
|
204
204
|
})
|
|
205
205
|
|
|
206
|
+
it('falls back to raw text when text is empty for contentType NONE messages', 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: '',
|
|
219
|
+
raw: {
|
|
220
|
+
id: 'msg012',
|
|
221
|
+
contentType: 'NONE',
|
|
222
|
+
text: 'actual text from raw',
|
|
223
|
+
createdTime: 1700000009000,
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
await flush()
|
|
227
|
+
|
|
228
|
+
expect(messages.length).toBe(1)
|
|
229
|
+
expect(messages[0].text).toBe('actual text from raw')
|
|
230
|
+
expect(messages[0].content_type).toBe('NONE')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('forwards LINE decryption errors on message events', async () => {
|
|
234
|
+
const client = createMockLineClient()
|
|
235
|
+
listener = new LineListener(client)
|
|
236
|
+
|
|
237
|
+
const messages: LinePushMessageEvent[] = []
|
|
238
|
+
listener.on('message', (event) => messages.push(event))
|
|
239
|
+
|
|
240
|
+
await listener.start()
|
|
241
|
+
mockInternalClientInstance.simulateMessage({
|
|
242
|
+
isMyMessage: false,
|
|
243
|
+
from: { type: 'USER', id: 'u456' },
|
|
244
|
+
to: { type: 'USER', id: 'u123' },
|
|
245
|
+
text: null,
|
|
246
|
+
decryption_error: {
|
|
247
|
+
code: 'missing_e2ee_key',
|
|
248
|
+
message: 'LINE message is encrypted with Letter Sealing, but this session has no saved E2EE key material.',
|
|
249
|
+
},
|
|
250
|
+
raw: {
|
|
251
|
+
id: 'msg013',
|
|
252
|
+
contentType: 'NONE',
|
|
253
|
+
createdTime: 1700000010000,
|
|
254
|
+
chunks: ['a', 'b'],
|
|
255
|
+
metadata: { e2eeMark: '2', e2eeVersion: '2' },
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
await flush()
|
|
259
|
+
|
|
260
|
+
expect(messages.length).toBe(1)
|
|
261
|
+
expect(messages[0].text).toBeNull()
|
|
262
|
+
expect(messages[0].decryption_error?.code).toBe('missing_e2ee_key')
|
|
263
|
+
})
|
|
264
|
+
|
|
206
265
|
it('coerces non-string contentMetadata values to strings', async () => {
|
|
207
266
|
const client = createMockLineClient()
|
|
208
267
|
listener = new LineListener(client)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'events'
|
|
2
2
|
|
|
3
|
-
import type { LineClient } from './client'
|
|
3
|
+
import type { LineClient, LineRawMessage } from './client'
|
|
4
4
|
import type { LineListenerEventMap, LinePushGenericEvent, LinePushMessageEvent } from './types'
|
|
5
5
|
|
|
6
6
|
const RECONNECT_BASE_DELAY = 1_000
|
|
@@ -94,19 +94,21 @@ export class LineListener {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
private emitMessage(msg:
|
|
97
|
+
private emitMessage(msg: LineRawMessage): void {
|
|
98
98
|
try {
|
|
99
99
|
const toType = msg.raw.toType
|
|
100
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
|
|
101
|
+
const chatId = String(isGroupOrRoom ? msg.to.id : msg.isMyMessage ? msg.to.id : msg.from.id)
|
|
102
102
|
|
|
103
|
+
const contentType = String(msg.raw.contentType ?? 'NONE')
|
|
103
104
|
const event: LinePushMessageEvent = {
|
|
104
105
|
type: 'message',
|
|
105
106
|
chat_id: chatId,
|
|
106
107
|
message_id: String(msg.raw.id),
|
|
107
|
-
author_id: msg.from.id,
|
|
108
|
-
text: msg.text
|
|
109
|
-
|
|
108
|
+
author_id: String(msg.from.id),
|
|
109
|
+
text: getMessageText(msg.text, msg.raw, contentType),
|
|
110
|
+
...(msg.decryption_error && { decryption_error: msg.decryption_error }),
|
|
111
|
+
content_type: contentType,
|
|
110
112
|
content_metadata: normalizeContentMetadata(msg.raw.contentMetadata),
|
|
111
113
|
sent_at: new Date(Number(msg.raw.createdTime)).toISOString(),
|
|
112
114
|
}
|
|
@@ -151,3 +153,21 @@ function normalizeContentMetadata(raw: unknown): Record<string, string> {
|
|
|
151
153
|
}
|
|
152
154
|
return result
|
|
153
155
|
}
|
|
156
|
+
|
|
157
|
+
function getMessageText(text: unknown, raw: unknown, contentType: string): string | null {
|
|
158
|
+
const direct = normalizeText(text)
|
|
159
|
+
if (direct !== null) return direct
|
|
160
|
+
if (!isTextContentType(contentType)) return null
|
|
161
|
+
|
|
162
|
+
if (!raw || typeof raw !== 'object') return null
|
|
163
|
+
return normalizeText((raw as Record<string, unknown>).text)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeText(value: unknown): string | null {
|
|
167
|
+
if (typeof value !== 'string') return null
|
|
168
|
+
return value.length > 0 ? value : null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isTextContentType(contentType: string): boolean {
|
|
172
|
+
return contentType === 'NONE' || contentType === '0'
|
|
173
|
+
}
|
|
@@ -87,6 +87,23 @@ describe('LineMessageSchema', () => {
|
|
|
87
87
|
expect(result.text).toBeNull()
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
it('parses message with decryption error', () => {
|
|
91
|
+
const data = {
|
|
92
|
+
message_id: 'msg789',
|
|
93
|
+
chat_id: 'u1234567890abcdef',
|
|
94
|
+
author_id: 'u9876543210fedcba',
|
|
95
|
+
text: null,
|
|
96
|
+
decryption_error: {
|
|
97
|
+
code: 'missing_e2ee_key',
|
|
98
|
+
message: 'LINE message is encrypted with Letter Sealing, but this session has no saved E2EE key material.',
|
|
99
|
+
},
|
|
100
|
+
content_type: 'NONE',
|
|
101
|
+
sent_at: '2026-03-29T00:00:00.000Z',
|
|
102
|
+
}
|
|
103
|
+
const result = LineMessageSchema.parse(data)
|
|
104
|
+
expect(result.decryption_error?.code).toBe('missing_e2ee_key')
|
|
105
|
+
})
|
|
106
|
+
|
|
90
107
|
it('rejects missing required fields', () => {
|
|
91
108
|
expect(() => LineMessageSchema.parse({ message_id: 'msg123' })).toThrow()
|
|
92
109
|
})
|
|
@@ -43,10 +43,16 @@ export interface LineMessage {
|
|
|
43
43
|
author_id: string
|
|
44
44
|
author_name?: string
|
|
45
45
|
text: string | null
|
|
46
|
+
decryption_error?: LineDecryptionError
|
|
46
47
|
content_type: string
|
|
47
48
|
sent_at: string
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export interface LineDecryptionError {
|
|
52
|
+
code: 'missing_e2ee_key' | 'decrypt_failed'
|
|
53
|
+
message: string
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
export interface LineSendResult {
|
|
51
57
|
success: boolean
|
|
52
58
|
chat_id: string
|
|
@@ -106,6 +112,12 @@ export const LineMessageSchema = z.object({
|
|
|
106
112
|
author_id: z.string(),
|
|
107
113
|
author_name: z.string().optional(),
|
|
108
114
|
text: z.string().nullable(),
|
|
115
|
+
decryption_error: z
|
|
116
|
+
.object({
|
|
117
|
+
code: z.enum(['missing_e2ee_key', 'decrypt_failed']),
|
|
118
|
+
message: z.string(),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
109
121
|
content_type: z.string(),
|
|
110
122
|
sent_at: z.string(),
|
|
111
123
|
})
|
|
@@ -137,6 +149,7 @@ export interface LinePushMessageEvent {
|
|
|
137
149
|
message_id: string
|
|
138
150
|
author_id: string
|
|
139
151
|
text: string | null
|
|
152
|
+
decryption_error?: LineDecryptionError
|
|
140
153
|
content_type: string
|
|
141
154
|
// Raw LINE contentMetadata (sticker IDs, file name/size, media URLs); empty for plain text.
|
|
142
155
|
content_metadata: Record<string, string>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeEach, describe, expect, mock, it } from 'bun:test'
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
|
|
2
2
|
import { mkdirSync, rmSync } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
@@ -14,6 +14,16 @@ import { type ExtractedWorkspace, TokenExtractor } from '@/platforms/slack/token
|
|
|
14
14
|
const testConfigDir = join(import.meta.dir, '.test-auth-config')
|
|
15
15
|
const testSlackDir = join(import.meta.dir, '.test-slack-data')
|
|
16
16
|
|
|
17
|
+
async function extractWithoutBrowserFallback(extractor: TokenExtractor): Promise<ExtractedWorkspace[]> {
|
|
18
|
+
const extractFromBrowsersSpy = spyOn(TokenExtractor.prototype, 'extractFromBrowsers').mockResolvedValue([])
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return await extractor.extract()
|
|
22
|
+
} finally {
|
|
23
|
+
extractFromBrowsersSpy.mockRestore()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
describe('TokenExtractor', () => {
|
|
18
28
|
let extractor: TokenExtractor
|
|
19
29
|
|
|
@@ -85,7 +95,7 @@ describe('TokenExtractor', () => {
|
|
|
85
95
|
extractor = new TokenExtractor('darwin', nonExistentPath)
|
|
86
96
|
|
|
87
97
|
// when
|
|
88
|
-
const result = await extractor
|
|
98
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
89
99
|
|
|
90
100
|
// then
|
|
91
101
|
expect(result).toEqual([])
|
|
@@ -97,7 +107,7 @@ describe('TokenExtractor', () => {
|
|
|
97
107
|
extractor = new TokenExtractor('darwin', testSlackDir)
|
|
98
108
|
|
|
99
109
|
// When: extract is called
|
|
100
|
-
const result = await extractor
|
|
110
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
101
111
|
|
|
102
112
|
// Then: Should return empty array
|
|
103
113
|
expect(result).toEqual([])
|
|
@@ -463,7 +473,7 @@ describe('Error Handling', () => {
|
|
|
463
473
|
const extractor = new TokenExtractor('darwin', nonExistentPath)
|
|
464
474
|
|
|
465
475
|
// when/then — falls back to browser profiles, returns empty array
|
|
466
|
-
const result = await extractor
|
|
476
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
467
477
|
expect(result).toEqual([])
|
|
468
478
|
})
|
|
469
479
|
|
|
@@ -472,7 +482,7 @@ describe('Error Handling', () => {
|
|
|
472
482
|
const extractor = new TokenExtractor('darwin', testSlackDir)
|
|
473
483
|
|
|
474
484
|
// When: Trying to extract from empty directory
|
|
475
|
-
const result = await extractor
|
|
485
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
476
486
|
|
|
477
487
|
// Then: Should return empty array
|
|
478
488
|
expect(result).toEqual([])
|
|
@@ -484,7 +494,7 @@ describe('Error Handling', () => {
|
|
|
484
494
|
const extractor = new TokenExtractor('darwin', testSlackDir)
|
|
485
495
|
|
|
486
496
|
// When: Trying to extract
|
|
487
|
-
const result = await extractor
|
|
497
|
+
const result = await extractWithoutBrowserFallback(extractor)
|
|
488
498
|
|
|
489
499
|
// Then: Should return empty array (no tokens found)
|
|
490
500
|
expect(result).toEqual([])
|