@rikaidev/yomi 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -4
- package/dist/ambient/ambient-event-graph.js +172 -0
- package/dist/ambient/ambient-observer.js +71 -0
- package/dist/ambient/event-graph-reducer.js +300 -0
- package/dist/ambient/ingestion-adapter.js +203 -0
- package/dist/ambient/journal/crypto.js +58 -0
- package/dist/ambient/journal/journal-store.js +284 -0
- package/dist/ambient/journal/types.js +7 -0
- package/dist/ambient/summaries/retriever.js +55 -0
- package/dist/ambient/summaries/summary-engine.js +128 -0
- package/dist/ambient/summaries/types.js +4 -0
- package/dist/attention/policy.js +101 -0
- package/dist/auth/credential-store.js +47 -17
- package/dist/auth/keychain.js +7 -54
- package/dist/cli/query.js +875 -0
- package/dist/cli/work-item.js +24 -0
- package/dist/insight/relationships.js +1 -1
- package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
- package/dist/line/auth/pwless/index.js +1 -1
- package/dist/line/client/auth-service/client.js +7 -8
- package/dist/line/client/buddy-service/client.js +73 -0
- package/dist/line/client/index.js +28 -3
- package/dist/line/client/obs-media-client.js +38 -0
- package/dist/line/client/parsers.js +1 -0
- package/dist/line/client/square-service/client.js +298 -0
- package/dist/line/client/sticker-meta.js +32 -0
- package/dist/line/client/sync-service/client.js +31 -3
- package/dist/line/client/talk-service/command-client.js +9 -1
- package/dist/line/client/talk-service/contact-query.js +8 -1
- package/dist/line/client/talk-service/message-box-query.js +17 -0
- package/dist/line/client/talk-service/message-id-query.js +27 -3
- package/dist/line/client/talk-service/query-client.js +32 -26
- package/dist/line/client/talk-service/requests.js +10 -11
- package/dist/line/core/auth-session-runtime.js +17 -2
- package/dist/line/core/chat-event.js +35 -0
- package/dist/line/core/chat-runtime-service.js +18 -23
- package/dist/line/core/config.js +1 -0
- package/dist/line/core/directory-runtime-service.js +10 -2
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
- package/dist/line/core/flex-message.js +453 -0
- package/dist/line/core/mention.js +1 -1
- package/dist/line/core/message-preview.js +60 -0
- package/dist/line/core/message-query-service.js +0 -20
- package/dist/line/core/service.js +1 -3
- package/dist/mcp/handlers/contacts.js +12 -6
- package/dist/mcp/handlers/conversations.js +91 -4
- package/dist/mcp/handlers/login.js +1 -1
- package/dist/mcp/handlers/message-actions.js +1 -1
- package/dist/mcp/handlers/search.js +1 -1
- package/dist/mcp/handlers/send.js +8 -11
- package/dist/mcp/handlers/shared.js +1 -1
- package/dist/mcp/integrator.js +168 -0
- package/dist/mcp/media.js +135 -4
- package/dist/mcp/names.js +78 -6
- package/dist/mcp/phi-guard.js +2 -2
- package/dist/search/bigram.js +2 -2
- package/dist/search/collector.js +1 -1
- package/dist/search/default-embedder.js +12 -3
- package/dist/search/embedder.js +1 -1
- package/dist/version.js +1 -1
- package/dist/work-item/adapter.js +1 -0
- package/dist/work-item/destination-contracts.js +1 -0
- package/dist/work-item/event-context.js +73 -0
- package/dist/work-item/gold-contract.js +101 -0
- package/dist/work-item/gold-coverage.js +96 -0
- package/dist/work-item/gold-derivation.js +67 -0
- package/dist/work-item/gold-split.js +143 -0
- package/dist/work-item/index.js +23 -0
- package/dist/work-item/line-adapter.js +111 -0
- package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
- package/dist/work-item/local-provider.js +38 -0
- package/dist/work-item/local-work-event-provider.js +56 -0
- package/dist/work-item/onnx-work-event-adapter.js +223 -0
- package/dist/work-item/provider.js +21 -0
- package/dist/work-item/registry.js +19 -0
- package/dist/work-item/service.js +85 -0
- package/dist/work-item/store.js +97 -0
- package/dist/work-item/types.js +79 -0
- package/dist/work-item/work-event-graph-engine.js +63 -0
- package/dist/work-item/work-event-inference.js +267 -0
- package/dist/work-item/work-event-matcher.js +105 -0
- package/dist/work-item/work-event-model-manifest.js +39 -0
- package/dist/work-item/work-event-store.js +125 -0
- package/dist/work-item/work-event.js +123 -0
- package/package.json +26 -2
- package/run.mjs +371 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSquareChatMid } from '../../line/client/square-service/client.js';
|
|
2
|
+
import { sanitizeMessagePreview } from '../../line/core/message-preview.js';
|
|
1
3
|
import { decryptLineMessage } from '../../line/core/message-query-service.js';
|
|
2
4
|
import { getExcludedChatIds } from '../../search/scope.js';
|
|
3
5
|
import { createCliLogger } from '../../util/log.js';
|
|
@@ -6,6 +8,16 @@ import { resolveConversationNames, resolveSenderNames } from '../names.js';
|
|
|
6
8
|
import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
|
|
7
9
|
import { jsonResult, toolError, toonText } from './shared.js';
|
|
8
10
|
const log = createCliLogger('Yomi');
|
|
11
|
+
function messageTimestamp(message) {
|
|
12
|
+
return Number(message?.deliveredTime || message?.createdTime || 0);
|
|
13
|
+
}
|
|
14
|
+
function latestMessage(messages) {
|
|
15
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
return messages.reduce((latest, candidate) => messageTimestamp(candidate) >= messageTimestamp(latest)
|
|
18
|
+
? candidate
|
|
19
|
+
: latest);
|
|
20
|
+
}
|
|
9
21
|
/**
|
|
10
22
|
* Handle `list_conversations` — list LINE conversations (chats, groups, rooms)
|
|
11
23
|
* with unread counts, a last-message preview, and a resolved display name.
|
|
@@ -15,6 +27,13 @@ const log = createCliLogger('Yomi');
|
|
|
15
27
|
* @returns MCP tool result.
|
|
16
28
|
*/
|
|
17
29
|
export async function handleListConversations(service, args) {
|
|
30
|
+
try {
|
|
31
|
+
await service.client.syncLongPoll(50, 1000);
|
|
32
|
+
if (service.sessionState?.saveSyncRevisions) {
|
|
33
|
+
await service.sessionState.saveSyncRevisions(Number(service.client.revision || 0), Number(service.client.globalRevision || 0), Number(service.client.individualRevision || 0));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
18
37
|
const result = await service.client.getMessageBoxes({
|
|
19
38
|
lastMessagesPerMessageBoxCount: 1,
|
|
20
39
|
messageBoxCountLimit: args.limit ?? 20,
|
|
@@ -30,17 +49,63 @@ export async function handleListConversations(service, args) {
|
|
|
30
49
|
// decrypted.
|
|
31
50
|
const acc = createPhiAccumulator();
|
|
32
51
|
const conversations = await Promise.all(boxes.map(async (box) => {
|
|
33
|
-
|
|
52
|
+
let lastMessage = latestMessage(box.lastMessages);
|
|
53
|
+
const cursorTime = Number(box.lastDeliveredMessageId?.deliveredTime || 0);
|
|
54
|
+
if (box.id &&
|
|
55
|
+
(!lastMessage || messageTimestamp(lastMessage) < cursorTime)) {
|
|
56
|
+
try {
|
|
57
|
+
const recent = await service.getRecentMessages(box.id, 1);
|
|
58
|
+
const candidate = latestMessage(recent);
|
|
59
|
+
if (candidate &&
|
|
60
|
+
messageTimestamp(candidate) >= messageTimestamp(lastMessage)) {
|
|
61
|
+
lastMessage = candidate;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
34
66
|
const decryptedLastMessage = lastMessage
|
|
35
67
|
? await decryptLineMessage(service.e2eeManager, lastMessage, box.id)
|
|
36
68
|
: null;
|
|
69
|
+
const previewText = sanitizeMessagePreview(decryptedLastMessage?.text ?? lastMessage?.text);
|
|
37
70
|
return {
|
|
38
71
|
id: box.id,
|
|
39
|
-
lastMessagePreview: maskInto(acc,
|
|
72
|
+
lastMessagePreview: maskInto(acc, previewText || (lastMessage?.text ? '[加密訊息]' : null)),
|
|
40
73
|
name: names.get(box.id) ?? null,
|
|
41
74
|
unreadCount: box.unreadCount ?? 0,
|
|
42
75
|
};
|
|
43
76
|
}));
|
|
77
|
+
try {
|
|
78
|
+
const squareChats = await service.client.getJoinedSquareChats(args.limit ?? 20);
|
|
79
|
+
for (const chat of squareChats) {
|
|
80
|
+
let last = chat.lastMessage;
|
|
81
|
+
let senderName = chat.lastSenderName;
|
|
82
|
+
if (!last) {
|
|
83
|
+
const recent = await service.client.getSquareChatMessages(chat.id, 1);
|
|
84
|
+
const normalized = recent.at(-1);
|
|
85
|
+
if (normalized) {
|
|
86
|
+
last = {
|
|
87
|
+
4: normalized.id,
|
|
88
|
+
5: normalized.createdTime,
|
|
89
|
+
10: normalized.text,
|
|
90
|
+
15: normalized.contentType,
|
|
91
|
+
};
|
|
92
|
+
senderName = normalized.fromName;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const preview = sanitizeMessagePreview(last?.[10]);
|
|
96
|
+
conversations.push({
|
|
97
|
+
id: chat.id,
|
|
98
|
+
lastMessagePreview: maskInto(acc, preview || (last ? '[社群訊息]' : null)),
|
|
99
|
+
name: maskInto(acc, chat.name),
|
|
100
|
+
unreadCount: chat.unreadCount,
|
|
101
|
+
...(senderName ? { lastSenderName: maskInto(acc, senderName) } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// OpenChat availability differs by LINE account/region. TalkService
|
|
107
|
+
// conversations remain authoritative when SquareService is unavailable.
|
|
108
|
+
}
|
|
44
109
|
const content = [
|
|
45
110
|
{ type: 'text', text: toonText(conversations) },
|
|
46
111
|
];
|
|
@@ -77,6 +142,27 @@ export async function handleGetChatMessages(service, args) {
|
|
|
77
142
|
if (!args.chatId) {
|
|
78
143
|
return toolError('chatId is required.');
|
|
79
144
|
}
|
|
145
|
+
if (isSquareChatMid(args.chatId)) {
|
|
146
|
+
const squareMessages = await service.client.getSquareChatMessages(args.chatId, args.count ?? 50);
|
|
147
|
+
const acc = createPhiAccumulator();
|
|
148
|
+
const shaped = squareMessages.map((message) => ({
|
|
149
|
+
createdTime: message.createdTime,
|
|
150
|
+
deliveredTime: message.createdTime,
|
|
151
|
+
from: message.fromMid,
|
|
152
|
+
fromName: maskInto(acc, message.fromName),
|
|
153
|
+
id: message.id,
|
|
154
|
+
mediaType: resolveLineMediaType(Number(message.contentType)),
|
|
155
|
+
mentions: message.contentMetadata?.MENTION ?? null,
|
|
156
|
+
text: maskInto(acc, sanitizeMessagePreview(message.text) || '[社群訊息]'),
|
|
157
|
+
e2eeDecrypted: null,
|
|
158
|
+
service: 'SQUARE',
|
|
159
|
+
}));
|
|
160
|
+
const content = [{ type: 'text', text: toonText(shaped) }];
|
|
161
|
+
const note = phiNote(acc);
|
|
162
|
+
if (note)
|
|
163
|
+
content.push(note);
|
|
164
|
+
return { content };
|
|
165
|
+
}
|
|
80
166
|
const fetched = args.before
|
|
81
167
|
? await service.getPreviousMessages(args.chatId, args.count ?? 50, args.before)
|
|
82
168
|
: await service.getRecentMessages(args.chatId, args.count ?? 50);
|
|
@@ -104,7 +190,8 @@ export async function handleGetChatMessages(service, args) {
|
|
|
104
190
|
// be observed against a live mentioning message before anyone builds
|
|
105
191
|
// the outbound (send-a-mention) side.
|
|
106
192
|
mentions: message.contentMetadata?.MENTION ?? null,
|
|
107
|
-
text: maskInto(acc, message.text)
|
|
193
|
+
text: maskInto(acc, sanitizeMessagePreview(message.text) ||
|
|
194
|
+
(message.text ? '[加密訊息]' : null)),
|
|
108
195
|
e2eeDecrypted: message.e2eeDecrypted ?? null,
|
|
109
196
|
// Emitted ONLY when the message decrypted but could not be authenticated
|
|
110
197
|
// (LINE E2EE v1 — AES-CBC, no tag, no AAD), so its presence is a signal
|
|
@@ -175,7 +262,7 @@ export async function handleGetUnreadDigest(service, args) {
|
|
|
175
262
|
mediaType: resolveLineMediaDescriptor(m) !== null
|
|
176
263
|
? resolveLineMediaType(Number(m.contentType))
|
|
177
264
|
: null,
|
|
178
|
-
text: maskInto(acc, m.text),
|
|
265
|
+
text: maskInto(acc, sanitizeMessagePreview(m.text) || (m.text ? '[加密訊息]' : null)),
|
|
179
266
|
})),
|
|
180
267
|
};
|
|
181
268
|
}));
|
|
@@ -44,7 +44,7 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
|
|
|
44
44
|
* Handle `login` on a LEGACY-era connection whose client supports form
|
|
45
45
|
* elicitation — drive the passwordless flow entirely through pushed
|
|
46
46
|
* elicitation requests, with no out-of-band channel required. A pre-flight
|
|
47
|
-
* form confirms the
|
|
47
|
+
* form confirms the allow-login-from-other-devices prerequisite (and collects
|
|
48
48
|
* phone/region when missing) BEFORE any PIN is issued; the PIN is then
|
|
49
49
|
* surfaced through a second elicitation fired without blocking
|
|
50
50
|
* `runPwlessLogin`, which keeps running while the human acts on their phone.
|
|
@@ -4,7 +4,7 @@ const log = createCliLogger('Yomi');
|
|
|
4
4
|
/**
|
|
5
5
|
* Handle `react_message` — REALLY adds a predefined reaction to a real LINE
|
|
6
6
|
* message now (TalkService react). Visible to the conversation. `reactionType`:
|
|
7
|
-
* 2=LIKE 👍, 3=LOVE ❤️, 4=LAUGH 😆, 5=SURPRISE 😮, 6=SAD 😢, 7=ANGRY 😡
|
|
7
|
+
* Protocol emoji mapping: 2=LIKE 👍, 3=LOVE ❤️, 4=LAUGH 😆, 5=SURPRISE 😮, 6=SAD 😢, 7=ANGRY 😡
|
|
8
8
|
* (default 2).
|
|
9
9
|
*
|
|
10
10
|
* @param service - Resumed LineProtocolService.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* result that either method ranks highly surfaces near the top. This is
|
|
18
18
|
* deliberately not "semantic OR keyword": pure semantic over a partially-
|
|
19
19
|
* embedded index silently drops exact matches that live in un-embedded
|
|
20
|
-
* messages (the "search
|
|
20
|
+
* messages (the "search for a place name misses the exact same place name" bug),
|
|
21
21
|
* and pure keyword misses paraphrases. Which methods actually contributed
|
|
22
22
|
* is always reported via the `mode` field (`hybrid` | `semantic` |
|
|
23
23
|
* `keyword`), never hidden.
|
|
@@ -226,6 +226,11 @@ export async function handleSendFile(service, args) {
|
|
|
226
226
|
* person's mid in contentMetadata. `displayName` is resolved from the mid when
|
|
227
227
|
* the caller omits it. One send per call; honest error on failure.
|
|
228
228
|
*
|
|
229
|
+
* The recipient reads this string, so it is always the contact's own profile
|
|
230
|
+
* name (contact-query field 22) and never the name a shared cache holds. That
|
|
231
|
+
* cache exists to show the user the name they gave a friend locally, which is
|
|
232
|
+
* a private label and must not travel to a third party.
|
|
233
|
+
*
|
|
229
234
|
* @param service - Resumed LineProtocolService.
|
|
230
235
|
* @param args - Tool arguments.
|
|
231
236
|
* @returns MCP tool result.
|
|
@@ -240,19 +245,11 @@ export async function handleSendContact(service, args) {
|
|
|
240
245
|
let displayName = args.displayName ?? '';
|
|
241
246
|
if (!displayName) {
|
|
242
247
|
try {
|
|
243
|
-
|
|
248
|
+
const contact = await service.getContact(args.contactMid);
|
|
249
|
+
displayName = contact?.displayName ?? '';
|
|
244
250
|
}
|
|
245
251
|
catch {
|
|
246
|
-
|
|
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
|
-
}
|
|
252
|
+
// Leave empty — LINE resolves the name from the mid on the recipient side.
|
|
256
253
|
}
|
|
257
254
|
}
|
|
258
255
|
const result = await service.sendContact(args.chatId, args.contactMid, displayName);
|
|
@@ -6,7 +6,7 @@ import { encode } from '@toon-format/toon';
|
|
|
6
6
|
* under src/mcp/handlers/ can import these without a circular dependency.
|
|
7
7
|
*/
|
|
8
8
|
// Yomi logs in on its own (via the `login`/`login_complete` tools, or
|
|
9
|
-
// `npx @rikaidev/yomi login`);
|
|
9
|
+
// `npx @rikaidev/yomi login`); session is shared with Yomi Desktop.
|
|
10
10
|
export const NO_CREDENTIALS_MESSAGE = 'No persisted LINE session. Call the `login` tool, or run `npx @rikaidev/yomi login` in a terminal.';
|
|
11
11
|
// Shown when LINE invalidated a previously working session — distinct from
|
|
12
12
|
// "never logged in" so the model tells the user the right story: the MCP
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal 1-Click MCP Integrator for Claude Desktop, Claude Cowork, Codex, Cursor, Windsurf, VS Code (Cline/Roo).
|
|
3
|
+
* Also automatically generates and registers Yomi Skills for AI Agent assistants.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
export function getClientTargets() {
|
|
9
|
+
const home = homedir();
|
|
10
|
+
return [
|
|
11
|
+
{
|
|
12
|
+
id: 'claude-desktop',
|
|
13
|
+
name: 'Claude Desktop & Cowork',
|
|
14
|
+
configPath: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
15
|
+
configType: 'mcpServers',
|
|
16
|
+
requiresApp: '/Applications/Claude.app',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'codex-desktop',
|
|
20
|
+
name: 'Codex Desktop & CLI',
|
|
21
|
+
configPath: join(home, '.codex', 'mcp.json'),
|
|
22
|
+
configType: 'mcpServers',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'cursor',
|
|
26
|
+
name: 'Cursor IDE',
|
|
27
|
+
configPath: join(home, '.cursor', 'mcp.json'),
|
|
28
|
+
configType: 'mcpServers',
|
|
29
|
+
requiresApp: '/Applications/Cursor.app',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'windsurf',
|
|
33
|
+
name: 'Windsurf (Codeium)',
|
|
34
|
+
configPath: join(home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
35
|
+
configType: 'mcpServers',
|
|
36
|
+
requiresApp: '/Applications/Windsurf.app',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'cline',
|
|
40
|
+
name: 'VS Code (Cline)',
|
|
41
|
+
configPath: join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'),
|
|
42
|
+
configType: 'mcpServers',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'roo-code',
|
|
46
|
+
name: 'VS Code (Roo Code)',
|
|
47
|
+
configPath: join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'cline_mcp_settings.json'),
|
|
48
|
+
configType: 'mcpServers',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'claude-cli',
|
|
52
|
+
name: 'Claude Code CLI',
|
|
53
|
+
configPath: join(home, '.claude.json'),
|
|
54
|
+
configType: 'mcpServers',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
export function getYomiMcpDefinition() {
|
|
59
|
+
const home = homedir();
|
|
60
|
+
const npxPath = existsSync('/opt/homebrew/bin/npx')
|
|
61
|
+
? '/opt/homebrew/bin/npx'
|
|
62
|
+
: existsSync('/usr/local/bin/npx')
|
|
63
|
+
? '/usr/local/bin/npx'
|
|
64
|
+
: 'npx';
|
|
65
|
+
return {
|
|
66
|
+
command: npxPath,
|
|
67
|
+
args: ['-y', '@rikaidev/yomi'],
|
|
68
|
+
env: {
|
|
69
|
+
PATH: `${home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function installYomiAgentSkill() {
|
|
74
|
+
const home = homedir();
|
|
75
|
+
const skillContent = `---
|
|
76
|
+
name: yomi-line-manager
|
|
77
|
+
description: Manage and interact with user's LINE account via Yomi E2EE MCP Server. Query messages, summarize chats, search history, find contacts, and draft replies.
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
# Yomi LINE Copilot Skill
|
|
81
|
+
|
|
82
|
+
You have real-time access to the user's LINE account through the Yomi Letter-Sealing E2EE MCP Server.
|
|
83
|
+
|
|
84
|
+
## Capabilities
|
|
85
|
+
1. \`get_recent_messages\`: Retrieve recent decrypted messages.
|
|
86
|
+
2. \`search_messages\`: Full-text search across conversations.
|
|
87
|
+
3. \`list_contacts\`: Retrieve friends and group information.
|
|
88
|
+
4. \`send_message\`: Send messages to chats with confirmation.
|
|
89
|
+
|
|
90
|
+
## Guidelines
|
|
91
|
+
- Summarize conversations concisely and highlight action items or mentions needing attention.
|
|
92
|
+
- When drafting a reply, ask the user for confirmation before executing \`send_message\`.
|
|
93
|
+
`;
|
|
94
|
+
const skillDirs = [
|
|
95
|
+
join(home, '.claude', 'skills', 'yomi-line-manager'),
|
|
96
|
+
join(home, '.gemini', 'antigravity-cli', 'builtin', 'skills', 'yomi-line-manager'),
|
|
97
|
+
join(home, '.codex', 'skills', 'yomi-line-manager'),
|
|
98
|
+
];
|
|
99
|
+
let installedPath = '';
|
|
100
|
+
for (const dir of skillDirs) {
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(dir)) {
|
|
103
|
+
mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
104
|
+
}
|
|
105
|
+
const skillFile = join(dir, 'SKILL.md');
|
|
106
|
+
writeFileSync(skillFile, skillContent, { mode: 0o644 });
|
|
107
|
+
installedPath = skillFile;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Ignore directory write failures for optional tools
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { success: true, path: installedPath };
|
|
114
|
+
}
|
|
115
|
+
export function integrateClient(target) {
|
|
116
|
+
try {
|
|
117
|
+
const dir = dirname(target.configPath);
|
|
118
|
+
if (!existsSync(dir)) {
|
|
119
|
+
mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
120
|
+
}
|
|
121
|
+
let config = {};
|
|
122
|
+
if (existsSync(target.configPath)) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = readFileSync(target.configPath, 'utf8');
|
|
125
|
+
config = JSON.parse(raw);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
config = {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
132
|
+
config.mcpServers = {};
|
|
133
|
+
}
|
|
134
|
+
config.mcpServers.yomi = getYomiMcpDefinition();
|
|
135
|
+
writeFileSync(target.configPath, JSON.stringify(config, null, 2), {
|
|
136
|
+
mode: 0o600,
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
success: true,
|
|
140
|
+
message: `Integrated Yomi MCP into ${target.name}`,
|
|
141
|
+
path: target.configPath,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
message: `Failed to configure ${target.name}: ${err.message}`,
|
|
148
|
+
path: target.configPath,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function integrateAllClients() {
|
|
153
|
+
const skillResult = installYomiAgentSkill();
|
|
154
|
+
const targets = getClientTargets();
|
|
155
|
+
const results = targets.map((t) => {
|
|
156
|
+
const res = integrateClient(t);
|
|
157
|
+
return {
|
|
158
|
+
id: t.id,
|
|
159
|
+
name: t.name,
|
|
160
|
+
...res,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
results,
|
|
165
|
+
totalSuccess: results.filter((r) => r.success).length,
|
|
166
|
+
skillInstalled: skillResult.success,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/dist/mcp/media.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* missing, or the message's content type is not a downloadable media type,
|
|
14
14
|
* callers get an honest error naming the reason.
|
|
15
15
|
*/
|
|
16
|
-
import { downloadLineObsMediaObject } from '../line/client/obs-media-client.js';
|
|
16
|
+
import { downloadLineMessageData, downloadLineObsMediaObject, } from '../line/client/obs-media-client.js';
|
|
17
|
+
import { isSquareChatMid } from '../line/client/square-service/client.js';
|
|
17
18
|
import { CONTENT_TYPE } from '../line/core/constants.js';
|
|
18
19
|
import { decryptLineMediaBytes } from '../line/core/media-decrypt.js';
|
|
19
20
|
/** LINE content types this server can download and decrypt via the OBS path. */
|
|
@@ -57,6 +58,51 @@ const MIME_BY_EXTENSION = {
|
|
|
57
58
|
webp: 'image/webp',
|
|
58
59
|
zip: 'application/zip',
|
|
59
60
|
};
|
|
61
|
+
const TRUSTED_PUBLIC_MEDIA_HOST_SUFFIXES = [
|
|
62
|
+
'line-scdn.net',
|
|
63
|
+
'line-apps.com',
|
|
64
|
+
'cdn.hinet.net',
|
|
65
|
+
];
|
|
66
|
+
export function resolvePublicMediaUrl(message, preview) {
|
|
67
|
+
const metadata = message?.contentMetadata ?? {};
|
|
68
|
+
if (metadata.PUBLIC !== 'true')
|
|
69
|
+
return null;
|
|
70
|
+
const raw = preview
|
|
71
|
+
? metadata.PREVIEW_URL || metadata.DOWNLOAD_URL
|
|
72
|
+
: metadata.DOWNLOAD_URL || metadata.PREVIEW_URL;
|
|
73
|
+
if (typeof raw !== 'string' || !raw)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(raw);
|
|
77
|
+
const hostname = url.hostname.toLowerCase();
|
|
78
|
+
if (url.protocol !== 'https:' ||
|
|
79
|
+
!TRUSTED_PUBLIC_MEDIA_HOST_SUFFIXES.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function downloadPublicMedia(url) {
|
|
89
|
+
const response = await fetch(url, { redirect: 'error' });
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(`Public LINE media download failed (${response.status})`);
|
|
92
|
+
}
|
|
93
|
+
const length = Number(response.headers.get('content-length') ?? 0);
|
|
94
|
+
if (Number.isFinite(length) && length > 25 * 1024 * 1024) {
|
|
95
|
+
throw new Error('Public LINE media exceeds 25 MB');
|
|
96
|
+
}
|
|
97
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
98
|
+
if (bytes.length === 0 || bytes.length > 25 * 1024 * 1024) {
|
|
99
|
+
throw new Error('Public LINE media returned invalid bytes');
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
bytes,
|
|
103
|
+
mimeType: response.headers.get('content-type')?.split(';')[0] || null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
60
106
|
/**
|
|
61
107
|
* Resolve the short media-kind label for a LINE content type.
|
|
62
108
|
*
|
|
@@ -98,6 +144,16 @@ function resolveMimeType(extension, contentType) {
|
|
|
98
144
|
}
|
|
99
145
|
return DEFAULT_MIME_BY_CONTENT_TYPE[contentType] || null;
|
|
100
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a legacy message's filename extension without requiring an OBS
|
|
149
|
+
* descriptor. Older Talk messages expose MEDIA_CONTENT_INFO but omit OID/SID.
|
|
150
|
+
*/
|
|
151
|
+
function resolveMediaInfoExtension(message) {
|
|
152
|
+
const info = parseJson(message?.contentMetadata?.MEDIA_CONTENT_INFO, {});
|
|
153
|
+
return typeof info.extension === 'string' && info.extension.trim()
|
|
154
|
+
? info.extension.trim()
|
|
155
|
+
: null;
|
|
156
|
+
}
|
|
101
157
|
/**
|
|
102
158
|
* Build a LINE media descriptor from a decrypted message.
|
|
103
159
|
*
|
|
@@ -115,10 +171,18 @@ export function resolveLineMediaDescriptor(message) {
|
|
|
115
171
|
}
|
|
116
172
|
const dataPayload = parseJson(message?.text, {});
|
|
117
173
|
const mediaInfo = parseJson(contentMetadata.MEDIA_CONTENT_INFO, {});
|
|
174
|
+
const fileName = dataPayload.fileName ||
|
|
175
|
+
(typeof contentMetadata.FILE_NAME === 'string' &&
|
|
176
|
+
contentMetadata.FILE_NAME) ||
|
|
177
|
+
(typeof contentMetadata.FILE_NAME_ORIGINAL === 'string' &&
|
|
178
|
+
contentMetadata.FILE_NAME_ORIGINAL) ||
|
|
179
|
+
(typeof contentMetadata.name === 'string' && contentMetadata.name) ||
|
|
180
|
+
null;
|
|
181
|
+
const extension = mediaInfo.extension || (fileName ? fileName.split('.').pop() : null) || null;
|
|
118
182
|
return {
|
|
119
|
-
fileName
|
|
183
|
+
fileName,
|
|
120
184
|
keyMaterial: dataPayload.keyMaterial || null,
|
|
121
|
-
mimeType: resolveMimeType(
|
|
185
|
+
mimeType: resolveMimeType(extension, Number(message?.contentType || 0)),
|
|
122
186
|
oid: String(contentMetadata.OID),
|
|
123
187
|
sid: String(contentMetadata.SID),
|
|
124
188
|
};
|
|
@@ -155,6 +219,38 @@ class UnsupportedMediaTypeError extends LineMediaAccessError {
|
|
|
155
219
|
* @returns Decrypted media bytes, resolved MIME type, filename, and content type.
|
|
156
220
|
*/
|
|
157
221
|
export async function fetchLineMessageMedia(service, chatId, messageId, preview) {
|
|
222
|
+
if (isSquareChatMid(chatId)) {
|
|
223
|
+
const mainMessages = await service.client.getSquareChatMessages(chatId, 100);
|
|
224
|
+
let message = mainMessages.find((candidate) => candidate.id === messageId);
|
|
225
|
+
if (!message) {
|
|
226
|
+
for (const root of mainMessages.filter((candidate) => candidate.isThreadRoot && candidate.threadMid)) {
|
|
227
|
+
const replies = await service.client.getSquareChatMessages(chatId, 100, root.threadMid);
|
|
228
|
+
message = replies.find((candidate) => candidate.id === messageId);
|
|
229
|
+
if (message)
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!message) {
|
|
234
|
+
throw new MissingDecryptMaterialError(`Square message ${messageId} not found in ${chatId}`);
|
|
235
|
+
}
|
|
236
|
+
const contentType = Number(message.contentType);
|
|
237
|
+
if (!DOWNLOADABLE_CONTENT_TYPES.has(contentType)) {
|
|
238
|
+
throw new UnsupportedMediaTypeError(contentType, messageId);
|
|
239
|
+
}
|
|
240
|
+
const downloaded = await downloadLineMessageData(service.client, {
|
|
241
|
+
messageId,
|
|
242
|
+
preview,
|
|
243
|
+
square: true,
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
bytes: downloaded.bytes,
|
|
247
|
+
contentType,
|
|
248
|
+
fileName: message.contentMetadata?.FILE_NAME || null,
|
|
249
|
+
mimeType: downloaded.mimeType ||
|
|
250
|
+
DEFAULT_MIME_BY_CONTENT_TYPE[contentType] ||
|
|
251
|
+
'application/octet-stream',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
158
254
|
const messages = await service.getRecentMessages(chatId, 100);
|
|
159
255
|
const message = messages.find((candidate) => candidate.id === messageId);
|
|
160
256
|
if (!message) {
|
|
@@ -164,9 +260,44 @@ export async function fetchLineMessageMedia(service, chatId, messageId, preview)
|
|
|
164
260
|
if (!DOWNLOADABLE_CONTENT_TYPES.has(contentType)) {
|
|
165
261
|
throw new UnsupportedMediaTypeError(contentType, messageId);
|
|
166
262
|
}
|
|
263
|
+
const publicMediaUrl = resolvePublicMediaUrl(message, preview);
|
|
264
|
+
if (publicMediaUrl) {
|
|
265
|
+
const downloaded = await downloadPublicMedia(publicMediaUrl);
|
|
266
|
+
const extension = publicMediaUrl.pathname.split('.').pop() ?? null;
|
|
267
|
+
return {
|
|
268
|
+
bytes: downloaded.bytes,
|
|
269
|
+
contentType,
|
|
270
|
+
fileName: null,
|
|
271
|
+
mimeType: downloaded.mimeType ||
|
|
272
|
+
resolveMimeType(extension, contentType) ||
|
|
273
|
+
'application/octet-stream',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
167
276
|
const descriptor = resolveLineMediaDescriptor(message);
|
|
168
277
|
if (!descriptor) {
|
|
169
|
-
|
|
278
|
+
// Older/non-E2EE Talk messages carry MEDIA_CONTENT_INFO but omit OID/SID.
|
|
279
|
+
// Their object is addressed by message id at OBS /r/talk/m/{id}; the
|
|
280
|
+
// similarly named TalkService methods are not real `/S4` methods and
|
|
281
|
+
// return Invalid method name. Keep the request at the OBS layer.
|
|
282
|
+
const downloaded = await downloadLineMessageData(service.client, {
|
|
283
|
+
messageId,
|
|
284
|
+
preview,
|
|
285
|
+
});
|
|
286
|
+
const contentMetadata = message?.contentMetadata || {};
|
|
287
|
+
const fileName = (typeof contentMetadata.FILE_NAME === 'string' &&
|
|
288
|
+
contentMetadata.FILE_NAME) ||
|
|
289
|
+
(typeof contentMetadata.FILE_NAME_ORIGINAL === 'string' &&
|
|
290
|
+
contentMetadata.FILE_NAME_ORIGINAL) ||
|
|
291
|
+
(typeof contentMetadata.name === 'string' && contentMetadata.name) ||
|
|
292
|
+
null;
|
|
293
|
+
return {
|
|
294
|
+
bytes: downloaded.bytes,
|
|
295
|
+
contentType,
|
|
296
|
+
fileName,
|
|
297
|
+
mimeType: downloaded.mimeType ||
|
|
298
|
+
resolveMimeType(resolveMediaInfoExtension(message), contentType) ||
|
|
299
|
+
'application/octet-stream',
|
|
300
|
+
};
|
|
170
301
|
}
|
|
171
302
|
const encryptedBytes = await downloadLineObsMediaObject(service.client, {
|
|
172
303
|
messageId,
|