agent-messenger 2.19.4 → 2.20.0
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 +5 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/line/client.d.ts +10 -1
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/client.js +156 -11
- package/dist/src/platforms/line/client.js.map +1 -1
- package/dist/src/platforms/line/e2ee-storage.d.ts +16 -0
- package/dist/src/platforms/line/e2ee-storage.d.ts.map +1 -0
- package/dist/src/platforms/line/e2ee-storage.js +93 -0
- package/dist/src/platforms/line/e2ee-storage.js.map +1 -0
- 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 +3 -2
- 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/platforms/teams/cli.d.ts.map +1 -1
- package/dist/src/platforms/teams/cli.js +2 -1
- package/dist/src/platforms/teams/cli.js.map +1 -1
- package/dist/src/platforms/teams/client.d.ts +4 -1
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +84 -0
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/chat.d.ts +13 -0
- package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/teams/commands/chat.js +111 -0
- package/dist/src/platforms/teams/commands/chat.js.map +1 -0
- package/dist/src/platforms/teams/commands/index.d.ts +1 -0
- package/dist/src/platforms/teams/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/index.js +1 -0
- package/dist/src/platforms/teams/commands/index.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +24 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +8 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/tui/adapters/line-adapter.js +1 -1
- package/dist/src/tui/adapters/line-adapter.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/docs/content/docs/cli/line.mdx +13 -11
- 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 +7 -5
- package/skills/agent-line/references/common-patterns.md +12 -3
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +20 -2
- package/skills/agent-teams/references/common-patterns.md +28 -0
- 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 +190 -2
- package/src/platforms/line/client.ts +183 -13
- package/src/platforms/line/e2ee-storage.test.ts +154 -0
- package/src/platforms/line/e2ee-storage.ts +119 -0
- package/src/platforms/line/index.test.ts +10 -0
- package/src/platforms/line/index.ts +1 -0
- package/src/platforms/line/listener.test.ts +32 -0
- package/src/platforms/line/listener.ts +5 -4
- 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/platforms/teams/cli.ts +2 -0
- package/src/platforms/teams/client.test.ts +96 -0
- package/src/platforms/teams/client.ts +133 -0
- package/src/platforms/teams/commands/chat.test.ts +100 -0
- package/src/platforms/teams/commands/chat.ts +131 -0
- package/src/platforms/teams/commands/index.ts +1 -0
- package/src/platforms/teams/types.ts +20 -0
- package/src/tui/adapters/line-adapter.ts +1 -1
- 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
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
} from '@/vendor/linejs/client/mod.js'
|
|
14
14
|
|
|
15
15
|
import { LineCredentialManager } from './credential-manager'
|
|
16
|
+
import { ensureSelfKeyForMid, migrateOwnE2EEKeys } from './e2ee-storage'
|
|
16
17
|
import type {
|
|
17
18
|
LineAccountCredentials,
|
|
18
19
|
LineChat,
|
|
19
20
|
LineDevice,
|
|
21
|
+
LineDecryptionError,
|
|
20
22
|
LineFriend,
|
|
21
23
|
LineLoginResult,
|
|
22
24
|
LineMessage,
|
|
@@ -26,15 +28,29 @@ import type {
|
|
|
26
28
|
import { LineError } from './types'
|
|
27
29
|
|
|
28
30
|
export interface LineRawMessage {
|
|
29
|
-
raw: {
|
|
31
|
+
raw: {
|
|
32
|
+
id: unknown
|
|
33
|
+
contentType?: unknown
|
|
34
|
+
contentMetadata?: unknown
|
|
35
|
+
createdTime?: unknown
|
|
36
|
+
toType?: unknown
|
|
37
|
+
to?: unknown
|
|
38
|
+
from?: unknown
|
|
39
|
+
text?: unknown
|
|
40
|
+
chunks?: unknown
|
|
41
|
+
metadata?: unknown
|
|
42
|
+
}
|
|
30
43
|
to: { id: unknown }
|
|
31
44
|
from: { id: unknown }
|
|
32
45
|
isMyMessage: boolean
|
|
33
46
|
text: string | null
|
|
47
|
+
decryption_error?: LineDecryptionError
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
export type LineRawEvent = { kind: 'message'; message: LineRawMessage } | { kind: 'event'; op: LineOperation }
|
|
37
51
|
|
|
52
|
+
type VendorMessage = Parameters<Client['base']['e2ee']['decryptE2EEMessage']>[0]
|
|
53
|
+
|
|
38
54
|
const MAX_MESSAGE_ID = 9223372036854775807n
|
|
39
55
|
|
|
40
56
|
function wrapError(error: unknown, code: string): LineError {
|
|
@@ -76,10 +92,19 @@ function getDefaultDevice(): LineDevice {
|
|
|
76
92
|
return 'ANDROIDSECONDARY'
|
|
77
93
|
}
|
|
78
94
|
|
|
79
|
-
function
|
|
95
|
+
function lineStorageDir(): string {
|
|
80
96
|
const dir = join(getConfigDir(), 'line-storage')
|
|
81
97
|
mkdirSync(dir, { recursive: true })
|
|
82
|
-
return
|
|
98
|
+
return dir
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Per-account stores stay isolated: blindly cloning default.json would copy another
|
|
102
|
+
// account's E2EE private keys into this account's file. E2EE key material is migrated
|
|
103
|
+
// explicitly via migrateOwnE2EEKeys after login resolves the account MID.
|
|
104
|
+
function createStorage(accountId?: string): FileStorage {
|
|
105
|
+
const dir = lineStorageDir()
|
|
106
|
+
const fileName = accountId ? `${accountId}.json` : 'default.json'
|
|
107
|
+
return new FileStorage(join(dir, fileName))
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
export class LineClient {
|
|
@@ -110,6 +135,7 @@ export class LineClient {
|
|
|
110
135
|
this.client = client
|
|
111
136
|
|
|
112
137
|
const profile = await client.base.talk.getProfile()
|
|
138
|
+
await this.persistAccountE2EEKeys(client, profile.mid)
|
|
113
139
|
const now = new Date().toISOString()
|
|
114
140
|
|
|
115
141
|
await this.credManager.setAccount({
|
|
@@ -146,6 +172,7 @@ export class LineClient {
|
|
|
146
172
|
{
|
|
147
173
|
email: options.email,
|
|
148
174
|
password: options.password,
|
|
175
|
+
e2ee: true,
|
|
149
176
|
onPincodeRequest: (pin) => options.onPincode(pin),
|
|
150
177
|
},
|
|
151
178
|
{ device, storage },
|
|
@@ -154,6 +181,7 @@ export class LineClient {
|
|
|
154
181
|
this.client = client
|
|
155
182
|
|
|
156
183
|
const profile = await client.base.talk.getProfile()
|
|
184
|
+
await this.persistAccountE2EEKeys(client, profile.mid)
|
|
157
185
|
const now = new Date().toISOString()
|
|
158
186
|
|
|
159
187
|
await this.credManager.setAccount({
|
|
@@ -188,15 +216,41 @@ export class LineClient {
|
|
|
188
216
|
}
|
|
189
217
|
|
|
190
218
|
const device: LineDevice = creds.device ?? getDefaultDevice()
|
|
191
|
-
const storage = createStorage()
|
|
219
|
+
const storage = createStorage(creds.account_id)
|
|
192
220
|
|
|
193
221
|
this.client = await linejsLoginWithAuthToken(creds.auth_token, { device, storage })
|
|
222
|
+
await this.repairSelfE2EEKey(this.client, creds.account_id)
|
|
194
223
|
return this
|
|
195
224
|
} catch (error) {
|
|
196
225
|
throw wrapError(error, 'login_failed')
|
|
197
226
|
}
|
|
198
227
|
}
|
|
199
228
|
|
|
229
|
+
// A fresh QR/email login writes E2EE keys into the shared default store. Copy only
|
|
230
|
+
// this account's verified key material into its isolated per-account store so token
|
|
231
|
+
// logins (which read the per-account store) can later encrypt for E2EE chats.
|
|
232
|
+
private async persistAccountE2EEKeys(client: Client, mid: string): Promise<void> {
|
|
233
|
+
try {
|
|
234
|
+
const advertised = await client.base.talk.getE2EEPublicKeys().catch(() => undefined)
|
|
235
|
+
const target = createStorage(mid)
|
|
236
|
+
await migrateOwnE2EEKeys(client.base.storage, target, mid, advertised)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
warnDegraded('persist account E2EE keys', error)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Token sessions don't perform an E2EE handshake, so getE2EESelfKeyData can't find
|
|
243
|
+
// a key under the account MID even when the keyId-addressed key already exists in
|
|
244
|
+
// storage. Promote it to the MID address, trusting only server-advertised keyIds.
|
|
245
|
+
private async repairSelfE2EEKey(client: Client, mid: string): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
const advertised = await client.base.talk.getE2EEPublicKeys().catch(() => undefined)
|
|
248
|
+
await ensureSelfKeyForMid(client.base.storage, mid, advertised)
|
|
249
|
+
} catch (error) {
|
|
250
|
+
warnDegraded('repair self E2EE key', error)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
200
254
|
async getProfile(): Promise<LineProfile> {
|
|
201
255
|
try {
|
|
202
256
|
const profile = await this.ensureClient().base.talk.getProfile()
|
|
@@ -337,19 +391,86 @@ export class LineClient {
|
|
|
337
391
|
},
|
|
338
392
|
})
|
|
339
393
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
394
|
+
const messages = rawMessages ?? []
|
|
395
|
+
const authorNames = await this.resolveAuthorNames(client, messages)
|
|
396
|
+
|
|
397
|
+
return await Promise.all(
|
|
398
|
+
messages.map(async (msg) => {
|
|
399
|
+
const { text, decryptionError } = await this.decryptMessageText(client, msg)
|
|
400
|
+
const authorId = String(msg.from ?? '')
|
|
401
|
+
const authorName = authorNames.get(authorId)
|
|
402
|
+
return {
|
|
403
|
+
message_id: String(msg.id),
|
|
404
|
+
chat_id: chatId,
|
|
405
|
+
author_id: authorId,
|
|
406
|
+
...(authorName && { author_name: authorName }),
|
|
407
|
+
text,
|
|
408
|
+
...(decryptionError && { decryption_error: decryptionError }),
|
|
409
|
+
content_type: String(msg.contentType ?? 'NONE'),
|
|
410
|
+
sent_at: new Date(Number(msg.createdTime)).toISOString(),
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
)
|
|
348
414
|
} catch (error) {
|
|
349
415
|
throw wrapError(error, 'get_messages_failed')
|
|
350
416
|
}
|
|
351
417
|
}
|
|
352
418
|
|
|
419
|
+
// getPreviousMessagesV2WithRequest returns Letter-Sealing messages as encrypted
|
|
420
|
+
// chunks with null text, so they must be decrypted with the same path streamEvents
|
|
421
|
+
// uses. Plain messages already carry text and skip decryption.
|
|
422
|
+
private async decryptMessageText(
|
|
423
|
+
client: Client,
|
|
424
|
+
msg: VendorMessage,
|
|
425
|
+
): Promise<{ text: string | null; decryptionError?: LineDecryptionError }> {
|
|
426
|
+
if (!isEncryptedChunkMessage(msg)) {
|
|
427
|
+
return { text: msg.text || null }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const decrypted = await client.base.e2ee.decryptE2EEMessage(normalizeE2EEMetadata(msg))
|
|
432
|
+
return { text: decrypted.text ?? null }
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return { text: null, decryptionError: getDecryptionError(error) }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// getContacts doesn't return the current user, so self-authored messages need
|
|
439
|
+
// getProfile separately. Lookups degrade to bare MIDs rather than failing.
|
|
440
|
+
private async resolveAuthorNames(
|
|
441
|
+
client: Client,
|
|
442
|
+
messages: ReadonlyArray<{ from?: unknown }>,
|
|
443
|
+
): Promise<Map<string, string>> {
|
|
444
|
+
const names = new Map<string, string>()
|
|
445
|
+
const authorMids = [...new Set(messages.map((m) => String(m.from ?? '')).filter((mid) => mid.length > 0))]
|
|
446
|
+
if (authorMids.length === 0) return names
|
|
447
|
+
|
|
448
|
+
const selfMid = client.base.profile?.mid
|
|
449
|
+
const contactMids = authorMids.filter((mid) => mid !== selfMid)
|
|
450
|
+
|
|
451
|
+
if (contactMids.length > 0) {
|
|
452
|
+
try {
|
|
453
|
+
const contacts = await client.base.talk.getContacts({ mids: contactMids })
|
|
454
|
+
for (const contact of contacts ?? []) {
|
|
455
|
+
if (contact.displayName) names.set(contact.mid, contact.displayName)
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
warnDegraded('resolve message author names', error)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (selfMid && authorMids.includes(selfMid) && !names.has(selfMid)) {
|
|
463
|
+
try {
|
|
464
|
+
const profile = await client.base.talk.getProfile()
|
|
465
|
+
if (profile.displayName) names.set(selfMid, profile.displayName)
|
|
466
|
+
} catch (error) {
|
|
467
|
+
warnDegraded('resolve own display name', error)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return names
|
|
472
|
+
}
|
|
473
|
+
|
|
353
474
|
async sendMessage(chatId: string, text: string): Promise<LineSendResult> {
|
|
354
475
|
try {
|
|
355
476
|
const client = this.ensureClient()
|
|
@@ -418,12 +539,15 @@ export class LineClient {
|
|
|
418
539
|
// surface the message with null text, since its text is unreadable.
|
|
419
540
|
let raw = op.message
|
|
420
541
|
let decrypted = true
|
|
542
|
+
let decryptionError: LineDecryptionError | undefined
|
|
421
543
|
try {
|
|
422
544
|
raw = await client.base.e2ee.decryptE2EEMessage(op.message)
|
|
423
|
-
} catch {
|
|
545
|
+
} catch (error) {
|
|
424
546
|
raw = op.message
|
|
425
547
|
decrypted = false
|
|
548
|
+
decryptionError = getDecryptionError(error)
|
|
426
549
|
}
|
|
550
|
+
decryptionError ??= getUndecryptableMessageError(raw)
|
|
427
551
|
yield {
|
|
428
552
|
kind: 'message',
|
|
429
553
|
message: {
|
|
@@ -432,6 +556,7 @@ export class LineClient {
|
|
|
432
556
|
from: { id: raw.from },
|
|
433
557
|
isMyMessage: selfMid === raw.from,
|
|
434
558
|
text: decrypted ? (raw.text ?? null) : null,
|
|
559
|
+
...(decryptionError && { decryption_error: decryptionError }),
|
|
435
560
|
},
|
|
436
561
|
}
|
|
437
562
|
}
|
|
@@ -449,3 +574,48 @@ export class LineClient {
|
|
|
449
574
|
return this.client
|
|
450
575
|
}
|
|
451
576
|
}
|
|
577
|
+
|
|
578
|
+
function getUndecryptableMessageError(raw: unknown): LineDecryptionError | undefined {
|
|
579
|
+
if (!isEncryptedChunkMessage(raw) || hasPlainText(raw)) return undefined
|
|
580
|
+
return {
|
|
581
|
+
code: 'missing_e2ee_key',
|
|
582
|
+
message: 'LINE message is encrypted with Letter Sealing, but this session has no saved E2EE key material.',
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function getDecryptionError(error: unknown): LineDecryptionError {
|
|
587
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
588
|
+
return {
|
|
589
|
+
code: /NoE2EEKey|E2EE Key has not been saved|saveE2EE/i.test(message) ? 'missing_e2ee_key' : 'decrypt_failed',
|
|
590
|
+
message,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function hasPlainText(raw: unknown): boolean {
|
|
595
|
+
if (!raw || typeof raw !== 'object') return false
|
|
596
|
+
const text = (raw as { text?: unknown }).text
|
|
597
|
+
return typeof text === 'string' && text.length > 0
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isEncryptedChunkMessage(raw: unknown): boolean {
|
|
601
|
+
if (!raw || typeof raw !== 'object') return false
|
|
602
|
+
const message = raw as { chunks?: unknown; contentMetadata?: unknown; metadata?: unknown }
|
|
603
|
+
if (!Array.isArray(message.chunks) || message.chunks.length === 0) return false
|
|
604
|
+
return hasE2EEMetadata(message.contentMetadata) || hasE2EEMetadata(message.metadata)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// decryptE2EEMessage reads messageObj.contentMetadata.e2eeVersion unconditionally.
|
|
608
|
+
// History messages from getPreviousMessagesV2WithRequest may carry E2EE metadata
|
|
609
|
+
// only under `metadata`, which would crash the decryptor on undefined.e2eeVersion.
|
|
610
|
+
function normalizeE2EEMetadata<T extends VendorMessage>(msg: T): T {
|
|
611
|
+
const m = msg as { contentMetadata?: unknown; metadata?: unknown }
|
|
612
|
+
if (hasE2EEMetadata(m.contentMetadata)) return msg
|
|
613
|
+
if (!hasE2EEMetadata(m.metadata)) return msg
|
|
614
|
+
return { ...msg, contentMetadata: m.metadata }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function hasE2EEMetadata(raw: unknown): boolean {
|
|
618
|
+
if (!raw || typeof raw !== 'object') return false
|
|
619
|
+
const metadata = raw as { e2eeMark?: unknown; e2eeVersion?: unknown }
|
|
620
|
+
return metadata.e2eeMark !== undefined || metadata.e2eeVersion !== undefined
|
|
621
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { ensureSelfKeyForMid, migrateOwnE2EEKeys } from './e2ee-storage'
|
|
4
|
+
|
|
5
|
+
function memStore(initial: Record<string, string> = {}) {
|
|
6
|
+
const data: Record<string, string> = { ...initial }
|
|
7
|
+
return {
|
|
8
|
+
data,
|
|
9
|
+
async get(key: string) {
|
|
10
|
+
return data[key]
|
|
11
|
+
},
|
|
12
|
+
async set(key: string, value: string) {
|
|
13
|
+
data[key] = value
|
|
14
|
+
},
|
|
15
|
+
async getAll() {
|
|
16
|
+
return { ...data }
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SELF_A = { keyId: 100, privKey: 'privA', pubKey: 'pubA', e2eeVersion: 2 }
|
|
22
|
+
const SELF_B = { keyId: 200, privKey: 'privB', pubKey: 'pubB', e2eeVersion: 2 }
|
|
23
|
+
|
|
24
|
+
describe('ensureSelfKeyForMid', () => {
|
|
25
|
+
it('keeps an existing MID-addressed self-key', async () => {
|
|
26
|
+
const store = memStore({ 'e2eeKeys:midA': JSON.stringify(SELF_A) })
|
|
27
|
+
|
|
28
|
+
const ok = await ensureSelfKeyForMid(store, 'midA', [{ keyId: 100 }])
|
|
29
|
+
|
|
30
|
+
expect(ok).toBe(true)
|
|
31
|
+
expect(JSON.parse(store.data['e2eeKeys:midA'])).toMatchObject({ privKey: 'privA' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('promotes a keyId-addressed self-key when the server advertises that keyId', async () => {
|
|
35
|
+
const store = memStore({ 'e2eeKeys:100': JSON.stringify(SELF_A) })
|
|
36
|
+
|
|
37
|
+
const ok = await ensureSelfKeyForMid(store, 'midA', [{ keyId: 100 }])
|
|
38
|
+
|
|
39
|
+
expect(ok).toBe(true)
|
|
40
|
+
expect(JSON.parse(store.data['e2eeKeys:midA'])).toMatchObject({ privKey: 'privA' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('supports the array-shaped public key (keyId at index 2)', async () => {
|
|
44
|
+
const store = memStore({ 'e2eeKeys:100': JSON.stringify(SELF_A) })
|
|
45
|
+
|
|
46
|
+
const ok = await ensureSelfKeyForMid(store, 'midA', [[undefined, undefined, 100] as never])
|
|
47
|
+
|
|
48
|
+
expect(ok).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('never promotes a foreign-MID-addressed key (no advertised match)', async () => {
|
|
52
|
+
// given: storage contaminated with another account's self-key under its MID
|
|
53
|
+
const store = memStore({ 'e2eeKeys:midB': JSON.stringify(SELF_B) })
|
|
54
|
+
|
|
55
|
+
// when: account A has no advertised keyId matching any stored key
|
|
56
|
+
const ok = await ensureSelfKeyForMid(store, 'midA', [{ keyId: 999 }])
|
|
57
|
+
|
|
58
|
+
// then: A's MID key is not created from B's material
|
|
59
|
+
expect(ok).toBe(false)
|
|
60
|
+
expect(store.data['e2eeKeys:midA']).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never promotes a keyId key the server did not advertise for this account', async () => {
|
|
64
|
+
const store = memStore({ 'e2eeKeys:200': JSON.stringify(SELF_B) })
|
|
65
|
+
|
|
66
|
+
const ok = await ensureSelfKeyForMid(store, 'midA', [{ keyId: 100 }])
|
|
67
|
+
|
|
68
|
+
expect(ok).toBe(false)
|
|
69
|
+
expect(store.data['e2eeKeys:midA']).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects a payload whose own keyId does not match the advertised slot', async () => {
|
|
73
|
+
// given: the slot e2eeKeys:100 holds a payload that claims keyId 999
|
|
74
|
+
const mislabeled = { keyId: 999, privKey: 'privX', pubKey: 'pubX', e2eeVersion: 2 }
|
|
75
|
+
const store = memStore({ 'e2eeKeys:100': JSON.stringify(mislabeled) })
|
|
76
|
+
|
|
77
|
+
const ok = await ensureSelfKeyForMid(store, 'midA', [{ keyId: 100 }])
|
|
78
|
+
|
|
79
|
+
expect(ok).toBe(false)
|
|
80
|
+
expect(store.data['e2eeKeys:midA']).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns false when no keys exist', async () => {
|
|
84
|
+
const store = memStore()
|
|
85
|
+
expect(await ensureSelfKeyForMid(store, 'midA', [{ keyId: 100 }])).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns false for an empty mid', async () => {
|
|
89
|
+
const store = memStore({ 'e2eeKeys:100': JSON.stringify(SELF_A) })
|
|
90
|
+
expect(await ensureSelfKeyForMid(store, '', [{ keyId: 100 }])).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('migrateOwnE2EEKeys', () => {
|
|
95
|
+
it('copies only the advertised keyId material into the target', async () => {
|
|
96
|
+
const source = memStore({
|
|
97
|
+
'e2eeKeys:100': JSON.stringify(SELF_A),
|
|
98
|
+
'e2eePublicKeys:100': 'pubkey-blob-A',
|
|
99
|
+
'e2eeKeys:200': JSON.stringify(SELF_B),
|
|
100
|
+
'e2eePublicKeys:200': 'pubkey-blob-B',
|
|
101
|
+
})
|
|
102
|
+
const target = memStore()
|
|
103
|
+
|
|
104
|
+
const count = await migrateOwnE2EEKeys(source, target, 'midA', [{ keyId: 100 }])
|
|
105
|
+
|
|
106
|
+
expect(count).toBe(1)
|
|
107
|
+
expect(JSON.parse(target.data['e2eeKeys:100'])).toMatchObject({ privKey: 'privA' })
|
|
108
|
+
expect(target.data['e2eePublicKeys:100']).toBe('pubkey-blob-A')
|
|
109
|
+
// foreign account B keys must not leak across
|
|
110
|
+
expect(target.data['e2eeKeys:200']).toBeUndefined()
|
|
111
|
+
expect(target.data['e2eePublicKeys:200']).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('copies the MID-addressed self-key when present', async () => {
|
|
115
|
+
const source = memStore({ 'e2eeKeys:midA': JSON.stringify(SELF_A) })
|
|
116
|
+
const target = memStore()
|
|
117
|
+
|
|
118
|
+
const count = await migrateOwnE2EEKeys(source, target, 'midA', [])
|
|
119
|
+
|
|
120
|
+
expect(count).toBe(1)
|
|
121
|
+
expect(JSON.parse(target.data['e2eeKeys:midA'])).toMatchObject({ privKey: 'privA' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('migrates nothing when only foreign keys are present', async () => {
|
|
125
|
+
const source = memStore({ 'e2eeKeys:midB': JSON.stringify(SELF_B), 'e2eeKeys:200': JSON.stringify(SELF_B) })
|
|
126
|
+
const target = memStore()
|
|
127
|
+
|
|
128
|
+
const count = await migrateOwnE2EEKeys(source, target, 'midA', [{ keyId: 100 }])
|
|
129
|
+
|
|
130
|
+
expect(count).toBe(0)
|
|
131
|
+
expect(Object.keys(target.data)).toHaveLength(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('skips a payload whose own keyId does not match the advertised slot', async () => {
|
|
135
|
+
const mislabeled = { keyId: 999, privKey: 'privX', pubKey: 'pubX', e2eeVersion: 2 }
|
|
136
|
+
const source = memStore({ 'e2eeKeys:100': JSON.stringify(mislabeled), 'e2eePublicKeys:100': 'blob' })
|
|
137
|
+
const target = memStore()
|
|
138
|
+
|
|
139
|
+
const count = await migrateOwnE2EEKeys(source, target, 'midA', [{ keyId: 100 }])
|
|
140
|
+
|
|
141
|
+
expect(count).toBe(0)
|
|
142
|
+
expect(target.data['e2eeKeys:100']).toBeUndefined()
|
|
143
|
+
expect(target.data['e2eePublicKeys:100']).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('ignores malformed key entries', async () => {
|
|
147
|
+
const source = memStore({ 'e2eeKeys:100': 'not-json', 'e2eeKeys:midA': '{"keyId":1}' })
|
|
148
|
+
const target = memStore()
|
|
149
|
+
|
|
150
|
+
const count = await migrateOwnE2EEKeys(source, target, 'midA', [{ keyId: 100 }])
|
|
151
|
+
|
|
152
|
+
expect(count).toBe(0)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface E2EEKeyStore {
|
|
2
|
+
get(key: string): Promise<unknown> | unknown
|
|
3
|
+
set(key: string, value: string): Promise<void> | void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface E2EEKeyData {
|
|
7
|
+
keyId: number | string
|
|
8
|
+
privKey: string
|
|
9
|
+
pubKey: string
|
|
10
|
+
e2eeVersion?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type E2EEPublicKey = { keyId?: number | string } | ReadonlyArray<unknown>
|
|
14
|
+
|
|
15
|
+
const SELF_KEY_PREFIX = 'e2eeKeys:'
|
|
16
|
+
|
|
17
|
+
function selfKey(id: number | string): string {
|
|
18
|
+
return `${SELF_KEY_PREFIX}${id}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isE2EEKeyData(value: unknown): value is E2EEKeyData {
|
|
22
|
+
if (!value || typeof value !== 'object') return false
|
|
23
|
+
const data = value as Partial<E2EEKeyData>
|
|
24
|
+
return typeof data.privKey === 'string' && typeof data.pubKey === 'string'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseKeyData(raw: unknown): E2EEKeyData | null {
|
|
28
|
+
if (typeof raw !== 'string') return isE2EEKeyData(raw) ? raw : null
|
|
29
|
+
try {
|
|
30
|
+
const parsed: unknown = JSON.parse(raw)
|
|
31
|
+
return isE2EEKeyData(parsed) ? parsed : null
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The stored payload carries its own keyId. Requiring it to equal the slot it was
|
|
38
|
+
// read from rejects entries mislabeled under an advertised keyId, so only a payload
|
|
39
|
+
// genuinely belonging to that keyId is ever trusted.
|
|
40
|
+
function keyDataMatchesSlot(data: E2EEKeyData, keyId: number | string): boolean {
|
|
41
|
+
return String(data.keyId) === String(keyId)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function keyIdOf(key: E2EEPublicKey): number | string | undefined {
|
|
45
|
+
if (Array.isArray(key)) {
|
|
46
|
+
const id = key[2]
|
|
47
|
+
return typeof id === 'number' || typeof id === 'string' ? id : undefined
|
|
48
|
+
}
|
|
49
|
+
const id = (key as { keyId?: number | string }).keyId
|
|
50
|
+
return typeof id === 'number' || typeof id === 'string' ? id : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function advertisedKeyIds(keys: ReadonlyArray<E2EEPublicKey> | undefined): Array<number | string> {
|
|
54
|
+
if (!keys) return []
|
|
55
|
+
const ids: Array<number | string> = []
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
const id = keyIdOf(key)
|
|
58
|
+
if (id !== undefined) ids.push(id)
|
|
59
|
+
}
|
|
60
|
+
return ids
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Ensures the account's own self-key is addressable by MID for getE2EESelfKeyData,
|
|
64
|
+
// which checks `e2eeKeys:<mid>` before falling back to a live Thrift channel. The
|
|
65
|
+
// keyId-addressed entry is only trusted when the server advertises that exact keyId
|
|
66
|
+
// for this account, so a contaminated store (another account's key copied in) can
|
|
67
|
+
// never promote a foreign private key to this MID. Returns true when a self-key
|
|
68
|
+
// becomes available under the MID.
|
|
69
|
+
export async function ensureSelfKeyForMid(
|
|
70
|
+
storage: E2EEKeyStore,
|
|
71
|
+
mid: string,
|
|
72
|
+
advertisedKeys: ReadonlyArray<E2EEPublicKey> | undefined,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
if (!mid) return false
|
|
75
|
+
|
|
76
|
+
const existing = parseKeyData(await storage.get(selfKey(mid)))
|
|
77
|
+
if (existing) return true
|
|
78
|
+
|
|
79
|
+
for (const keyId of advertisedKeyIds(advertisedKeys)) {
|
|
80
|
+
const candidate = parseKeyData(await storage.get(selfKey(keyId)))
|
|
81
|
+
if (candidate && keyDataMatchesSlot(candidate, keyId)) {
|
|
82
|
+
await storage.set(selfKey(mid), JSON.stringify(candidate))
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Copies only this account's E2EE key material out of a shared (default) store into
|
|
91
|
+
// an isolated per-account store. Trust is anchored to the server-advertised keyIds
|
|
92
|
+
// for `mid`, so foreign-account keys present in the shared store are never carried
|
|
93
|
+
// over. Used right after a fresh login resolves the account MID.
|
|
94
|
+
export async function migrateOwnE2EEKeys(
|
|
95
|
+
source: E2EEKeyStore,
|
|
96
|
+
target: E2EEKeyStore,
|
|
97
|
+
mid: string,
|
|
98
|
+
advertisedKeys: ReadonlyArray<E2EEPublicKey> | undefined,
|
|
99
|
+
): Promise<number> {
|
|
100
|
+
if (!mid) return 0
|
|
101
|
+
|
|
102
|
+
let migrated = 0
|
|
103
|
+
const own = parseKeyData(await source.get(selfKey(mid)))
|
|
104
|
+
if (own) {
|
|
105
|
+
await target.set(selfKey(mid), JSON.stringify(own))
|
|
106
|
+
migrated++
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const keyId of advertisedKeyIds(advertisedKeys)) {
|
|
110
|
+
const data = parseKeyData(await source.get(selfKey(keyId)))
|
|
111
|
+
if (!data || !keyDataMatchesSlot(data, keyId)) continue
|
|
112
|
+
await target.set(selfKey(keyId), JSON.stringify(data))
|
|
113
|
+
migrated++
|
|
114
|
+
const publicKey = await source.get(`e2eePublicKeys:${keyId}`)
|
|
115
|
+
if (typeof publicKey === 'string') await target.set(`e2eePublicKeys:${keyId}`, publicKey)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return migrated
|
|
119
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -230,6 +230,38 @@ describe('LineListener', () => {
|
|
|
230
230
|
expect(messages[0].content_type).toBe('NONE')
|
|
231
231
|
})
|
|
232
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
|
+
|
|
233
265
|
it('coerces non-string contentMetadata values to strings', async () => {
|
|
234
266
|
const client = createMockLineClient()
|
|
235
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,20 @@ 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
103
|
const contentType = String(msg.raw.contentType ?? 'NONE')
|
|
104
104
|
const event: LinePushMessageEvent = {
|
|
105
105
|
type: 'message',
|
|
106
106
|
chat_id: chatId,
|
|
107
107
|
message_id: String(msg.raw.id),
|
|
108
|
-
author_id: msg.from.id,
|
|
108
|
+
author_id: String(msg.from.id),
|
|
109
109
|
text: getMessageText(msg.text, msg.raw, contentType),
|
|
110
|
+
...(msg.decryption_error && { decryption_error: msg.decryption_error }),
|
|
110
111
|
content_type: contentType,
|
|
111
112
|
content_metadata: normalizeContentMetadata(msg.raw.contentMetadata),
|
|
112
113
|
sent_at: new Date(Number(msg.raw.createdTime)).toISOString(),
|