@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,523 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildMentionMetadata } from '../../line/core/mention.js';
|
|
4
|
+
import { createCliLogger } from '../../util/log.js';
|
|
5
|
+
import { toolError } from './shared.js';
|
|
6
|
+
const log = createCliLogger('Yomi');
|
|
7
|
+
/**
|
|
8
|
+
* Handle `send_message` — REALLY sends a text message to a real LINE
|
|
9
|
+
* conversation, E2EE-encrypted for the target (pairwise for a 1:1 `u...`
|
|
10
|
+
* chat, group key for `c.../r...`). Exactly one send per call: no retry,
|
|
11
|
+
* no queueing, no background delivery. If the E2EE key material cannot be
|
|
12
|
+
* resolved, the underlying encrypt call throws and this returns an honest
|
|
13
|
+
* error — it never falls back to sending plaintext.
|
|
14
|
+
*
|
|
15
|
+
* `mentions`, when provided, are validated against `args.text` and encoded
|
|
16
|
+
* into `contentMetadata.MENTION` (see `../line/core/mention.ts`) so LINE
|
|
17
|
+
* renders the `@name` already present in `text` as a real, notifying
|
|
18
|
+
* mention. Validation failure (bad offsets, overlap, a range not starting
|
|
19
|
+
* at `@`) returns an honest tool error and sends nothing — a malformed
|
|
20
|
+
* mention must never silently degrade into a plain, non-notifying string.
|
|
21
|
+
*
|
|
22
|
+
* @param service - Resumed LineProtocolService.
|
|
23
|
+
* @param args - Tool arguments.
|
|
24
|
+
* @returns MCP tool result with the sent message id.
|
|
25
|
+
*/
|
|
26
|
+
export async function handleSendMessage(service, args) {
|
|
27
|
+
if (!args.chatId || !args.text) {
|
|
28
|
+
return toolError('chatId and text are required.');
|
|
29
|
+
}
|
|
30
|
+
let contentMetadata;
|
|
31
|
+
if (args.mentions && args.mentions.length > 0) {
|
|
32
|
+
try {
|
|
33
|
+
contentMetadata = {
|
|
34
|
+
MENTION: buildMentionMetadata(args.text, args.mentions),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return toolError(`Invalid mentions: ${error?.message ?? String(error)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// A reply sets three request fields (NOT contentMetadata): relatedMessageId
|
|
42
|
+
// (21), messageRelationType (22 = MessageRelationType.REPLY = 3), and
|
|
43
|
+
// relatedMessageServiceCode (24 = ServiceCode.TALK = 1). The latter two are
|
|
44
|
+
// i32 enums; LINE rejects the message if they are missing or the wrong type.
|
|
45
|
+
// LINE renders the quote from these and reflects the relation into the
|
|
46
|
+
// recipient's contentMetadata on delivery.
|
|
47
|
+
const reply = args.replyToMessageId
|
|
48
|
+
? {
|
|
49
|
+
relatedMessageId: args.replyToMessageId,
|
|
50
|
+
messageRelationType: 3,
|
|
51
|
+
relatedMessageServiceCode: 1,
|
|
52
|
+
}
|
|
53
|
+
: undefined;
|
|
54
|
+
const sent = await service.sendMessage(args.chatId, args.text, contentMetadata, reply);
|
|
55
|
+
const messageId = sent?.id ?? sent?.messageId ?? null;
|
|
56
|
+
log.info('send_message.sent', { chatId: args.chatId, messageId });
|
|
57
|
+
let read = false;
|
|
58
|
+
try {
|
|
59
|
+
const r = await service.markChatRead(args.chatId);
|
|
60
|
+
read = r.marked;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
log.warn('send_message.mark_read_failed', {
|
|
64
|
+
chatId: args.chatId,
|
|
65
|
+
error: error?.message ?? String(error),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: 'text',
|
|
72
|
+
text: JSON.stringify({ sent: true, messageId, read }, null, 2),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Handle `send_image` — REALLY sends an E2EE image to a real LINE
|
|
79
|
+
* conversation right now (upload-then-send: encrypted original + preview
|
|
80
|
+
* object uploaded to OBS, then the key material sealed in an E2EE data
|
|
81
|
+
* message). Works for 1:1 chats, groups, and rooms — see the dispatch
|
|
82
|
+
* logic in message-command-service.ts's `sendImage`. Exactly one send per
|
|
83
|
+
* call: no retry, no queueing, no background delivery. If the E2EE key
|
|
84
|
+
* material cannot be resolved (including a group whose key cannot be
|
|
85
|
+
* resolved) or the OBS upload is rejected, this returns an honest error —
|
|
86
|
+
* it never fabricates a success.
|
|
87
|
+
*
|
|
88
|
+
* @param service - Resumed LineProtocolService.
|
|
89
|
+
* @param args - Tool arguments.
|
|
90
|
+
* @returns MCP tool result with the sent message id and uploaded object id.
|
|
91
|
+
*/
|
|
92
|
+
export async function handleSendImage(service, args) {
|
|
93
|
+
if (!args.chatId) {
|
|
94
|
+
return toolError('chatId is required.');
|
|
95
|
+
}
|
|
96
|
+
const hasPath = Boolean(args.imagePath);
|
|
97
|
+
const hasBase64 = Boolean(args.imageBase64);
|
|
98
|
+
if (hasPath === hasBase64) {
|
|
99
|
+
return toolError('Provide exactly one of imagePath or imageBase64.');
|
|
100
|
+
}
|
|
101
|
+
let imageBytes;
|
|
102
|
+
let fileName = null;
|
|
103
|
+
if (args.imagePath) {
|
|
104
|
+
try {
|
|
105
|
+
imageBytes = await fs.readFile(args.imagePath);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return toolError(`Could not read imagePath "${args.imagePath}": ${error?.message ?? String(error)}`);
|
|
109
|
+
}
|
|
110
|
+
fileName = path.basename(args.imagePath);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
imageBytes = Buffer.from(args.imageBase64, 'base64');
|
|
114
|
+
}
|
|
115
|
+
if (imageBytes.length === 0) {
|
|
116
|
+
return toolError('Resolved image is empty.');
|
|
117
|
+
}
|
|
118
|
+
const result = await service.sendImage(args.chatId, imageBytes, fileName);
|
|
119
|
+
log.info('send_image.sent', {
|
|
120
|
+
chatId: args.chatId,
|
|
121
|
+
messageId: result?.messageId,
|
|
122
|
+
oid: result?.oid,
|
|
123
|
+
});
|
|
124
|
+
let read = false;
|
|
125
|
+
try {
|
|
126
|
+
const r = await service.markChatRead(args.chatId);
|
|
127
|
+
read = r.marked;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
log.warn('send_image.mark_read_failed', {
|
|
131
|
+
chatId: args.chatId,
|
|
132
|
+
error: error?.message ?? String(error),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: 'text',
|
|
139
|
+
text: JSON.stringify({
|
|
140
|
+
messageId: result?.messageId ?? null,
|
|
141
|
+
oid: result?.oid ?? null,
|
|
142
|
+
sent: true,
|
|
143
|
+
read,
|
|
144
|
+
}, null, 2),
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Handle `send_file` — REALLY sends an E2EE file attachment to a real LINE
|
|
151
|
+
* conversation right now. Mirrors handleSendImage but for arbitrary files: the
|
|
152
|
+
* bytes come from `filePath` or `fileBase64`, and `fileName` (required for the
|
|
153
|
+
* base64 form) is sealed E2EE so the recipient sees the original name. One send
|
|
154
|
+
* per call; on any failure it returns an honest error and sends nothing.
|
|
155
|
+
*
|
|
156
|
+
* @param service - Resumed LineProtocolService.
|
|
157
|
+
* @param args - Tool arguments.
|
|
158
|
+
* @returns MCP tool result.
|
|
159
|
+
*/
|
|
160
|
+
export async function handleSendFile(service, args) {
|
|
161
|
+
if (!args.chatId) {
|
|
162
|
+
return toolError('chatId is required.');
|
|
163
|
+
}
|
|
164
|
+
const hasPath = Boolean(args.filePath);
|
|
165
|
+
const hasBase64 = Boolean(args.fileBase64);
|
|
166
|
+
if (hasPath === hasBase64) {
|
|
167
|
+
return toolError('Provide exactly one of filePath or fileBase64.');
|
|
168
|
+
}
|
|
169
|
+
let fileBytes;
|
|
170
|
+
let fileName;
|
|
171
|
+
if (args.filePath) {
|
|
172
|
+
try {
|
|
173
|
+
fileBytes = await fs.readFile(args.filePath);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return toolError(`Could not read filePath "${args.filePath}": ${error?.message ?? String(error)}`);
|
|
177
|
+
}
|
|
178
|
+
fileName = args.fileName || path.basename(args.filePath);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
if (!args.fileName) {
|
|
182
|
+
return toolError('fileName is required when sending fileBase64.');
|
|
183
|
+
}
|
|
184
|
+
fileBytes = Buffer.from(args.fileBase64, 'base64');
|
|
185
|
+
fileName = args.fileName;
|
|
186
|
+
}
|
|
187
|
+
if (fileBytes.length === 0) {
|
|
188
|
+
return toolError('Resolved file is empty.');
|
|
189
|
+
}
|
|
190
|
+
const result = await service.sendFile(args.chatId, fileBytes, fileName);
|
|
191
|
+
log.info('send_file.sent', {
|
|
192
|
+
chatId: args.chatId,
|
|
193
|
+
messageId: result?.messageId,
|
|
194
|
+
oid: result?.oid,
|
|
195
|
+
fileName,
|
|
196
|
+
});
|
|
197
|
+
let read = false;
|
|
198
|
+
try {
|
|
199
|
+
const r = await service.markChatRead(args.chatId);
|
|
200
|
+
read = r.marked;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
log.warn('send_file.mark_read_failed', {
|
|
204
|
+
chatId: args.chatId,
|
|
205
|
+
error: error?.message ?? String(error),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: 'text',
|
|
212
|
+
text: JSON.stringify({
|
|
213
|
+
messageId: result?.messageId ?? null,
|
|
214
|
+
oid: result?.oid ?? null,
|
|
215
|
+
fileName,
|
|
216
|
+
sent: true,
|
|
217
|
+
read,
|
|
218
|
+
}, null, 2),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Handle `send_contact` — REALLY shares a LINE contact card (contentType
|
|
225
|
+
* CONTACT) to a real conversation now. Not media: it carries the shared
|
|
226
|
+
* person's mid in contentMetadata. `displayName` is resolved from the mid when
|
|
227
|
+
* the caller omits it. One send per call; honest error on failure.
|
|
228
|
+
*
|
|
229
|
+
* @param service - Resumed LineProtocolService.
|
|
230
|
+
* @param args - Tool arguments.
|
|
231
|
+
* @returns MCP tool result.
|
|
232
|
+
*/
|
|
233
|
+
export async function handleSendContact(service, args) {
|
|
234
|
+
if (!args.chatId) {
|
|
235
|
+
return toolError('chatId is required.');
|
|
236
|
+
}
|
|
237
|
+
if (!args.contactMid) {
|
|
238
|
+
return toolError('contactMid is required.');
|
|
239
|
+
}
|
|
240
|
+
let displayName = args.displayName ?? '';
|
|
241
|
+
if (!displayName) {
|
|
242
|
+
try {
|
|
243
|
+
displayName = service.resolveName(args.contactMid) || '';
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
displayName = '';
|
|
247
|
+
}
|
|
248
|
+
if (!displayName) {
|
|
249
|
+
try {
|
|
250
|
+
const contact = await service.getContact(args.contactMid);
|
|
251
|
+
displayName = contact?.displayName ?? '';
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Leave empty — LINE resolves the name from the mid on the recipient side.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const result = await service.sendContact(args.chatId, args.contactMid, displayName);
|
|
259
|
+
log.info('send_contact.sent', {
|
|
260
|
+
chatId: args.chatId,
|
|
261
|
+
contactMid: args.contactMid,
|
|
262
|
+
messageId: result?.messageId,
|
|
263
|
+
});
|
|
264
|
+
let read = false;
|
|
265
|
+
try {
|
|
266
|
+
const r = await service.markChatRead(args.chatId);
|
|
267
|
+
read = r.marked;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
log.warn('send_contact.mark_read_failed', {
|
|
271
|
+
chatId: args.chatId,
|
|
272
|
+
error: error?.message ?? String(error),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: 'text',
|
|
279
|
+
text: JSON.stringify({
|
|
280
|
+
messageId: result?.messageId ?? null,
|
|
281
|
+
contactMid: args.contactMid,
|
|
282
|
+
displayName,
|
|
283
|
+
sent: true,
|
|
284
|
+
read,
|
|
285
|
+
}, null, 2),
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Handle `send_sticker` — REALLY sends a LINE sticker to a real conversation
|
|
292
|
+
* now. Not media: it names the sticker by package + id. One send per call.
|
|
293
|
+
*
|
|
294
|
+
* @param service - Resumed LineProtocolService.
|
|
295
|
+
* @param args - Tool arguments.
|
|
296
|
+
* @returns MCP tool result.
|
|
297
|
+
*/
|
|
298
|
+
export async function handleSendSticker(service, args) {
|
|
299
|
+
if (!args.chatId) {
|
|
300
|
+
return toolError('chatId is required.');
|
|
301
|
+
}
|
|
302
|
+
if (!args.stickerId || !args.packageId) {
|
|
303
|
+
return toolError('stickerId and packageId are required.');
|
|
304
|
+
}
|
|
305
|
+
const result = await service.sendSticker(args.chatId, args.stickerId, args.packageId, args.version);
|
|
306
|
+
log.info('send_sticker.sent', {
|
|
307
|
+
chatId: args.chatId,
|
|
308
|
+
stickerId: args.stickerId,
|
|
309
|
+
packageId: args.packageId,
|
|
310
|
+
messageId: result?.messageId,
|
|
311
|
+
});
|
|
312
|
+
let read = false;
|
|
313
|
+
try {
|
|
314
|
+
const r = await service.markChatRead(args.chatId);
|
|
315
|
+
read = r.marked;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
log.warn('send_sticker.mark_read_failed', {
|
|
319
|
+
chatId: args.chatId,
|
|
320
|
+
error: error?.message ?? String(error),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
content: [
|
|
325
|
+
{
|
|
326
|
+
type: 'text',
|
|
327
|
+
text: JSON.stringify({
|
|
328
|
+
messageId: result?.messageId ?? null,
|
|
329
|
+
stickerId: args.stickerId,
|
|
330
|
+
packageId: args.packageId,
|
|
331
|
+
sent: true,
|
|
332
|
+
read,
|
|
333
|
+
}, null, 2),
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Handle `send_location` — REALLY sends a location message (a map pin) to a
|
|
340
|
+
* real conversation now. Not media and not E2EE: a LOCATION message carrying
|
|
341
|
+
* latitude/longitude plus an optional title/address. One send per call.
|
|
342
|
+
*
|
|
343
|
+
* @param service - Resumed LineProtocolService.
|
|
344
|
+
* @param args - Tool arguments.
|
|
345
|
+
* @returns MCP tool result.
|
|
346
|
+
*/
|
|
347
|
+
export async function handleSendLocation(service, args) {
|
|
348
|
+
if (!args.chatId) {
|
|
349
|
+
return toolError('chatId is required.');
|
|
350
|
+
}
|
|
351
|
+
if (typeof args.latitude !== 'number' ||
|
|
352
|
+
typeof args.longitude !== 'number' ||
|
|
353
|
+
!Number.isFinite(args.latitude) ||
|
|
354
|
+
!Number.isFinite(args.longitude)) {
|
|
355
|
+
return toolError('latitude and longitude (finite numbers) are required.');
|
|
356
|
+
}
|
|
357
|
+
const result = await service.sendLocation(args.chatId, args.latitude, args.longitude, args.title, args.address);
|
|
358
|
+
log.info('send_location.sent', {
|
|
359
|
+
chatId: args.chatId,
|
|
360
|
+
messageId: result?.messageId,
|
|
361
|
+
});
|
|
362
|
+
let read = false;
|
|
363
|
+
try {
|
|
364
|
+
const r = await service.markChatRead(args.chatId);
|
|
365
|
+
read = r.marked;
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
log.warn('send_location.mark_read_failed', {
|
|
369
|
+
chatId: args.chatId,
|
|
370
|
+
error: error?.message ?? String(error),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
content: [
|
|
375
|
+
{
|
|
376
|
+
type: 'text',
|
|
377
|
+
text: JSON.stringify({
|
|
378
|
+
messageId: result?.messageId ?? null,
|
|
379
|
+
latitude: args.latitude,
|
|
380
|
+
longitude: args.longitude,
|
|
381
|
+
sent: true,
|
|
382
|
+
read,
|
|
383
|
+
}, null, 2),
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Handle `send_audio` — REALLY sends an E2EE audio message to a real
|
|
390
|
+
* conversation now, via the same upload-then-send pipeline as send_file.
|
|
391
|
+
* Bytes come from `filePath` or `audioBase64`; `durationMs` (when known)
|
|
392
|
+
* drives the recipient's player progress bar. One send per call.
|
|
393
|
+
*
|
|
394
|
+
* @param service - Resumed LineProtocolService.
|
|
395
|
+
* @param args - Tool arguments.
|
|
396
|
+
* @returns MCP tool result.
|
|
397
|
+
*/
|
|
398
|
+
export async function handleSendAudio(service, args) {
|
|
399
|
+
if (!args.chatId) {
|
|
400
|
+
return toolError('chatId is required.');
|
|
401
|
+
}
|
|
402
|
+
const hasPath = Boolean(args.filePath);
|
|
403
|
+
const hasBase64 = Boolean(args.audioBase64);
|
|
404
|
+
if (hasPath === hasBase64) {
|
|
405
|
+
return toolError('Provide exactly one of filePath or audioBase64.');
|
|
406
|
+
}
|
|
407
|
+
let audioBytes;
|
|
408
|
+
let fileName = args.fileName ?? null;
|
|
409
|
+
if (args.filePath) {
|
|
410
|
+
try {
|
|
411
|
+
audioBytes = await fs.readFile(args.filePath);
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
return toolError(`Could not read filePath "${args.filePath}": ${error?.message ?? String(error)}`);
|
|
415
|
+
}
|
|
416
|
+
fileName = fileName ?? path.basename(args.filePath);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
audioBytes = Buffer.from(args.audioBase64, 'base64');
|
|
420
|
+
}
|
|
421
|
+
if (audioBytes.length === 0) {
|
|
422
|
+
return toolError('Resolved audio is empty.');
|
|
423
|
+
}
|
|
424
|
+
const result = await service.sendAudio(args.chatId, audioBytes, fileName, args.durationMs);
|
|
425
|
+
log.info('send_audio.sent', {
|
|
426
|
+
chatId: args.chatId,
|
|
427
|
+
messageId: result?.messageId,
|
|
428
|
+
oid: result?.oid,
|
|
429
|
+
});
|
|
430
|
+
let read = false;
|
|
431
|
+
try {
|
|
432
|
+
const r = await service.markChatRead(args.chatId);
|
|
433
|
+
read = r.marked;
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
log.warn('send_audio.mark_read_failed', {
|
|
437
|
+
chatId: args.chatId,
|
|
438
|
+
error: error?.message ?? String(error),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
content: [
|
|
443
|
+
{
|
|
444
|
+
type: 'text',
|
|
445
|
+
text: JSON.stringify({
|
|
446
|
+
messageId: result?.messageId ?? null,
|
|
447
|
+
oid: result?.oid ?? null,
|
|
448
|
+
sent: true,
|
|
449
|
+
read,
|
|
450
|
+
}, null, 2),
|
|
451
|
+
},
|
|
452
|
+
],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Handle `send_video` — REALLY sends an E2EE video message to a real
|
|
457
|
+
* conversation now, through the same upload-then-send pipeline as send_file.
|
|
458
|
+
* Bytes come from `filePath` or `videoBase64`; `durationMs` (when known) drives
|
|
459
|
+
* the recipient's player scrubber. The video uses LINE's chunked video E2EE
|
|
460
|
+
* (per-128KB-chunk hash MAC) so it plays and verifies on official clients. One
|
|
461
|
+
* send per call.
|
|
462
|
+
*
|
|
463
|
+
* @param service - Resumed LineProtocolService.
|
|
464
|
+
* @param args - Tool arguments.
|
|
465
|
+
* @returns MCP tool result.
|
|
466
|
+
*/
|
|
467
|
+
export async function handleSendVideo(service, args) {
|
|
468
|
+
if (!args.chatId) {
|
|
469
|
+
return toolError('chatId is required.');
|
|
470
|
+
}
|
|
471
|
+
const hasPath = Boolean(args.filePath);
|
|
472
|
+
const hasBase64 = Boolean(args.videoBase64);
|
|
473
|
+
if (hasPath === hasBase64) {
|
|
474
|
+
return toolError('Provide exactly one of filePath or videoBase64.');
|
|
475
|
+
}
|
|
476
|
+
let videoBytes;
|
|
477
|
+
let fileName = args.fileName ?? null;
|
|
478
|
+
if (args.filePath) {
|
|
479
|
+
try {
|
|
480
|
+
videoBytes = await fs.readFile(args.filePath);
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
return toolError(`Could not read filePath "${args.filePath}": ${error?.message ?? String(error)}`);
|
|
484
|
+
}
|
|
485
|
+
fileName = fileName ?? path.basename(args.filePath);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
videoBytes = Buffer.from(args.videoBase64, 'base64');
|
|
489
|
+
}
|
|
490
|
+
if (videoBytes.length === 0) {
|
|
491
|
+
return toolError('Resolved video is empty.');
|
|
492
|
+
}
|
|
493
|
+
const result = await service.sendVideo(args.chatId, videoBytes, fileName, args.durationMs);
|
|
494
|
+
log.info('send_video.sent', {
|
|
495
|
+
chatId: args.chatId,
|
|
496
|
+
messageId: result?.messageId,
|
|
497
|
+
oid: result?.oid,
|
|
498
|
+
});
|
|
499
|
+
let read = false;
|
|
500
|
+
try {
|
|
501
|
+
const r = await service.markChatRead(args.chatId);
|
|
502
|
+
read = r.marked;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
log.warn('send_video.mark_read_failed', {
|
|
506
|
+
chatId: args.chatId,
|
|
507
|
+
error: error?.message ?? String(error),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
content: [
|
|
512
|
+
{
|
|
513
|
+
type: 'text',
|
|
514
|
+
text: JSON.stringify({
|
|
515
|
+
messageId: result?.messageId ?? null,
|
|
516
|
+
oid: result?.oid ?? null,
|
|
517
|
+
sent: true,
|
|
518
|
+
read,
|
|
519
|
+
}, null, 2),
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the Yomi MCP handler modules — the always-fresh
|
|
3
|
+
* session-required error, a plain-text tool error builder, and a JSON
|
|
4
|
+
* result wrapper. Split out of the former handlers.ts so every domain file
|
|
5
|
+
* under src/mcp/handlers/ can import these without a circular dependency.
|
|
6
|
+
*/
|
|
7
|
+
// Yomi logs in on its own (via the `login`/`login_complete` tools, or
|
|
8
|
+
// `npx @rikaidev/yomi login`); it does not depend on inboxd for anything.
|
|
9
|
+
export const NO_CREDENTIALS_MESSAGE = 'No persisted LINE session. Call the `login` tool, or run `npx @rikaidev/yomi login` in a terminal.';
|
|
10
|
+
// Shown when LINE invalidated a previously working session — distinct from
|
|
11
|
+
// "never logged in" so the model tells the user the right story: the MCP
|
|
12
|
+
// connection is healthy, LINE revoked this device's token (usually because
|
|
13
|
+
// the same account logged in somewhere else).
|
|
14
|
+
export const SESSION_REVOKED_MESSAGE = 'LINE signed this device out — usually because the same account logged in ' +
|
|
15
|
+
'somewhere else (another device, or a terminal `login` run). The MCP ' +
|
|
16
|
+
'connection itself is fine. Call the `login` tool to reconnect; cached ' +
|
|
17
|
+
'credentials usually complete without a new PIN.';
|
|
18
|
+
/**
|
|
19
|
+
* Build the always-fresh session-required error payload.
|
|
20
|
+
*
|
|
21
|
+
* @returns MCP tool error content.
|
|
22
|
+
*/
|
|
23
|
+
export function sessionRequiredError() {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: 'text', text: NO_CREDENTIALS_MESSAGE }],
|
|
26
|
+
isError: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Shown when the saved token simply aged out and the silent refresh failed.
|
|
30
|
+
// Recovery is the same `login` call as a revocation, but the cause is not — no
|
|
31
|
+
// competing login happened, so claiming one sends the user hunting for a device
|
|
32
|
+
// that never signed in.
|
|
33
|
+
export const SESSION_EXPIRED_MESSAGE = 'The saved LINE session expired and the automatic token refresh failed. ' +
|
|
34
|
+
'Nothing signed this device out — the token simply aged out, and the MCP ' +
|
|
35
|
+
'connection itself is fine. Call the `login` tool to reconnect; cached ' +
|
|
36
|
+
'credentials usually complete without a new PIN.';
|
|
37
|
+
/**
|
|
38
|
+
* Build the session-revoked error payload (LINE logged this device out).
|
|
39
|
+
*
|
|
40
|
+
* @param detail - Optional raw LINE error message, appended as diagnostics.
|
|
41
|
+
* @returns MCP tool error content.
|
|
42
|
+
*/
|
|
43
|
+
export function sessionRevokedError(detail) {
|
|
44
|
+
const text = detail
|
|
45
|
+
? `${SESSION_REVOKED_MESSAGE}\n\n(diagnostic: ${detail})`
|
|
46
|
+
: SESSION_REVOKED_MESSAGE;
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text', text }],
|
|
49
|
+
isError: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the session-expired error payload (token aged out, refresh failed).
|
|
54
|
+
*
|
|
55
|
+
* @param detail - Optional raw LINE error message, appended as diagnostics.
|
|
56
|
+
* @returns MCP tool error content.
|
|
57
|
+
*/
|
|
58
|
+
export function sessionExpiredError(detail) {
|
|
59
|
+
const text = detail
|
|
60
|
+
? `${SESSION_EXPIRED_MESSAGE}\n\n(diagnostic: ${detail})`
|
|
61
|
+
: SESSION_EXPIRED_MESSAGE;
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text', text }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build a plain-text MCP tool error payload.
|
|
69
|
+
*
|
|
70
|
+
* @param message - Human-readable error message.
|
|
71
|
+
* @returns MCP tool error content.
|
|
72
|
+
*/
|
|
73
|
+
export function toolError(message) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: message }],
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Wrap a plain result object as a JSON MCP tool result. */
|
|
80
|
+
export function jsonResult(value) {
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fetchStickerImage, fetchStickerPackageMeta, } from '../../line/client/sticker-meta.js';
|
|
2
|
+
import { toolError } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Handle `preview_sticker` — render sticker images so the caller can SEE them
|
|
5
|
+
* (returns MCP image content from the public sticker CDN). With `stickerId`,
|
|
6
|
+
* previews that one sticker; otherwise previews the first `limit` stickers of
|
|
7
|
+
* `packageId`. Each image is preceded by a text line naming its ids so the
|
|
8
|
+
* caller can map an image back to a (stickerId, packageId) for send_sticker.
|
|
9
|
+
* Read-only.
|
|
10
|
+
*
|
|
11
|
+
* @param service - Resumed LineProtocolService (unused; CDN-only).
|
|
12
|
+
* @param args - Tool arguments.
|
|
13
|
+
* @returns MCP tool result with interleaved id labels and images.
|
|
14
|
+
*/
|
|
15
|
+
export async function handlePreviewSticker(_service, args) {
|
|
16
|
+
if (!args?.packageId) {
|
|
17
|
+
return toolError('packageId is required.');
|
|
18
|
+
}
|
|
19
|
+
let ids;
|
|
20
|
+
if (args.stickerId) {
|
|
21
|
+
ids = [args.stickerId];
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const meta = await fetchStickerPackageMeta(args.packageId);
|
|
25
|
+
if (!meta) {
|
|
26
|
+
return toolError(`No sticker package found for packageId "${args.packageId}".`);
|
|
27
|
+
}
|
|
28
|
+
ids = meta.stickerIds.slice(0, args.limit ?? 8);
|
|
29
|
+
}
|
|
30
|
+
const content = [];
|
|
31
|
+
for (const id of ids) {
|
|
32
|
+
const img = await fetchStickerImage(id);
|
|
33
|
+
if (!img) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
content.push({
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: `stickerId ${id} (packageId ${args.packageId}) — pass these to send_sticker`,
|
|
39
|
+
});
|
|
40
|
+
content.push({
|
|
41
|
+
type: 'image',
|
|
42
|
+
data: img.toString('base64'),
|
|
43
|
+
mimeType: 'image/png',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (content.length === 0) {
|
|
47
|
+
return toolError('No sticker previews available for that package/sticker.');
|
|
48
|
+
}
|
|
49
|
+
return { content };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Handle `list_stickers` — the sticker packages the account owns (and can
|
|
53
|
+
* therefore send). Read-only.
|
|
54
|
+
*
|
|
55
|
+
* @param service - Resumed LineProtocolService.
|
|
56
|
+
* @param args - Tool arguments.
|
|
57
|
+
* @returns MCP tool result.
|
|
58
|
+
*/
|
|
59
|
+
export async function handleListStickers(service, args) {
|
|
60
|
+
const packages = await service.listStickerPackages(args?.language);
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: JSON.stringify({ total: packages.length, packages }, null, 2),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Handle `search_stickers` — search the account's OWNED sticker packages by
|
|
72
|
+
* title and expand matches into individual sticker ids ready for send_sticker.
|
|
73
|
+
* Read-only.
|
|
74
|
+
*
|
|
75
|
+
* @param service - Resumed LineProtocolService.
|
|
76
|
+
* @param args - Tool arguments.
|
|
77
|
+
* @returns MCP tool result.
|
|
78
|
+
*/
|
|
79
|
+
export async function handleSearchStickers(service, args) {
|
|
80
|
+
if (!args?.query) {
|
|
81
|
+
return toolError('query is required.');
|
|
82
|
+
}
|
|
83
|
+
const result = await service.searchStickerPackages(args.query, args.language, args.limit);
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
86
|
+
};
|
|
87
|
+
}
|