@rikaidev/yomi 0.4.2 → 0.5.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/README.md +18 -4
- package/dist/attention/policy.js +95 -0
- package/dist/auth/credential-store.js +32 -11
- package/dist/auth/keychain.js +7 -54
- package/dist/cli/query.js +668 -0
- package/dist/insight/relationships.js +1 -1
- package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
- package/dist/line/auth/pwless/index.js +1 -1
- package/dist/line/client/index.js +0 -2
- package/dist/line/client/obs-media-client.js +37 -0
- package/dist/line/client/parsers.js +1 -0
- package/dist/line/client/sticker-meta.js +32 -0
- package/dist/line/client/sync-service/client.js +31 -3
- package/dist/line/client/talk-service/contact-query.js +8 -1
- package/dist/line/client/talk-service/message-box-query.js +17 -0
- package/dist/line/client/talk-service/query-client.js +32 -26
- package/dist/line/client/talk-service/requests.js +1 -11
- package/dist/line/core/auth-session-runtime.js +16 -1
- package/dist/line/core/chat-event.js +33 -0
- package/dist/line/core/chat-runtime-service.js +0 -20
- package/dist/line/core/directory-runtime-service.js +10 -2
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
- package/dist/line/core/flex-message.js +130 -0
- package/dist/line/core/mention.js +1 -1
- package/dist/line/core/message-preview.js +60 -0
- package/dist/line/core/message-query-service.js +0 -20
- package/dist/line/core/service.js +1 -2
- package/dist/mcp/handlers/conversations.js +37 -4
- package/dist/mcp/handlers/login.js +1 -1
- package/dist/mcp/handlers/message-actions.js +1 -1
- package/dist/mcp/handlers/search.js +1 -1
- package/dist/mcp/handlers/shared.js +1 -1
- package/dist/mcp/integrator.js +168 -0
- package/dist/mcp/media.js +34 -2
- package/dist/mcp/phi-guard.js +2 -2
- package/dist/search/bigram.js +2 -2
- package/dist/search/collector.js +1 -1
- package/dist/search/embedder.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- package/run.mjs +218 -2
|
@@ -49,7 +49,7 @@ export const PAAK_AUTH_CLIENT_CEILING_MS = PWLESS_POLL_TIMEOUT_MS * PAAK_AUTH_PO
|
|
|
49
49
|
* This is an EXTERNAL FACT, not derived from any of our code or protocol
|
|
50
50
|
* traffic. Source: LINE Help Center (zh-Hant),
|
|
51
51
|
* https://help.line.me/line/IOSSecondary/?contentId=20018574&lang=zh-Hant
|
|
52
|
-
* — on the primary device,
|
|
52
|
+
* — on the primary device, select the device signing in and confirm the user
|
|
53
53
|
* within 3 minutes of the code being displayed.
|
|
54
54
|
*/
|
|
55
55
|
export const LINE_PIN_CODE_LIFETIME_MS = 3 * 60 * 1000;
|
|
@@ -9,6 +9,43 @@ const OBS_HOST = 'obs.line-apps.com';
|
|
|
9
9
|
// (LINE_GW_HOST_DOMAIN). obs.line-apps.com returns 400 for the /oa/ upload
|
|
10
10
|
// route; only the gateway accepts it, and only with an encrypted access token.
|
|
11
11
|
const OBS_UPLOAD_HOST = 'gwz.line.naver.jp';
|
|
12
|
+
/**
|
|
13
|
+
* Download media sent by a legacy/non-E2EE Talk client.
|
|
14
|
+
*
|
|
15
|
+
* These messages do not carry the OID/SID pair used by the newer E2EE OBS
|
|
16
|
+
* object format. LINE's self-client protocol exposes them under the message
|
|
17
|
+
* object route instead:
|
|
18
|
+
* GET https://obs.line-apps.com/r/talk/m/{messageId}[\/preview]
|
|
19
|
+
*
|
|
20
|
+
* This is deliberately not implemented as a TalkService call. There is no
|
|
21
|
+
* `downloadMessageContent*` method on the current `/S4` service; sending such
|
|
22
|
+
* a Thrift call yields `Invalid method name`.
|
|
23
|
+
*/
|
|
24
|
+
export async function downloadLineMessageData(client, options) {
|
|
25
|
+
const messageId = String(options.messageId || '').trim();
|
|
26
|
+
if (!messageId) {
|
|
27
|
+
throw new Error('LINE legacy media download requires a message id');
|
|
28
|
+
}
|
|
29
|
+
const suffix = options.preview ? '/preview' : '';
|
|
30
|
+
const requestUrl = `https://${OBS_HOST}/r/talk/m/${encodeURIComponent(messageId)}${suffix}`;
|
|
31
|
+
const response = await fetch(requestUrl, {
|
|
32
|
+
method: 'GET',
|
|
33
|
+
headers: {
|
|
34
|
+
Accept: 'application/json, text/plain, */*',
|
|
35
|
+
'User-Agent': LINE_APP_CONFIG.userAgent,
|
|
36
|
+
'X-Line-Access': client.authToken,
|
|
37
|
+
'X-Line-Application': LINE_APP_CONFIG.lineApp,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
41
|
+
if (!response.ok || bytes.length === 0) {
|
|
42
|
+
throw new Error(`LINE legacy media download failed: status=${response.status} bytes=${bytes.length}`);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
bytes,
|
|
46
|
+
mimeType: response.headers.get('content-type')?.split(';', 1)[0] || null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
12
49
|
/**
|
|
13
50
|
* Build LINE talk metadata required for OBS media downloads.
|
|
14
51
|
*
|
|
@@ -8,6 +8,30 @@
|
|
|
8
8
|
* package the caller wants to expand into sendable sticker ids.
|
|
9
9
|
*/
|
|
10
10
|
const CDN = 'https://stickershop.line-scdn.net/stickershop/v1/product';
|
|
11
|
+
// Metadata is public and immutable for the lifetime of a picker session. Keep
|
|
12
|
+
// one in-flight/result promise per package so opening the picker or changing
|
|
13
|
+
// locale does not refetch the same package repeatedly.
|
|
14
|
+
const packageMetaCache = new Map();
|
|
15
|
+
/** Pick the title users expect for the app's current language. */
|
|
16
|
+
export function localizedStickerTitle(titles, language = 'en', fallback = '') {
|
|
17
|
+
const values = titles ?? {};
|
|
18
|
+
const normalized = language.replace('_', '-').toLowerCase();
|
|
19
|
+
const preferred = normalized.startsWith('zh')
|
|
20
|
+
? ['zh-Hant', 'zh_TW', 'zh-TW', 'zh']
|
|
21
|
+
: normalized.startsWith('ja')
|
|
22
|
+
? ['ja']
|
|
23
|
+
: ['en'];
|
|
24
|
+
for (const key of preferred) {
|
|
25
|
+
const value = values[key];
|
|
26
|
+
if (typeof value === 'string' && value.trim())
|
|
27
|
+
return value.trim();
|
|
28
|
+
}
|
|
29
|
+
const english = values.en;
|
|
30
|
+
if (typeof english === 'string' && english.trim())
|
|
31
|
+
return english.trim();
|
|
32
|
+
const first = Object.values(values).find((value) => typeof value === 'string' && value.trim());
|
|
33
|
+
return first?.trim() || fallback;
|
|
34
|
+
}
|
|
11
35
|
/**
|
|
12
36
|
* Fetch one sticker package's public metadata (title + individual sticker ids).
|
|
13
37
|
*
|
|
@@ -31,6 +55,14 @@ export async function fetchStickerPackageMeta(packageId) {
|
|
|
31
55
|
.filter((id) => id !== null),
|
|
32
56
|
};
|
|
33
57
|
}
|
|
58
|
+
export function fetchStickerPackageMetaCached(packageId) {
|
|
59
|
+
const cached = packageMetaCache.get(packageId);
|
|
60
|
+
if (cached)
|
|
61
|
+
return cached;
|
|
62
|
+
const request = fetchStickerPackageMeta(packageId);
|
|
63
|
+
packageMetaCache.set(packageId, request);
|
|
64
|
+
return request;
|
|
65
|
+
}
|
|
34
66
|
/**
|
|
35
67
|
* Fetch one sticker's PNG preview image from the public sticker CDN.
|
|
36
68
|
*
|
|
@@ -52,6 +52,29 @@ function buildSyncPayload(runtime, count) {
|
|
|
52
52
|
];
|
|
53
53
|
return encodeCallMessage('sync', runtime.seq++, fields);
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* A negative revision is uninitialised, never a valid LINE checkpoint.
|
|
57
|
+
* Short-lived callers (for example the desktop `chats` bridge) call
|
|
58
|
+
* syncLongPoll directly, so initialise the cursor at the protocol boundary.
|
|
59
|
+
*/
|
|
60
|
+
export async function ensureSyncRevision(runtime, lineLog) {
|
|
61
|
+
const current = Number(runtime.revision);
|
|
62
|
+
if (Number.isFinite(current) && current >= 0)
|
|
63
|
+
return;
|
|
64
|
+
const result = await runtime.sendTalk('getLastOpRevision', []);
|
|
65
|
+
if (result.fields?.[1]) {
|
|
66
|
+
const exc = result.fields[1];
|
|
67
|
+
lineLog.error('revision.fetch.failed', { error: JSON.stringify(exc) });
|
|
68
|
+
throw new Error(`getLastOpRevision: ${exc[2] || exc[1] || 'unknown'}`);
|
|
69
|
+
}
|
|
70
|
+
const rawRevision = result.fields?.[0];
|
|
71
|
+
const revision = typeof rawRevision === 'bigint' ? Number(rawRevision) : Number(rawRevision);
|
|
72
|
+
if (!Number.isFinite(revision) || revision < 0) {
|
|
73
|
+
throw new Error(`getLastOpRevision returned invalid revision: ${String(rawRevision)}`);
|
|
74
|
+
}
|
|
75
|
+
runtime.revision = revision;
|
|
76
|
+
lineLog.info('revision.fetch', { type: typeof rawRevision, value: revision });
|
|
77
|
+
}
|
|
55
78
|
/**
|
|
56
79
|
* Parse raw LINE operations into normalized operation objects.
|
|
57
80
|
*
|
|
@@ -99,15 +122,18 @@ function throwSyncException(result, lineLog) {
|
|
|
99
122
|
* @param response - Top-level sync response payload.
|
|
100
123
|
* @returns Raw operations array.
|
|
101
124
|
*/
|
|
102
|
-
function updateSyncRevisions(runtime, response) {
|
|
125
|
+
export function updateSyncRevisions(runtime, response) {
|
|
103
126
|
const operationResponse = response?.[1];
|
|
104
127
|
const ops = operationResponse?.[1];
|
|
105
128
|
const nextRevision = response?.[2];
|
|
106
129
|
const lastGlobalRevision = operationResponse?.[2];
|
|
107
130
|
const lastIndividualRevision = operationResponse?.[3];
|
|
108
131
|
if (typeof nextRevision === 'number' || typeof nextRevision === 'bigint') {
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
const candidate = typeof nextRevision === 'bigint' ? Number(nextRevision) : nextRevision;
|
|
133
|
+
if (Number.isFinite(candidate) &&
|
|
134
|
+
candidate >= Number(runtime.revision || 0)) {
|
|
135
|
+
runtime.revision = candidate;
|
|
136
|
+
}
|
|
111
137
|
}
|
|
112
138
|
if (typeof lastGlobalRevision === 'number' ||
|
|
113
139
|
typeof lastGlobalRevision === 'bigint') {
|
|
@@ -218,6 +244,7 @@ export function createSyncClient(runtime) {
|
|
|
218
244
|
* @returns Parsed operations.
|
|
219
245
|
*/
|
|
220
246
|
async sync(count = 50) {
|
|
247
|
+
await ensureSyncRevision(runtime, getLineLog(runtime));
|
|
221
248
|
const data = buildSyncPayload(runtime, count);
|
|
222
249
|
const result = await sendRequest(runtime.host, LINE_APP_CONFIG.syncPath, data, { 'X-Line-Access': runtime.authToken }, 10000);
|
|
223
250
|
if (result.error === 'timeout') {
|
|
@@ -236,6 +263,7 @@ export function createSyncClient(runtime) {
|
|
|
236
263
|
async syncLongPoll(count = 50, timeoutMs = 60000) {
|
|
237
264
|
const lineLog = getLineLog(runtime);
|
|
238
265
|
const startedAt = Date.now();
|
|
266
|
+
await ensureSyncRevision(runtime, lineLog);
|
|
239
267
|
const data = buildSyncPayload(runtime, count);
|
|
240
268
|
const result = await sendRequest(runtime.host, LINE_APP_CONFIG.syncPath, data, { 'X-Line-Access': runtime.authToken }, timeoutMs + 5000);
|
|
241
269
|
if (result.error === 'timeout') {
|
|
@@ -8,7 +8,10 @@ function normalizeNumericValue(value) {
|
|
|
8
8
|
if (typeof value === 'bigint') {
|
|
9
9
|
return Number(value);
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
if (value === null || value === undefined || value === '')
|
|
12
|
+
return null;
|
|
13
|
+
const number = Number(value);
|
|
14
|
+
return Number.isFinite(number) ? number : null;
|
|
12
15
|
}
|
|
13
16
|
/**
|
|
14
17
|
* Extract profile fields from one raw contact struct.
|
|
@@ -27,6 +30,10 @@ function mapContactProfileFields(contact) {
|
|
|
27
30
|
picturePath: contact[37] || null,
|
|
28
31
|
statusMessageContentMetadata: contact[43] || {},
|
|
29
32
|
profileId: contact[49] || null,
|
|
33
|
+
attributes: normalizeNumericValue(contact[35]),
|
|
34
|
+
settings: normalizeNumericValue(contact[36]),
|
|
35
|
+
notificationDisabled: Boolean((normalizeNumericValue(contact[36]) ?? 0) & 1),
|
|
36
|
+
isOfficial: Boolean((normalizeNumericValue(contact[35]) ?? 0) & 32),
|
|
30
37
|
};
|
|
31
38
|
}
|
|
32
39
|
/**
|
|
@@ -106,6 +106,23 @@ export function mapMessageBoxList(boxes) {
|
|
|
106
106
|
.filter(Boolean)
|
|
107
107
|
: [];
|
|
108
108
|
}
|
|
109
|
+
/** Merge paged message boxes without duplicate rows and stop on a stalled cursor. */
|
|
110
|
+
export function mergeMessageBoxPages(pages) {
|
|
111
|
+
const merged = new Map();
|
|
112
|
+
for (const page of pages) {
|
|
113
|
+
for (const box of page.messageBoxes ?? []) {
|
|
114
|
+
if (box?.id)
|
|
115
|
+
merged.set(String(box.id), box);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return [...merged.values()];
|
|
119
|
+
}
|
|
120
|
+
export function nextMessageBoxCursor(boxes, previous) {
|
|
121
|
+
const next = String(boxes.at(-1)?.id ?? '');
|
|
122
|
+
if (!next || next === previous)
|
|
123
|
+
return undefined;
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
109
126
|
/**
|
|
110
127
|
* Emit one debug log for previous-message responses.
|
|
111
128
|
*
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
import { parseMessages } from '../parsers.js';
|
|
7
7
|
import { fetchChats } from './chat-query.js';
|
|
8
8
|
import { mapContactList } from './contact-query.js';
|
|
9
|
-
import { buildEndMessageIdFields, buildMessageBoxRequest, logPreviousMessagesResponse, logRecentMessagesResponse, mapMessageBoxList, } from './message-box-query.js';
|
|
9
|
+
import { buildEndMessageIdFields, buildMessageBoxRequest, logPreviousMessagesResponse, logRecentMessagesResponse, mapMessageBoxList, mergeMessageBoxPages, nextMessageBoxCursor, } from './message-box-query.js';
|
|
10
10
|
import { fetchMessagesByIds } from './message-id-query.js';
|
|
11
|
-
import {
|
|
11
|
+
import { buildGetAllChatMidsRequest, buildGetAllContactIdsRequest, buildGetMessageBoxesRequest, buildMidListRequest, buildMidLookupRequest, buildPreviousMessagesRequest, buildRecentMessagesRequest, } from './requests.js';
|
|
12
12
|
/** Sync reason constant matching CHRLINE reference. */
|
|
13
13
|
const SYNC_REASON = 7;
|
|
14
14
|
/** Sync reason used by getPreviousMessagesV2WithRequest for secondary-device history sync. */
|
|
@@ -122,6 +122,36 @@ export function createTalkQueryClient(runtime) {
|
|
|
122
122
|
hasNext: Boolean(root?.[2]),
|
|
123
123
|
};
|
|
124
124
|
},
|
|
125
|
+
/** Retrieve every message box using the server's min/max chat cursor. */
|
|
126
|
+
async getAllMessageBoxes(options = {}) {
|
|
127
|
+
const pageSize = Math.max(1, options.messageBoxCountLimit ?? 100);
|
|
128
|
+
const pages = [];
|
|
129
|
+
// getMessageBoxes paginates toward older boxes with minChatId. Using
|
|
130
|
+
// maxChatId here makes LINE repeat almost the whole first page (and can
|
|
131
|
+
// make a full inbox look capped at 100 rows).
|
|
132
|
+
let minChatId = options.minChatId;
|
|
133
|
+
const seenCursors = new Set();
|
|
134
|
+
for (;;) {
|
|
135
|
+
const page = await this.getMessageBoxes({
|
|
136
|
+
...options,
|
|
137
|
+
messageBoxCountLimit: pageSize,
|
|
138
|
+
...(minChatId ? { minChatId } : {}),
|
|
139
|
+
});
|
|
140
|
+
pages.push(page);
|
|
141
|
+
const boxes = page.messageBoxes;
|
|
142
|
+
if (!page.hasNext || boxes.length === 0)
|
|
143
|
+
break;
|
|
144
|
+
const next = nextMessageBoxCursor(boxes, minChatId);
|
|
145
|
+
if (!next || seenCursors.has(next))
|
|
146
|
+
break;
|
|
147
|
+
seenCursors.add(next);
|
|
148
|
+
minChatId = next;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
messageBoxes: mergeMessageBoxPages(pages),
|
|
152
|
+
hasNext: false,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
125
155
|
/**
|
|
126
156
|
* Retrieve previous messages through one message-box cursor.
|
|
127
157
|
*
|
|
@@ -191,29 +221,5 @@ export function createTalkQueryClient(runtime) {
|
|
|
191
221
|
logRecentMessagesResponse(chatId, count, raw, messages);
|
|
192
222
|
return messages;
|
|
193
223
|
},
|
|
194
|
-
/**
|
|
195
|
-
* Retrieve binary media content for one LINE message.
|
|
196
|
-
*
|
|
197
|
-
* @param messageId - LINE message identifier.
|
|
198
|
-
* @param requestId - Client request identifier.
|
|
199
|
-
* @returns Message content bytes.
|
|
200
|
-
*/
|
|
201
|
-
async downloadMessageContent(messageId, requestId = `yomi-${Date.now()}`) {
|
|
202
|
-
const result = await runtime.sendTalk('downloadMessageContent', buildDownloadMessageContentRequest(requestId, messageId));
|
|
203
|
-
const content = result.fields?.[0];
|
|
204
|
-
return Buffer.isBuffer(content) ? content : Buffer.from(content || '');
|
|
205
|
-
},
|
|
206
|
-
/**
|
|
207
|
-
* Retrieve binary preview media content for one LINE message.
|
|
208
|
-
*
|
|
209
|
-
* @param messageId - LINE message identifier.
|
|
210
|
-
* @param requestId - Client request identifier.
|
|
211
|
-
* @returns Message preview bytes.
|
|
212
|
-
*/
|
|
213
|
-
async downloadMessageContentPreview(messageId, requestId = `yomi-${Date.now()}`) {
|
|
214
|
-
const result = await runtime.sendTalk('downloadMessageContentPreview', buildDownloadMessageContentRequest(requestId, messageId));
|
|
215
|
-
const content = result.fields?.[0];
|
|
216
|
-
return Buffer.isBuffer(content) ? content : Buffer.from(content || '');
|
|
217
|
-
},
|
|
218
224
|
};
|
|
219
225
|
}
|
|
@@ -148,16 +148,6 @@ export function buildPreviousMessagesRequest(request, endMessageIdFields, syncRe
|
|
|
148
148
|
export function buildRecentMessagesRequest(chatId, count) {
|
|
149
149
|
return [stringField(2, chatId), i32Field(3, count)];
|
|
150
150
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Build a message-content download request.
|
|
153
|
-
*
|
|
154
|
-
* @param requestId - Client request identifier.
|
|
155
|
-
* @param messageId - LINE message identifier.
|
|
156
|
-
* @returns Thrift request fields.
|
|
157
|
-
*/
|
|
158
|
-
export function buildDownloadMessageContentRequest(requestId, messageId) {
|
|
159
|
-
return [stringField(1, requestId), stringField(2, messageId)];
|
|
160
|
-
}
|
|
161
151
|
/**
|
|
162
152
|
* Build a single-MID lookup request.
|
|
163
153
|
*
|
|
@@ -300,7 +290,7 @@ export function buildCreateChatRequest(name, mids, chatType = 1) {
|
|
|
300
290
|
* Single struct arg at field 1: `{ reqSeq, messageId, reactionType{ predefined } }`.
|
|
301
291
|
* The messageId is a thrift i64 (LINE message ids are 64-bit), so it is passed
|
|
302
292
|
* as a BigInt. `reactionType` is LINE's predefinedReactionType enum:
|
|
303
|
-
* 2 = LIKE 👍, 3 = LOVE ❤️, 4 = LAUGH 😆, 5 = SURPRISE 😮, 6 = SAD 😢, 7 = ANGRY 😡.
|
|
293
|
+
* Protocol emoji mapping: 2 = LIKE 👍, 3 = LOVE ❤️, 4 = LAUGH 😆, 5 = SURPRISE 😮, 6 = SAD 😢, 7 = ANGRY 😡.
|
|
304
294
|
*
|
|
305
295
|
* @param messageId - Target message id (numeric string).
|
|
306
296
|
* @param reactionType - Predefined reaction type (default 2 = LIKE).
|
|
@@ -58,7 +58,9 @@ async function restoreE2EEState(service, lineLog) {
|
|
|
58
58
|
* @param lineLog - Logger instance.
|
|
59
59
|
*/
|
|
60
60
|
async function restoreBootstrapKeys(service, lineLog) {
|
|
61
|
-
|
|
61
|
+
// Resume is intentionally read-only. Recovered keys are imported into
|
|
62
|
+
// this process but are not written back to the durable Keychain blob.
|
|
63
|
+
const bootstrapKeys = await recoverBootstrapKeys(service, { persist: false });
|
|
62
64
|
if (bootstrapKeys?.length) {
|
|
63
65
|
service.e2eeWarning = false;
|
|
64
66
|
service.emit('e2eeWarning', {
|
|
@@ -89,6 +91,11 @@ async function validateRestoredSession(service, lineLog) {
|
|
|
89
91
|
const profile = await service.client.getProfile();
|
|
90
92
|
if (profile) {
|
|
91
93
|
service.profile = profile;
|
|
94
|
+
// Session resume is a read-only/background path. Persisting profile
|
|
95
|
+
// metadata here rewrites the entire macOS Keychain blob on every
|
|
96
|
+
// desktop polling process and triggers a security permission prompt.
|
|
97
|
+
// Login and explicit account changes own durable profile writes; a
|
|
98
|
+
// resume may use this in-memory profile for the current request only.
|
|
92
99
|
lineLog.info('session.restore.validated', {
|
|
93
100
|
profile: profile.displayName || profile.mid,
|
|
94
101
|
});
|
|
@@ -130,6 +137,14 @@ async function handleSessionValidationError(service, lineLog, validationErr) {
|
|
|
130
137
|
return false;
|
|
131
138
|
}
|
|
132
139
|
if (AUTH_ERROR_REGEX.test(msg)) {
|
|
140
|
+
if (process.env.YOMI_READ_ONLY_SESSION === '1') {
|
|
141
|
+
lineLog.info('auth.refresh.skip', { reason: 'read_only_session' });
|
|
142
|
+
service.loginRequired = true;
|
|
143
|
+
service.loginReason = 'expired';
|
|
144
|
+
service.setState(STATE.DISCONNECTED);
|
|
145
|
+
service.emit('line:loginRequired');
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
133
148
|
lineLog.info('session.restore.refresh_attempt');
|
|
134
149
|
const refreshed = await tryRefreshToken(service);
|
|
135
150
|
if (refreshed) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function chatEventParticipantMids(message) {
|
|
2
|
+
if (Number(message?.contentType) !== 18)
|
|
3
|
+
return [];
|
|
4
|
+
const raw = message?.contentMetadata?.LOC_ARGS;
|
|
5
|
+
if (typeof raw !== 'string')
|
|
6
|
+
return [];
|
|
7
|
+
return raw
|
|
8
|
+
.split('\u001e')
|
|
9
|
+
.map((value) => value.trim())
|
|
10
|
+
.filter((value) => value.startsWith('u'));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Interpret LINE's private ChatEvent localization keys observed in real group
|
|
14
|
+
* traffic. Unknown keys remain explicit so new events never masquerade as a
|
|
15
|
+
* decoded action.
|
|
16
|
+
*/
|
|
17
|
+
export function interpretChatEvent(message, names) {
|
|
18
|
+
const key = typeof message?.contentMetadata?.LOC_KEY === 'string'
|
|
19
|
+
? message.contentMetadata.LOC_KEY
|
|
20
|
+
: '';
|
|
21
|
+
const mids = chatEventParticipantMids(message);
|
|
22
|
+
const name = (index) => names.get(mids[index]) || '一位成員';
|
|
23
|
+
switch (key) {
|
|
24
|
+
case 'C_GI':
|
|
25
|
+
return mids.length >= 2
|
|
26
|
+
? `${name(0)} 邀請 ${name(1)} 加入群組`
|
|
27
|
+
: '有人邀請成員加入群組';
|
|
28
|
+
case 'C_MJ':
|
|
29
|
+
return mids.length >= 1 ? `${name(0)} 加入群組` : '有成員加入群組';
|
|
30
|
+
default:
|
|
31
|
+
return key ? `[聊天室活動:${key}]` : '[聊天室活動]';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -154,26 +154,6 @@ export function createChatRuntimeService(service) {
|
|
|
154
154
|
async getPreviousMessages(chatId, count = 50, before = {}) {
|
|
155
155
|
return createMessageQueryService(() => service.client, service.e2eeManager).getPreviousMessages(chatId, count, before);
|
|
156
156
|
},
|
|
157
|
-
/**
|
|
158
|
-
* Download the original LINE content bytes for one message.
|
|
159
|
-
*
|
|
160
|
-
* @param messageId - LINE message identifier.
|
|
161
|
-
* @param requestId - Optional request identifier.
|
|
162
|
-
* @returns Original message content bytes.
|
|
163
|
-
*/
|
|
164
|
-
async downloadMessageContent(messageId, requestId) {
|
|
165
|
-
return createMessageQueryService(() => service.client, service.e2eeManager).downloadMessageContent(messageId, requestId);
|
|
166
|
-
},
|
|
167
|
-
/**
|
|
168
|
-
* Download the LINE preview bytes for one message.
|
|
169
|
-
*
|
|
170
|
-
* @param messageId - LINE message identifier.
|
|
171
|
-
* @param requestId - Optional request identifier.
|
|
172
|
-
* @returns Preview content bytes.
|
|
173
|
-
*/
|
|
174
|
-
async downloadMessageContentPreview(messageId, requestId) {
|
|
175
|
-
return createMessageQueryService(() => service.client, service.e2eeManager).downloadMessageContentPreview(messageId, requestId);
|
|
176
|
-
},
|
|
177
157
|
/**
|
|
178
158
|
* Mark a LINE conversation read up to a message via the explicit
|
|
179
159
|
* sendChatChecked boundary. When messageId is omitted, resolves the most
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LINE core directory/runtime capability.
|
|
3
3
|
*/
|
|
4
|
-
import { fetchStickerPackageMeta } from '../client/sticker-meta.js';
|
|
4
|
+
import { fetchStickerPackageMeta, localizedStickerTitle, } from '../client/sticker-meta.js';
|
|
5
5
|
import { createContactQueryService } from './contact-query-service.js';
|
|
6
6
|
import { createGroupQueryService } from './group-query-service.js';
|
|
7
7
|
/**
|
|
@@ -46,7 +46,15 @@ export function createDirectoryRuntimeService(service) {
|
|
|
46
46
|
* @returns Owned sticker packages: { packageId, title, version }.
|
|
47
47
|
*/
|
|
48
48
|
async listStickerPackages(language = 'en') {
|
|
49
|
-
|
|
49
|
+
const owned = await service.client.getOwnedStickerPackages(language);
|
|
50
|
+
return Promise.all(owned.map(async (pkg) => {
|
|
51
|
+
const meta = await fetchStickerPackageMeta(String(pkg.packageId)).catch(() => null);
|
|
52
|
+
return {
|
|
53
|
+
...pkg,
|
|
54
|
+
title: localizedStickerTitle(meta?.title, language, pkg.title),
|
|
55
|
+
titles: meta?.title ?? {},
|
|
56
|
+
};
|
|
57
|
+
}));
|
|
50
58
|
},
|
|
51
59
|
/**
|
|
52
60
|
* Search the account's OWNED sticker packages by title substring, and
|
|
@@ -12,7 +12,7 @@ import { decryptKeyChain } from '../index.js';
|
|
|
12
12
|
* @param service - LineProtocolService-like object
|
|
13
13
|
* @returns Recovered key list, or null when bootstrap material is unavailable
|
|
14
14
|
*/
|
|
15
|
-
export async function recoverBootstrapKeys(service) {
|
|
15
|
+
export async function recoverBootstrapKeys(service, options = {}) {
|
|
16
16
|
const bootstrapInput = await readBootstrapRecoveryInput(service);
|
|
17
17
|
if (!bootstrapInput) {
|
|
18
18
|
return null;
|
|
@@ -21,7 +21,13 @@ export async function recoverBootstrapKeys(service) {
|
|
|
21
21
|
if (!keys) {
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
service.e2eeManager.importKeys(keys);
|
|
25
|
+
if (bootstrapInput.mid) {
|
|
26
|
+
service.e2eeManager.bindSelfKeysToMid(bootstrapInput.mid);
|
|
27
|
+
}
|
|
28
|
+
if (options.persist !== false) {
|
|
29
|
+
await service.sessionState.saveE2EEKeys(keys);
|
|
30
|
+
}
|
|
25
31
|
return keys;
|
|
26
32
|
}
|
|
27
33
|
/**
|
|
@@ -122,20 +128,6 @@ function rebuildBootstrapKeys(input) {
|
|
|
122
128
|
}));
|
|
123
129
|
return Array.isArray(keys) && keys.length > 0 ? keys : null;
|
|
124
130
|
}
|
|
125
|
-
/**
|
|
126
|
-
* Persist recovered keys into the in-memory manager and session state.
|
|
127
|
-
*
|
|
128
|
-
* @param service - LineProtocolService-like object.
|
|
129
|
-
* @param keys - Recovered key list.
|
|
130
|
-
* @param mid - Optional self MID.
|
|
131
|
-
*/
|
|
132
|
-
async function persistRecoveredKeys(service, keys, mid) {
|
|
133
|
-
service.e2eeManager.importKeys(keys);
|
|
134
|
-
if (mid) {
|
|
135
|
-
service.e2eeManager.bindSelfKeysToMid(mid);
|
|
136
|
-
}
|
|
137
|
-
await service.sessionState.saveE2EEKeys(keys);
|
|
138
|
-
}
|
|
139
131
|
/**
|
|
140
132
|
* Decode a base64-encoded string into a Buffer when possible.
|
|
141
133
|
*
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const FLEX_PAYLOAD_KEYS = [
|
|
2
|
+
'FLEX_JSON',
|
|
3
|
+
'FLEX_CONTENTS',
|
|
4
|
+
'FLEX_MESSAGE',
|
|
5
|
+
'CONTENTS',
|
|
6
|
+
'MARKUP_JSON',
|
|
7
|
+
];
|
|
8
|
+
function parseObject(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return value;
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(trimmed);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function webUrl(value) {
|
|
22
|
+
if (typeof value !== 'string')
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(value);
|
|
26
|
+
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
27
|
+
? url.toString()
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Interpret LINE bubble/carousel content in header→hero→body→footer order. */
|
|
35
|
+
export function interpretFlexPayload(payload) {
|
|
36
|
+
const texts = [];
|
|
37
|
+
const images = [];
|
|
38
|
+
const actions = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
let visited = 0;
|
|
41
|
+
const addText = (value) => {
|
|
42
|
+
if (typeof value !== 'string')
|
|
43
|
+
return;
|
|
44
|
+
const clean = value.replace(/\s+/g, ' ').trim();
|
|
45
|
+
if (clean && !texts.includes(clean))
|
|
46
|
+
texts.push(clean);
|
|
47
|
+
};
|
|
48
|
+
const walk = (raw, depth = 0) => {
|
|
49
|
+
if (depth > 24 || visited >= 800)
|
|
50
|
+
return;
|
|
51
|
+
const value = parseObject(raw);
|
|
52
|
+
if (!value || typeof value !== 'object' || seen.has(value))
|
|
53
|
+
return;
|
|
54
|
+
seen.add(value);
|
|
55
|
+
visited += 1;
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
for (const item of value)
|
|
58
|
+
walk(item, depth + 1);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const node = value;
|
|
62
|
+
const type = typeof node.type === 'string' ? node.type.toLowerCase() : '';
|
|
63
|
+
if (type === 'text' || type === 'span')
|
|
64
|
+
addText(node.text);
|
|
65
|
+
if (type === 'image' || type === 'video') {
|
|
66
|
+
const image = webUrl(node.url) || webUrl(node.previewUrl);
|
|
67
|
+
if (image && !images.includes(image))
|
|
68
|
+
images.push(image);
|
|
69
|
+
}
|
|
70
|
+
const action = parseObject(node.action);
|
|
71
|
+
if (action && typeof action === 'object' && !Array.isArray(action)) {
|
|
72
|
+
const record = action;
|
|
73
|
+
const label = [record.label, record.displayText, record.text].find((item) => typeof item === 'string' && item.trim().length > 0) ?? '';
|
|
74
|
+
const url = webUrl(record.uri);
|
|
75
|
+
if (label || url)
|
|
76
|
+
actions.push({ label: label.trim(), url });
|
|
77
|
+
}
|
|
78
|
+
const ordered = [
|
|
79
|
+
'header',
|
|
80
|
+
'hero',
|
|
81
|
+
'body',
|
|
82
|
+
'footer',
|
|
83
|
+
'contents',
|
|
84
|
+
'altContent',
|
|
85
|
+
];
|
|
86
|
+
for (const key of ordered)
|
|
87
|
+
if (key in node)
|
|
88
|
+
walk(node[key], depth + 1);
|
|
89
|
+
for (const [key, child] of Object.entries(node)) {
|
|
90
|
+
if (ordered.includes(key) ||
|
|
91
|
+
['action', 'type', 'text', 'url', 'previewUrl'].includes(key))
|
|
92
|
+
continue;
|
|
93
|
+
if (child && typeof child === 'object')
|
|
94
|
+
walk(child, depth + 1);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(payload);
|
|
98
|
+
const machineLabels = new Set(['uri', 'url', 'link', 'open', 'action']);
|
|
99
|
+
const actionLabels = actions.map((item) => item.label).filter(Boolean);
|
|
100
|
+
const readableActionLabels = actionLabels.filter((label) => !machineLabels.has(label.toLowerCase()));
|
|
101
|
+
const readable = [
|
|
102
|
+
...texts,
|
|
103
|
+
...readableActionLabels.filter((label) => !texts.includes(label)),
|
|
104
|
+
];
|
|
105
|
+
return {
|
|
106
|
+
summary: readable.join(' · ').slice(0, 700),
|
|
107
|
+
imageUrl: images[0] ?? null,
|
|
108
|
+
actionUrl: actions.find((item) => item.url)?.url ?? null,
|
|
109
|
+
actionLabels,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function interpretFlexMessage(message) {
|
|
113
|
+
const metadata = message?.contentMetadata && typeof message.contentMetadata === 'object'
|
|
114
|
+
? message.contentMetadata
|
|
115
|
+
: {};
|
|
116
|
+
for (const key of FLEX_PAYLOAD_KEYS) {
|
|
117
|
+
const parsed = parseObject(metadata[key]);
|
|
118
|
+
if (parsed)
|
|
119
|
+
return interpretFlexPayload(parsed);
|
|
120
|
+
}
|
|
121
|
+
const textPayload = parseObject(message?.text);
|
|
122
|
+
return textPayload
|
|
123
|
+
? interpretFlexPayload(textPayload)
|
|
124
|
+
: {
|
|
125
|
+
summary: '',
|
|
126
|
+
imageUrl: null,
|
|
127
|
+
actionUrl: null,
|
|
128
|
+
actionLabels: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - `M` is the mentioned user's MID; `S`/`E` are the start/end offsets as
|
|
11
11
|
* *strings*, not numbers.
|
|
12
12
|
* - The range is half-open `[S, E)`, measured in UTF-16 code units — i.e.
|
|
13
|
-
* plain
|
|
13
|
+
* plain JavaScript string indices (a three-code-unit name plus `@` matched `S:"0", E:"4"`).
|
|
14
14
|
* - Key order inside a mentionee object is not significant.
|
|
15
15
|
* - MENTION rides in `contentMetadata`, which is never part of an E2EE
|
|
16
16
|
* ciphertext payload — it was observed in the clear on a real message.
|