@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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +81 -0
  3. package/PRIVACY.md +39 -0
  4. package/README.md +659 -0
  5. package/dist/auth/credential-store.js +395 -0
  6. package/dist/auth/keychain.js +256 -0
  7. package/dist/cli/login.js +142 -0
  8. package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
  9. package/dist/line/auth/protocol/index.js +3 -0
  10. package/dist/line/auth/protocol/login-metadata.js +159 -0
  11. package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
  12. package/dist/line/auth/pwless/index.js +163 -0
  13. package/dist/line/auth/pwless/steps.js +213 -0
  14. package/dist/line/client/auth-service/client.js +240 -0
  15. package/dist/line/client/e2ee-service/client.js +69 -0
  16. package/dist/line/client/e2ee-service/requests.js +92 -0
  17. package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
  18. package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
  19. package/dist/line/client/index.js +213 -0
  20. package/dist/line/client/obs-media-client.js +140 -0
  21. package/dist/line/client/parsers.js +69 -0
  22. package/dist/line/client/shop-service/client.js +63 -0
  23. package/dist/line/client/sticker-meta.js +46 -0
  24. package/dist/line/client/sync-service/client.js +317 -0
  25. package/dist/line/client/talk-service/chat-query.js +122 -0
  26. package/dist/line/client/talk-service/command-client.js +242 -0
  27. package/dist/line/client/talk-service/contact-query.js +77 -0
  28. package/dist/line/client/talk-service/message-box-query.js +137 -0
  29. package/dist/line/client/talk-service/message-id-query.js +241 -0
  30. package/dist/line/client/talk-service/query-client.js +219 -0
  31. package/dist/line/client/talk-service/requests.js +383 -0
  32. package/dist/line/client/transport.js +138 -0
  33. package/dist/line/core/auth-session-logout.js +55 -0
  34. package/dist/line/core/auth-session-runtime.js +231 -0
  35. package/dist/line/core/auth-session-service.js +85 -0
  36. package/dist/line/core/chat-runtime-service.js +343 -0
  37. package/dist/line/core/client-runtime.js +17 -0
  38. package/dist/line/core/config.js +67 -0
  39. package/dist/line/core/constants.js +56 -0
  40. package/dist/line/core/contact-query-service.js +20 -0
  41. package/dist/line/core/directory-runtime-service.js +83 -0
  42. package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
  43. package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
  44. package/dist/line/core/e2ee/index.js +6 -0
  45. package/dist/line/core/e2ee/keys/decrypt.js +110 -0
  46. package/dist/line/core/e2ee/keys/encrypt.js +117 -0
  47. package/dist/line/core/e2ee/keys/group-key.js +260 -0
  48. package/dist/line/core/e2ee/keys/key-manager.js +231 -0
  49. package/dist/line/core/e2ee/keys/key-payload.js +130 -0
  50. package/dist/line/core/e2ee/keys/key-types.js +1 -0
  51. package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
  52. package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
  53. package/dist/line/core/e2ee/keys/peer-key.js +191 -0
  54. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
  55. package/dist/line/core/e2ee/types.js +4 -0
  56. package/dist/line/core/group-query-service.js +20 -0
  57. package/dist/line/core/media-decrypt.js +78 -0
  58. package/dist/line/core/media-encrypt.js +97 -0
  59. package/dist/line/core/mention.js +89 -0
  60. package/dist/line/core/message-command-service.js +454 -0
  61. package/dist/line/core/message-query-service.js +161 -0
  62. package/dist/line/core/service.js +134 -0
  63. package/dist/line/core/session-state/index.js +243 -0
  64. package/dist/line/core/session-state/persist.js +192 -0
  65. package/dist/line/core/store.js +8 -0
  66. package/dist/line/core/thrift/binary/binary-reader.js +151 -0
  67. package/dist/line/core/thrift/binary/binary-writer.js +157 -0
  68. package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
  69. package/dist/line/core/thrift/compact/compact-reader.js +180 -0
  70. package/dist/line/core/thrift/compact/compact-writer.js +161 -0
  71. package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
  72. package/dist/line/core/thrift/fields/builders.js +124 -0
  73. package/dist/line/core/thrift/index.js +12 -0
  74. package/dist/line/core/thrift/message/decoder.js +55 -0
  75. package/dist/line/core/thrift/message/encoder.js +25 -0
  76. package/dist/line/core/thrift/primitives/varint.js +64 -0
  77. package/dist/line/core/thrift/struct/reader.js +172 -0
  78. package/dist/line/core/thrift/struct/writer.js +189 -0
  79. package/dist/line/core/thrift/types.js +23 -0
  80. package/dist/line/core/video-thumbnail.js +104 -0
  81. package/dist/mcp/handlers/contacts.js +166 -0
  82. package/dist/mcp/handlers/conversations.js +193 -0
  83. package/dist/mcp/handlers/groups.js +123 -0
  84. package/dist/mcp/handlers/index.js +15 -0
  85. package/dist/mcp/handlers/login.js +473 -0
  86. package/dist/mcp/handlers/media.js +69 -0
  87. package/dist/mcp/handlers/message-actions.js +62 -0
  88. package/dist/mcp/handlers/scope.js +120 -0
  89. package/dist/mcp/handlers/search.js +196 -0
  90. package/dist/mcp/handlers/send.js +523 -0
  91. package/dist/mcp/handlers/shared.js +84 -0
  92. package/dist/mcp/handlers/stickers.js +87 -0
  93. package/dist/mcp/media.js +228 -0
  94. package/dist/mcp/names.js +133 -0
  95. package/dist/mcp/phi-guard.js +105 -0
  96. package/dist/mcp/policy.js +34 -0
  97. package/dist/mcp/server.js +301 -0
  98. package/dist/mcp/tools.js +761 -0
  99. package/dist/mcp/ui/capability.js +26 -0
  100. package/dist/mcp/ui/login-app-protocol.js +118 -0
  101. package/dist/mcp/ui/login-app-styles.js +142 -0
  102. package/dist/mcp/ui/login-app-view.js +361 -0
  103. package/dist/mcp/ui/login-app.js +72 -0
  104. package/dist/mcp/ui/login-structured-content.js +1 -0
  105. package/dist/mcp/ui/resource.js +35 -0
  106. package/dist/mcp/ui/tools-with-ui.js +40 -0
  107. package/dist/search/bigram.js +86 -0
  108. package/dist/search/capture.js +140 -0
  109. package/dist/search/collector.js +176 -0
  110. package/dist/search/default-embedder.js +101 -0
  111. package/dist/search/embedder.js +13 -0
  112. package/dist/search/scope.js +142 -0
  113. package/dist/search/sqlite.js +141 -0
  114. package/dist/search/store.js +469 -0
  115. package/dist/util/data-dir.js +55 -0
  116. package/dist/util/log.js +86 -0
  117. package/dist/version.js +7 -0
  118. package/package.json +63 -0
  119. 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
+ }