@rikaidev/yomi 0.4.2 → 0.6.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 +46 -4
- package/dist/ambient/ambient-event-graph.js +172 -0
- package/dist/ambient/ambient-observer.js +71 -0
- package/dist/ambient/event-graph-reducer.js +300 -0
- package/dist/ambient/ingestion-adapter.js +203 -0
- package/dist/ambient/journal/crypto.js +58 -0
- package/dist/ambient/journal/journal-store.js +284 -0
- package/dist/ambient/journal/types.js +7 -0
- package/dist/ambient/summaries/retriever.js +55 -0
- package/dist/ambient/summaries/summary-engine.js +128 -0
- package/dist/ambient/summaries/types.js +4 -0
- package/dist/attention/policy.js +101 -0
- package/dist/auth/credential-store.js +47 -17
- package/dist/auth/keychain.js +7 -54
- package/dist/cli/query.js +875 -0
- package/dist/cli/work-item.js +24 -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/auth-service/client.js +7 -8
- package/dist/line/client/buddy-service/client.js +73 -0
- package/dist/line/client/index.js +28 -3
- package/dist/line/client/obs-media-client.js +38 -0
- package/dist/line/client/parsers.js +1 -0
- package/dist/line/client/square-service/client.js +298 -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/command-client.js +9 -1
- 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/message-id-query.js +27 -3
- package/dist/line/client/talk-service/query-client.js +32 -26
- package/dist/line/client/talk-service/requests.js +10 -11
- package/dist/line/core/auth-session-runtime.js +17 -2
- package/dist/line/core/chat-event.js +35 -0
- package/dist/line/core/chat-runtime-service.js +18 -23
- package/dist/line/core/config.js +1 -0
- 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 +453 -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 -3
- package/dist/mcp/handlers/contacts.js +12 -6
- package/dist/mcp/handlers/conversations.js +91 -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/send.js +8 -11
- package/dist/mcp/handlers/shared.js +1 -1
- package/dist/mcp/integrator.js +168 -0
- package/dist/mcp/media.js +135 -4
- package/dist/mcp/names.js +78 -6
- 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/default-embedder.js +12 -3
- package/dist/search/embedder.js +1 -1
- package/dist/version.js +1 -1
- package/dist/work-item/adapter.js +1 -0
- package/dist/work-item/destination-contracts.js +1 -0
- package/dist/work-item/event-context.js +73 -0
- package/dist/work-item/gold-contract.js +101 -0
- package/dist/work-item/gold-coverage.js +96 -0
- package/dist/work-item/gold-derivation.js +67 -0
- package/dist/work-item/gold-split.js +143 -0
- package/dist/work-item/index.js +23 -0
- package/dist/work-item/line-adapter.js +111 -0
- package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
- package/dist/work-item/local-provider.js +38 -0
- package/dist/work-item/local-work-event-provider.js +56 -0
- package/dist/work-item/onnx-work-event-adapter.js +223 -0
- package/dist/work-item/provider.js +21 -0
- package/dist/work-item/registry.js +19 -0
- package/dist/work-item/service.js +85 -0
- package/dist/work-item/store.js +97 -0
- package/dist/work-item/types.js +79 -0
- package/dist/work-item/work-event-graph-engine.js +63 -0
- package/dist/work-item/work-event-inference.js +267 -0
- package/dist/work-item/work-event-matcher.js +105 -0
- package/dist/work-item/work-event-model-manifest.js +39 -0
- package/dist/work-item/work-event-store.js +125 -0
- package/dist/work-item/work-event.js +123 -0
- package/package.json +26 -2
- package/run.mjs +371 -2
|
@@ -0,0 +1,875 @@
|
|
|
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 { isSquareChatMid } from '../line/client/square-service/client.js';
|
|
7
|
+
import { fetchStickerImage, fetchStickerPackageMetaCached, localizedStickerTitle, } from '../line/client/sticker-meta.js';
|
|
8
|
+
import { chatEventParticipantMids, interpretChatEvent, } from '../line/core/chat-event.js';
|
|
9
|
+
import { interpretFlexMessage, processFlexMessage, } from '../line/core/flex-message.js';
|
|
10
|
+
import { sanitizeMessagePreview } from '../line/core/message-preview.js';
|
|
11
|
+
import { decryptLineMessage } from '../line/core/message-query-service.js';
|
|
12
|
+
import { LineProtocolService } from '../line/core/service.js';
|
|
13
|
+
import { handleListConversations } from '../mcp/handlers/conversations.js';
|
|
14
|
+
import { resolveConversationNames, resolveSenderNames } from '../mcp/names.js';
|
|
15
|
+
const attentionCachePath = join(process.env.YOMI_DATA_DIR || join(homedir(), '.yomi'), 'attention-cache.json');
|
|
16
|
+
const ATTENTION_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const ATTENTION_CACHE_MAX_ENTRIES = 1000;
|
|
18
|
+
const ATTENTION_BATCH_SIZE = 32;
|
|
19
|
+
const ATTENTION_POLICY_VERSION = 5;
|
|
20
|
+
function attentionSignature(item) {
|
|
21
|
+
return createHash('sha256')
|
|
22
|
+
.update(JSON.stringify([
|
|
23
|
+
ATTENTION_POLICY_VERSION,
|
|
24
|
+
item.id,
|
|
25
|
+
item.lastMessage,
|
|
26
|
+
item.isOfficial,
|
|
27
|
+
item.isMuted,
|
|
28
|
+
]))
|
|
29
|
+
.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
async function loadAttentionCache() {
|
|
32
|
+
try {
|
|
33
|
+
const cache = JSON.parse(await readFile(attentionCachePath, 'utf8'));
|
|
34
|
+
const cutoff = Date.now() - ATTENTION_CACHE_TTL_MS;
|
|
35
|
+
return Object.fromEntries(Object.entries(cache).filter(([, entry]) => (entry.updatedAt || 0) >= cutoff));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function saveAttentionCache(cache) {
|
|
42
|
+
const bounded = Object.fromEntries(Object.entries(cache)
|
|
43
|
+
.sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
|
|
44
|
+
.slice(0, ATTENTION_CACHE_MAX_ENTRIES));
|
|
45
|
+
await mkdir(dirname(attentionCachePath), { recursive: true });
|
|
46
|
+
await writeFile(attentionCachePath, JSON.stringify(bounded), { mode: 0o600 });
|
|
47
|
+
}
|
|
48
|
+
export async function cliClassifyAttentionJson(items) {
|
|
49
|
+
const cache = await loadAttentionCache();
|
|
50
|
+
const batch = items.slice(0, ATTENTION_BATCH_SIZE);
|
|
51
|
+
if (batch.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
const evaluations = await scoreAttentionBatch(batch.map((item) => ({
|
|
54
|
+
id: item.id,
|
|
55
|
+
text: item.lastMessage,
|
|
56
|
+
unreadCount: 0,
|
|
57
|
+
isMuted: item.isMuted,
|
|
58
|
+
isOfficial: item.isOfficial,
|
|
59
|
+
hasMention: item.hasMention,
|
|
60
|
+
})));
|
|
61
|
+
const results = batch.map((item, index) => {
|
|
62
|
+
const result = evaluations[index];
|
|
63
|
+
cache[attentionSignature(item)] = { ...result, updatedAt: Date.now() };
|
|
64
|
+
return {
|
|
65
|
+
id: item.id,
|
|
66
|
+
attentionScore: result.score,
|
|
67
|
+
attentionTier: result.tier,
|
|
68
|
+
attentionReason: result.reason,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
await saveAttentionCache(cache);
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
/** Read-only bridge for the desktop sticker picker. */
|
|
75
|
+
export async function cliListStickersJson(language = 'zh_TW') {
|
|
76
|
+
const service = new LineProtocolService();
|
|
77
|
+
if (!(await service.resumeSession()))
|
|
78
|
+
return [];
|
|
79
|
+
const owned = await service.listStickerPackages(language);
|
|
80
|
+
return Promise.all(owned.map(async (pkg) => {
|
|
81
|
+
const packageId = String(pkg.packageId ?? '');
|
|
82
|
+
if (!packageId)
|
|
83
|
+
return pkg;
|
|
84
|
+
const meta = await fetchStickerPackageMetaCached(packageId);
|
|
85
|
+
return {
|
|
86
|
+
...pkg,
|
|
87
|
+
title: localizedStickerTitle(meta?.title, language, String(pkg.title ?? '')),
|
|
88
|
+
};
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
/** Read-only bridge for owned sticker previews; never sends a message. */
|
|
92
|
+
export async function cliStickerPreviewsJson(packageId, limit = 12, language = 'en') {
|
|
93
|
+
if (!packageId)
|
|
94
|
+
return [];
|
|
95
|
+
const service = new LineProtocolService();
|
|
96
|
+
if (!(await service.resumeSession()))
|
|
97
|
+
return [];
|
|
98
|
+
// The package must be owned before the public CDN metadata is expanded.
|
|
99
|
+
const owned = await service.listStickerPackages(language);
|
|
100
|
+
if (!owned.some((item) => String(item.packageId) === packageId))
|
|
101
|
+
return [];
|
|
102
|
+
const meta = await fetchStickerPackageMetaCached(packageId);
|
|
103
|
+
if (!meta)
|
|
104
|
+
return [];
|
|
105
|
+
const result = [];
|
|
106
|
+
for (const stickerId of meta.stickerIds.slice(0, Math.max(1, limit))) {
|
|
107
|
+
const bytes = await fetchStickerImage(stickerId);
|
|
108
|
+
if (!bytes)
|
|
109
|
+
continue;
|
|
110
|
+
result.push({
|
|
111
|
+
stickerId,
|
|
112
|
+
packageId,
|
|
113
|
+
data: bytes.toString('base64'),
|
|
114
|
+
mimeType: 'image/png',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
function parseMediaText(value) {
|
|
120
|
+
if (typeof value !== 'string' || !value.trim())
|
|
121
|
+
return {};
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(value);
|
|
124
|
+
return parsed && typeof parsed === 'object'
|
|
125
|
+
? parsed
|
|
126
|
+
: {};
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Only expose web URLs from LINE rich-message metadata. */
|
|
133
|
+
function safeWebUrl(value) {
|
|
134
|
+
if (typeof value !== 'string' || !value.trim())
|
|
135
|
+
return null;
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(value.trim());
|
|
138
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
139
|
+
? url.toString()
|
|
140
|
+
: null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function firstWebLink(value) {
|
|
147
|
+
if (!value || typeof value !== 'object')
|
|
148
|
+
return null;
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
for (const item of value) {
|
|
151
|
+
const found = firstWebLink(item);
|
|
152
|
+
if (found)
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const record = value;
|
|
158
|
+
for (const [key, item] of Object.entries(record)) {
|
|
159
|
+
if (key === 'linkUri') {
|
|
160
|
+
const found = safeWebUrl(item);
|
|
161
|
+
if (found)
|
|
162
|
+
return found;
|
|
163
|
+
}
|
|
164
|
+
const nested = firstWebLink(item);
|
|
165
|
+
if (nested)
|
|
166
|
+
return nested;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
export function richFields(message) {
|
|
171
|
+
const metadata = message?.contentMetadata && typeof message.contentMetadata === 'object'
|
|
172
|
+
? message.contentMetadata
|
|
173
|
+
: {};
|
|
174
|
+
const interpreted = interpretFlexMessage(message);
|
|
175
|
+
const flexDocument = processFlexMessage(message);
|
|
176
|
+
const explicitAlt = sanitizeMessagePreview(metadata.ALT_TEXT) || '';
|
|
177
|
+
const altText = interpreted.summary || explicitAlt;
|
|
178
|
+
const mediaUrl = safeWebUrl(metadata.DOWNLOAD_URL) || interpreted.imageUrl;
|
|
179
|
+
const redirect = safeWebUrl(metadata.LINE_TAG_REDIRECTOR);
|
|
180
|
+
let markup = metadata.MARKUP_JSON;
|
|
181
|
+
if (typeof markup === 'string') {
|
|
182
|
+
try {
|
|
183
|
+
markup = JSON.parse(markup);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
markup = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
altText,
|
|
191
|
+
mediaUrl,
|
|
192
|
+
actionUrl: redirect || interpreted.actionUrl || firstWebLink(markup),
|
|
193
|
+
flexDocument,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export { sanitizeMessagePreview } from '../line/core/message-preview.js';
|
|
197
|
+
export function messagePlaceholder(contentType, hasOpaquePayload = false) {
|
|
198
|
+
switch (contentType) {
|
|
199
|
+
case 0:
|
|
200
|
+
return hasOpaquePayload ? '[yomi:encrypted]' : '[yomi:message]';
|
|
201
|
+
case 7:
|
|
202
|
+
return '[yomi:sticker]';
|
|
203
|
+
case 4:
|
|
204
|
+
return '[yomi:web]';
|
|
205
|
+
case 5:
|
|
206
|
+
return '[yomi:pdf]';
|
|
207
|
+
case 6:
|
|
208
|
+
return '[yomi:call]';
|
|
209
|
+
case 8:
|
|
210
|
+
return '[yomi:status]';
|
|
211
|
+
case 9:
|
|
212
|
+
return '[yomi:gift]';
|
|
213
|
+
case 10:
|
|
214
|
+
return '[yomi:note]';
|
|
215
|
+
case 11:
|
|
216
|
+
return '[yomi:app]';
|
|
217
|
+
case 12:
|
|
218
|
+
return '[yomi:share]';
|
|
219
|
+
case 13:
|
|
220
|
+
return '[yomi:contact]';
|
|
221
|
+
case 15:
|
|
222
|
+
return '[yomi:location]';
|
|
223
|
+
case 16:
|
|
224
|
+
return '[yomi:post]';
|
|
225
|
+
case 17:
|
|
226
|
+
return '[yomi:rich]';
|
|
227
|
+
case 18:
|
|
228
|
+
return '[yomi:chat_event]';
|
|
229
|
+
case 19:
|
|
230
|
+
return '[yomi:music]';
|
|
231
|
+
case 20:
|
|
232
|
+
return '[yomi:payment]';
|
|
233
|
+
case 21:
|
|
234
|
+
return '[yomi:extended_image]';
|
|
235
|
+
case 22:
|
|
236
|
+
return '[yomi:rich]';
|
|
237
|
+
case 1:
|
|
238
|
+
return '[yomi:image]';
|
|
239
|
+
case 2:
|
|
240
|
+
return '[yomi:video]';
|
|
241
|
+
case 3:
|
|
242
|
+
return '[yomi:audio]';
|
|
243
|
+
case 14:
|
|
244
|
+
return '[yomi:file]';
|
|
245
|
+
default:
|
|
246
|
+
return hasOpaquePayload
|
|
247
|
+
? '[yomi:encrypted]'
|
|
248
|
+
: `[yomi:line_content:${contentType}]`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export function messageFallback(message) {
|
|
252
|
+
if (message?.contentMetadata?.UNSENT === 'true')
|
|
253
|
+
return '[yomi:unsent]';
|
|
254
|
+
const altText = messageAltText(message?.contentMetadata);
|
|
255
|
+
if (altText)
|
|
256
|
+
return altText;
|
|
257
|
+
return message?.e2eeDecryptFailure
|
|
258
|
+
? '[yomi:decrypt_failed]'
|
|
259
|
+
: messagePlaceholder(Number(message?.contentType || 0), Boolean(message?.text));
|
|
260
|
+
}
|
|
261
|
+
/** Convert LINE's private CALL transport string into a stable UI token. */
|
|
262
|
+
export function callHistoryPreview(value, isSelf) {
|
|
263
|
+
if (typeof value !== 'string')
|
|
264
|
+
return '[yomi:call]';
|
|
265
|
+
const match = /^Call History\s*:\s*(\d+)\s+millisecs,\s*Result:\s*(\d+)$/i.exec(value.trim());
|
|
266
|
+
if (!match)
|
|
267
|
+
return '[yomi:call]';
|
|
268
|
+
const milliseconds = Number(match[1]);
|
|
269
|
+
const result = Number(match[2]);
|
|
270
|
+
if (result === 16 && milliseconds > 0) {
|
|
271
|
+
return `[yomi:call_duration:${Math.floor(milliseconds / 1000)}]`;
|
|
272
|
+
}
|
|
273
|
+
if (result === 77) {
|
|
274
|
+
return isSelf ? '[yomi:call_no_answer]' : '[yomi:call_missed]';
|
|
275
|
+
}
|
|
276
|
+
return '[yomi:call]';
|
|
277
|
+
}
|
|
278
|
+
function messageAltText(metadata) {
|
|
279
|
+
for (const key of ['ALT_TEXT', 'altText', 'ALT']) {
|
|
280
|
+
const value = sanitizeMessagePreview(metadata?.[key]);
|
|
281
|
+
if (value)
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
function parseMentionRanges(metadata) {
|
|
287
|
+
if (typeof metadata.MENTION !== 'string')
|
|
288
|
+
return [];
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(metadata.MENTION);
|
|
291
|
+
return (parsed.MENTIONEES || []).flatMap((mention) => {
|
|
292
|
+
const location = Number(mention.S);
|
|
293
|
+
const end = Number(mention.E);
|
|
294
|
+
return Number.isInteger(location) &&
|
|
295
|
+
Number.isInteger(end) &&
|
|
296
|
+
end > location
|
|
297
|
+
? [{ location, length: end - location }]
|
|
298
|
+
: [];
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export function parseEmojiRanges(metadata) {
|
|
306
|
+
if (typeof metadata.REPLACE !== 'string')
|
|
307
|
+
return [];
|
|
308
|
+
try {
|
|
309
|
+
const parsed = JSON.parse(metadata.REPLACE);
|
|
310
|
+
return (parsed.sticon?.resources || []).flatMap((resource) => {
|
|
311
|
+
const location = Number(resource.S);
|
|
312
|
+
const end = Number(resource.E);
|
|
313
|
+
if (!Number.isInteger(location) ||
|
|
314
|
+
!Number.isInteger(end) ||
|
|
315
|
+
end <= location ||
|
|
316
|
+
!resource.productId ||
|
|
317
|
+
!resource.sticonId)
|
|
318
|
+
return [];
|
|
319
|
+
return [
|
|
320
|
+
{
|
|
321
|
+
location,
|
|
322
|
+
length: end - location,
|
|
323
|
+
url: `https://stickershop.line-scdn.net/sticonshop/v1/sticon/${resource.productId}/android/${resource.sticonId}.png`,
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function isRichContentType(contentType) {
|
|
333
|
+
return contentType === 17 || contentType === 22;
|
|
334
|
+
}
|
|
335
|
+
function formatTimestamp(timestamp) {
|
|
336
|
+
if (!timestamp || timestamp <= 0)
|
|
337
|
+
return '';
|
|
338
|
+
const date = new Date(Number(timestamp));
|
|
339
|
+
const now = new Date();
|
|
340
|
+
const isToday = date.getFullYear() === now.getFullYear() &&
|
|
341
|
+
date.getMonth() === now.getMonth() &&
|
|
342
|
+
date.getDate() === now.getDate();
|
|
343
|
+
const yesterday = new Date(now);
|
|
344
|
+
yesterday.setDate(now.getDate() - 1);
|
|
345
|
+
const isYesterday = date.getFullYear() === yesterday.getFullYear() &&
|
|
346
|
+
date.getMonth() === yesterday.getMonth() &&
|
|
347
|
+
date.getDate() === yesterday.getDate();
|
|
348
|
+
const hours = date.getHours().toString().padStart(2, '0');
|
|
349
|
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
350
|
+
if (isToday) {
|
|
351
|
+
return `${hours}:${minutes}`;
|
|
352
|
+
}
|
|
353
|
+
if (isYesterday) {
|
|
354
|
+
return '昨天';
|
|
355
|
+
}
|
|
356
|
+
if (date.getFullYear() === now.getFullYear()) {
|
|
357
|
+
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
|
358
|
+
}
|
|
359
|
+
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
|
|
360
|
+
}
|
|
361
|
+
function messageTimestamp(message) {
|
|
362
|
+
return Number(message?.deliveredTime || message?.createdTime || 0);
|
|
363
|
+
}
|
|
364
|
+
function latestMessage(messages) {
|
|
365
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
366
|
+
return null;
|
|
367
|
+
return messages.reduce((latest, candidate) => messageTimestamp(candidate) >= messageTimestamp(latest)
|
|
368
|
+
? candidate
|
|
369
|
+
: latest);
|
|
370
|
+
}
|
|
371
|
+
function squareMessageContent(message) {
|
|
372
|
+
const contentType = Number(message?.contentType ?? message?.[15] ?? 0);
|
|
373
|
+
const metadata = message?.contentMetadata ?? message?.[18];
|
|
374
|
+
const text = messageAltText(metadata) ||
|
|
375
|
+
sanitizeMessagePreview(message?.text ?? message?.[10]) ||
|
|
376
|
+
null;
|
|
377
|
+
return { contentType, metadata, text };
|
|
378
|
+
}
|
|
379
|
+
export function isRenderableSquareMessage(message) {
|
|
380
|
+
const content = squareMessageContent(message);
|
|
381
|
+
return content.contentType !== 0 || content.text !== null;
|
|
382
|
+
}
|
|
383
|
+
/** Normalize provider-specific message windows to the newest chronological page. */
|
|
384
|
+
export function newestMessageWindow(messages, count) {
|
|
385
|
+
return [...messages]
|
|
386
|
+
.sort((a, b) => messageTimestamp(a) - messageTimestamp(b))
|
|
387
|
+
.slice(-Math.max(0, count));
|
|
388
|
+
}
|
|
389
|
+
export async function cliListConversationsJson(args = {}, activeService) {
|
|
390
|
+
const service = activeService ?? new LineProtocolService();
|
|
391
|
+
if (!activeService && !(await service.resumeSession()))
|
|
392
|
+
return [];
|
|
393
|
+
try {
|
|
394
|
+
// Advance the Talk revision before reading message boxes. A fresh client
|
|
395
|
+
// process starts with no checkpoint; syncLongPoll now obtains the server's
|
|
396
|
+
// last-op revision before issuing sync, then we persist the monotonic
|
|
397
|
+
// checkpoint through the session-state owner for the next bridge process.
|
|
398
|
+
try {
|
|
399
|
+
await service.client.syncLongPoll(50, 1000);
|
|
400
|
+
if (service.sessionState?.setRevisions) {
|
|
401
|
+
await service.sessionState.setRevisions(Number(service.client.revision || 0), Number(service.client.globalRevision || 0), Number(service.client.individualRevision || 0));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch { }
|
|
405
|
+
const result = await service.client.getAllMessageBoxes({
|
|
406
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
407
|
+
...(args.limit ? { messageBoxCountLimit: args.limit } : {}),
|
|
408
|
+
withUnreadCount: true,
|
|
409
|
+
});
|
|
410
|
+
const boxes = result.messageBoxes || [];
|
|
411
|
+
const chatIds = boxes.map((box) => box.id).filter(Boolean);
|
|
412
|
+
const names = await resolveConversationNames(service, chatIds);
|
|
413
|
+
// Resolve contacts picture paths
|
|
414
|
+
const userMids = chatIds.filter((id) => id.startsWith('u'));
|
|
415
|
+
const contactMap = new Map();
|
|
416
|
+
if (userMids.length > 0) {
|
|
417
|
+
try {
|
|
418
|
+
const contacts = await service.client.getContacts(userMids);
|
|
419
|
+
for (const c of contacts || []) {
|
|
420
|
+
if (c?.mid)
|
|
421
|
+
contactMap.set(c.mid, c);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch { }
|
|
425
|
+
}
|
|
426
|
+
const conversations = await Promise.all(boxes.map(async (box) => {
|
|
427
|
+
let lastMsg = latestMessage(box.lastMessages);
|
|
428
|
+
const boxDeliveredTime = Number(box.lastDeliveredMessageId?.deliveredTime || 0);
|
|
429
|
+
// Some LINE responses return a lastMessages array that lags the
|
|
430
|
+
// last-delivered cursor. Fetch one message only for that box so the
|
|
431
|
+
// inbox preview and date cannot remain days behind.
|
|
432
|
+
if (box.id &&
|
|
433
|
+
(!lastMsg || messageTimestamp(lastMsg) < boxDeliveredTime)) {
|
|
434
|
+
try {
|
|
435
|
+
const recent = await service.getRecentMessages(box.id, 1);
|
|
436
|
+
const candidate = latestMessage(recent);
|
|
437
|
+
if (candidate &&
|
|
438
|
+
messageTimestamp(candidate) >= messageTimestamp(lastMsg)) {
|
|
439
|
+
lastMsg = candidate;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch { }
|
|
443
|
+
}
|
|
444
|
+
let preview = '[yomi:no_message]';
|
|
445
|
+
let timestamp = 0;
|
|
446
|
+
if (lastMsg) {
|
|
447
|
+
timestamp = messageTimestamp(lastMsg);
|
|
448
|
+
const rich = isRichContentType(Number(lastMsg.contentType))
|
|
449
|
+
? richFields(lastMsg)
|
|
450
|
+
: null;
|
|
451
|
+
const rawPreview = Number(lastMsg.contentType) === 6
|
|
452
|
+
? callHistoryPreview(lastMsg.text, lastMsg.from === service.profile?.mid)
|
|
453
|
+
: rich?.altText ||
|
|
454
|
+
messageAltText(lastMsg.contentMetadata) ||
|
|
455
|
+
sanitizeMessagePreview(lastMsg.text);
|
|
456
|
+
if (rawPreview) {
|
|
457
|
+
preview = rawPreview;
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const decrypted = await decryptLineMessage(service.e2eeManager, lastMsg, box.id);
|
|
461
|
+
const decryptedPreview = sanitizeMessagePreview(decrypted?.text);
|
|
462
|
+
if (decryptedPreview)
|
|
463
|
+
preview = decryptedPreview;
|
|
464
|
+
else if (lastMsg.contentType)
|
|
465
|
+
preview = messagePlaceholder(Number(lastMsg.contentType));
|
|
466
|
+
else if (lastMsg.text)
|
|
467
|
+
preview = messagePlaceholder(0, true);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const isGroup = box.id?.startsWith('c') || box.id?.startsWith('r');
|
|
471
|
+
const defaultTitle = isGroup ? '群組對話' : '好友對話';
|
|
472
|
+
const title = names.get(box.id) || defaultTitle;
|
|
473
|
+
const chatCached = service.chatCache.get(box.id);
|
|
474
|
+
const contact = contactMap.get(box.id);
|
|
475
|
+
let picPath = null;
|
|
476
|
+
if (isGroup && chatCached?.picturePath) {
|
|
477
|
+
picPath = chatCached.picturePath;
|
|
478
|
+
}
|
|
479
|
+
else if (!isGroup && contact?.picturePath) {
|
|
480
|
+
picPath = contact.picturePath;
|
|
481
|
+
}
|
|
482
|
+
const pictureUrl = picPath
|
|
483
|
+
? `https://obs.line-scdn.net/${picPath.replace(/^\/+/, '')}`
|
|
484
|
+
: null;
|
|
485
|
+
const isMuted = isGroup
|
|
486
|
+
? Boolean(chatCached?.notificationDisabled)
|
|
487
|
+
: Boolean(contact?.notificationDisabled ?? chatCached?.notificationDisabled);
|
|
488
|
+
const unreadCount = box.unreadCount ?? 0;
|
|
489
|
+
return {
|
|
490
|
+
id: box.id,
|
|
491
|
+
title,
|
|
492
|
+
lastMessage: preview.replace(/\n+/g, ' ').trim(),
|
|
493
|
+
timeString: formatTimestamp(timestamp),
|
|
494
|
+
timestamp,
|
|
495
|
+
unreadCount,
|
|
496
|
+
isMuted,
|
|
497
|
+
isGroup,
|
|
498
|
+
hasMention: false,
|
|
499
|
+
pictureUrl,
|
|
500
|
+
isOfficial: Boolean(contact?.isOfficial),
|
|
501
|
+
attentionScore: 0,
|
|
502
|
+
attentionTier: 'pending',
|
|
503
|
+
attentionReason: 'pending',
|
|
504
|
+
};
|
|
505
|
+
}));
|
|
506
|
+
// OpenChat (SquareService) is a separate inbox. Its `m...`/legacy `t...`
|
|
507
|
+
// chats never
|
|
508
|
+
// appear in TalkService message boxes, so merge the two authoritative
|
|
509
|
+
// lists before attention scoring and sorting. SquareChatStatus supplies
|
|
510
|
+
// the authoritative latest message cheaply enough for background polls.
|
|
511
|
+
try {
|
|
512
|
+
const squareChats = await service.client.getJoinedSquareChats(args.limit ?? 100);
|
|
513
|
+
const hydratedSquareChats = await Promise.all(squareChats.map(async (chat) => {
|
|
514
|
+
if (chat.lastMessage)
|
|
515
|
+
return chat;
|
|
516
|
+
let hydrated = chat;
|
|
517
|
+
try {
|
|
518
|
+
const status = await service.client.getSquareChatStatus(chat.id);
|
|
519
|
+
hydrated = {
|
|
520
|
+
...chat,
|
|
521
|
+
lastMessage: status?.[3]?.[1] || null,
|
|
522
|
+
lastSenderName: status?.[4] || null,
|
|
523
|
+
unreadCount: Number(status?.[5]?.[2] || chat.unreadCount),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
catch { }
|
|
527
|
+
if (isRenderableSquareMessage(hydrated.lastMessage))
|
|
528
|
+
return hydrated;
|
|
529
|
+
try {
|
|
530
|
+
const messages = await service.client.getSquareChatMessages(chat.id, 100);
|
|
531
|
+
return {
|
|
532
|
+
...hydrated,
|
|
533
|
+
lastMessage: [...messages].reverse().find(isRenderableSquareMessage) ?? null,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return hydrated;
|
|
538
|
+
}
|
|
539
|
+
}));
|
|
540
|
+
for (const chat of hydratedSquareChats) {
|
|
541
|
+
if (conversations.some((item) => item.id === chat.id))
|
|
542
|
+
continue;
|
|
543
|
+
const last = chat.lastMessage;
|
|
544
|
+
const timestamp = Number(last?.createdTime || last?.[6] || last?.[5] || 0);
|
|
545
|
+
const { contentType, text } = squareMessageContent(last);
|
|
546
|
+
conversations.push({
|
|
547
|
+
id: chat.id,
|
|
548
|
+
title: chat.name,
|
|
549
|
+
lastMessage: text ||
|
|
550
|
+
(contentType
|
|
551
|
+
? messagePlaceholder(contentType)
|
|
552
|
+
: '[yomi:no_message]'),
|
|
553
|
+
timeString: formatTimestamp(timestamp),
|
|
554
|
+
timestamp,
|
|
555
|
+
unreadCount: chat.unreadCount,
|
|
556
|
+
isMuted: chat.isMuted,
|
|
557
|
+
isGroup: true,
|
|
558
|
+
hasMention: false,
|
|
559
|
+
pictureUrl: chat.pictureHash
|
|
560
|
+
? `https://obs.line-scdn.net/${chat.pictureHash}`
|
|
561
|
+
: null,
|
|
562
|
+
isOfficial: false,
|
|
563
|
+
attentionScore: 0,
|
|
564
|
+
attentionTier: 'pending',
|
|
565
|
+
attentionReason: 'pending',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// Older/regional accounts may not expose SquareService. Ordinary LINE
|
|
571
|
+
// conversations must remain usable when OpenChat discovery is absent.
|
|
572
|
+
}
|
|
573
|
+
const cache = await loadAttentionCache();
|
|
574
|
+
const pending = [];
|
|
575
|
+
conversations.forEach((conversation, index) => {
|
|
576
|
+
const cached = cache[attentionSignature(conversation)];
|
|
577
|
+
if (cached) {
|
|
578
|
+
conversation.attentionScore = cached.score;
|
|
579
|
+
conversation.attentionTier = cached.tier;
|
|
580
|
+
conversation.attentionReason = cached.reason;
|
|
581
|
+
}
|
|
582
|
+
else if (conversation.isMuted || conversation.unreadCount === 0) {
|
|
583
|
+
conversation.attentionScore = conversation.isMuted ? -100 : 0;
|
|
584
|
+
conversation.attentionTier = 'filtered';
|
|
585
|
+
conversation.attentionReason = conversation.isMuted ? 'muted' : 'read';
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
pending.push(index);
|
|
589
|
+
conversation.attentionScore = 0;
|
|
590
|
+
conversation.attentionTier = 'pending';
|
|
591
|
+
conversation.attentionReason = 'pending';
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
if (args.semantic && pending.length > 0) {
|
|
595
|
+
try {
|
|
596
|
+
const evaluations = await scoreAttentionBatch(pending.map((index) => {
|
|
597
|
+
const conversation = conversations[index];
|
|
598
|
+
return {
|
|
599
|
+
id: conversation.id,
|
|
600
|
+
text: conversation.lastMessage,
|
|
601
|
+
unreadCount: 0,
|
|
602
|
+
isMuted: conversation.isMuted,
|
|
603
|
+
isOfficial: conversation.isOfficial,
|
|
604
|
+
hasMention: conversation.hasMention,
|
|
605
|
+
};
|
|
606
|
+
}));
|
|
607
|
+
pending.forEach((conversationIndex, resultIndex) => {
|
|
608
|
+
const conversation = conversations[conversationIndex];
|
|
609
|
+
const result = evaluations[resultIndex];
|
|
610
|
+
cache[attentionSignature(conversation)] = {
|
|
611
|
+
...result,
|
|
612
|
+
updatedAt: Date.now(),
|
|
613
|
+
};
|
|
614
|
+
conversation.attentionScore = result.score;
|
|
615
|
+
conversation.attentionTier = result.tier;
|
|
616
|
+
conversation.attentionReason = result.reason;
|
|
617
|
+
});
|
|
618
|
+
await saveAttentionCache(cache);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
pending.forEach((index) => {
|
|
622
|
+
conversations[index].attentionReason = 'ai_unavailable';
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Sort by timestamp descending
|
|
627
|
+
conversations.sort((a, b) => b.timestamp - a.timestamp);
|
|
628
|
+
if (args.attentionOnly) {
|
|
629
|
+
return conversations.filter((c) => ['now', 'today', 'know'].includes(c.attentionTier));
|
|
630
|
+
}
|
|
631
|
+
return conversations;
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return [];
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
export async function cliGetChatMessagesJson(chatId, count = 30, activeService) {
|
|
638
|
+
const service = activeService ?? new LineProtocolService();
|
|
639
|
+
if (!activeService && !(await service.resumeSession()))
|
|
640
|
+
return [];
|
|
641
|
+
try {
|
|
642
|
+
if (isSquareChatMid(chatId)) {
|
|
643
|
+
// Square's small-limit response can be the oldest edge of its server
|
|
644
|
+
// window rather than the newest page. Fetch the protocol's full recent
|
|
645
|
+
// window, normalize by timestamp, then expose the requested tail.
|
|
646
|
+
const squareWindow = await service.client.getSquareChatMessages(chatId, Math.max(100, count));
|
|
647
|
+
let latestStatusMessage = null;
|
|
648
|
+
try {
|
|
649
|
+
latestStatusMessage =
|
|
650
|
+
await service.client.getSquareChatLatestMessage(chatId);
|
|
651
|
+
}
|
|
652
|
+
catch { }
|
|
653
|
+
const combinedWindow = latestStatusMessage
|
|
654
|
+
? [...squareWindow, latestStatusMessage]
|
|
655
|
+
: squareWindow;
|
|
656
|
+
const meaningfulWindow = combinedWindow.filter((message) => Number(message.contentType ?? 0) !== 0 ||
|
|
657
|
+
Boolean(sanitizeMessagePreview(message.text)) ||
|
|
658
|
+
Boolean(messageAltText(message.contentMetadata)));
|
|
659
|
+
const messages = newestMessageWindow(meaningfulWindow, count);
|
|
660
|
+
const shapeSquareMessage = (message) => ({
|
|
661
|
+
id: message.id,
|
|
662
|
+
fromMid: message.fromMid,
|
|
663
|
+
fromName: message.isSelf ? '我' : message.fromName,
|
|
664
|
+
fromPictureUrl: message.fromPictureUrl,
|
|
665
|
+
isSelf: message.isSelf,
|
|
666
|
+
text: (message.contentType === 0
|
|
667
|
+
? sanitizeMessagePreview(message.text) ||
|
|
668
|
+
messageAltText(message.contentMetadata)
|
|
669
|
+
: '') ||
|
|
670
|
+
(message.contentType ? messagePlaceholder(message.contentType) : ''),
|
|
671
|
+
timeString: formatTimestamp(message.createdTime),
|
|
672
|
+
timestamp: message.createdTime,
|
|
673
|
+
contentType: message.contentType,
|
|
674
|
+
mediaType: {
|
|
675
|
+
1: 'image',
|
|
676
|
+
2: 'video',
|
|
677
|
+
3: 'audio',
|
|
678
|
+
7: 'sticker',
|
|
679
|
+
14: 'file',
|
|
680
|
+
17: 'rich',
|
|
681
|
+
22: 'rich',
|
|
682
|
+
}[message.contentType] || null,
|
|
683
|
+
mediaUrl: message.contentType === 7 && message.contentMetadata?.STKID
|
|
684
|
+
? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${message.contentMetadata.STKID}/android/sticker.png`
|
|
685
|
+
: null,
|
|
686
|
+
actionUrl: null,
|
|
687
|
+
fileName: message.contentMetadata?.FILE_NAME || null,
|
|
688
|
+
isDecryptFailure: false,
|
|
689
|
+
isUnsent: false,
|
|
690
|
+
mentionRanges: [],
|
|
691
|
+
threadMid: message.threadMid,
|
|
692
|
+
isThreadRoot: message.isThreadRoot,
|
|
693
|
+
emojiRanges: parseEmojiRanges(message.contentMetadata || {}),
|
|
694
|
+
});
|
|
695
|
+
return Promise.all(messages.map(async (message) => {
|
|
696
|
+
const shaped = shapeSquareMessage(message);
|
|
697
|
+
if (!message.isThreadRoot || !message.threadMid)
|
|
698
|
+
return shaped;
|
|
699
|
+
try {
|
|
700
|
+
const threadMessages = await service.client.getSquareChatMessages(chatId, 50, message.threadMid);
|
|
701
|
+
shaped.threadReplies = threadMessages
|
|
702
|
+
.filter((reply) => reply.id !== message.id)
|
|
703
|
+
.map(shapeSquareMessage);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
shaped.threadReplies = [];
|
|
707
|
+
}
|
|
708
|
+
return shaped;
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
// Always use the service-owned query boundary here. It opportunistically
|
|
712
|
+
// decrypts E2EE envelopes; reading service.client directly leaves media
|
|
713
|
+
// messages with contentType=0 and opaque text, which makes image/file
|
|
714
|
+
// rendering look empty even though LINE returned the message.
|
|
715
|
+
const rawMessages = await service.getRecentMessages(chatId, count);
|
|
716
|
+
const mids = Array.from(new Set(rawMessages
|
|
717
|
+
.flatMap((message) => [
|
|
718
|
+
message.from,
|
|
719
|
+
...chatEventParticipantMids(message),
|
|
720
|
+
])
|
|
721
|
+
.filter(Boolean)));
|
|
722
|
+
const names = await resolveSenderNames(service, mids);
|
|
723
|
+
const userMids = Array.from(new Set(mids.filter((m) => m.startsWith('u'))));
|
|
724
|
+
const contactMap = new Map();
|
|
725
|
+
if (userMids.length > 0) {
|
|
726
|
+
try {
|
|
727
|
+
const contacts = await service.client.getContacts(userMids);
|
|
728
|
+
for (const c of contacts || []) {
|
|
729
|
+
if (c?.mid)
|
|
730
|
+
contactMap.set(c.mid, c);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch { }
|
|
734
|
+
}
|
|
735
|
+
const selfMid = service.profile?.mid;
|
|
736
|
+
const selfName = service.profile?.displayName || '我';
|
|
737
|
+
const selfPicPath = service.profile?.picturePath;
|
|
738
|
+
const selfPicUrl = selfPicPath
|
|
739
|
+
? `https://obs.line-scdn.net/${selfPicPath.replace(/^\/+/, '')}`
|
|
740
|
+
: null;
|
|
741
|
+
const formatted = rawMessages.map((m) => {
|
|
742
|
+
const isSelf = m.from === selfMid;
|
|
743
|
+
const fromName = isSelf ? selfName : names.get(m.from) || '成員';
|
|
744
|
+
const contact = contactMap.get(m.from);
|
|
745
|
+
const picPath = isSelf ? selfPicPath : contact?.picturePath;
|
|
746
|
+
const fromPictureUrl = isSelf
|
|
747
|
+
? selfPicUrl
|
|
748
|
+
: picPath
|
|
749
|
+
? `https://obs.line-scdn.net/${picPath.replace(/^\/+/, '')}`
|
|
750
|
+
: null;
|
|
751
|
+
const contentType = Number(m.contentType || 0);
|
|
752
|
+
const metadata = m.contentMetadata || {};
|
|
753
|
+
const mediaText = parseMediaText(m.text);
|
|
754
|
+
const stickerId = metadata.STKID || metadata.stickerId;
|
|
755
|
+
const rich = isRichContentType(contentType) ? richFields(m) : null;
|
|
756
|
+
let text = sanitizeMessagePreview(m.text);
|
|
757
|
+
if (contentType === 6)
|
|
758
|
+
text = callHistoryPreview(m.text, isSelf);
|
|
759
|
+
if (contentType === 18)
|
|
760
|
+
text = interpretChatEvent(m, names);
|
|
761
|
+
if (rich?.altText)
|
|
762
|
+
text = rich.altText;
|
|
763
|
+
if (!text) {
|
|
764
|
+
text = messageFallback(m);
|
|
765
|
+
}
|
|
766
|
+
const timestamp = Number(m.deliveredTime || m.createdTime || 0);
|
|
767
|
+
return {
|
|
768
|
+
id: String(m.id),
|
|
769
|
+
fromMid: m.from || '',
|
|
770
|
+
fromName,
|
|
771
|
+
fromPictureUrl,
|
|
772
|
+
isSelf,
|
|
773
|
+
text,
|
|
774
|
+
timeString: formatTimestamp(timestamp),
|
|
775
|
+
timestamp,
|
|
776
|
+
contentType,
|
|
777
|
+
mediaType: {
|
|
778
|
+
1: 'image',
|
|
779
|
+
2: 'video',
|
|
780
|
+
3: 'audio',
|
|
781
|
+
6: 'call',
|
|
782
|
+
7: 'sticker',
|
|
783
|
+
14: 'file',
|
|
784
|
+
17: 'rich',
|
|
785
|
+
22: 'rich',
|
|
786
|
+
}[contentType] || null,
|
|
787
|
+
mediaUrl: rich?.mediaUrl ||
|
|
788
|
+
(stickerId
|
|
789
|
+
? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${stickerId}/android/sticker.png`
|
|
790
|
+
: null),
|
|
791
|
+
actionUrl: rich?.actionUrl || null,
|
|
792
|
+
flexDocument: rich?.flexDocument || null,
|
|
793
|
+
fileName: (typeof mediaText.fileName === 'string'
|
|
794
|
+
? mediaText.fileName
|
|
795
|
+
: null) ||
|
|
796
|
+
(typeof metadata.FILE_NAME === 'string'
|
|
797
|
+
? metadata.FILE_NAME
|
|
798
|
+
: null) ||
|
|
799
|
+
(typeof metadata.FILE_NAME_ORIGINAL === 'string'
|
|
800
|
+
? metadata.FILE_NAME_ORIGINAL
|
|
801
|
+
: null) ||
|
|
802
|
+
(typeof metadata.name === 'string' ? metadata.name : null),
|
|
803
|
+
isDecryptFailure: Boolean(m.e2eeDecryptFailure),
|
|
804
|
+
isUnsent: metadata.UNSENT === 'true',
|
|
805
|
+
mentionRanges: parseMentionRanges(metadata),
|
|
806
|
+
emojiRanges: parseEmojiRanges(metadata),
|
|
807
|
+
};
|
|
808
|
+
});
|
|
809
|
+
// Chronological order (oldest to newest) for chat bubble view
|
|
810
|
+
formatted.sort((a, b) => a.timestamp - b.timestamp);
|
|
811
|
+
return formatted;
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
return [];
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
export async function cliMarkReadJson(chatId, messageId) {
|
|
818
|
+
const service = new LineProtocolService();
|
|
819
|
+
const ok = await service.resumeSession();
|
|
820
|
+
if (!ok)
|
|
821
|
+
return { success: false, marked: false };
|
|
822
|
+
try {
|
|
823
|
+
const res = await service.markChatRead(chatId, messageId);
|
|
824
|
+
return { success: true, marked: res.marked };
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return { success: false, marked: false };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
export async function cliRecentSummary() {
|
|
831
|
+
const service = new LineProtocolService();
|
|
832
|
+
const ok = await service.resumeSession();
|
|
833
|
+
if (!ok) {
|
|
834
|
+
return '【LINE 狀態】尚未連線或憑證已過期,請先於選單列重新登入。';
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
const result = await handleListConversations(service, { limit: 12 });
|
|
838
|
+
const rawText = result.content?.[0]?.text || '';
|
|
839
|
+
const name = service.profile?.displayName || 'LINE User';
|
|
840
|
+
let output = `【LINE 即時訊息摘要 — ${name}】\n• 加密狀態:Letter-Sealing (E2EE) 已解密安全連線\n\n`;
|
|
841
|
+
// Parse TOON format lines
|
|
842
|
+
const lines = rawText.split('\n').filter((l) => l.trim().length > 0);
|
|
843
|
+
const chats = [];
|
|
844
|
+
for (const line of lines) {
|
|
845
|
+
if (line.startsWith('[') || line.includes('{id,'))
|
|
846
|
+
continue;
|
|
847
|
+
// Format: id,lastMessagePreview,name,unreadCount
|
|
848
|
+
// Note that preview might contain quotes or escaped newlines
|
|
849
|
+
const trimmed = line.trim();
|
|
850
|
+
const parts = trimmed.split(',');
|
|
851
|
+
if (parts.length >= 4) {
|
|
852
|
+
const unread = parseInt(parts[parts.length - 1], 10) || 0;
|
|
853
|
+
const chatName = parts[parts.length - 2] || '聊天室';
|
|
854
|
+
const previewRaw = parts.slice(1, parts.length - 2).join(',');
|
|
855
|
+
const preview = previewRaw === 'null'
|
|
856
|
+
? '(無文字或貼圖/媒體)'
|
|
857
|
+
: previewRaw.replace(/^"|"$/g, '').replace(/\\n/g, ' ');
|
|
858
|
+
chats.push({ name: chatName, unread, preview });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (chats.length > 0) {
|
|
862
|
+
chats.forEach((chat) => {
|
|
863
|
+
const unreadBadge = chat.unread > 0 ? ` 🔴 **[未讀 ${chat.unread} 則]**` : '';
|
|
864
|
+
output += `• **${chat.name}**${unreadBadge}\n ↳ 「${chat.preview}」\n\n`;
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
output += '目前沒有最近的聊天室紀錄。\n';
|
|
869
|
+
}
|
|
870
|
+
return output.trim();
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
return `查詢 LINE 訊息時發生錯誤:${err.message || String(err)}`;
|
|
874
|
+
}
|
|
875
|
+
}
|