agent-messenger 2.19.5 → 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/dist/package.json +1 -1
- package/dist/src/platforms/line/client.d.ts +4 -0
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/client.js +109 -17
- 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/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/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 +5 -2
- 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 +168 -16
- package/src/platforms/line/client.ts +127 -23
- package/src/platforms/line/e2ee-storage.test.ts +154 -0
- package/src/platforms/line/e2ee-storage.ts +119 -0
- 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
|
@@ -438,6 +438,34 @@ SNAPSHOT=$(teams_cmd agent-teams snapshot)
|
|
|
438
438
|
|
|
439
439
|
**When to use**: Any script that runs for more than a few minutes.
|
|
440
440
|
|
|
441
|
+
## Pattern 12: Personal Account Chats (No Teams)
|
|
442
|
+
|
|
443
|
+
**Use case**: Message from a personal Microsoft account (`@outlook.com` / `@live.com`), which has no teams or channels — only 1:1, group, and self ("to me") chats
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
#!/bin/bash
|
|
447
|
+
|
|
448
|
+
# Personal accounts have no teams, so `team list` is empty and team/channel
|
|
449
|
+
# commands fail with "No current team set". Use the `chat` commands instead.
|
|
450
|
+
|
|
451
|
+
agent-teams auth extract 2>/dev/null || true
|
|
452
|
+
|
|
453
|
+
# List chats (type is oneOnOne, group, or self)
|
|
454
|
+
CHATS=$(agent-teams chat list)
|
|
455
|
+
echo "$CHATS" | jq -r '.[] | " \(.type): \(.id) — \(.topic // .last_message // "")"'
|
|
456
|
+
|
|
457
|
+
# Pick a chat ID (here: the self "to me" notes thread)
|
|
458
|
+
CHAT_ID=$(echo "$CHATS" | jq -r '.[] | select(.type=="self") | .id')
|
|
459
|
+
|
|
460
|
+
# Read history and send a message
|
|
461
|
+
agent-teams chat history "$CHAT_ID" --limit 20
|
|
462
|
+
agent-teams chat send "$CHAT_ID" "Reminder: stand-up at 10am"
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
**When to use**: Any workflow on a personal/consumer Teams account. Get chat IDs from `chat list` — they look like `19:guid1_guid2@unq.gbl.spaces` (1:1), `19:guid@thread.tacv2` (group), or `48:notes` (self).
|
|
466
|
+
|
|
467
|
+
**Note**: Work accounts also have 1:1 and group chats and can use these same `chat` commands.
|
|
468
|
+
|
|
441
469
|
## Best Practices
|
|
442
470
|
|
|
443
471
|
### 1. Always Handle Token Expiry
|
|
@@ -56,9 +56,10 @@ describe('LineClient', () => {
|
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
describe('getMessages()', () => {
|
|
59
|
-
function clientWithTalk(talk: Record<string, unknown>): LineClient {
|
|
59
|
+
function clientWithTalk(talk: Record<string, unknown>, e2ee?: Record<string, unknown>): LineClient {
|
|
60
60
|
const client = new LineClient()
|
|
61
|
-
|
|
61
|
+
const { profile, ...talkMethods } = talk
|
|
62
|
+
;(client as any).client = { base: { talk: talkMethods, profile, e2ee: e2ee ?? {} } }
|
|
62
63
|
return client
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -106,28 +107,179 @@ describe('LineClient', () => {
|
|
|
106
107
|
expect(result.map((m) => m.text)).toEqual(['c', 'b'])
|
|
107
108
|
})
|
|
108
109
|
|
|
109
|
-
it('
|
|
110
|
+
it('resolves author MIDs to display names via getContacts', async () => {
|
|
110
111
|
const client = clientWithTalk({
|
|
112
|
+
profile: { mid: 'me' },
|
|
111
113
|
getServerTime: async () => 1700000000000,
|
|
112
114
|
getPreviousMessagesV2WithRequest: async () => [
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
{ id: '10', from: 'u1', text: 'hi', contentType: 'NONE', createdTime: 1700000001000 },
|
|
116
|
+
{ id: '11', from: 'u2', text: 'yo', contentType: 'NONE', createdTime: 1700000002000 },
|
|
117
|
+
],
|
|
118
|
+
getContacts: async ({ mids }: { mids: string[] }) =>
|
|
119
|
+
mids.map((mid) => ({ mid, displayName: mid === 'u1' ? 'Alice' : 'Bob' })),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const result = await client.getMessages('chat1', { count: 2 })
|
|
123
|
+
expect(result.map((m) => m.author_name)).toEqual(['Alice', 'Bob'])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('batch-resolves unique authors in a single getContacts call', async () => {
|
|
127
|
+
let contactCalls = 0
|
|
128
|
+
const client = clientWithTalk({
|
|
129
|
+
profile: { mid: 'me' },
|
|
130
|
+
getServerTime: async () => 1700000000000,
|
|
131
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
132
|
+
{ id: '10', from: 'u1', text: 'a', contentType: 'NONE', createdTime: 1700000001000 },
|
|
133
|
+
{ id: '11', from: 'u1', text: 'b', contentType: 'NONE', createdTime: 1700000002000 },
|
|
134
|
+
{ id: '12', from: 'u2', text: 'c', contentType: 'NONE', createdTime: 1700000003000 },
|
|
122
135
|
],
|
|
136
|
+
getContacts: async ({ mids }: { mids: string[] }) => {
|
|
137
|
+
contactCalls++
|
|
138
|
+
expect([...mids].sort()).toEqual(['u1', 'u2'])
|
|
139
|
+
return mids.map((mid) => ({ mid, displayName: mid.toUpperCase() }))
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const result = await client.getMessages('chat1', { count: 3 })
|
|
144
|
+
expect(contactCalls).toBe(1)
|
|
145
|
+
expect(result.map((m) => m.author_name)).toEqual(['U1', 'U1', 'U2'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('resolves the current user via getProfile since getContacts omits self', async () => {
|
|
149
|
+
const client = clientWithTalk({
|
|
150
|
+
profile: { mid: 'me' },
|
|
151
|
+
getServerTime: async () => 1700000000000,
|
|
152
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
153
|
+
{ id: '10', from: 'me', text: 'mine', contentType: 'NONE', createdTime: 1700000001000 },
|
|
154
|
+
],
|
|
155
|
+
getContacts: async () => [],
|
|
156
|
+
getProfile: async () => ({ mid: 'me', displayName: 'My Name' }),
|
|
123
157
|
})
|
|
124
158
|
|
|
125
159
|
const result = await client.getMessages('chat1', { count: 1 })
|
|
126
|
-
expect(result[0].
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
160
|
+
expect(result[0].author_name).toBe('My Name')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('falls back to bare author_id when name resolution fails', async () => {
|
|
164
|
+
const client = clientWithTalk({
|
|
165
|
+
profile: { mid: 'me' },
|
|
166
|
+
getServerTime: async () => 1700000000000,
|
|
167
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
168
|
+
{ id: '10', from: 'u1', text: 'hi', contentType: 'NONE', createdTime: 1700000001000 },
|
|
169
|
+
],
|
|
170
|
+
getContacts: async () => {
|
|
171
|
+
throw new Error('network down')
|
|
172
|
+
},
|
|
130
173
|
})
|
|
174
|
+
|
|
175
|
+
const result = await client.getMessages('chat1', { count: 1 })
|
|
176
|
+
expect(result[0].author_name).toBeUndefined()
|
|
177
|
+
expect(result[0].author_id).toBe('u1')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('decrypts Letter-Sealing chunk messages via decryptE2EEMessage', async () => {
|
|
181
|
+
const encrypted = {
|
|
182
|
+
id: '40',
|
|
183
|
+
from: 'u1',
|
|
184
|
+
text: null,
|
|
185
|
+
contentType: 'NONE',
|
|
186
|
+
createdTime: 1700000004000,
|
|
187
|
+
chunks: ['a', 'b'],
|
|
188
|
+
metadata: { e2eeMark: '2', e2eeVersion: '2' },
|
|
189
|
+
}
|
|
190
|
+
const client = clientWithTalk(
|
|
191
|
+
{
|
|
192
|
+
getServerTime: async () => 1700000000000,
|
|
193
|
+
getPreviousMessagesV2WithRequest: async () => [encrypted],
|
|
194
|
+
},
|
|
195
|
+
{ decryptE2EEMessage: async (m: { id: string }) => ({ ...m, text: 'decrypted secret' }) },
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const result = await client.getMessages('chat1', { count: 1 })
|
|
199
|
+
expect(result[0].text).toBe('decrypted secret')
|
|
200
|
+
expect(result[0].decryption_error).toBeUndefined()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('normalizes metadata-shaped history messages to contentMetadata before decrypting', async () => {
|
|
204
|
+
let received: { contentMetadata?: unknown } | undefined
|
|
205
|
+
const client = clientWithTalk(
|
|
206
|
+
{
|
|
207
|
+
getServerTime: async () => 1700000000000,
|
|
208
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
209
|
+
{
|
|
210
|
+
id: '40',
|
|
211
|
+
from: 'u1',
|
|
212
|
+
text: null,
|
|
213
|
+
contentType: 'NONE',
|
|
214
|
+
createdTime: 1700000004000,
|
|
215
|
+
chunks: ['a', 'b'],
|
|
216
|
+
metadata: { e2eeMark: '2', e2eeVersion: '2' },
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
decryptE2EEMessage: async (m: { contentMetadata?: unknown }) => {
|
|
222
|
+
received = m
|
|
223
|
+
// mirror the vendor decryptor: it reads contentMetadata.e2eeVersion
|
|
224
|
+
const meta = m.contentMetadata as { e2eeVersion?: string }
|
|
225
|
+
return { text: `v${meta.e2eeVersion}` }
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const result = await client.getMessages('chat1', { count: 1 })
|
|
231
|
+
expect((received?.contentMetadata as { e2eeVersion?: string })?.e2eeVersion).toBe('2')
|
|
232
|
+
expect(result[0].text).toBe('v2')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('surfaces missing_e2ee_key when decryption fails for lack of keys', async () => {
|
|
236
|
+
const client = clientWithTalk(
|
|
237
|
+
{
|
|
238
|
+
getServerTime: async () => 1700000000000,
|
|
239
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
240
|
+
{
|
|
241
|
+
id: '40',
|
|
242
|
+
from: 'u1',
|
|
243
|
+
text: null,
|
|
244
|
+
contentType: 'NONE',
|
|
245
|
+
createdTime: 1700000004000,
|
|
246
|
+
chunks: ['a', 'b'],
|
|
247
|
+
metadata: { e2eeMark: '2', e2eeVersion: '2' },
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
decryptE2EEMessage: async () => {
|
|
253
|
+
throw new Error('NoE2EEKey: E2EE Key has not been saved')
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const result = await client.getMessages('chat1', { count: 1 })
|
|
259
|
+
expect(result[0].text).toBeNull()
|
|
260
|
+
expect(result[0].decryption_error?.code).toBe('missing_e2ee_key')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('does not decrypt plain messages that already carry text', async () => {
|
|
264
|
+
let decryptCalls = 0
|
|
265
|
+
const client = clientWithTalk(
|
|
266
|
+
{
|
|
267
|
+
getServerTime: async () => 1700000000000,
|
|
268
|
+
getPreviousMessagesV2WithRequest: async () => [
|
|
269
|
+
{ id: '50', from: 'u1', text: 'plain hello', contentType: 'NONE', createdTime: 1700000005000 },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
decryptE2EEMessage: async () => {
|
|
274
|
+
decryptCalls++
|
|
275
|
+
return { text: 'should-not-run' }
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const result = await client.getMessages('chat1', { count: 1 })
|
|
281
|
+
expect(result[0].text).toBe('plain hello')
|
|
282
|
+
expect(decryptCalls).toBe(0)
|
|
131
283
|
})
|
|
132
284
|
})
|
|
133
285
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
95
|
+
function lineStorageDir(): string {
|
|
93
96
|
const dir = join(getConfigDir(), 'line-storage')
|
|
94
97
|
mkdirSync(dir, { recursive: true })
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
return dir
|
|
99
|
+
}
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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()
|
|
@@ -510,6 +604,16 @@ function isEncryptedChunkMessage(raw: unknown): boolean {
|
|
|
510
604
|
return hasE2EEMetadata(message.contentMetadata) || hasE2EEMetadata(message.metadata)
|
|
511
605
|
}
|
|
512
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
|
+
|
|
513
617
|
function hasE2EEMetadata(raw: unknown): boolean {
|
|
514
618
|
if (!raw || typeof raw !== 'object') return false
|
|
515
619
|
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
|
+
})
|