@rikaidev/yomi 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +81 -0
- package/PRIVACY.md +39 -0
- package/README.md +659 -0
- package/dist/auth/credential-store.js +395 -0
- package/dist/auth/keychain.js +256 -0
- package/dist/cli/login.js +142 -0
- package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
- package/dist/line/auth/protocol/index.js +3 -0
- package/dist/line/auth/protocol/login-metadata.js +159 -0
- package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
- package/dist/line/auth/pwless/index.js +163 -0
- package/dist/line/auth/pwless/steps.js +213 -0
- package/dist/line/client/auth-service/client.js +240 -0
- package/dist/line/client/e2ee-service/client.js +69 -0
- package/dist/line/client/e2ee-service/requests.js +92 -0
- package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
- package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
- package/dist/line/client/index.js +213 -0
- package/dist/line/client/obs-media-client.js +140 -0
- package/dist/line/client/parsers.js +69 -0
- package/dist/line/client/shop-service/client.js +63 -0
- package/dist/line/client/sticker-meta.js +46 -0
- package/dist/line/client/sync-service/client.js +317 -0
- package/dist/line/client/talk-service/chat-query.js +122 -0
- package/dist/line/client/talk-service/command-client.js +242 -0
- package/dist/line/client/talk-service/contact-query.js +77 -0
- package/dist/line/client/talk-service/message-box-query.js +137 -0
- package/dist/line/client/talk-service/message-id-query.js +241 -0
- package/dist/line/client/talk-service/query-client.js +219 -0
- package/dist/line/client/talk-service/requests.js +383 -0
- package/dist/line/client/transport.js +138 -0
- package/dist/line/core/auth-session-logout.js +55 -0
- package/dist/line/core/auth-session-runtime.js +231 -0
- package/dist/line/core/auth-session-service.js +85 -0
- package/dist/line/core/chat-runtime-service.js +343 -0
- package/dist/line/core/client-runtime.js +17 -0
- package/dist/line/core/config.js +67 -0
- package/dist/line/core/constants.js +56 -0
- package/dist/line/core/contact-query-service.js +20 -0
- package/dist/line/core/directory-runtime-service.js +83 -0
- package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
- package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
- package/dist/line/core/e2ee/index.js +6 -0
- package/dist/line/core/e2ee/keys/decrypt.js +110 -0
- package/dist/line/core/e2ee/keys/encrypt.js +117 -0
- package/dist/line/core/e2ee/keys/group-key.js +260 -0
- package/dist/line/core/e2ee/keys/key-manager.js +231 -0
- package/dist/line/core/e2ee/keys/key-payload.js +130 -0
- package/dist/line/core/e2ee/keys/key-types.js +1 -0
- package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
- package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
- package/dist/line/core/e2ee/keys/peer-key.js +191 -0
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
- package/dist/line/core/e2ee/types.js +4 -0
- package/dist/line/core/group-query-service.js +20 -0
- package/dist/line/core/media-decrypt.js +78 -0
- package/dist/line/core/media-encrypt.js +97 -0
- package/dist/line/core/mention.js +89 -0
- package/dist/line/core/message-command-service.js +454 -0
- package/dist/line/core/message-query-service.js +161 -0
- package/dist/line/core/service.js +134 -0
- package/dist/line/core/session-state/index.js +243 -0
- package/dist/line/core/session-state/persist.js +192 -0
- package/dist/line/core/store.js +8 -0
- package/dist/line/core/thrift/binary/binary-reader.js +151 -0
- package/dist/line/core/thrift/binary/binary-writer.js +157 -0
- package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
- package/dist/line/core/thrift/compact/compact-reader.js +180 -0
- package/dist/line/core/thrift/compact/compact-writer.js +161 -0
- package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
- package/dist/line/core/thrift/fields/builders.js +124 -0
- package/dist/line/core/thrift/index.js +12 -0
- package/dist/line/core/thrift/message/decoder.js +55 -0
- package/dist/line/core/thrift/message/encoder.js +25 -0
- package/dist/line/core/thrift/primitives/varint.js +64 -0
- package/dist/line/core/thrift/struct/reader.js +172 -0
- package/dist/line/core/thrift/struct/writer.js +189 -0
- package/dist/line/core/thrift/types.js +23 -0
- package/dist/line/core/video-thumbnail.js +104 -0
- package/dist/mcp/handlers/contacts.js +166 -0
- package/dist/mcp/handlers/conversations.js +193 -0
- package/dist/mcp/handlers/groups.js +123 -0
- package/dist/mcp/handlers/index.js +15 -0
- package/dist/mcp/handlers/login.js +473 -0
- package/dist/mcp/handlers/media.js +69 -0
- package/dist/mcp/handlers/message-actions.js +62 -0
- package/dist/mcp/handlers/scope.js +120 -0
- package/dist/mcp/handlers/search.js +196 -0
- package/dist/mcp/handlers/send.js +523 -0
- package/dist/mcp/handlers/shared.js +84 -0
- package/dist/mcp/handlers/stickers.js +87 -0
- package/dist/mcp/media.js +228 -0
- package/dist/mcp/names.js +133 -0
- package/dist/mcp/phi-guard.js +105 -0
- package/dist/mcp/policy.js +34 -0
- package/dist/mcp/server.js +301 -0
- package/dist/mcp/tools.js +761 -0
- package/dist/mcp/ui/capability.js +26 -0
- package/dist/mcp/ui/login-app-protocol.js +118 -0
- package/dist/mcp/ui/login-app-styles.js +142 -0
- package/dist/mcp/ui/login-app-view.js +361 -0
- package/dist/mcp/ui/login-app.js +72 -0
- package/dist/mcp/ui/login-structured-content.js +1 -0
- package/dist/mcp/ui/resource.js +35 -0
- package/dist/mcp/ui/tools-with-ui.js +40 -0
- package/dist/search/bigram.js +86 -0
- package/dist/search/capture.js +140 -0
- package/dist/search/collector.js +176 -0
- package/dist/search/default-embedder.js +101 -0
- package/dist/search/embedder.js +13 -0
- package/dist/search/scope.js +142 -0
- package/dist/search/sqlite.js +141 -0
- package/dist/search/store.js +469 -0
- package/dist/util/data-dir.js +55 -0
- package/dist/util/log.js +86 -0
- package/dist/version.js +7 -0
- package/package.json +63 -0
- package/run.mjs +71 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCompact struct writer.
|
|
3
|
+
*/
|
|
4
|
+
import { Buffer } from 'node:buffer';
|
|
5
|
+
import { varint, zigzag, zigzag64 } from '../primitives/varint.js';
|
|
6
|
+
const COMPACT_TYPE_BY_THRIFT_TYPE = {
|
|
7
|
+
3: 3,
|
|
8
|
+
6: 4,
|
|
9
|
+
8: 5,
|
|
10
|
+
10: 6,
|
|
11
|
+
11: 8,
|
|
12
|
+
12: 12,
|
|
13
|
+
13: 11,
|
|
14
|
+
14: 10,
|
|
15
|
+
15: 9,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Check whether a value is a Buffer instance.
|
|
19
|
+
*
|
|
20
|
+
* @param value - Candidate value.
|
|
21
|
+
* @returns True when the value is a Buffer.
|
|
22
|
+
*/
|
|
23
|
+
function isBufferLike(value) {
|
|
24
|
+
return Buffer.isBuffer(value);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check whether a value looks like a struct field tuple array.
|
|
28
|
+
*
|
|
29
|
+
* @param value - Candidate value.
|
|
30
|
+
* @returns True when the value is a struct field tuple array.
|
|
31
|
+
*/
|
|
32
|
+
function isStructFieldTupleArray(value) {
|
|
33
|
+
return (Array.isArray(value) &&
|
|
34
|
+
value.every((item) => Array.isArray(item) && item.length === 3));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Write one compact field header.
|
|
38
|
+
*
|
|
39
|
+
* @param parts - Buffer accumulator.
|
|
40
|
+
* @param compactType - Compact wire type.
|
|
41
|
+
* @param delta - Delta from previous field ID.
|
|
42
|
+
* @param fieldId - Absolute field ID.
|
|
43
|
+
*/
|
|
44
|
+
function writeHeader(parts, compactType, delta, fieldId) {
|
|
45
|
+
parts.push(delta > 0 && delta <= 15
|
|
46
|
+
? Buffer.from([(delta << 4) | compactType])
|
|
47
|
+
: Buffer.concat([Buffer.from([compactType]), zigzag(fieldId)]));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Write one TCompact MAP payload.
|
|
51
|
+
*
|
|
52
|
+
* @param parts - Buffer accumulator.
|
|
53
|
+
* @param value - Map tuple payload.
|
|
54
|
+
*/
|
|
55
|
+
function writeMap(parts, value) {
|
|
56
|
+
const [keyType, valueType, entriesObject] = value;
|
|
57
|
+
const compactMap = { 8: 5, 11: 8, 12: 12 };
|
|
58
|
+
const compactKeyType = compactMap[keyType] || keyType;
|
|
59
|
+
const compactValueType = compactMap[valueType] || valueType;
|
|
60
|
+
const entries = Object.entries(entriesObject);
|
|
61
|
+
parts.push(varint(entries.length));
|
|
62
|
+
if (entries.length > 0) {
|
|
63
|
+
parts.push(Buffer.from([(compactKeyType << 4) | compactValueType]));
|
|
64
|
+
}
|
|
65
|
+
for (const [key, item] of entries) {
|
|
66
|
+
if (keyType === 11) {
|
|
67
|
+
const encodedKey = Buffer.from(key, 'utf-8');
|
|
68
|
+
parts.push(varint(encodedKey.length), encodedKey);
|
|
69
|
+
}
|
|
70
|
+
else if (keyType === 8) {
|
|
71
|
+
parts.push(zigzag(Number(key)));
|
|
72
|
+
}
|
|
73
|
+
if (valueType === 11) {
|
|
74
|
+
const encodedValue = typeof item === 'string' ? Buffer.from(item, 'utf-8') : item;
|
|
75
|
+
parts.push(varint(encodedValue.length), encodedValue);
|
|
76
|
+
}
|
|
77
|
+
else if (valueType === 8) {
|
|
78
|
+
parts.push(zigzag(item));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Write one TCompact LIST payload.
|
|
84
|
+
*
|
|
85
|
+
* @param parts - Buffer accumulator.
|
|
86
|
+
* @param value - List tuple payload.
|
|
87
|
+
*/
|
|
88
|
+
function writeList(parts, value) {
|
|
89
|
+
const [elementType, items] = value;
|
|
90
|
+
const compactMap = { 8: 5, 11: 8, 12: 12 };
|
|
91
|
+
const compactElementType = compactMap[elementType] || elementType;
|
|
92
|
+
if (items.length < 15) {
|
|
93
|
+
parts.push(Buffer.from([(items.length << 4) | compactElementType]));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
parts.push(Buffer.from([0xf0 | compactElementType]), varint(items.length));
|
|
97
|
+
}
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
if (elementType === 11) {
|
|
100
|
+
const encodedItem = typeof item === 'string'
|
|
101
|
+
? Buffer.from(item, 'utf-8')
|
|
102
|
+
: isBufferLike(item)
|
|
103
|
+
? item
|
|
104
|
+
: Buffer.alloc(0);
|
|
105
|
+
parts.push(varint(encodedItem.length), encodedItem);
|
|
106
|
+
}
|
|
107
|
+
else if (elementType === 8) {
|
|
108
|
+
parts.push(zigzag(Number(item)));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Write one primitive or composite thrift field value.
|
|
114
|
+
*
|
|
115
|
+
* @param parts - Buffer accumulator.
|
|
116
|
+
* @param type - Thrift field type ID.
|
|
117
|
+
* @param value - Field value.
|
|
118
|
+
*/
|
|
119
|
+
function writeFieldValue(parts, type, value) {
|
|
120
|
+
if (type === 3) {
|
|
121
|
+
parts.push(Buffer.from([value & 0xff]));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (type === 6 || type === 8) {
|
|
125
|
+
parts.push(zigzag(value));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (type === 10) {
|
|
129
|
+
parts.push(zigzag64(value));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (type === 11) {
|
|
133
|
+
const encodedValue = typeof value === 'string'
|
|
134
|
+
? Buffer.from(value, 'utf-8')
|
|
135
|
+
: isBufferLike(value)
|
|
136
|
+
? value
|
|
137
|
+
: Buffer.alloc(0);
|
|
138
|
+
parts.push(varint(encodedValue.length), encodedValue);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (type === 12) {
|
|
142
|
+
if (isStructFieldTupleArray(value) && value.length) {
|
|
143
|
+
parts.push(writeFieldsArray(value, { v: 0 }));
|
|
144
|
+
}
|
|
145
|
+
parts.push(Buffer.from([0]));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (type === 13) {
|
|
149
|
+
writeMap(parts, value);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (type === 15 || type === 14) {
|
|
153
|
+
// SET (14) is wire-identical to LIST (15) in TCompact — same header and
|
|
154
|
+
// element payload; only the field-header type nibble (set above) differs.
|
|
155
|
+
writeList(parts, value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Recursively write field definitions into a TCompact struct body.
|
|
160
|
+
*
|
|
161
|
+
* @param fields - Array of [type, fieldId, value] tuples.
|
|
162
|
+
* @param lastFieldId - Mutable field ID tracker for delta encoding.
|
|
163
|
+
* @param lastFieldId.v - Last written field ID.
|
|
164
|
+
* @returns Serialized struct body.
|
|
165
|
+
*/
|
|
166
|
+
export function writeFieldsArray(fields, lastFieldId) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
for (const field of fields) {
|
|
169
|
+
if (!field) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const [type, fieldId, value] = field;
|
|
173
|
+
const delta = fieldId - lastFieldId.v;
|
|
174
|
+
if (type === 2) {
|
|
175
|
+
const compactType = value ? 1 : 2;
|
|
176
|
+
writeHeader(parts, compactType, delta, fieldId);
|
|
177
|
+
lastFieldId.v = fieldId;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const compactType = COMPACT_TYPE_BY_THRIFT_TYPE[type];
|
|
181
|
+
if (compactType === undefined) {
|
|
182
|
+
throw new Error(`Unsupported thrift type: ${type}`);
|
|
183
|
+
}
|
|
184
|
+
writeHeader(parts, compactType, delta, fieldId);
|
|
185
|
+
lastFieldId.v = fieldId;
|
|
186
|
+
writeFieldValue(parts, type, value);
|
|
187
|
+
}
|
|
188
|
+
return Buffer.concat(parts);
|
|
189
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrift types and constants
|
|
3
|
+
*/
|
|
4
|
+
export const THRIFT_TYPE = {
|
|
5
|
+
STOP: 0,
|
|
6
|
+
BOOL: 2,
|
|
7
|
+
BYTE: 3,
|
|
8
|
+
DOUBLE: 4,
|
|
9
|
+
I16: 6,
|
|
10
|
+
I32: 8,
|
|
11
|
+
I64: 10,
|
|
12
|
+
STRING: 11,
|
|
13
|
+
STRUCT: 12,
|
|
14
|
+
MAP: 13,
|
|
15
|
+
SET: 14,
|
|
16
|
+
LIST: 15,
|
|
17
|
+
};
|
|
18
|
+
export const MSG_TYPE = {
|
|
19
|
+
CALL: 1,
|
|
20
|
+
REPLY: 2,
|
|
21
|
+
EXCEPTION: 3,
|
|
22
|
+
ONEWAY: 4,
|
|
23
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
/** Longest edge of the generated poster, matching LINE's own video thumbnails. */
|
|
7
|
+
const THUMBNAIL_MAX_EDGE = 384;
|
|
8
|
+
/**
|
|
9
|
+
* Read a JPEG's pixel dimensions from its first Start-Of-Frame marker.
|
|
10
|
+
*
|
|
11
|
+
* MEDIA_THUMB_INFO must carry the poster's real width/height, and we produce
|
|
12
|
+
* the poster ourselves as JPEG, so a tiny dependency-free SOF parser avoids
|
|
13
|
+
* pulling in an image-decoding library. Returns null if no SOF marker is found.
|
|
14
|
+
*
|
|
15
|
+
* @param jpeg - JPEG bytes.
|
|
16
|
+
* @returns `{ width, height }`, or null when the markers can't be read.
|
|
17
|
+
*/
|
|
18
|
+
function readJpegDimensions(jpeg) {
|
|
19
|
+
// Skip the SOI (0xFFD8); then walk marker segments until a SOF marker.
|
|
20
|
+
let offset = 2;
|
|
21
|
+
while (offset + 9 < jpeg.length) {
|
|
22
|
+
if (jpeg[offset] !== 0xff) {
|
|
23
|
+
offset += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const marker = jpeg[offset + 1];
|
|
27
|
+
// SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15 carry frame dimensions;
|
|
28
|
+
// the non-SOF markers in those ranges (DHT 0xC4, JPG 0xC8, DAC 0xCC) don't.
|
|
29
|
+
const isSof = marker >= 0xc0 &&
|
|
30
|
+
marker <= 0xcf &&
|
|
31
|
+
marker !== 0xc4 &&
|
|
32
|
+
marker !== 0xc8 &&
|
|
33
|
+
marker !== 0xcc;
|
|
34
|
+
if (isSof) {
|
|
35
|
+
const height = jpeg.readUInt16BE(offset + 5);
|
|
36
|
+
const width = jpeg.readUInt16BE(offset + 7);
|
|
37
|
+
return { width, height };
|
|
38
|
+
}
|
|
39
|
+
// Standalone markers (RSTn/SOI/EOI/TEM) have no length word; everything
|
|
40
|
+
// else is followed by a 2-byte segment length to skip past.
|
|
41
|
+
if ((marker >= 0xd0 && marker <= 0xd9) ||
|
|
42
|
+
marker === 0x01 ||
|
|
43
|
+
marker === 0xff) {
|
|
44
|
+
offset += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const segmentLength = jpeg.readUInt16BE(offset + 2);
|
|
48
|
+
offset += 2 + segmentLength;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Extract a poster frame from a video, best-effort, using ffmpeg.
|
|
54
|
+
*
|
|
55
|
+
* Grabs the first frame, scaled to fit within {@link THUMBNAIL_MAX_EDGE} on the
|
|
56
|
+
* longest edge (aspect preserved), encoded as JPEG — the same shape LINE's own
|
|
57
|
+
* clients attach as a video's `__ud-preview`. Returns null (never throws) when
|
|
58
|
+
* ffmpeg is absent or fails, so the caller sends the video without a poster
|
|
59
|
+
* rather than not at all.
|
|
60
|
+
*
|
|
61
|
+
* @param videoBytes - Raw video bytes.
|
|
62
|
+
* @returns `{ jpeg, width, height }`, or null when no poster could be made.
|
|
63
|
+
*/
|
|
64
|
+
export async function extractVideoThumbnail(videoBytes) {
|
|
65
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'yomi-vthumb-'));
|
|
66
|
+
const inPath = path.join(dir, `in-${crypto.randomUUID()}`);
|
|
67
|
+
const outPath = path.join(dir, `out-${crypto.randomUUID()}.jpg`);
|
|
68
|
+
try {
|
|
69
|
+
await fs.writeFile(inPath, videoBytes);
|
|
70
|
+
await new Promise((resolve, reject) => {
|
|
71
|
+
const proc = spawn('ffmpeg', [
|
|
72
|
+
'-y',
|
|
73
|
+
'-i',
|
|
74
|
+
inPath,
|
|
75
|
+
'-frames:v',
|
|
76
|
+
'1',
|
|
77
|
+
'-vf',
|
|
78
|
+
`scale=${THUMBNAIL_MAX_EDGE}:${THUMBNAIL_MAX_EDGE}:force_original_aspect_ratio=decrease`,
|
|
79
|
+
'-f',
|
|
80
|
+
'mjpeg',
|
|
81
|
+
'-q:v',
|
|
82
|
+
'5',
|
|
83
|
+
outPath,
|
|
84
|
+
], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
85
|
+
proc.on('error', reject);
|
|
86
|
+
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)));
|
|
87
|
+
});
|
|
88
|
+
const jpeg = await fs.readFile(outPath);
|
|
89
|
+
if (jpeg.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const dims = readJpegDimensions(jpeg);
|
|
93
|
+
if (!dims) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return { jpeg, width: dims.width, height: dims.height };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await fs.rm(dir, { force: true, recursive: true }).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createCliLogger } from '../../util/log.js';
|
|
2
|
+
import { resolveUserNames } from '../names.js';
|
|
3
|
+
import { jsonResult, toolError } from './shared.js';
|
|
4
|
+
const log = createCliLogger('Yomi');
|
|
5
|
+
/**
|
|
6
|
+
* Fetch and normalize the authenticated user's full friend list in one
|
|
7
|
+
* round trip: enumerate friend MIDs, then resolve them all through a
|
|
8
|
+
* single batched getContacts call. Also warms the shared nameCache so
|
|
9
|
+
* later tool calls (get_chat_messages, list_conversations) skip the
|
|
10
|
+
* network for these MIDs.
|
|
11
|
+
*
|
|
12
|
+
* @param service - Resumed LineProtocolService.
|
|
13
|
+
* @returns Normalized friend contacts (unresolved names surfaced as null, never fabricated).
|
|
14
|
+
*/
|
|
15
|
+
async function fetchAllContacts(service) {
|
|
16
|
+
const mids = await service.client.getAllContactIds();
|
|
17
|
+
if (!Array.isArray(mids) || mids.length === 0) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const contacts = await service.client.getContacts(mids);
|
|
21
|
+
const summaries = [];
|
|
22
|
+
for (const contact of contacts) {
|
|
23
|
+
if (!contact?.mid) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (contact.displayName) {
|
|
27
|
+
service.nameCache.set(contact.mid, contact.displayName);
|
|
28
|
+
}
|
|
29
|
+
summaries.push({
|
|
30
|
+
mid: contact.mid,
|
|
31
|
+
displayName: contact.displayName ?? null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return summaries;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Handle `list_contacts` — the raw LINE friend list, straight from
|
|
38
|
+
* getAllContactIds + getContacts. No ranking, no scoring: whatever order
|
|
39
|
+
* LINE returns is what callers get.
|
|
40
|
+
*
|
|
41
|
+
* @param service - Resumed LineProtocolService.
|
|
42
|
+
* @returns MCP tool result.
|
|
43
|
+
*/
|
|
44
|
+
export async function handleListContacts(service) {
|
|
45
|
+
const contacts = await fetchAllContacts(service);
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{ type: 'text', text: JSON.stringify(contacts, null, 2) },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Handle `find_contact` — case-insensitive substring match over the
|
|
54
|
+
* friend list's displayName, so a caller can resolve a person's name to
|
|
55
|
+
* the MID `send_message` needs for a 1:1. Pure lookup: no fuzzy scoring,
|
|
56
|
+
* no ranking by interaction history.
|
|
57
|
+
*
|
|
58
|
+
* @param service - Resumed LineProtocolService.
|
|
59
|
+
* @param args - Tool arguments.
|
|
60
|
+
* @returns MCP tool result.
|
|
61
|
+
*/
|
|
62
|
+
export async function handleFindContact(service, args) {
|
|
63
|
+
if (!args.name) {
|
|
64
|
+
return toolError('name is required.');
|
|
65
|
+
}
|
|
66
|
+
const needle = args.name.toLowerCase();
|
|
67
|
+
const contacts = await fetchAllContacts(service);
|
|
68
|
+
const matches = contacts.filter((contact) => contact.displayName?.toLowerCase().includes(needle));
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{ type: 'text', text: JSON.stringify(matches, null, 2) },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Handle `get_group_members` — the raw member list of one LINE group/room.
|
|
77
|
+
*
|
|
78
|
+
* Members come from getChats(withMembers): the chat's `extra` union carries
|
|
79
|
+
* the group-chat record at field 1, whose field 4 is a
|
|
80
|
+
* `{ memberMid: joinTimestamp }` map and field 5 the pending-invitation map.
|
|
81
|
+
* MIDs are resolved to display names via the shared batched/cached resolver.
|
|
82
|
+
* (getGroup returns null for these chats and is not used.)
|
|
83
|
+
*
|
|
84
|
+
* @param service - Resumed LineProtocolService.
|
|
85
|
+
* @param args - Tool arguments.
|
|
86
|
+
* @returns MCP tool result.
|
|
87
|
+
*/
|
|
88
|
+
export async function handleGetGroupMembers(service, args) {
|
|
89
|
+
if (!args.chatId) {
|
|
90
|
+
return toolError('chatId is required.');
|
|
91
|
+
}
|
|
92
|
+
const chats = await service.client.getChats([args.chatId], true);
|
|
93
|
+
const chat = Array.isArray(chats) ? chats[0] : chats;
|
|
94
|
+
const groupExtra = chat?.extra?.['1'];
|
|
95
|
+
if (!groupExtra) {
|
|
96
|
+
return toolError(`No membership data for chatId "${args.chatId}" (not a group chat, or LINE returned no member list).`);
|
|
97
|
+
}
|
|
98
|
+
const memberMids = Object.keys(groupExtra['4'] ?? {});
|
|
99
|
+
const invitedMids = Object.keys(groupExtra['5'] ?? {});
|
|
100
|
+
const names = await resolveUserNames(service, [...memberMids, ...invitedMids]);
|
|
101
|
+
const summaries = [
|
|
102
|
+
...memberMids.map((mid) => ({
|
|
103
|
+
mid,
|
|
104
|
+
displayName: names.get(mid) ?? null,
|
|
105
|
+
invited: false,
|
|
106
|
+
})),
|
|
107
|
+
...invitedMids.map((mid) => ({
|
|
108
|
+
mid,
|
|
109
|
+
displayName: names.get(mid) ?? null,
|
|
110
|
+
invited: true,
|
|
111
|
+
})),
|
|
112
|
+
];
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{ type: 'text', text: JSON.stringify(summaries, null, 2) },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Handle `add_friend` — REALLY adds a person to THIS account's LINE friends now
|
|
121
|
+
* by MID (TalkService findAndAddContactsByMid).
|
|
122
|
+
*
|
|
123
|
+
* @param service - Resumed LineProtocolService.
|
|
124
|
+
* @param args - Tool arguments.
|
|
125
|
+
* @returns MCP tool result.
|
|
126
|
+
*/
|
|
127
|
+
export async function handleAddFriend(service, args) {
|
|
128
|
+
if (!args.mid) {
|
|
129
|
+
return toolError('mid is required.');
|
|
130
|
+
}
|
|
131
|
+
const result = await service.addFriend(args.mid);
|
|
132
|
+
log.info('add_friend.done', { mid: args.mid });
|
|
133
|
+
return jsonResult(result);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Handle `block_contact` — REALLY blocks a contact for THIS account now
|
|
137
|
+
* (TalkService blockContact). Reversible with unblock_contact.
|
|
138
|
+
*
|
|
139
|
+
* @param service - Resumed LineProtocolService.
|
|
140
|
+
* @param args - Tool arguments.
|
|
141
|
+
* @returns MCP tool result.
|
|
142
|
+
*/
|
|
143
|
+
export async function handleBlockContact(service, args) {
|
|
144
|
+
if (!args.mid) {
|
|
145
|
+
return toolError('mid is required.');
|
|
146
|
+
}
|
|
147
|
+
const result = await service.blockContact(args.mid);
|
|
148
|
+
log.info('block_contact.done', { mid: args.mid });
|
|
149
|
+
return jsonResult(result);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Handle `unblock_contact` — REALLY unblocks a previously blocked contact for
|
|
153
|
+
* THIS account now (TalkService unblockContact).
|
|
154
|
+
*
|
|
155
|
+
* @param service - Resumed LineProtocolService.
|
|
156
|
+
* @param args - Tool arguments.
|
|
157
|
+
* @returns MCP tool result.
|
|
158
|
+
*/
|
|
159
|
+
export async function handleUnblockContact(service, args) {
|
|
160
|
+
if (!args.mid) {
|
|
161
|
+
return toolError('mid is required.');
|
|
162
|
+
}
|
|
163
|
+
const result = await service.unblockContact(args.mid);
|
|
164
|
+
log.info('unblock_contact.done', { mid: args.mid });
|
|
165
|
+
return jsonResult(result);
|
|
166
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { decryptLineMessage } from '../../line/core/message-query-service.js';
|
|
2
|
+
import { getExcludedChatIds } from '../../search/scope.js';
|
|
3
|
+
import { createCliLogger } from '../../util/log.js';
|
|
4
|
+
import { resolveLineMediaDescriptor, resolveLineMediaType } from '../media.js';
|
|
5
|
+
import { resolveConversationNames, resolveSenderNames } from '../names.js';
|
|
6
|
+
import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
|
|
7
|
+
import { toolError } from './shared.js';
|
|
8
|
+
const log = createCliLogger('Yomi');
|
|
9
|
+
/**
|
|
10
|
+
* Handle `list_conversations` — list LINE conversations (chats, groups, rooms)
|
|
11
|
+
* with unread counts, a last-message preview, and a resolved display name.
|
|
12
|
+
*
|
|
13
|
+
* @param service - Resumed LineProtocolService.
|
|
14
|
+
* @param args - Tool arguments.
|
|
15
|
+
* @returns MCP tool result.
|
|
16
|
+
*/
|
|
17
|
+
export async function handleListConversations(service, args) {
|
|
18
|
+
const result = await service.client.getMessageBoxes({
|
|
19
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
20
|
+
messageBoxCountLimit: args.limit ?? 20,
|
|
21
|
+
withUnreadCount: true,
|
|
22
|
+
});
|
|
23
|
+
const boxes = result.messageBoxes || [];
|
|
24
|
+
const names = await resolveConversationNames(service, boxes.map((box) => box.id).filter(Boolean));
|
|
25
|
+
// The last message embedded in getMessageBoxes is E2EE-wrapped for most
|
|
26
|
+
// chats; run it through the same local decrypt path get_chat_messages
|
|
27
|
+
// uses so previews aren't silently empty. No extra network fetch per
|
|
28
|
+
// conversation — decryptLineMessage only resolves already-known/cached
|
|
29
|
+
// E2EE keys, the same cost paid whenever any message from that chat is
|
|
30
|
+
// decrypted.
|
|
31
|
+
const acc = createPhiAccumulator();
|
|
32
|
+
const conversations = await Promise.all(boxes.map(async (box) => {
|
|
33
|
+
const lastMessage = box.lastMessages?.[box.lastMessages.length - 1] || null;
|
|
34
|
+
const decryptedLastMessage = lastMessage
|
|
35
|
+
? await decryptLineMessage(service.e2eeManager, lastMessage, box.id)
|
|
36
|
+
: null;
|
|
37
|
+
return {
|
|
38
|
+
id: box.id,
|
|
39
|
+
lastMessagePreview: maskInto(acc, decryptedLastMessage?.text ?? null),
|
|
40
|
+
name: names.get(box.id) ?? null,
|
|
41
|
+
unreadCount: box.unreadCount ?? 0,
|
|
42
|
+
};
|
|
43
|
+
}));
|
|
44
|
+
const content = [
|
|
45
|
+
{ type: 'text', text: JSON.stringify(conversations, null, 2) },
|
|
46
|
+
];
|
|
47
|
+
const note = phiNote(acc);
|
|
48
|
+
if (note)
|
|
49
|
+
content.push(note);
|
|
50
|
+
return { content };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Handle `get_chat_messages` — fetch messages from one conversation,
|
|
54
|
+
* E2EE-decrypted when key material is available, each with a resolved sender
|
|
55
|
+
* name; pages older history with `before`.
|
|
56
|
+
*
|
|
57
|
+
* Without `before`, returns the most recent `count` messages (unchanged
|
|
58
|
+
* behavior). With `before`, fetches exactly one page of messages older
|
|
59
|
+
* than that cursor via getPreviousMessagesV2WithRequest — no pagination
|
|
60
|
+
* loop, no bulk backfill.
|
|
61
|
+
*
|
|
62
|
+
* `mentions` is a deliberately read-only probe: LINE mentions are not in
|
|
63
|
+
* `text` (a plain `@name` there is just text, not a real mention — the
|
|
64
|
+
* actual mention data lives in `contentMetadata.MENTION`, already parsed
|
|
65
|
+
* per-message by parsers.ts). Nobody has verified MENTION's shape against
|
|
66
|
+
* a real payload, so it is passed through raw and unparsed rather than
|
|
67
|
+
* guessed at. This is the spec-gathering step; do not "finish" mentions by
|
|
68
|
+
* inventing a parsed shape or an outbound (send-a-mention) implementation
|
|
69
|
+
* from this alone — wait for an operator to observe the real payload
|
|
70
|
+
* against a live mentioning message first.
|
|
71
|
+
*
|
|
72
|
+
* @param service - Resumed LineProtocolService.
|
|
73
|
+
* @param args - Tool arguments.
|
|
74
|
+
* @returns MCP tool result.
|
|
75
|
+
*/
|
|
76
|
+
export async function handleGetChatMessages(service, args) {
|
|
77
|
+
if (!args.chatId) {
|
|
78
|
+
return toolError('chatId is required.');
|
|
79
|
+
}
|
|
80
|
+
const fetched = args.before
|
|
81
|
+
? await service.getPreviousMessages(args.chatId, args.count ?? 50, args.before)
|
|
82
|
+
: await service.getRecentMessages(args.chatId, args.count ?? 50);
|
|
83
|
+
// LINE's getPreviousMessagesV2 treats endMessageId as INCLUSIVE, so the
|
|
84
|
+
// cursor message reappears at the top of each older page. Drop it so
|
|
85
|
+
// `before` returns strictly-older messages and callers can page without
|
|
86
|
+
// dedup.
|
|
87
|
+
const messages = args.before?.messageId
|
|
88
|
+
? fetched.filter((message) => String(message.id) !== String(args.before?.messageId))
|
|
89
|
+
: fetched;
|
|
90
|
+
const names = await resolveSenderNames(service, messages.map((message) => message.from).filter(Boolean));
|
|
91
|
+
const acc = createPhiAccumulator();
|
|
92
|
+
const shaped = messages.map((message) => ({
|
|
93
|
+
createdTime: message.createdTime,
|
|
94
|
+
deliveredTime: message.deliveredTime,
|
|
95
|
+
from: message.from,
|
|
96
|
+
fromName: names.get(message.from) ?? null,
|
|
97
|
+
id: message.id,
|
|
98
|
+
mediaType: resolveLineMediaDescriptor(message) !== null
|
|
99
|
+
? resolveLineMediaType(Number(message.contentType))
|
|
100
|
+
: null,
|
|
101
|
+
// Read-only probe (see JSDoc above `handleGetChatMessages`): raw,
|
|
102
|
+
// unparsed passthrough of contentMetadata.MENTION. Do not parse this
|
|
103
|
+
// into a structured shape here — its real format is unknown and must
|
|
104
|
+
// be observed against a live mentioning message before anyone builds
|
|
105
|
+
// the outbound (send-a-mention) side.
|
|
106
|
+
mentions: message.contentMetadata?.MENTION ?? null,
|
|
107
|
+
text: maskInto(acc, message.text),
|
|
108
|
+
e2eeDecrypted: message.e2eeDecrypted ?? null,
|
|
109
|
+
// Emitted ONLY when the message decrypted but could not be authenticated
|
|
110
|
+
// (LINE E2EE v1 — AES-CBC, no tag, no AAD), so its presence is a signal
|
|
111
|
+
// rather than noise on the ~98% of traffic that is v2 and verified.
|
|
112
|
+
...(message.e2eeDecrypted && message.e2eeIntegrityVerified === false
|
|
113
|
+
? { e2eeIntegrityVerified: false }
|
|
114
|
+
: {}),
|
|
115
|
+
}));
|
|
116
|
+
const content = [
|
|
117
|
+
{ type: 'text', text: JSON.stringify(shaped, null, 2) },
|
|
118
|
+
];
|
|
119
|
+
const note = phiNote(acc);
|
|
120
|
+
if (note)
|
|
121
|
+
content.push(note);
|
|
122
|
+
return { content };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Handle `mark_read` — explicitly send a LINE read receipt (sendChatChecked)
|
|
126
|
+
* for one conversation. This is the user-intent path; background capture and
|
|
127
|
+
* get_unread_digest never mark read. When messageId is omitted, marks read up
|
|
128
|
+
* to the latest message. Honest failure if nothing can be marked.
|
|
129
|
+
*
|
|
130
|
+
* @param service - Resumed LineProtocolService.
|
|
131
|
+
* @param args - Tool arguments.
|
|
132
|
+
* @returns MCP tool result.
|
|
133
|
+
*/
|
|
134
|
+
export async function handleMarkRead(service, args) {
|
|
135
|
+
if (!args.chatId) {
|
|
136
|
+
return toolError('chatId is required.');
|
|
137
|
+
}
|
|
138
|
+
const result = await service.markChatRead(args.chatId, args.messageId);
|
|
139
|
+
log.info('mark_read.done', { chatId: args.chatId, marked: result.marked });
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Handle `get_unread_digest` — one-shot composite: every conversation with
|
|
146
|
+
* unread messages, each with its most recent messages, so a caller can
|
|
147
|
+
* summarize/triage in a single call instead of list_conversations + N
|
|
148
|
+
* get_chat_messages. Purely read-only: it never marks anything read and never
|
|
149
|
+
* touches the local search index. Denylist-excluded chats are omitted. No
|
|
150
|
+
* unread → empty list (honest, not fabricated).
|
|
151
|
+
*
|
|
152
|
+
* @param service - Resumed LineProtocolService.
|
|
153
|
+
* @param args - Tool arguments.
|
|
154
|
+
* @returns MCP tool result.
|
|
155
|
+
*/
|
|
156
|
+
export async function handleGetUnreadDigest(service, args) {
|
|
157
|
+
const result = await service.client.getMessageBoxes({
|
|
158
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
159
|
+
messageBoxCountLimit: args.limit ?? 20,
|
|
160
|
+
withUnreadCount: true,
|
|
161
|
+
});
|
|
162
|
+
const excluded = getExcludedChatIds();
|
|
163
|
+
const unreadBoxes = (result.messageBoxes || []).filter((box) => (box.unreadCount ?? 0) > 0 && box.id && !excluded.has(box.id));
|
|
164
|
+
const names = await resolveConversationNames(service, unreadBoxes.map((box) => box.id));
|
|
165
|
+
const perChat = args.perChat ?? 10;
|
|
166
|
+
const acc = createPhiAccumulator();
|
|
167
|
+
const digest = await Promise.all(unreadBoxes.map(async (box) => {
|
|
168
|
+
const messages = await service.getRecentMessages(box.id, perChat);
|
|
169
|
+
const senderNames = await resolveSenderNames(service, messages.map((m) => m.from).filter(Boolean));
|
|
170
|
+
return {
|
|
171
|
+
chatId: box.id,
|
|
172
|
+
name: names.get(box.id) ?? null,
|
|
173
|
+
unreadCount: box.unreadCount ?? 0,
|
|
174
|
+
messages: messages.map((m) => ({
|
|
175
|
+
createdTime: m.createdTime,
|
|
176
|
+
from: m.from,
|
|
177
|
+
fromName: senderNames.get(m.from) ?? null,
|
|
178
|
+
id: m.id,
|
|
179
|
+
mediaType: resolveLineMediaDescriptor(m) !== null
|
|
180
|
+
? resolveLineMediaType(Number(m.contentType))
|
|
181
|
+
: null,
|
|
182
|
+
text: maskInto(acc, m.text),
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
}));
|
|
186
|
+
const content = [
|
|
187
|
+
{ type: 'text', text: JSON.stringify(digest, null, 2) },
|
|
188
|
+
];
|
|
189
|
+
const note = phiNote(acc);
|
|
190
|
+
if (note)
|
|
191
|
+
content.push(note);
|
|
192
|
+
return { content };
|
|
193
|
+
}
|