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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/line/client.d.ts +6 -1
  5. package/dist/src/platforms/line/client.d.ts.map +1 -1
  6. package/dist/src/platforms/line/client.js +65 -12
  7. package/dist/src/platforms/line/client.js.map +1 -1
  8. package/dist/src/platforms/line/index.d.ts +1 -1
  9. package/dist/src/platforms/line/index.d.ts.map +1 -1
  10. package/dist/src/platforms/line/index.js.map +1 -1
  11. package/dist/src/platforms/line/listener.d.ts.map +1 -1
  12. package/dist/src/platforms/line/listener.js +24 -4
  13. package/dist/src/platforms/line/listener.js.map +1 -1
  14. package/dist/src/platforms/line/types.d.ts +13 -0
  15. package/dist/src/platforms/line/types.d.ts.map +1 -1
  16. package/dist/src/platforms/line/types.js +6 -0
  17. package/dist/src/platforms/line/types.js.map +1 -1
  18. package/dist/src/vendor/linejs/_dist/client/login.d.ts +2 -1
  19. package/dist/src/vendor/linejs/client/login.js +3 -2
  20. package/dist/src/vendor/linejs/client/login.test.ts +11 -0
  21. package/package.json +1 -1
  22. package/skills/agent-channeltalk/SKILL.md +1 -1
  23. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  24. package/skills/agent-discord/SKILL.md +1 -1
  25. package/skills/agent-discordbot/SKILL.md +1 -1
  26. package/skills/agent-instagram/SKILL.md +1 -1
  27. package/skills/agent-kakaotalk/SKILL.md +1 -1
  28. package/skills/agent-line/SKILL.md +1 -1
  29. package/skills/agent-line/references/common-patterns.md +9 -3
  30. package/skills/agent-slack/SKILL.md +1 -1
  31. package/skills/agent-slackbot/SKILL.md +1 -1
  32. package/skills/agent-teams/SKILL.md +1 -1
  33. package/skills/agent-telegram/SKILL.md +1 -1
  34. package/skills/agent-telegrambot/SKILL.md +1 -1
  35. package/skills/agent-webex/SKILL.md +1 -1
  36. package/skills/agent-wechatbot/SKILL.md +1 -1
  37. package/skills/agent-whatsapp/SKILL.md +1 -1
  38. package/skills/agent-whatsappbot/SKILL.md +1 -1
  39. package/src/platforms/line/client.test.ts +36 -0
  40. package/src/platforms/line/client.ts +79 -13
  41. package/src/platforms/line/index.test.ts +10 -0
  42. package/src/platforms/line/index.ts +1 -0
  43. package/src/platforms/line/listener.test.ts +59 -0
  44. package/src/platforms/line/listener.ts +26 -6
  45. package/src/platforms/line/types.test.ts +17 -0
  46. package/src/platforms/line/types.ts +13 -0
  47. package/src/platforms/slack/commands/auth.test.ts +16 -6
  48. package/src/platforms/slack/token-extractor.test.ts +34 -7
  49. package/src/vendor/linejs/_dist/client/login.d.ts +2 -1
  50. package/src/vendor/linejs/client/login.js +3 -2
  51. 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
- console.log(`[${event.chat_id}] ${event.author_id}: ${event.text}`)
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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.19.3
4
+ version: 2.19.5
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -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: { id: unknown; contentType?: unknown; createdTime?: unknown; toType?: unknown; to?: unknown; from?: unknown }
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
- return new FileStorage(join(dir, `${accountId ?? 'default'}.json`))
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
- message_id: String(msg.id),
342
- chat_id: chatId,
343
- author_id: String(msg.from ?? ''),
344
- text: msg.text || null,
345
- content_type: String(msg.contentType ?? 'NONE'),
346
- sent_at: new Date(Number(msg.createdTime)).toISOString(),
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
+ })
@@ -6,6 +6,7 @@ export type {
6
6
  LineAccountCredentials,
7
7
  LineChat,
8
8
  LineConfig,
9
+ LineDecryptionError,
9
10
  LineDevice,
10
11
  LineFriend,
11
12
  LineListenerEventMap,
@@ -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: any): void {
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 ?? null,
109
- content_type: String(msg.raw.contentType ?? 'NONE'),
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.extract()
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.extract()
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.extract()
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.extract()
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.extract()
497
+ const result = await extractWithoutBrowserFallback(extractor)
488
498
 
489
499
  // Then: Should return empty array (no tokens found)
490
500
  expect(result).toEqual([])