@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.
Files changed (41) hide show
  1. package/README.md +18 -4
  2. package/dist/attention/policy.js +95 -0
  3. package/dist/auth/credential-store.js +32 -11
  4. package/dist/auth/keychain.js +7 -54
  5. package/dist/cli/query.js +668 -0
  6. package/dist/insight/relationships.js +1 -1
  7. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  8. package/dist/line/auth/pwless/index.js +1 -1
  9. package/dist/line/client/index.js +0 -2
  10. package/dist/line/client/obs-media-client.js +37 -0
  11. package/dist/line/client/parsers.js +1 -0
  12. package/dist/line/client/sticker-meta.js +32 -0
  13. package/dist/line/client/sync-service/client.js +31 -3
  14. package/dist/line/client/talk-service/contact-query.js +8 -1
  15. package/dist/line/client/talk-service/message-box-query.js +17 -0
  16. package/dist/line/client/talk-service/query-client.js +32 -26
  17. package/dist/line/client/talk-service/requests.js +1 -11
  18. package/dist/line/core/auth-session-runtime.js +16 -1
  19. package/dist/line/core/chat-event.js +33 -0
  20. package/dist/line/core/chat-runtime-service.js +0 -20
  21. package/dist/line/core/directory-runtime-service.js +10 -2
  22. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  23. package/dist/line/core/flex-message.js +130 -0
  24. package/dist/line/core/mention.js +1 -1
  25. package/dist/line/core/message-preview.js +60 -0
  26. package/dist/line/core/message-query-service.js +0 -20
  27. package/dist/line/core/service.js +1 -2
  28. package/dist/mcp/handlers/conversations.js +37 -4
  29. package/dist/mcp/handlers/login.js +1 -1
  30. package/dist/mcp/handlers/message-actions.js +1 -1
  31. package/dist/mcp/handlers/search.js +1 -1
  32. package/dist/mcp/handlers/shared.js +1 -1
  33. package/dist/mcp/integrator.js +168 -0
  34. package/dist/mcp/media.js +34 -2
  35. package/dist/mcp/phi-guard.js +2 -2
  36. package/dist/search/bigram.js +2 -2
  37. package/dist/search/collector.js +1 -1
  38. package/dist/search/embedder.js +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +4 -1
  41. 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, tick the device signing in and tap 「用戶確認」
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;
@@ -128,8 +128,6 @@ export class LineClient extends EventEmitter {
128
128
  openAuthSession;
129
129
  issueV3TokenForPrimary;
130
130
  restoreE2EEKeyBackup;
131
- downloadMessageContent;
132
- downloadMessageContentPreview;
133
131
  logoutZ;
134
132
  getOwnedStickerPackages;
135
133
  updateChatName;
@@ -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
  *
@@ -26,6 +26,7 @@ export function parseMessage(msg) {
26
26
  ? Number(msg[7])
27
27
  : (msg[6] ?? msg[7]),
28
28
  text: msg[10] || null,
29
+ contentPreview: msg[17] || null,
29
30
  contentType: msg[15] || 0,
30
31
  contentMetadata: msg[18] || {},
31
32
  chunks: msg[20] || null,
@@ -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
- runtime.revision =
110
- typeof nextRevision === 'bigint' ? Number(nextRevision) : nextRevision;
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
- return value || null;
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 { buildDownloadMessageContentRequest, buildGetAllChatMidsRequest, buildGetAllContactIdsRequest, buildGetMessageBoxesRequest, buildMidListRequest, buildMidLookupRequest, buildPreviousMessagesRequest, buildRecentMessagesRequest, } from './requests.js';
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
- const bootstrapKeys = await recoverBootstrapKeys(service);
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
- return service.client.getOwnedStickerPackages(language);
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
- await persistRecoveredKeys(service, keys, bootstrapInput.mid);
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 JS string indices (`"@鄭富源".length === 4` matched `S:"0", E:"4"`).
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.