@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.
- package/LICENSE +21 -0
- package/NOTICE +81 -0
- package/PRIVACY.md +39 -0
- package/README.md +659 -0
- package/dist/auth/credential-store.js +395 -0
- package/dist/auth/keychain.js +256 -0
- package/dist/cli/login.js +142 -0
- package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
- package/dist/line/auth/protocol/index.js +3 -0
- package/dist/line/auth/protocol/login-metadata.js +159 -0
- package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
- package/dist/line/auth/pwless/index.js +163 -0
- package/dist/line/auth/pwless/steps.js +213 -0
- package/dist/line/client/auth-service/client.js +240 -0
- package/dist/line/client/e2ee-service/client.js +69 -0
- package/dist/line/client/e2ee-service/requests.js +92 -0
- package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
- package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
- package/dist/line/client/index.js +213 -0
- package/dist/line/client/obs-media-client.js +140 -0
- package/dist/line/client/parsers.js +69 -0
- package/dist/line/client/shop-service/client.js +63 -0
- package/dist/line/client/sticker-meta.js +46 -0
- package/dist/line/client/sync-service/client.js +317 -0
- package/dist/line/client/talk-service/chat-query.js +122 -0
- package/dist/line/client/talk-service/command-client.js +242 -0
- package/dist/line/client/talk-service/contact-query.js +77 -0
- package/dist/line/client/talk-service/message-box-query.js +137 -0
- package/dist/line/client/talk-service/message-id-query.js +241 -0
- package/dist/line/client/talk-service/query-client.js +219 -0
- package/dist/line/client/talk-service/requests.js +383 -0
- package/dist/line/client/transport.js +138 -0
- package/dist/line/core/auth-session-logout.js +55 -0
- package/dist/line/core/auth-session-runtime.js +231 -0
- package/dist/line/core/auth-session-service.js +85 -0
- package/dist/line/core/chat-runtime-service.js +343 -0
- package/dist/line/core/client-runtime.js +17 -0
- package/dist/line/core/config.js +67 -0
- package/dist/line/core/constants.js +56 -0
- package/dist/line/core/contact-query-service.js +20 -0
- package/dist/line/core/directory-runtime-service.js +83 -0
- package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
- package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
- package/dist/line/core/e2ee/index.js +6 -0
- package/dist/line/core/e2ee/keys/decrypt.js +110 -0
- package/dist/line/core/e2ee/keys/encrypt.js +117 -0
- package/dist/line/core/e2ee/keys/group-key.js +260 -0
- package/dist/line/core/e2ee/keys/key-manager.js +231 -0
- package/dist/line/core/e2ee/keys/key-payload.js +130 -0
- package/dist/line/core/e2ee/keys/key-types.js +1 -0
- package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
- package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
- package/dist/line/core/e2ee/keys/peer-key.js +191 -0
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
- package/dist/line/core/e2ee/types.js +4 -0
- package/dist/line/core/group-query-service.js +20 -0
- package/dist/line/core/media-decrypt.js +78 -0
- package/dist/line/core/media-encrypt.js +97 -0
- package/dist/line/core/mention.js +89 -0
- package/dist/line/core/message-command-service.js +454 -0
- package/dist/line/core/message-query-service.js +161 -0
- package/dist/line/core/service.js +134 -0
- package/dist/line/core/session-state/index.js +243 -0
- package/dist/line/core/session-state/persist.js +192 -0
- package/dist/line/core/store.js +8 -0
- package/dist/line/core/thrift/binary/binary-reader.js +151 -0
- package/dist/line/core/thrift/binary/binary-writer.js +157 -0
- package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
- package/dist/line/core/thrift/compact/compact-reader.js +180 -0
- package/dist/line/core/thrift/compact/compact-writer.js +161 -0
- package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
- package/dist/line/core/thrift/fields/builders.js +124 -0
- package/dist/line/core/thrift/index.js +12 -0
- package/dist/line/core/thrift/message/decoder.js +55 -0
- package/dist/line/core/thrift/message/encoder.js +25 -0
- package/dist/line/core/thrift/primitives/varint.js +64 -0
- package/dist/line/core/thrift/struct/reader.js +172 -0
- package/dist/line/core/thrift/struct/writer.js +189 -0
- package/dist/line/core/thrift/types.js +23 -0
- package/dist/line/core/video-thumbnail.js +104 -0
- package/dist/mcp/handlers/contacts.js +166 -0
- package/dist/mcp/handlers/conversations.js +193 -0
- package/dist/mcp/handlers/groups.js +123 -0
- package/dist/mcp/handlers/index.js +15 -0
- package/dist/mcp/handlers/login.js +473 -0
- package/dist/mcp/handlers/media.js +69 -0
- package/dist/mcp/handlers/message-actions.js +62 -0
- package/dist/mcp/handlers/scope.js +120 -0
- package/dist/mcp/handlers/search.js +196 -0
- package/dist/mcp/handlers/send.js +523 -0
- package/dist/mcp/handlers/shared.js +84 -0
- package/dist/mcp/handlers/stickers.js +87 -0
- package/dist/mcp/media.js +228 -0
- package/dist/mcp/names.js +133 -0
- package/dist/mcp/phi-guard.js +105 -0
- package/dist/mcp/policy.js +34 -0
- package/dist/mcp/server.js +301 -0
- package/dist/mcp/tools.js +761 -0
- package/dist/mcp/ui/capability.js +26 -0
- package/dist/mcp/ui/login-app-protocol.js +118 -0
- package/dist/mcp/ui/login-app-styles.js +142 -0
- package/dist/mcp/ui/login-app-view.js +361 -0
- package/dist/mcp/ui/login-app.js +72 -0
- package/dist/mcp/ui/login-structured-content.js +1 -0
- package/dist/mcp/ui/resource.js +35 -0
- package/dist/mcp/ui/tools-with-ui.js +40 -0
- package/dist/search/bigram.js +86 -0
- package/dist/search/capture.js +140 -0
- package/dist/search/collector.js +176 -0
- package/dist/search/default-embedder.js +101 -0
- package/dist/search/embedder.js +13 -0
- package/dist/search/scope.js +142 -0
- package/dist/search/sqlite.js +141 -0
- package/dist/search/store.js +469 -0
- package/dist/util/data-dir.js +55 -0
- package/dist/util/log.js +86 -0
- package/dist/version.js +7 -0
- package/package.json +63 -0
- 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
|
+
}
|