@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
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { scoreAttentionBatch } from '../attention/policy.js';
|
|
6
|
+
import { fetchStickerImage, fetchStickerPackageMetaCached, localizedStickerTitle, } from '../line/client/sticker-meta.js';
|
|
7
|
+
import { chatEventParticipantMids, interpretChatEvent, } from '../line/core/chat-event.js';
|
|
8
|
+
import { interpretFlexMessage } from '../line/core/flex-message.js';
|
|
9
|
+
import { sanitizeMessagePreview } from '../line/core/message-preview.js';
|
|
10
|
+
import { decryptLineMessage } from '../line/core/message-query-service.js';
|
|
11
|
+
import { LineProtocolService } from '../line/core/service.js';
|
|
12
|
+
import { handleListConversations } from '../mcp/handlers/conversations.js';
|
|
13
|
+
import { resolveConversationNames, resolveSenderNames } from '../mcp/names.js';
|
|
14
|
+
const attentionCachePath = join(process.env.YOMI_DATA_DIR || join(homedir(), '.yomi'), 'attention-cache.json');
|
|
15
|
+
const ATTENTION_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const ATTENTION_CACHE_MAX_ENTRIES = 1000;
|
|
17
|
+
const ATTENTION_BATCH_SIZE = 8;
|
|
18
|
+
const ATTENTION_POLICY_VERSION = 2;
|
|
19
|
+
function attentionSignature(item) {
|
|
20
|
+
return createHash('sha256')
|
|
21
|
+
.update(JSON.stringify([
|
|
22
|
+
ATTENTION_POLICY_VERSION,
|
|
23
|
+
item.id,
|
|
24
|
+
item.lastMessage,
|
|
25
|
+
item.isOfficial,
|
|
26
|
+
item.isMuted,
|
|
27
|
+
]))
|
|
28
|
+
.digest('hex');
|
|
29
|
+
}
|
|
30
|
+
async function loadAttentionCache() {
|
|
31
|
+
try {
|
|
32
|
+
const cache = JSON.parse(await readFile(attentionCachePath, 'utf8'));
|
|
33
|
+
const cutoff = Date.now() - ATTENTION_CACHE_TTL_MS;
|
|
34
|
+
return Object.fromEntries(Object.entries(cache).filter(([, entry]) => (entry.updatedAt || 0) >= cutoff));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function saveAttentionCache(cache) {
|
|
41
|
+
const bounded = Object.fromEntries(Object.entries(cache)
|
|
42
|
+
.sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
|
|
43
|
+
.slice(0, ATTENTION_CACHE_MAX_ENTRIES));
|
|
44
|
+
await mkdir(dirname(attentionCachePath), { recursive: true });
|
|
45
|
+
await writeFile(attentionCachePath, JSON.stringify(bounded), { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
export async function cliClassifyAttentionJson(items) {
|
|
48
|
+
const cache = await loadAttentionCache();
|
|
49
|
+
const batch = items.slice(0, ATTENTION_BATCH_SIZE);
|
|
50
|
+
if (batch.length === 0)
|
|
51
|
+
return [];
|
|
52
|
+
const evaluations = await scoreAttentionBatch(batch.map((item) => ({
|
|
53
|
+
id: item.id,
|
|
54
|
+
text: item.lastMessage,
|
|
55
|
+
unreadCount: 0,
|
|
56
|
+
isMuted: item.isMuted,
|
|
57
|
+
isOfficial: item.isOfficial,
|
|
58
|
+
hasMention: item.hasMention,
|
|
59
|
+
})));
|
|
60
|
+
const results = batch.map((item, index) => {
|
|
61
|
+
const result = evaluations[index];
|
|
62
|
+
cache[attentionSignature(item)] = { ...result, updatedAt: Date.now() };
|
|
63
|
+
return {
|
|
64
|
+
id: item.id,
|
|
65
|
+
attentionScore: result.score + (item.unreadCount > 0 ? 5 : 0),
|
|
66
|
+
attentionTier: result.tier,
|
|
67
|
+
attentionReason: result.reason,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
await saveAttentionCache(cache);
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
/** Read-only bridge for the desktop sticker picker. */
|
|
74
|
+
export async function cliListStickersJson(language = 'zh_TW') {
|
|
75
|
+
const service = new LineProtocolService();
|
|
76
|
+
if (!(await service.resumeSession()))
|
|
77
|
+
return [];
|
|
78
|
+
const owned = await service.listStickerPackages(language);
|
|
79
|
+
return Promise.all(owned.map(async (pkg) => {
|
|
80
|
+
const packageId = String(pkg.packageId ?? '');
|
|
81
|
+
if (!packageId)
|
|
82
|
+
return pkg;
|
|
83
|
+
const meta = await fetchStickerPackageMetaCached(packageId);
|
|
84
|
+
return {
|
|
85
|
+
...pkg,
|
|
86
|
+
title: localizedStickerTitle(meta?.title, language, String(pkg.title ?? '')),
|
|
87
|
+
};
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
/** Read-only bridge for owned sticker previews; never sends a message. */
|
|
91
|
+
export async function cliStickerPreviewsJson(packageId, limit = 12, language = 'en') {
|
|
92
|
+
if (!packageId)
|
|
93
|
+
return [];
|
|
94
|
+
const service = new LineProtocolService();
|
|
95
|
+
if (!(await service.resumeSession()))
|
|
96
|
+
return [];
|
|
97
|
+
// The package must be owned before the public CDN metadata is expanded.
|
|
98
|
+
const owned = await service.listStickerPackages(language);
|
|
99
|
+
if (!owned.some((item) => String(item.packageId) === packageId))
|
|
100
|
+
return [];
|
|
101
|
+
const meta = await fetchStickerPackageMetaCached(packageId);
|
|
102
|
+
if (!meta)
|
|
103
|
+
return [];
|
|
104
|
+
const result = [];
|
|
105
|
+
for (const stickerId of meta.stickerIds.slice(0, Math.max(1, limit))) {
|
|
106
|
+
const bytes = await fetchStickerImage(stickerId);
|
|
107
|
+
if (!bytes)
|
|
108
|
+
continue;
|
|
109
|
+
result.push({
|
|
110
|
+
stickerId,
|
|
111
|
+
packageId,
|
|
112
|
+
data: bytes.toString('base64'),
|
|
113
|
+
mimeType: 'image/png',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function parseMediaText(value) {
|
|
119
|
+
if (typeof value !== 'string' || !value.trim())
|
|
120
|
+
return {};
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(value);
|
|
123
|
+
return parsed && typeof parsed === 'object'
|
|
124
|
+
? parsed
|
|
125
|
+
: {};
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Only expose web URLs from LINE rich-message metadata. */
|
|
132
|
+
function safeWebUrl(value) {
|
|
133
|
+
if (typeof value !== 'string' || !value.trim())
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
const url = new URL(value.trim());
|
|
137
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
138
|
+
? url.toString()
|
|
139
|
+
: null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function firstWebLink(value) {
|
|
146
|
+
if (!value || typeof value !== 'object')
|
|
147
|
+
return null;
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
for (const item of value) {
|
|
150
|
+
const found = firstWebLink(item);
|
|
151
|
+
if (found)
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const record = value;
|
|
157
|
+
for (const [key, item] of Object.entries(record)) {
|
|
158
|
+
if (key === 'linkUri') {
|
|
159
|
+
const found = safeWebUrl(item);
|
|
160
|
+
if (found)
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
const nested = firstWebLink(item);
|
|
164
|
+
if (nested)
|
|
165
|
+
return nested;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
export function richFields(message) {
|
|
170
|
+
const metadata = message?.contentMetadata && typeof message.contentMetadata === 'object'
|
|
171
|
+
? message.contentMetadata
|
|
172
|
+
: {};
|
|
173
|
+
const interpreted = interpretFlexMessage(message);
|
|
174
|
+
const explicitAlt = sanitizeMessagePreview(metadata.ALT_TEXT) || '';
|
|
175
|
+
const altText = interpreted.summary || explicitAlt;
|
|
176
|
+
const mediaUrl = safeWebUrl(metadata.DOWNLOAD_URL) || interpreted.imageUrl;
|
|
177
|
+
const redirect = safeWebUrl(metadata.LINE_TAG_REDIRECTOR);
|
|
178
|
+
let markup = metadata.MARKUP_JSON;
|
|
179
|
+
if (typeof markup === 'string') {
|
|
180
|
+
try {
|
|
181
|
+
markup = JSON.parse(markup);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
markup = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
altText,
|
|
189
|
+
mediaUrl,
|
|
190
|
+
actionUrl: redirect || interpreted.actionUrl || firstWebLink(markup),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export { sanitizeMessagePreview } from '../line/core/message-preview.js';
|
|
194
|
+
export function messagePlaceholder(contentType, hasOpaquePayload = false) {
|
|
195
|
+
switch (contentType) {
|
|
196
|
+
case 0:
|
|
197
|
+
return hasOpaquePayload ? '[加密訊息]' : '[訊息]';
|
|
198
|
+
case 7:
|
|
199
|
+
return '[貼圖]';
|
|
200
|
+
case 4:
|
|
201
|
+
return '[網頁內容]';
|
|
202
|
+
case 5:
|
|
203
|
+
return '[PDF 文件]';
|
|
204
|
+
case 6:
|
|
205
|
+
return '[通話紀錄]';
|
|
206
|
+
case 8:
|
|
207
|
+
return '[動態狀態]';
|
|
208
|
+
case 9:
|
|
209
|
+
return '[禮物]';
|
|
210
|
+
case 10:
|
|
211
|
+
return '[群組記事本]';
|
|
212
|
+
case 11:
|
|
213
|
+
return '[應用程式連結]';
|
|
214
|
+
case 12:
|
|
215
|
+
return '[分享連結]';
|
|
216
|
+
case 13:
|
|
217
|
+
return '[聯絡人]';
|
|
218
|
+
case 15:
|
|
219
|
+
return '[位置資訊]';
|
|
220
|
+
case 16:
|
|
221
|
+
return '[貼文通知]';
|
|
222
|
+
case 17:
|
|
223
|
+
return '[互動卡片]';
|
|
224
|
+
case 18:
|
|
225
|
+
return '[聊天室活動]';
|
|
226
|
+
case 19:
|
|
227
|
+
return '[音樂]';
|
|
228
|
+
case 20:
|
|
229
|
+
return '[付款資訊]';
|
|
230
|
+
case 21:
|
|
231
|
+
return '[延伸圖片]';
|
|
232
|
+
case 22:
|
|
233
|
+
return '[互動卡片]';
|
|
234
|
+
case 1:
|
|
235
|
+
return '[圖片]';
|
|
236
|
+
case 2:
|
|
237
|
+
return '[影片]';
|
|
238
|
+
case 3:
|
|
239
|
+
return '[語音訊息]';
|
|
240
|
+
case 14:
|
|
241
|
+
return '[檔案]';
|
|
242
|
+
default:
|
|
243
|
+
return hasOpaquePayload ? '[加密訊息]' : `[LINE 內容 ${contentType}]`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export function messageFallback(message) {
|
|
247
|
+
if (message?.contentMetadata?.UNSENT === 'true')
|
|
248
|
+
return '訊息已收回';
|
|
249
|
+
return message?.e2eeDecryptFailure
|
|
250
|
+
? '[無法解密的訊息]'
|
|
251
|
+
: messagePlaceholder(Number(message?.contentType || 0), Boolean(message?.text));
|
|
252
|
+
}
|
|
253
|
+
function parseMentionRanges(metadata) {
|
|
254
|
+
if (typeof metadata.MENTION !== 'string')
|
|
255
|
+
return [];
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(metadata.MENTION);
|
|
258
|
+
return (parsed.MENTIONEES || []).flatMap((mention) => {
|
|
259
|
+
const location = Number(mention.S);
|
|
260
|
+
const end = Number(mention.E);
|
|
261
|
+
return Number.isInteger(location) &&
|
|
262
|
+
Number.isInteger(end) &&
|
|
263
|
+
end > location
|
|
264
|
+
? [{ location, length: end - location }]
|
|
265
|
+
: [];
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function isRichContentType(contentType) {
|
|
273
|
+
return contentType === 17 || contentType === 22;
|
|
274
|
+
}
|
|
275
|
+
function formatTimestamp(timestamp) {
|
|
276
|
+
if (!timestamp || timestamp <= 0)
|
|
277
|
+
return '';
|
|
278
|
+
const date = new Date(Number(timestamp));
|
|
279
|
+
const now = new Date();
|
|
280
|
+
const isToday = date.getFullYear() === now.getFullYear() &&
|
|
281
|
+
date.getMonth() === now.getMonth() &&
|
|
282
|
+
date.getDate() === now.getDate();
|
|
283
|
+
const yesterday = new Date(now);
|
|
284
|
+
yesterday.setDate(now.getDate() - 1);
|
|
285
|
+
const isYesterday = date.getFullYear() === yesterday.getFullYear() &&
|
|
286
|
+
date.getMonth() === yesterday.getMonth() &&
|
|
287
|
+
date.getDate() === yesterday.getDate();
|
|
288
|
+
const hours = date.getHours().toString().padStart(2, '0');
|
|
289
|
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
290
|
+
if (isToday) {
|
|
291
|
+
return `${hours}:${minutes}`;
|
|
292
|
+
}
|
|
293
|
+
if (isYesterday) {
|
|
294
|
+
return '昨天';
|
|
295
|
+
}
|
|
296
|
+
if (date.getFullYear() === now.getFullYear()) {
|
|
297
|
+
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
|
298
|
+
}
|
|
299
|
+
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
|
|
300
|
+
}
|
|
301
|
+
function messageTimestamp(message) {
|
|
302
|
+
return Number(message?.deliveredTime || message?.createdTime || 0);
|
|
303
|
+
}
|
|
304
|
+
function latestMessage(messages) {
|
|
305
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
306
|
+
return null;
|
|
307
|
+
return messages.reduce((latest, candidate) => messageTimestamp(candidate) >= messageTimestamp(latest)
|
|
308
|
+
? candidate
|
|
309
|
+
: latest);
|
|
310
|
+
}
|
|
311
|
+
export async function cliListConversationsJson(args = {}) {
|
|
312
|
+
const service = new LineProtocolService();
|
|
313
|
+
const ok = await service.resumeSession();
|
|
314
|
+
if (!ok)
|
|
315
|
+
return [];
|
|
316
|
+
try {
|
|
317
|
+
// Advance the Talk revision before reading message boxes. A fresh client
|
|
318
|
+
// process starts with no checkpoint; syncLongPoll now obtains the server's
|
|
319
|
+
// last-op revision before issuing sync, then we persist the monotonic
|
|
320
|
+
// checkpoint through the session-state owner for the next bridge process.
|
|
321
|
+
try {
|
|
322
|
+
await service.client.syncLongPoll(50, 1000);
|
|
323
|
+
if (service.sessionState?.setRevisions) {
|
|
324
|
+
await service.sessionState.setRevisions(Number(service.client.revision || 0), Number(service.client.globalRevision || 0), Number(service.client.individualRevision || 0));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch { }
|
|
328
|
+
const result = args.firstPage
|
|
329
|
+
? await service.client.getMessageBoxes({
|
|
330
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
331
|
+
messageBoxCountLimit: args.limit ?? 100,
|
|
332
|
+
withUnreadCount: true,
|
|
333
|
+
})
|
|
334
|
+
: await service.client.getAllMessageBoxes({
|
|
335
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
336
|
+
...(args.limit ? { messageBoxCountLimit: args.limit } : {}),
|
|
337
|
+
withUnreadCount: true,
|
|
338
|
+
});
|
|
339
|
+
const boxes = result.messageBoxes || [];
|
|
340
|
+
const chatIds = boxes.map((box) => box.id).filter(Boolean);
|
|
341
|
+
const names = await resolveConversationNames(service, chatIds);
|
|
342
|
+
// Resolve contacts picture paths
|
|
343
|
+
const userMids = chatIds.filter((id) => id.startsWith('u'));
|
|
344
|
+
const contactMap = new Map();
|
|
345
|
+
if (userMids.length > 0) {
|
|
346
|
+
try {
|
|
347
|
+
const contacts = await service.client.getContacts(userMids);
|
|
348
|
+
for (const c of contacts || []) {
|
|
349
|
+
if (c?.mid)
|
|
350
|
+
contactMap.set(c.mid, c);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch { }
|
|
354
|
+
}
|
|
355
|
+
const conversations = await Promise.all(boxes.map(async (box) => {
|
|
356
|
+
let lastMsg = latestMessage(box.lastMessages);
|
|
357
|
+
const boxDeliveredTime = Number(box.lastDeliveredMessageId?.deliveredTime || 0);
|
|
358
|
+
// Some LINE responses return a lastMessages array that lags the
|
|
359
|
+
// last-delivered cursor. Fetch one message only for that box so the
|
|
360
|
+
// inbox preview and date cannot remain days behind.
|
|
361
|
+
if (box.id &&
|
|
362
|
+
(!lastMsg || messageTimestamp(lastMsg) < boxDeliveredTime)) {
|
|
363
|
+
try {
|
|
364
|
+
const recent = await service.getRecentMessages(box.id, 1);
|
|
365
|
+
const candidate = latestMessage(recent);
|
|
366
|
+
if (candidate &&
|
|
367
|
+
messageTimestamp(candidate) >= messageTimestamp(lastMsg)) {
|
|
368
|
+
lastMsg = candidate;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch { }
|
|
372
|
+
}
|
|
373
|
+
let preview = '(無訊息)';
|
|
374
|
+
let timestamp = 0;
|
|
375
|
+
if (lastMsg) {
|
|
376
|
+
timestamp = messageTimestamp(lastMsg);
|
|
377
|
+
const rich = isRichContentType(Number(lastMsg.contentType))
|
|
378
|
+
? richFields(lastMsg)
|
|
379
|
+
: null;
|
|
380
|
+
const rawPreview = rich?.altText || sanitizeMessagePreview(lastMsg.text);
|
|
381
|
+
if (rawPreview) {
|
|
382
|
+
preview = rawPreview;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const decrypted = await decryptLineMessage(service.e2eeManager, lastMsg, box.id);
|
|
386
|
+
const decryptedPreview = sanitizeMessagePreview(decrypted?.text);
|
|
387
|
+
if (decryptedPreview)
|
|
388
|
+
preview = decryptedPreview;
|
|
389
|
+
else if (lastMsg.contentType)
|
|
390
|
+
preview = messagePlaceholder(Number(lastMsg.contentType));
|
|
391
|
+
else if (lastMsg.text)
|
|
392
|
+
preview = messagePlaceholder(0, true);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const isGroup = box.id?.startsWith('c') || box.id?.startsWith('r');
|
|
396
|
+
const defaultTitle = isGroup ? '群組對話' : '好友對話';
|
|
397
|
+
const title = names.get(box.id) || defaultTitle;
|
|
398
|
+
const chatCached = service.chatCache.get(box.id);
|
|
399
|
+
const contact = contactMap.get(box.id);
|
|
400
|
+
let picPath = null;
|
|
401
|
+
if (isGroup && chatCached?.picturePath) {
|
|
402
|
+
picPath = chatCached.picturePath;
|
|
403
|
+
}
|
|
404
|
+
else if (!isGroup && contact?.picturePath) {
|
|
405
|
+
picPath = contact.picturePath;
|
|
406
|
+
}
|
|
407
|
+
const pictureUrl = picPath
|
|
408
|
+
? `https://obs.line-scdn.net/${picPath.replace(/^\/+/, '')}`
|
|
409
|
+
: null;
|
|
410
|
+
const isMuted = isGroup
|
|
411
|
+
? Boolean(chatCached?.notificationDisabled)
|
|
412
|
+
: Boolean(contact?.notificationDisabled ?? chatCached?.notificationDisabled);
|
|
413
|
+
const unreadCount = box.unreadCount ?? 0;
|
|
414
|
+
return {
|
|
415
|
+
id: box.id,
|
|
416
|
+
title,
|
|
417
|
+
lastMessage: preview.replace(/\n+/g, ' ').trim(),
|
|
418
|
+
timeString: formatTimestamp(timestamp),
|
|
419
|
+
timestamp,
|
|
420
|
+
unreadCount,
|
|
421
|
+
isMuted,
|
|
422
|
+
isGroup,
|
|
423
|
+
hasMention: false,
|
|
424
|
+
pictureUrl,
|
|
425
|
+
isOfficial: Boolean(contact?.isOfficial),
|
|
426
|
+
attentionScore: 0,
|
|
427
|
+
attentionTier: 'filtered',
|
|
428
|
+
attentionReason: '正在整理新訊息…',
|
|
429
|
+
};
|
|
430
|
+
}));
|
|
431
|
+
const cache = await loadAttentionCache();
|
|
432
|
+
const pending = [];
|
|
433
|
+
conversations.forEach((conversation, index) => {
|
|
434
|
+
const cached = cache[attentionSignature(conversation)];
|
|
435
|
+
if (cached) {
|
|
436
|
+
conversation.attentionScore =
|
|
437
|
+
cached.score + (conversation.unreadCount > 0 ? 5 : 0);
|
|
438
|
+
conversation.attentionTier = cached.tier;
|
|
439
|
+
conversation.attentionReason = cached.reason;
|
|
440
|
+
}
|
|
441
|
+
else if (conversation.isMuted || conversation.unreadCount === 0) {
|
|
442
|
+
conversation.attentionScore = conversation.isMuted ? -100 : 0;
|
|
443
|
+
conversation.attentionTier = 'filtered';
|
|
444
|
+
conversation.attentionReason = conversation.isMuted
|
|
445
|
+
? '已靜音'
|
|
446
|
+
: '已讀,等待需要時分析';
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
pending.push(index);
|
|
450
|
+
// Fast first paint: uncached unread messages stay visible until the
|
|
451
|
+
// background semantic pass refines them. No model loads on this path.
|
|
452
|
+
conversation.attentionScore = conversation.isOfficial ? 0 : 20;
|
|
453
|
+
conversation.attentionTier = conversation.isOfficial
|
|
454
|
+
? 'filtered'
|
|
455
|
+
: 'know';
|
|
456
|
+
conversation.attentionReason = '正在整理新訊息…';
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
if (args.semantic && pending.length > 0) {
|
|
460
|
+
try {
|
|
461
|
+
const evaluations = await scoreAttentionBatch(pending.map((index) => {
|
|
462
|
+
const conversation = conversations[index];
|
|
463
|
+
return {
|
|
464
|
+
id: conversation.id,
|
|
465
|
+
text: conversation.lastMessage,
|
|
466
|
+
unreadCount: 0,
|
|
467
|
+
isMuted: conversation.isMuted,
|
|
468
|
+
isOfficial: conversation.isOfficial,
|
|
469
|
+
hasMention: conversation.hasMention,
|
|
470
|
+
};
|
|
471
|
+
}));
|
|
472
|
+
pending.forEach((conversationIndex, resultIndex) => {
|
|
473
|
+
const conversation = conversations[conversationIndex];
|
|
474
|
+
const result = evaluations[resultIndex];
|
|
475
|
+
cache[attentionSignature(conversation)] = {
|
|
476
|
+
...result,
|
|
477
|
+
updatedAt: Date.now(),
|
|
478
|
+
};
|
|
479
|
+
conversation.attentionScore =
|
|
480
|
+
result.score + (conversation.unreadCount > 0 ? 5 : 0);
|
|
481
|
+
conversation.attentionTier = result.tier;
|
|
482
|
+
conversation.attentionReason = result.reason;
|
|
483
|
+
});
|
|
484
|
+
await saveAttentionCache(cache);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
pending.forEach((index) => {
|
|
488
|
+
conversations[index].attentionReason = '暫時無法判斷重要性,稍後再試';
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Sort by timestamp descending
|
|
493
|
+
conversations.sort((a, b) => b.timestamp - a.timestamp);
|
|
494
|
+
if (args.attentionOnly) {
|
|
495
|
+
return conversations.filter((c) => c.attentionTier !== 'filtered');
|
|
496
|
+
}
|
|
497
|
+
return conversations;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
export async function cliGetChatMessagesJson(chatId, count = 30) {
|
|
504
|
+
const service = new LineProtocolService();
|
|
505
|
+
const ok = await service.resumeSession();
|
|
506
|
+
if (!ok)
|
|
507
|
+
return [];
|
|
508
|
+
try {
|
|
509
|
+
// Always use the service-owned query boundary here. It opportunistically
|
|
510
|
+
// decrypts E2EE envelopes; reading service.client directly leaves media
|
|
511
|
+
// messages with contentType=0 and opaque text, which makes image/file
|
|
512
|
+
// rendering look empty even though LINE returned the message.
|
|
513
|
+
const rawMessages = await service.getRecentMessages(chatId, count);
|
|
514
|
+
const mids = Array.from(new Set(rawMessages
|
|
515
|
+
.flatMap((message) => [
|
|
516
|
+
message.from,
|
|
517
|
+
...chatEventParticipantMids(message),
|
|
518
|
+
])
|
|
519
|
+
.filter(Boolean)));
|
|
520
|
+
const names = await resolveSenderNames(service, mids);
|
|
521
|
+
const userMids = Array.from(new Set(mids.filter((m) => m.startsWith('u'))));
|
|
522
|
+
const contactMap = new Map();
|
|
523
|
+
if (userMids.length > 0) {
|
|
524
|
+
try {
|
|
525
|
+
const contacts = await service.client.getContacts(userMids);
|
|
526
|
+
for (const c of contacts || []) {
|
|
527
|
+
if (c?.mid)
|
|
528
|
+
contactMap.set(c.mid, c);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch { }
|
|
532
|
+
}
|
|
533
|
+
const selfMid = service.profile?.mid;
|
|
534
|
+
const selfName = service.profile?.displayName || '我';
|
|
535
|
+
const selfPicPath = service.profile?.picturePath;
|
|
536
|
+
const selfPicUrl = selfPicPath
|
|
537
|
+
? `https://obs.line-scdn.net/${selfPicPath.replace(/^\/+/, '')}`
|
|
538
|
+
: null;
|
|
539
|
+
const formatted = rawMessages.map((m) => {
|
|
540
|
+
const isSelf = m.from === selfMid;
|
|
541
|
+
const fromName = isSelf ? selfName : names.get(m.from) || '成員';
|
|
542
|
+
const contact = contactMap.get(m.from);
|
|
543
|
+
const picPath = isSelf ? selfPicPath : contact?.picturePath;
|
|
544
|
+
const fromPictureUrl = isSelf
|
|
545
|
+
? selfPicUrl
|
|
546
|
+
: picPath
|
|
547
|
+
? `https://obs.line-scdn.net/${picPath.replace(/^\/+/, '')}`
|
|
548
|
+
: null;
|
|
549
|
+
const contentType = Number(m.contentType || 0);
|
|
550
|
+
const metadata = m.contentMetadata || {};
|
|
551
|
+
const mediaText = parseMediaText(m.text);
|
|
552
|
+
const stickerId = metadata.STKID || metadata.stickerId;
|
|
553
|
+
const rich = isRichContentType(contentType) ? richFields(m) : null;
|
|
554
|
+
let text = sanitizeMessagePreview(m.text);
|
|
555
|
+
if (contentType === 18)
|
|
556
|
+
text = interpretChatEvent(m, names);
|
|
557
|
+
if (rich?.altText)
|
|
558
|
+
text = rich.altText;
|
|
559
|
+
if (!text) {
|
|
560
|
+
text = messageFallback(m);
|
|
561
|
+
}
|
|
562
|
+
const timestamp = Number(m.deliveredTime || m.createdTime || 0);
|
|
563
|
+
return {
|
|
564
|
+
id: String(m.id),
|
|
565
|
+
fromMid: m.from || '',
|
|
566
|
+
fromName,
|
|
567
|
+
fromPictureUrl,
|
|
568
|
+
isSelf,
|
|
569
|
+
text,
|
|
570
|
+
timeString: formatTimestamp(timestamp),
|
|
571
|
+
timestamp,
|
|
572
|
+
contentType,
|
|
573
|
+
mediaType: {
|
|
574
|
+
1: 'image',
|
|
575
|
+
2: 'video',
|
|
576
|
+
3: 'audio',
|
|
577
|
+
7: 'sticker',
|
|
578
|
+
14: 'file',
|
|
579
|
+
17: 'rich',
|
|
580
|
+
22: 'rich',
|
|
581
|
+
}[contentType] || null,
|
|
582
|
+
mediaUrl: rich?.mediaUrl ||
|
|
583
|
+
(stickerId
|
|
584
|
+
? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${stickerId}/android/sticker.png`
|
|
585
|
+
: null),
|
|
586
|
+
actionUrl: rich?.actionUrl || null,
|
|
587
|
+
fileName: (typeof mediaText.fileName === 'string'
|
|
588
|
+
? mediaText.fileName
|
|
589
|
+
: null) ||
|
|
590
|
+
(typeof metadata.FILE_NAME === 'string'
|
|
591
|
+
? metadata.FILE_NAME
|
|
592
|
+
: null) ||
|
|
593
|
+
(typeof metadata.FILE_NAME_ORIGINAL === 'string'
|
|
594
|
+
? metadata.FILE_NAME_ORIGINAL
|
|
595
|
+
: null) ||
|
|
596
|
+
(typeof metadata.name === 'string' ? metadata.name : null),
|
|
597
|
+
isDecryptFailure: Boolean(m.e2eeDecryptFailure),
|
|
598
|
+
isUnsent: metadata.UNSENT === 'true',
|
|
599
|
+
mentionRanges: parseMentionRanges(metadata),
|
|
600
|
+
};
|
|
601
|
+
});
|
|
602
|
+
// Chronological order (oldest to newest) for chat bubble view
|
|
603
|
+
formatted.sort((a, b) => a.timestamp - b.timestamp);
|
|
604
|
+
return formatted;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
export async function cliMarkReadJson(chatId, messageId) {
|
|
611
|
+
const service = new LineProtocolService();
|
|
612
|
+
const ok = await service.resumeSession();
|
|
613
|
+
if (!ok)
|
|
614
|
+
return { success: false, marked: false };
|
|
615
|
+
try {
|
|
616
|
+
const res = await service.markChatRead(chatId, messageId);
|
|
617
|
+
return { success: true, marked: res.marked };
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return { success: false, marked: false };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
export async function cliRecentSummary() {
|
|
624
|
+
const service = new LineProtocolService();
|
|
625
|
+
const ok = await service.resumeSession();
|
|
626
|
+
if (!ok) {
|
|
627
|
+
return '【LINE 狀態】尚未連線或憑證已過期,請先於選單列重新登入。';
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const result = await handleListConversations(service, { limit: 12 });
|
|
631
|
+
const rawText = result.content?.[0]?.text || '';
|
|
632
|
+
const name = service.profile?.displayName || 'LINE User';
|
|
633
|
+
let output = `【LINE 即時訊息摘要 — ${name}】\n• 加密狀態:Letter-Sealing (E2EE) 已解密安全連線\n\n`;
|
|
634
|
+
// Parse TOON format lines
|
|
635
|
+
const lines = rawText.split('\n').filter((l) => l.trim().length > 0);
|
|
636
|
+
const chats = [];
|
|
637
|
+
for (const line of lines) {
|
|
638
|
+
if (line.startsWith('[') || line.includes('{id,'))
|
|
639
|
+
continue;
|
|
640
|
+
// Format: id,lastMessagePreview,name,unreadCount
|
|
641
|
+
// Note that preview might contain quotes or escaped newlines
|
|
642
|
+
const trimmed = line.trim();
|
|
643
|
+
const parts = trimmed.split(',');
|
|
644
|
+
if (parts.length >= 4) {
|
|
645
|
+
const unread = parseInt(parts[parts.length - 1], 10) || 0;
|
|
646
|
+
const chatName = parts[parts.length - 2] || '聊天室';
|
|
647
|
+
const previewRaw = parts.slice(1, parts.length - 2).join(',');
|
|
648
|
+
const preview = previewRaw === 'null'
|
|
649
|
+
? '(無文字或貼圖/媒體)'
|
|
650
|
+
: previewRaw.replace(/^"|"$/g, '').replace(/\\n/g, ' ');
|
|
651
|
+
chats.push({ name: chatName, unread, preview });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (chats.length > 0) {
|
|
655
|
+
chats.forEach((chat) => {
|
|
656
|
+
const unreadBadge = chat.unread > 0 ? ` 🔴 **[未讀 ${chat.unread} 則]**` : '';
|
|
657
|
+
output += `• **${chat.name}**${unreadBadge}\n ↳ 「${chat.preview}」\n\n`;
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
output += '目前沒有最近的聊天室紀錄。\n';
|
|
662
|
+
}
|
|
663
|
+
return output.trim();
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
return `查詢 LINE 訊息時發生錯誤:${err.message || String(err)}`;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The relationship layer: who you talk to, how, and how often — the
|
|
2
|
+
* The relationship layer: who you talk to, how, and how often — the contact/relationship
|
|
3
3
|
* axis of the analysis, built purely from interaction structure (no message
|
|
4
4
|
* content). This is unsupervised context for the agent's final judgement, not a
|
|
5
5
|
* judgement itself.
|
|
@@ -65,6 +65,18 @@ export async function performPwlessLogin(service, phone, region, loggingInState,
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
service.profile = await service.client.getProfile();
|
|
68
|
+
if (service.profile) {
|
|
69
|
+
if (service.profile.displayName) {
|
|
70
|
+
await service.credentialStore.set('displayName', service.profile.displayName);
|
|
71
|
+
}
|
|
72
|
+
if (service.profile.picturePath) {
|
|
73
|
+
await service.credentialStore.set('picturePath', service.profile.picturePath);
|
|
74
|
+
await service.credentialStore.set('pictureUrl', `https://obs.line-scdn.net/${service.profile.picturePath}`);
|
|
75
|
+
}
|
|
76
|
+
if (service.profile.statusMessage) {
|
|
77
|
+
await service.credentialStore.set('statusMessage', service.profile.statusMessage);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
68
80
|
// Display name only — do not print the mid alongside it.
|
|
69
81
|
log?.info?.('profile.ok', {
|
|
70
82
|
displayName: service.profile?.displayName ?? null,
|