@rikaidev/yomi 0.2.6

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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +81 -0
  3. package/PRIVACY.md +39 -0
  4. package/README.md +659 -0
  5. package/dist/auth/credential-store.js +395 -0
  6. package/dist/auth/keychain.js +256 -0
  7. package/dist/cli/login.js +142 -0
  8. package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
  9. package/dist/line/auth/protocol/index.js +3 -0
  10. package/dist/line/auth/protocol/login-metadata.js +159 -0
  11. package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
  12. package/dist/line/auth/pwless/index.js +163 -0
  13. package/dist/line/auth/pwless/steps.js +213 -0
  14. package/dist/line/client/auth-service/client.js +240 -0
  15. package/dist/line/client/e2ee-service/client.js +69 -0
  16. package/dist/line/client/e2ee-service/requests.js +92 -0
  17. package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
  18. package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
  19. package/dist/line/client/index.js +213 -0
  20. package/dist/line/client/obs-media-client.js +140 -0
  21. package/dist/line/client/parsers.js +69 -0
  22. package/dist/line/client/shop-service/client.js +63 -0
  23. package/dist/line/client/sticker-meta.js +46 -0
  24. package/dist/line/client/sync-service/client.js +317 -0
  25. package/dist/line/client/talk-service/chat-query.js +122 -0
  26. package/dist/line/client/talk-service/command-client.js +242 -0
  27. package/dist/line/client/talk-service/contact-query.js +77 -0
  28. package/dist/line/client/talk-service/message-box-query.js +137 -0
  29. package/dist/line/client/talk-service/message-id-query.js +241 -0
  30. package/dist/line/client/talk-service/query-client.js +219 -0
  31. package/dist/line/client/talk-service/requests.js +383 -0
  32. package/dist/line/client/transport.js +138 -0
  33. package/dist/line/core/auth-session-logout.js +55 -0
  34. package/dist/line/core/auth-session-runtime.js +231 -0
  35. package/dist/line/core/auth-session-service.js +85 -0
  36. package/dist/line/core/chat-runtime-service.js +343 -0
  37. package/dist/line/core/client-runtime.js +17 -0
  38. package/dist/line/core/config.js +67 -0
  39. package/dist/line/core/constants.js +56 -0
  40. package/dist/line/core/contact-query-service.js +20 -0
  41. package/dist/line/core/directory-runtime-service.js +83 -0
  42. package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
  43. package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
  44. package/dist/line/core/e2ee/index.js +6 -0
  45. package/dist/line/core/e2ee/keys/decrypt.js +110 -0
  46. package/dist/line/core/e2ee/keys/encrypt.js +117 -0
  47. package/dist/line/core/e2ee/keys/group-key.js +260 -0
  48. package/dist/line/core/e2ee/keys/key-manager.js +231 -0
  49. package/dist/line/core/e2ee/keys/key-payload.js +130 -0
  50. package/dist/line/core/e2ee/keys/key-types.js +1 -0
  51. package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
  52. package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
  53. package/dist/line/core/e2ee/keys/peer-key.js +191 -0
  54. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
  55. package/dist/line/core/e2ee/types.js +4 -0
  56. package/dist/line/core/group-query-service.js +20 -0
  57. package/dist/line/core/media-decrypt.js +78 -0
  58. package/dist/line/core/media-encrypt.js +97 -0
  59. package/dist/line/core/mention.js +89 -0
  60. package/dist/line/core/message-command-service.js +454 -0
  61. package/dist/line/core/message-query-service.js +161 -0
  62. package/dist/line/core/service.js +134 -0
  63. package/dist/line/core/session-state/index.js +243 -0
  64. package/dist/line/core/session-state/persist.js +192 -0
  65. package/dist/line/core/store.js +8 -0
  66. package/dist/line/core/thrift/binary/binary-reader.js +151 -0
  67. package/dist/line/core/thrift/binary/binary-writer.js +157 -0
  68. package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
  69. package/dist/line/core/thrift/compact/compact-reader.js +180 -0
  70. package/dist/line/core/thrift/compact/compact-writer.js +161 -0
  71. package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
  72. package/dist/line/core/thrift/fields/builders.js +124 -0
  73. package/dist/line/core/thrift/index.js +12 -0
  74. package/dist/line/core/thrift/message/decoder.js +55 -0
  75. package/dist/line/core/thrift/message/encoder.js +25 -0
  76. package/dist/line/core/thrift/primitives/varint.js +64 -0
  77. package/dist/line/core/thrift/struct/reader.js +172 -0
  78. package/dist/line/core/thrift/struct/writer.js +189 -0
  79. package/dist/line/core/thrift/types.js +23 -0
  80. package/dist/line/core/video-thumbnail.js +104 -0
  81. package/dist/mcp/handlers/contacts.js +166 -0
  82. package/dist/mcp/handlers/conversations.js +193 -0
  83. package/dist/mcp/handlers/groups.js +123 -0
  84. package/dist/mcp/handlers/index.js +15 -0
  85. package/dist/mcp/handlers/login.js +473 -0
  86. package/dist/mcp/handlers/media.js +69 -0
  87. package/dist/mcp/handlers/message-actions.js +62 -0
  88. package/dist/mcp/handlers/scope.js +120 -0
  89. package/dist/mcp/handlers/search.js +196 -0
  90. package/dist/mcp/handlers/send.js +523 -0
  91. package/dist/mcp/handlers/shared.js +84 -0
  92. package/dist/mcp/handlers/stickers.js +87 -0
  93. package/dist/mcp/media.js +228 -0
  94. package/dist/mcp/names.js +133 -0
  95. package/dist/mcp/phi-guard.js +105 -0
  96. package/dist/mcp/policy.js +34 -0
  97. package/dist/mcp/server.js +301 -0
  98. package/dist/mcp/tools.js +761 -0
  99. package/dist/mcp/ui/capability.js +26 -0
  100. package/dist/mcp/ui/login-app-protocol.js +118 -0
  101. package/dist/mcp/ui/login-app-styles.js +142 -0
  102. package/dist/mcp/ui/login-app-view.js +361 -0
  103. package/dist/mcp/ui/login-app.js +72 -0
  104. package/dist/mcp/ui/login-structured-content.js +1 -0
  105. package/dist/mcp/ui/resource.js +35 -0
  106. package/dist/mcp/ui/tools-with-ui.js +40 -0
  107. package/dist/search/bigram.js +86 -0
  108. package/dist/search/capture.js +140 -0
  109. package/dist/search/collector.js +176 -0
  110. package/dist/search/default-embedder.js +101 -0
  111. package/dist/search/embedder.js +13 -0
  112. package/dist/search/scope.js +142 -0
  113. package/dist/search/sqlite.js +141 -0
  114. package/dist/search/store.js +469 -0
  115. package/dist/util/data-dir.js +55 -0
  116. package/dist/util/log.js +86 -0
  117. package/dist/version.js +7 -0
  118. package/package.json +63 -0
  119. package/run.mjs +71 -0
@@ -0,0 +1,454 @@
1
+ import crypto from 'node:crypto';
2
+ import { uploadLineObsMediaObject } from '../client/obs-media-client.js';
3
+ import { requireLineClient } from './client-runtime.js';
4
+ import { CONTENT_TYPE } from './constants.js';
5
+ import { buildLineVideoChunkHashes, encryptLineMediaBytes, encryptLineVideoBytes, } from './media-encrypt.js';
6
+ import { i32Field } from './thrift/fields/builders.js';
7
+ import { extractVideoThumbnail } from './video-thumbnail.js';
8
+ /** LINE OBS service namespace per media kind (image / file / audio / video). */
9
+ const OBS_SID_IMAGE = 'emi';
10
+ const OBS_SID_FILE = 'emf';
11
+ const OBS_SID_AUDIO = 'ema';
12
+ const OBS_SID_VIDEO = 'emv';
13
+ /**
14
+ * The one E2EE media send pipeline, shared by every media kind. Encrypts the
15
+ * bytes with a fresh random key, uploads the ciphertext (original + the
16
+ * protocol-required preview object) to OBS, then sends an E2EE data message
17
+ * that seals the key material (plus any per-kind `sealed` fields) and points
18
+ * at the uploaded object via OID/SID. Image vs. file differ ONLY in `spec`
19
+ * (namespace, content type, what is sealed vs. exposed) — sendImage/sendFile
20
+ * are thin callers so there is exactly one implementation, never a second copy
21
+ * that can drift.
22
+ *
23
+ * @param client - Connected LINE client.
24
+ * @param mid - Sender's own MID (the OBS object is addressed by the sender).
25
+ * @param e2eeManager - LINE E2EE manager (dispatches pairwise vs. group by recipient MID).
26
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
27
+ * @param bytes - Raw (plaintext) media bytes.
28
+ * @param spec - Per-kind knobs.
29
+ * @returns The sent message id and uploaded object id.
30
+ */
31
+ async function sendE2EEMedia(client, mid, e2eeManager, to, bytes, spec) {
32
+ const accessToken = await acquireUploadAccessToken(client);
33
+ const keyMaterial = crypto.randomBytes(32).toString('base64');
34
+ const encryptedFile = await (spec.encrypt ?? encryptLineMediaBytes)(bytes, keyMaterial);
35
+ const { oid } = await uploadLineObsMediaObject({
36
+ accessToken,
37
+ data: encryptedFile,
38
+ mid,
39
+ objectPath: `reqid-${crypto.randomUUID()}`,
40
+ params: { name: spec.uploadName, type: 'file' },
41
+ sid: spec.sid,
42
+ });
43
+ // Auxiliary objects addressed by an oid suffix, plus any extra plaintext
44
+ // metadata they contribute. Default: the `__ud-preview` object the LINE
45
+ // protocol requires even without a separate thumbnail (same ciphertext).
46
+ // Video swaps in the `__ud-hash` chunk-hash manifest and a real encrypted
47
+ // `__ud-preview` thumbnail (with MEDIA_THUMB_INFO).
48
+ const aux = spec.auxObjects
49
+ ? await spec.auxObjects(encryptedFile, keyMaterial)
50
+ : { objects: [{ suffix: '__ud-preview', data: encryptedFile }] };
51
+ for (const object of aux.objects) {
52
+ await uploadLineObsMediaObject({
53
+ accessToken,
54
+ data: object.data,
55
+ mid,
56
+ objectPath: `${oid}${object.suffix}`,
57
+ params: {},
58
+ sid: spec.sid,
59
+ });
60
+ }
61
+ const encrypted = await e2eeManager.encryptE2EEMessage(to, { keyMaterial, ...(spec.sealed ?? {}) }, spec.contentType);
62
+ const contentMetadata = {
63
+ ...encrypted.contentMetadata,
64
+ e2eeVersion: '2',
65
+ OID: oid,
66
+ SID: spec.sid,
67
+ ...spec.metadata(encryptedFile.length),
68
+ ...(aux.metadata ?? {}),
69
+ };
70
+ const sent = await client.sendMessage({
71
+ chunks: encrypted.chunks,
72
+ contentMetadata,
73
+ contentType: spec.contentType,
74
+ relatedMessageId: null,
75
+ text: null,
76
+ to,
77
+ });
78
+ return { messageId: sent?.id ?? null, oid, sent: true };
79
+ }
80
+ /**
81
+ * Acquire the encrypted access token the OBS upload gateway requires (the
82
+ * plain session JWT is rejected there). LINE's TalkService returns a
83
+ * `\x1e`-separated token; the gateway wants the second segment.
84
+ *
85
+ * @param client - Connected LINE client.
86
+ * @returns Encrypted access token segment for X-Line-Access on uploads.
87
+ */
88
+ async function acquireUploadAccessToken(client) {
89
+ const result = await client.sendTalk('acquireEncryptedAccessToken', [
90
+ i32Field(2, 2),
91
+ ]);
92
+ const token = result?.fields?.[0];
93
+ if (typeof token !== 'string' || !token.includes('\x1e')) {
94
+ throw new Error('Failed to acquire encrypted access token for media upload');
95
+ }
96
+ return token.split('\x1e')[1];
97
+ }
98
+ /**
99
+ * Resolve the lowercase file extension from a filename, defaulting to jpg
100
+ * when absent or ambiguous.
101
+ *
102
+ * @param fileName - Original filename, if known.
103
+ * @returns Lowercase extension without the leading dot.
104
+ */
105
+ function resolveImageExtension(fileName) {
106
+ const match = /\.([a-zA-Z0-9]+)$/.exec(fileName ?? '');
107
+ return match ? match[1].toLowerCase() : 'jpg';
108
+ }
109
+ /**
110
+ * Build the LINE message command boundary for a connected runtime.
111
+ *
112
+ * @param getClient - Deferred LINE client accessor
113
+ * @param e2eeManager - LINE E2EE manager
114
+ * @returns Message command methods
115
+ */
116
+ export function createMessageCommandService(getClient, e2eeManager) {
117
+ return {
118
+ /**
119
+ * Send a LINE message, optionally preparing an E2EE payload first.
120
+ *
121
+ * `text.contentMetadata`, when supplied, is merged onto whatever
122
+ * `contentMetadata` the send would otherwise carry — e.g. an outbound
123
+ * `MENTION` payload (see `../mention.ts`) built by the caller.
124
+ * `contentMetadata` is a plain Thrift map field carried alongside the
125
+ * message, never part of the E2EE ciphertext chunks, so merging it in
126
+ * does not touch what gets encrypted or how; it is a no-op when the
127
+ * caller supplies nothing, so existing behavior is unchanged.
128
+ *
129
+ * @param to - Recipient MID
130
+ * @param text - Text content or richer message options
131
+ * @returns LINE sendMessage result
132
+ */
133
+ async sendMessage(to, text) {
134
+ if (typeof text === 'object' && text !== null && text.e2ee) {
135
+ const encrypted = await e2eeManager.encryptE2EEMessage(to, text.text, text.contentType ?? 0);
136
+ return requireLineClient(getClient).sendMessage({
137
+ to,
138
+ text: null,
139
+ contentType: encrypted.contentType,
140
+ contentMetadata: text.contentMetadata
141
+ ? { ...encrypted.contentMetadata, ...text.contentMetadata }
142
+ : encrypted.contentMetadata,
143
+ chunks: encrypted.chunks,
144
+ relatedMessageId: text.relatedMessageId ?? null,
145
+ messageRelationType: text.messageRelationType ?? null,
146
+ relatedMessageServiceCode: text.relatedMessageServiceCode ?? null,
147
+ });
148
+ }
149
+ // Plaintext path, extended to carry contentMetadata (e.g. mentions)
150
+ // without requiring the E2EE branch above. Does not disturb the
151
+ // legacy `sendMessage(to, "plain string")` call form other call
152
+ // sites rely on — that keeps hitting the final line untouched.
153
+ if (typeof text === 'object' && text !== null && text.contentMetadata) {
154
+ return requireLineClient(getClient).sendMessage({
155
+ to,
156
+ text: text.text ?? null,
157
+ contentMetadata: text.contentMetadata,
158
+ });
159
+ }
160
+ return requireLineClient(getClient).sendMessage(to, text);
161
+ },
162
+ /**
163
+ * Send one E2EE image via the LINE OBS upload-then-send flow: encrypt
164
+ * the image bytes with a fresh random key, upload the ciphertext
165
+ * (original + preview object), then seal that key material inside an
166
+ * E2EE data message pointing at the uploaded object.
167
+ *
168
+ * Supports 1:1 chats, groups, and rooms — `e2eeManager.encryptE2EEMessage`
169
+ * below dispatches on the recipient MID's prefix (`u`/`c`/`r`) and takes
170
+ * the pairwise peer-key path for `u` or the group-key path for `c`/`r`.
171
+ * The OBS upload itself has no 1:1 dependency: it is addressed by the
172
+ * *sender's own* mid (`e2eeManager.getProfileMid()`), not the recipient,
173
+ * since it is uploading the sender's media object for later retrieval.
174
+ *
175
+ * Exactly one send per call — no retry, no queueing. If the E2EE key
176
+ * material cannot be resolved (peer negotiation fails, or a group's
177
+ * E2EE key cannot be resolved) or the OBS upload is rejected, the
178
+ * underlying call throws and no message is sent — never a silent
179
+ * fallback.
180
+ *
181
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
182
+ * @param imageBytes - Raw (plaintext) image bytes to send.
183
+ * @param fileName - Original filename, used to derive the extension.
184
+ * @returns The sent message id and the uploaded object id.
185
+ */
186
+ async sendImage(to, imageBytes, fileName) {
187
+ const client = requireLineClient(getClient);
188
+ const mid = e2eeManager.getProfileMid?.();
189
+ if (!mid) {
190
+ throw new Error('Cannot send image without an authenticated LINE profile MID');
191
+ }
192
+ const extension = resolveImageExtension(fileName);
193
+ return sendE2EEMedia(client, mid, e2eeManager, to, imageBytes, {
194
+ sid: OBS_SID_IMAGE,
195
+ contentType: CONTENT_TYPE.IMAGE,
196
+ uploadName: fileName ?? `image.${extension}`,
197
+ metadata: (encryptedLength) => ({
198
+ MEDIA_CONTENT_INFO: JSON.stringify({
199
+ animated: false,
200
+ category: 'original',
201
+ extension,
202
+ fileSize: encryptedLength,
203
+ }),
204
+ }),
205
+ });
206
+ },
207
+ /**
208
+ * Send one E2EE file attachment (any type) through the same upload-then-
209
+ * send pipeline as {@link sendImage}. The only differences: the file OBS
210
+ * namespace, contentType FILE, and the original filename sealed inside the
211
+ * E2EE payload (LINE keeps a file's name end-to-end encrypted, not in
212
+ * plaintext metadata) so the recipient recovers it on decrypt.
213
+ *
214
+ * Exactly one send per call — no retry. Throws (sending nothing) if the
215
+ * profile MID is missing, the filename is empty, E2EE key material cannot
216
+ * be resolved, or the OBS upload is rejected.
217
+ *
218
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
219
+ * @param fileBytes - Raw (plaintext) file bytes to send.
220
+ * @param fileName - Original filename (required; sealed E2EE for the recipient).
221
+ * @returns The sent message id and the uploaded object id.
222
+ */
223
+ async sendFile(to, fileBytes, fileName) {
224
+ const client = requireLineClient(getClient);
225
+ const mid = e2eeManager.getProfileMid?.();
226
+ if (!mid) {
227
+ throw new Error('Cannot send file without an authenticated LINE profile MID');
228
+ }
229
+ if (!fileName) {
230
+ throw new Error('Cannot send file without a fileName');
231
+ }
232
+ return sendE2EEMedia(client, mid, e2eeManager, to, fileBytes, {
233
+ sid: OBS_SID_FILE,
234
+ contentType: CONTENT_TYPE.FILE,
235
+ uploadName: fileName,
236
+ sealed: { fileName },
237
+ // Files carry FILE_SIZE (plaintext size) and NO MEDIA_CONTENT_INFO —
238
+ // this matches what LINE's own clients emit for a file attachment.
239
+ metadata: () => ({ FILE_SIZE: String(fileBytes.length) }),
240
+ });
241
+ },
242
+ /**
243
+ * Send one E2EE audio message through the same upload-then-send pipeline as
244
+ * image/file. Audio uses the whole-file AES path (like image), so unlike a
245
+ * file its name is not sealed. contentMetadata carries FILE_SIZE and, when
246
+ * known, DURATION (milliseconds) for the recipient's player progress bar.
247
+ *
248
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
249
+ * @param audioBytes - Raw (plaintext) audio bytes.
250
+ * @param fileName - Original filename (used only for the OBS upload name).
251
+ * @param durationMs - Audio duration in ms; omitted when unknown.
252
+ * @returns The sent message id and the uploaded object id.
253
+ */
254
+ async sendAudio(to, audioBytes, fileName, durationMs) {
255
+ const client = requireLineClient(getClient);
256
+ const mid = e2eeManager.getProfileMid?.();
257
+ if (!mid) {
258
+ throw new Error('Cannot send audio without an authenticated LINE profile MID');
259
+ }
260
+ return sendE2EEMedia(client, mid, e2eeManager, to, audioBytes, {
261
+ sid: OBS_SID_AUDIO,
262
+ contentType: CONTENT_TYPE.AUDIO,
263
+ uploadName: fileName ?? 'audio.m4a',
264
+ metadata: () => {
265
+ const meta = {
266
+ FILE_SIZE: String(audioBytes.length),
267
+ };
268
+ if (durationMs != null && Number.isFinite(durationMs)) {
269
+ meta.DURATION = String(Math.round(durationMs));
270
+ }
271
+ return meta;
272
+ },
273
+ });
274
+ },
275
+ /**
276
+ * Send one E2EE video through the shared upload-then-send pipeline. Video
277
+ * reuses everything image/file/audio do — the ciphertext is the same
278
+ * whole-file AES-256-CTR bytes — and differs in only two `spec` knobs:
279
+ * `encrypt` swaps the MAC to cover the per-128KB-chunk hashes (so a receiver
280
+ * can verify integrity while streaming), and `auxObjects` uploads the
281
+ * `__ud-hash` chunk-hash manifest that receiver reads. A poster frame is
282
+ * extracted with ffmpeg (best-effort) and uploaded as the encrypted
283
+ * `__ud-preview` (with MEDIA_THUMB_INFO) so the video shows a thumbnail
284
+ * before download; when ffmpeg is unavailable the video still sends, just
285
+ * without a poster.
286
+ *
287
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
288
+ * @param videoBytes - Raw (plaintext) video bytes.
289
+ * @param fileName - Original filename (used only for the OBS upload name).
290
+ * @param durationMs - Video duration in ms; omitted when unknown.
291
+ * @returns The sent message id and the uploaded object id.
292
+ */
293
+ async sendVideo(to, videoBytes, fileName, durationMs) {
294
+ const client = requireLineClient(getClient);
295
+ const mid = e2eeManager.getProfileMid?.();
296
+ if (!mid) {
297
+ throw new Error('Cannot send video without an authenticated LINE profile MID');
298
+ }
299
+ // Extract the poster once, up front, so the auxObjects hook can seal it
300
+ // under the pipeline's key material. null (no ffmpeg / failure) => no
301
+ // poster, and the video still sends.
302
+ const thumbnail = await extractVideoThumbnail(videoBytes);
303
+ return sendE2EEMedia(client, mid, e2eeManager, to, videoBytes, {
304
+ sid: OBS_SID_VIDEO,
305
+ contentType: CONTENT_TYPE.VIDEO,
306
+ uploadName: fileName ?? 'video.mp4',
307
+ encrypt: encryptLineVideoBytes,
308
+ // Two auxiliary objects. `__ud-hash`: the concatenated per-chunk SHA-256
309
+ // hashes over the ciphertext WITHOUT its trailing 32-byte MAC — exactly
310
+ // the bytes a streaming receiver re-hashes per chunk (omitted for an
311
+ // empty ciphertext). `__ud-preview`: the poster, encrypted under the
312
+ // SAME key material via the whole-file media path (like an image), with
313
+ // its dimensions surfaced as MEDIA_THUMB_INFO.
314
+ //
315
+ // KNOWN WEAKNESS (upstream, do not "fix" locally): reusing keyMaterial
316
+ // for the poster reuses the AES-CTR keystream. deriveLineMediaKeyMaterial
317
+ // is deterministic, so body and poster get byte-identical encKey AND
318
+ // nonce, and encrypting two different plaintexts under one keystream is
319
+ // a two-time pad: C_body XOR C_poster = P_body XOR P_poster over the
320
+ // shorter length. Both objects sit at derivable OBS paths (`oid` and
321
+ // `oid__ud-preview`), and MP4/JPEG headers are predictable enough to
322
+ // bootstrap each other, so roughly a poster's worth of video plaintext
323
+ // is recoverable by anyone holding both — starting with LINE's servers,
324
+ // which is exactly who E2EE is meant to exclude.
325
+ //
326
+ // It cannot be fixed here. Receivers (this one included, see
327
+ // mcp/media.ts) decrypt `__ud-preview` with the message's keyMaterial,
328
+ // so deriving a separate nonce would make real LINE clients unable to
329
+ // open posters Yomi sends. The fix belongs upstream — domain-separate
330
+ // the HKDF info per object, or seal a second keyMaterial. The only local
331
+ // mitigation is sending no poster at all: `extractVideoThumbnail`
332
+ // returning null already takes that path and the video still sends.
333
+ auxObjects: async (mainBody, keyMaterial) => {
334
+ const objects = [];
335
+ const hashes = buildLineVideoChunkHashes(mainBody.subarray(0, Math.max(0, mainBody.length - 32)));
336
+ if (hashes.length > 0) {
337
+ objects.push({ suffix: '__ud-hash', data: hashes });
338
+ }
339
+ let metadata;
340
+ if (thumbnail) {
341
+ objects.push({
342
+ suffix: '__ud-preview',
343
+ data: await encryptLineMediaBytes(thumbnail.jpeg, keyMaterial),
344
+ });
345
+ metadata = {
346
+ MEDIA_THUMB_INFO: JSON.stringify({
347
+ width: thumbnail.width,
348
+ height: thumbnail.height,
349
+ }),
350
+ };
351
+ }
352
+ return { objects, metadata };
353
+ },
354
+ metadata: () => {
355
+ const meta = {
356
+ FILE_SIZE: String(videoBytes.length),
357
+ };
358
+ if (durationMs != null && Number.isFinite(durationMs)) {
359
+ meta.DURATION = String(Math.round(durationMs));
360
+ }
361
+ return meta;
362
+ },
363
+ });
364
+ },
365
+ /**
366
+ * Share a LINE contact card. Unlike image/file, this is NOT media: no OBS
367
+ * upload and no media E2EE — it is a plain message with contentType CONTACT
368
+ * whose `contentMetadata` names the shared person by mid. The shape mirrors
369
+ * what LINE clients emit: `{ mid, displayName, app_extension_type: 'null' }`
370
+ * (LINE's server adds `seq`). Exactly one send per call.
371
+ *
372
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
373
+ * @param contactMid - MID of the person whose card is being shared.
374
+ * @param displayName - Display name to show on the card (may be empty; LINE resolves from mid).
375
+ * @returns The sent message id.
376
+ */
377
+ async sendContact(to, contactMid, displayName) {
378
+ if (!contactMid) {
379
+ throw new Error('Cannot send contact without a contactMid');
380
+ }
381
+ const sent = await requireLineClient(getClient).sendMessage({
382
+ to,
383
+ text: null,
384
+ contentType: CONTENT_TYPE.CONTACT,
385
+ contentMetadata: {
386
+ mid: contactMid,
387
+ displayName: displayName ?? '',
388
+ app_extension_type: 'null',
389
+ },
390
+ relatedMessageId: null,
391
+ });
392
+ return { messageId: sent?.id ?? null, sent: true };
393
+ },
394
+ /**
395
+ * Send a LINE sticker. Like send_contact this is a plain message with a
396
+ * dedicated contentType (STICKER) whose contentMetadata names the sticker
397
+ * by package + id — no OBS, no media E2EE. Shape { STKID, STKPKGID, STKVER }
398
+ * mirrors what LINE clients emit (LINE's server adds STKTXT/seq).
399
+ *
400
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
401
+ * @param stickerId - LINE sticker id (STKID).
402
+ * @param packageId - LINE sticker package id (STKPKGID).
403
+ * @param version - Sticker version (STKVER); defaults to '1'.
404
+ * @returns The sent message id.
405
+ */
406
+ /**
407
+ * Send a location message (contentType LOCATION) — a plain, non-E2EE
408
+ * message carrying a Location struct (title, address, latitude, longitude).
409
+ *
410
+ * @param to - Recipient MID (1:1 `u...`, group `c...`, or room `r...`).
411
+ * @param latitude - Latitude (Thrift double).
412
+ * @param longitude - Longitude (Thrift double).
413
+ * @param title - Optional place name shown on the pin.
414
+ * @param address - Optional address shown under the pin.
415
+ * @returns The sent message id.
416
+ */
417
+ async sendLocation(to, latitude, longitude, title = '', address = '') {
418
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
419
+ throw new Error('Cannot send location without finite latitude/longitude');
420
+ }
421
+ // LINE Letter-Seals location too ("can not send using plain mode"
422
+ // otherwise). encryptE2EEMessage wraps an object at contentType 15 as
423
+ // {"location": data} and encrypts it into the chunks — coordinates ride
424
+ // inside the ciphertext, not a plaintext Thrift Location struct.
425
+ const encrypted = await e2eeManager.encryptE2EEMessage(to, { title, address, latitude, longitude }, CONTENT_TYPE.LOCATION);
426
+ const sent = await requireLineClient(getClient).sendMessage({
427
+ to,
428
+ text: null,
429
+ contentType: CONTENT_TYPE.LOCATION,
430
+ contentMetadata: encrypted.contentMetadata,
431
+ chunks: encrypted.chunks,
432
+ relatedMessageId: null,
433
+ });
434
+ return { messageId: sent?.id ?? null, sent: true };
435
+ },
436
+ async sendSticker(to, stickerId, packageId, version = '1') {
437
+ if (!stickerId || !packageId) {
438
+ throw new Error('Cannot send sticker without stickerId and packageId');
439
+ }
440
+ const sent = await requireLineClient(getClient).sendMessage({
441
+ to,
442
+ text: null,
443
+ contentType: CONTENT_TYPE.STICKER,
444
+ contentMetadata: {
445
+ STKID: stickerId,
446
+ STKPKGID: packageId,
447
+ STKVER: version,
448
+ },
449
+ relatedMessageId: null,
450
+ });
451
+ return { messageId: sent?.id ?? null, sent: true };
452
+ },
453
+ };
454
+ }
@@ -0,0 +1,161 @@
1
+ import { requireLineClient } from './client-runtime.js';
2
+ /**
3
+ * Infer the LINE conversation type from a stable chat MID.
4
+ *
5
+ * @param chatId - LINE chat, room, or user MID.
6
+ * @returns LINE toType value when inferable.
7
+ */
8
+ function inferLineToType(chatId) {
9
+ if (!chatId) {
10
+ return null;
11
+ }
12
+ if (chatId.startsWith('u')) {
13
+ return 0;
14
+ }
15
+ if (chatId.startsWith('c')) {
16
+ return 1;
17
+ }
18
+ if (chatId.startsWith('r')) {
19
+ return 2;
20
+ }
21
+ return null;
22
+ }
23
+ /**
24
+ * Attach the canonical LINE chat context required by E2EE decrypt.
25
+ *
26
+ * @param message - Raw LINE message payload.
27
+ * @param chatId - Requested LINE chat MID.
28
+ * @returns Message with stable chat context.
29
+ */
30
+ export function normalizeLineMessageContext(message, chatId = null) {
31
+ const resolvedChatId = message?.chatMid || chatId || null;
32
+ const inferredToType = inferLineToType(resolvedChatId);
33
+ return {
34
+ ...message,
35
+ chatMid: resolvedChatId || message?.chatMid,
36
+ toType: message?.toType ?? inferredToType ?? message?.toType,
37
+ };
38
+ }
39
+ /**
40
+ * Build stable diagnostics for one failed E2EE decrypt attempt.
41
+ *
42
+ * @param decrypted - E2EE decrypt result.
43
+ * @param taggedMessage - Message with canonical LINE context.
44
+ * @returns Diagnostic metadata.
45
+ */
46
+ function buildE2EEDecryptFailure(decrypted, taggedMessage) {
47
+ return {
48
+ error: decrypted.error || null,
49
+ envelopeInfo: decrypted.envelopeInfo || null,
50
+ isSelf: decrypted.isSelf ?? null,
51
+ isUserChat: decrypted.isUserChat ?? null,
52
+ reason: decrypted.reason || 'decrypt_failed',
53
+ receiverKeyId: decrypted.receiverKeyId ?? null,
54
+ senderKeyId: decrypted.senderKeyId ?? null,
55
+ toType: decrypted.toType ?? taggedMessage.toType ?? null,
56
+ };
57
+ }
58
+ /**
59
+ * Decrypt one LINE message through the service-owned E2EE manager.
60
+ *
61
+ * @param e2eeManager - LINE E2EE manager.
62
+ * @param message - Raw LINE message payload.
63
+ * @param chatId - Optional chat id to attach to the message.
64
+ * @returns Message with decrypted text flags when available.
65
+ */
66
+ export async function decryptLineMessage(e2eeManager, message, chatId = null) {
67
+ const taggedMessage = normalizeLineMessageContext(message, chatId);
68
+ if (!e2eeManager?.tryDecrypt) {
69
+ return taggedMessage;
70
+ }
71
+ const decrypted = await e2eeManager.tryDecrypt(taggedMessage);
72
+ if (decrypted.decrypted) {
73
+ return {
74
+ ...taggedMessage,
75
+ e2eeDecrypted: true,
76
+ // Surfaced so a caller can distinguish "we could read it" from "we can
77
+ // vouch for it". V1 messages decrypt without any integrity check, so a
78
+ // tampered one is indistinguishable from a genuine one at this layer.
79
+ e2eeIntegrityVerified: decrypted.integrityVerified !== false,
80
+ text: decrypted.text,
81
+ };
82
+ }
83
+ if (taggedMessage?.contentMetadata?.e2eeVersion && !taggedMessage.text) {
84
+ return {
85
+ ...taggedMessage,
86
+ e2eeDecrypted: false,
87
+ e2eeDecryptFailure: buildE2EEDecryptFailure(decrypted, taggedMessage),
88
+ };
89
+ }
90
+ return taggedMessage;
91
+ }
92
+ /**
93
+ * Decrypt a LINE message batch through the service-owned E2EE manager.
94
+ *
95
+ * @param e2eeManager - LINE E2EE manager.
96
+ * @param messages - Raw LINE message payloads.
97
+ * @param chatId - Optional chat id to attach to each message.
98
+ * @returns Messages with decrypted text flags when available.
99
+ */
100
+ export async function decryptLineMessages(e2eeManager, messages, chatId = null) {
101
+ return Promise.all(messages.map((message) => decryptLineMessage(e2eeManager, message, chatId)));
102
+ }
103
+ /**
104
+ * Build the LINE message query boundary for a connected runtime.
105
+ *
106
+ * @param getClient - Deferred LINE client accessor
107
+ * @param e2eeManager - LINE E2EE manager
108
+ * @returns Message query methods
109
+ */
110
+ export function createMessageQueryService(getClient, e2eeManager) {
111
+ return {
112
+ /**
113
+ * Fetch recent messages and opportunistically decrypt E2EE payloads.
114
+ *
115
+ * @param chatId - LINE chat MID
116
+ * @param count - Maximum number of messages to fetch
117
+ * @returns Recent LINE messages
118
+ */
119
+ async getRecentMessages(chatId, count = 50) {
120
+ const messages = await requireLineClient(getClient).getRecentMessages(chatId, count);
121
+ return decryptLineMessages(e2eeManager, messages, chatId);
122
+ },
123
+ /**
124
+ * Fetch one page of messages older than a cursor and opportunistically
125
+ * decrypt E2EE payloads. Fetches exactly one page — no pagination loop.
126
+ *
127
+ * @param chatId - LINE chat MID
128
+ * @param count - Maximum number of messages to fetch
129
+ * @param before - Cursor identifying the oldest already-seen message (messageId and/or deliveredTime)
130
+ * @returns Previous LINE messages, older than the cursor
131
+ */
132
+ async getPreviousMessages(chatId, count = 50, before = {}) {
133
+ const messages = await requireLineClient(getClient).getPreviousMessagesV2WithRequest({
134
+ endMessageId: before,
135
+ messageBoxId: chatId,
136
+ messagesCount: count,
137
+ });
138
+ return decryptLineMessages(e2eeManager, messages, chatId);
139
+ },
140
+ /**
141
+ * Download the original LINE content bytes for one message.
142
+ *
143
+ * @param messageId - LINE message identifier
144
+ * @param requestId - Optional request identifier
145
+ * @returns Original message content bytes
146
+ */
147
+ async downloadMessageContent(messageId, requestId = `yomi-${Date.now()}`) {
148
+ return requireLineClient(getClient).downloadMessageContent(messageId, requestId);
149
+ },
150
+ /**
151
+ * Download the LINE preview bytes for one message.
152
+ *
153
+ * @param messageId - LINE message identifier
154
+ * @param requestId - Optional request identifier
155
+ * @returns Preview content bytes
156
+ */
157
+ async downloadMessageContentPreview(messageId, requestId = `yomi-${Date.now()}`) {
158
+ return requireLineClient(getClient).downloadMessageContentPreview(messageId, requestId);
159
+ },
160
+ };
161
+ }