agent-messenger 2.19.5 → 2.20.1

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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/line/client.d.ts +4 -0
  4. package/dist/src/platforms/line/client.d.ts.map +1 -1
  5. package/dist/src/platforms/line/client.js +124 -24
  6. package/dist/src/platforms/line/client.js.map +1 -1
  7. package/dist/src/platforms/line/e2ee-storage.d.ts +16 -0
  8. package/dist/src/platforms/line/e2ee-storage.d.ts.map +1 -0
  9. package/dist/src/platforms/line/e2ee-storage.js +93 -0
  10. package/dist/src/platforms/line/e2ee-storage.js.map +1 -0
  11. package/dist/src/platforms/teams/cli.d.ts.map +1 -1
  12. package/dist/src/platforms/teams/cli.js +2 -1
  13. package/dist/src/platforms/teams/cli.js.map +1 -1
  14. package/dist/src/platforms/teams/client.d.ts +4 -1
  15. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  16. package/dist/src/platforms/teams/client.js +84 -0
  17. package/dist/src/platforms/teams/client.js.map +1 -1
  18. package/dist/src/platforms/teams/commands/chat.d.ts +13 -0
  19. package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -0
  20. package/dist/src/platforms/teams/commands/chat.js +111 -0
  21. package/dist/src/platforms/teams/commands/chat.js.map +1 -0
  22. package/dist/src/platforms/teams/commands/index.d.ts +1 -0
  23. package/dist/src/platforms/teams/commands/index.d.ts.map +1 -1
  24. package/dist/src/platforms/teams/commands/index.js +1 -0
  25. package/dist/src/platforms/teams/commands/index.js.map +1 -1
  26. package/dist/src/platforms/teams/types.d.ts +24 -0
  27. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  28. package/dist/src/platforms/teams/types.js +8 -0
  29. package/dist/src/platforms/teams/types.js.map +1 -1
  30. package/dist/src/tui/adapters/line-adapter.js +1 -1
  31. package/dist/src/tui/adapters/line-adapter.js.map +1 -1
  32. package/docs/content/docs/cli/line.mdx +13 -11
  33. package/package.json +1 -1
  34. package/skills/agent-channeltalk/SKILL.md +1 -1
  35. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  36. package/skills/agent-discord/SKILL.md +1 -1
  37. package/skills/agent-discordbot/SKILL.md +1 -1
  38. package/skills/agent-instagram/SKILL.md +1 -1
  39. package/skills/agent-kakaotalk/SKILL.md +1 -1
  40. package/skills/agent-line/SKILL.md +7 -5
  41. package/skills/agent-line/references/common-patterns.md +5 -2
  42. package/skills/agent-slack/SKILL.md +1 -1
  43. package/skills/agent-slackbot/SKILL.md +1 -1
  44. package/skills/agent-teams/SKILL.md +20 -2
  45. package/skills/agent-teams/references/common-patterns.md +28 -0
  46. package/skills/agent-telegram/SKILL.md +1 -1
  47. package/skills/agent-telegrambot/SKILL.md +1 -1
  48. package/skills/agent-webex/SKILL.md +1 -1
  49. package/skills/agent-wechatbot/SKILL.md +1 -1
  50. package/skills/agent-whatsapp/SKILL.md +1 -1
  51. package/skills/agent-whatsappbot/SKILL.md +1 -1
  52. package/src/platforms/line/client.test.ts +223 -20
  53. package/src/platforms/line/client.ts +141 -29
  54. package/src/platforms/line/e2ee-storage.test.ts +154 -0
  55. package/src/platforms/line/e2ee-storage.ts +119 -0
  56. package/src/platforms/teams/cli.ts +2 -0
  57. package/src/platforms/teams/client.test.ts +96 -0
  58. package/src/platforms/teams/client.ts +133 -0
  59. package/src/platforms/teams/commands/chat.test.ts +100 -0
  60. package/src/platforms/teams/commands/chat.ts +131 -0
  61. package/src/platforms/teams/commands/index.ts +1 -0
  62. package/src/platforms/teams/types.ts +20 -0
  63. package/src/tui/adapters/line-adapter.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
1
+ import { 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'
@@ -13,6 +13,7 @@ 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,
@@ -48,6 +49,8 @@ export interface LineRawMessage {
48
49
 
49
50
  export type LineRawEvent = { kind: 'message'; message: LineRawMessage } | { kind: 'event'; op: LineOperation }
50
51
 
52
+ type VendorMessage = Parameters<Client['base']['e2ee']['decryptE2EEMessage']>[0]
53
+
51
54
  const MAX_MESSAGE_ID = 9223372036854775807n
52
55
 
53
56
  function wrapError(error: unknown, code: string): LineError {
@@ -89,17 +92,19 @@ function getDefaultDevice(): LineDevice {
89
92
  return 'ANDROIDSECONDARY'
90
93
  }
91
94
 
92
- function createStorage(accountId?: string): FileStorage {
95
+ function lineStorageDir(): string {
93
96
  const dir = join(getConfigDir(), 'line-storage')
94
97
  mkdirSync(dir, { recursive: true })
95
- const defaultPath = join(dir, 'default.json')
96
- if (!accountId) return new FileStorage(defaultPath)
98
+ return dir
99
+ }
97
100
 
98
- const accountPath = join(dir, `${accountId}.json`)
99
- if (!existsSync(accountPath) && existsSync(defaultPath)) {
100
- copyFileSync(defaultPath, accountPath)
101
- }
102
- return new FileStorage(accountPath)
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))
103
108
  }
104
109
 
105
110
  export class LineClient {
@@ -130,7 +135,7 @@ export class LineClient {
130
135
  this.client = client
131
136
 
132
137
  const profile = await client.base.talk.getProfile()
133
- createStorage(profile.mid)
138
+ await this.persistAccountE2EEKeys(client, profile.mid)
134
139
  const now = new Date().toISOString()
135
140
 
136
141
  await this.credManager.setAccount({
@@ -176,7 +181,7 @@ export class LineClient {
176
181
  this.client = client
177
182
 
178
183
  const profile = await client.base.talk.getProfile()
179
- createStorage(profile.mid)
184
+ await this.persistAccountE2EEKeys(client, profile.mid)
180
185
  const now = new Date().toISOString()
181
186
 
182
187
  await this.credManager.setAccount({
@@ -214,12 +219,38 @@ export class LineClient {
214
219
  const storage = createStorage(creds.account_id)
215
220
 
216
221
  this.client = await linejsLoginWithAuthToken(creds.auth_token, { device, storage })
222
+ await this.repairSelfE2EEKey(this.client, creds.account_id)
217
223
  return this
218
224
  } catch (error) {
219
225
  throw wrapError(error, 'login_failed')
220
226
  }
221
227
  }
222
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
+
223
254
  async getProfile(): Promise<LineProfile> {
224
255
  try {
225
256
  const profile = await this.ensureClient().base.talk.getProfile()
@@ -360,23 +391,86 @@ export class LineClient {
360
391
  },
361
392
  })
362
393
 
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
- })
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
+ )
375
414
  } catch (error) {
376
415
  throw wrapError(error, 'get_messages_failed')
377
416
  }
378
417
  }
379
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
+
380
474
  async sendMessage(chatId: string, text: string): Promise<LineSendResult> {
381
475
  try {
382
476
  const client = this.ensureClient()
@@ -439,6 +533,12 @@ export class LineClient {
439
533
  })) {
440
534
  yield { kind: 'event', op }
441
535
  if (op.type === 'SEND_MESSAGE' || op.type === 'RECEIVE_MESSAGE') {
536
+ // Plain messages already carry their text and must skip decryption, same
537
+ // as the history path (decryptMessageText). Force-decrypting a plain op
538
+ // returns an object without `.text`, silently dropping the message to
539
+ // null text with no decryption_error — indistinguishable from an empty
540
+ // message downstream.
541
+ //
442
542
  // A single undecryptable message must not kill the stream: the failing
443
543
  // op stays in the sync window and would be re-fetched every poll, causing
444
544
  // an endless decrypt-fail -> reconnect loop. Fall back to the raw op and
@@ -446,12 +546,14 @@ export class LineClient {
446
546
  let raw = op.message
447
547
  let decrypted = true
448
548
  let decryptionError: LineDecryptionError | undefined
449
- try {
450
- raw = await client.base.e2ee.decryptE2EEMessage(op.message)
451
- } catch (error) {
452
- raw = op.message
453
- decrypted = false
454
- decryptionError = getDecryptionError(error)
549
+ if (isEncryptedChunkMessage(op.message)) {
550
+ try {
551
+ raw = await client.base.e2ee.decryptE2EEMessage(normalizeE2EEMetadata(op.message))
552
+ } catch (error) {
553
+ raw = op.message
554
+ decrypted = false
555
+ decryptionError = getDecryptionError(error)
556
+ }
455
557
  }
456
558
  decryptionError ??= getUndecryptableMessageError(raw)
457
559
  yield {
@@ -510,6 +612,16 @@ function isEncryptedChunkMessage(raw: unknown): boolean {
510
612
  return hasE2EEMetadata(message.contentMetadata) || hasE2EEMetadata(message.metadata)
511
613
  }
512
614
 
615
+ // decryptE2EEMessage reads messageObj.contentMetadata.e2eeVersion unconditionally.
616
+ // History messages from getPreviousMessagesV2WithRequest may carry E2EE metadata
617
+ // only under `metadata`, which would crash the decryptor on undefined.e2eeVersion.
618
+ function normalizeE2EEMetadata<T extends VendorMessage>(msg: T): T {
619
+ const m = msg as { contentMetadata?: unknown; metadata?: unknown }
620
+ if (hasE2EEMetadata(m.contentMetadata)) return msg
621
+ if (!hasE2EEMetadata(m.metadata)) return msg
622
+ return { ...msg, contentMetadata: m.metadata }
623
+ }
624
+
513
625
  function hasE2EEMetadata(raw: unknown): boolean {
514
626
  if (!raw || typeof raw !== 'object') return false
515
627
  const metadata = raw as { e2eeMark?: unknown; e2eeVersion?: unknown }
@@ -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
+ }
@@ -7,6 +7,7 @@ import pkg from '../../../package.json' with { type: 'json' }
7
7
  import {
8
8
  authCommand,
9
9
  channelCommand,
10
+ chatCommand,
10
11
  fileCommand,
11
12
  messageCommand,
12
13
  reactionCommand,
@@ -49,6 +50,7 @@ program.hook('preAction', async (_thisCommand, actionCommand) => {
49
50
  program.addCommand(authCommand)
50
51
  program.addCommand(teamCommand)
51
52
  program.addCommand(channelCommand)
53
+ program.addCommand(chatCommand)
52
54
  program.addCommand(fileCommand)
53
55
  program.addCommand(messageCommand)
54
56
  program.addCommand(reactionCommand)
@@ -169,6 +169,102 @@ describe('TeamsClient', () => {
169
169
  })
170
170
  })
171
171
 
172
+ describe('listChats', () => {
173
+ it('classifies chats and excludes teams', async () => {
174
+ mockResponse({
175
+ conversations: [
176
+ {
177
+ id: '19:team@thread.tacv2',
178
+ threadProperties: { groupId: '111', spaceThreadTopic: 'Team One', threadType: 'space' },
179
+ },
180
+ {
181
+ id: '48:notes',
182
+ threadProperties: { threadType: 'streamofnotes', productThreadType: 'StreamOfNotes' },
183
+ lastMessage: { content: 'Hi', composetime: '2024-01-03T00:00:00.000Z' },
184
+ },
185
+ {
186
+ id: '19:1on1@unq.gbl.spaces',
187
+ lastMessage: { content: '<p>Hi there</p>', composetime: '2024-01-01T00:00:00.000Z' },
188
+ },
189
+ {
190
+ id: '19:group@thread.tacv2',
191
+ threadProperties: { topic: 'Group Chat', threadType: 'chat' },
192
+ lastMessage: { content: 'Hello group', composetime: '2024-01-02T00:00:00.000Z' },
193
+ },
194
+ ],
195
+ })
196
+
197
+ const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
198
+ const chats = await client.listChats()
199
+
200
+ expect(chats).toHaveLength(3)
201
+ expect(chats[0]).toMatchObject({ id: '48:notes', type: 'self', last_message: 'Hi' })
202
+ expect(chats[1]).toMatchObject({ id: '19:1on1@unq.gbl.spaces', type: 'oneOnOne', last_message: 'Hi there' })
203
+ expect(chats[2]).toMatchObject({ id: '19:group@thread.tacv2', type: 'group', topic: 'Group Chat' })
204
+ expect(fetchCalls[0].url).toBe(
205
+ 'https://msgapi.teams.live.com/v1/users/ME/conversations?view=msnp24Equivalent&pageSize=500',
206
+ )
207
+ })
208
+ })
209
+
210
+ describe('getChatMessages', () => {
211
+ it('returns user messages and filters system events', async () => {
212
+ mockResponse({
213
+ messages: [
214
+ {
215
+ id: 'm1',
216
+ content: '<p>Hello</p>',
217
+ from: 'host/users/ME/contacts/8:alice',
218
+ imdisplayname: 'Alice',
219
+ composetime: '2024-01-01T00:00:00.000Z',
220
+ messagetype: 'RichText/Html',
221
+ },
222
+ {
223
+ id: 'm2',
224
+ content: 'Bob joined',
225
+ imdisplayname: 'System',
226
+ composetime: '2024-01-01T00:01:00.000Z',
227
+ messagetype: 'ThreadActivity/AddMember',
228
+ },
229
+ ],
230
+ })
231
+
232
+ const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
233
+ const messages = await client.getChatMessages('19:1on1@unq.gbl.spaces', 30)
234
+
235
+ expect(messages).toHaveLength(1)
236
+ expect(messages[0].id).toBe('m1')
237
+ expect(messages[0].content).toBe('Hello')
238
+ expect(messages[0].author.displayName).toBe('Alice')
239
+ expect(messages[0].channel_id).toBe('19:1on1@unq.gbl.spaces')
240
+ expect(fetchCalls[0].url).toBe(
241
+ 'https://msgapi.teams.live.com/v1/users/ME/conversations/19%3A1on1%40unq.gbl.spaces/messages?startTime=0&view=msnp24Equivalent&pageSize=30',
242
+ )
243
+ })
244
+ })
245
+
246
+ describe('sendChatMessage', () => {
247
+ it('sends an HTML-escaped message to a chat', async () => {
248
+ mockResponse({ OriginalArrivalTime: 1704067200000 })
249
+
250
+ const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
251
+ const message = await client.sendChatMessage('19:1on1@unq.gbl.spaces', 'a <b> & c')
252
+
253
+ expect(message.content).toBe('a <b> & c')
254
+ expect(fetchCalls[0].url).toBe(
255
+ 'https://msgapi.teams.live.com/v1/users/ME/conversations/19%3A1on1%40unq.gbl.spaces/messages',
256
+ )
257
+ expect(fetchCalls[0].options?.method).toBe('POST')
258
+ expect(fetchCalls[0].options?.body).toBe(
259
+ JSON.stringify({
260
+ content: 'a &lt;b&gt; &amp; c',
261
+ messagetype: 'RichText/Html',
262
+ contenttype: 'text',
263
+ }),
264
+ )
265
+ })
266
+ })
267
+
172
268
  describe('getTeam', () => {
173
269
  it('returns team info', async () => {
174
270
  mockResponse({ id: '111', name: 'Test Team', description: 'A test team' })