@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,123 @@
|
|
|
1
|
+
import { createCliLogger } from '../../util/log.js';
|
|
2
|
+
import { jsonResult, toolError } from './shared.js';
|
|
3
|
+
const log = createCliLogger('Yomi');
|
|
4
|
+
/**
|
|
5
|
+
* Handle `rename_group` — REALLY renames a real LINE group/chat now
|
|
6
|
+
* (TalkService updateChat). Visible to every member. One call, one rename.
|
|
7
|
+
*
|
|
8
|
+
* @param service - Resumed LineProtocolService.
|
|
9
|
+
* @param args - Tool arguments.
|
|
10
|
+
* @returns MCP tool result.
|
|
11
|
+
*/
|
|
12
|
+
export async function handleRenameGroup(service, args) {
|
|
13
|
+
if (!args.chatId) {
|
|
14
|
+
return toolError('chatId is required.');
|
|
15
|
+
}
|
|
16
|
+
if (!args.name) {
|
|
17
|
+
return toolError('name is required.');
|
|
18
|
+
}
|
|
19
|
+
const result = await service.renameGroup(args.chatId, args.name);
|
|
20
|
+
log.info('rename_group.done', { chatId: args.chatId });
|
|
21
|
+
return jsonResult(result);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Handle `invite_member` — REALLY invites members into a real LINE group now
|
|
25
|
+
* (TalkService inviteIntoChat). Invitees must accept before joining a group.
|
|
26
|
+
*
|
|
27
|
+
* @param service - Resumed LineProtocolService.
|
|
28
|
+
* @param args - Tool arguments.
|
|
29
|
+
* @returns MCP tool result.
|
|
30
|
+
*/
|
|
31
|
+
export async function handleInviteMember(service, args) {
|
|
32
|
+
if (!args.chatId) {
|
|
33
|
+
return toolError('chatId is required.');
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(args.mids) || args.mids.length === 0) {
|
|
36
|
+
return toolError('mids must be a non-empty array of member MIDs.');
|
|
37
|
+
}
|
|
38
|
+
const result = await service.inviteToGroup(args.chatId, args.mids);
|
|
39
|
+
log.info('invite_member.done', {
|
|
40
|
+
chatId: args.chatId,
|
|
41
|
+
count: args.mids.length,
|
|
42
|
+
});
|
|
43
|
+
return jsonResult(result);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Handle `kick_member` — REALLY removes members from a real LINE group now
|
|
47
|
+
* (TalkService deleteOtherFromChat). The removed members lose access
|
|
48
|
+
* immediately; visible to every member. Irreversible without re-inviting.
|
|
49
|
+
*
|
|
50
|
+
* @param service - Resumed LineProtocolService.
|
|
51
|
+
* @param args - Tool arguments.
|
|
52
|
+
* @returns MCP tool result.
|
|
53
|
+
*/
|
|
54
|
+
export async function handleKickMember(service, args) {
|
|
55
|
+
if (!args.chatId) {
|
|
56
|
+
return toolError('chatId is required.');
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(args.mids) || args.mids.length === 0) {
|
|
59
|
+
return toolError('mids must be a non-empty array of member MIDs.');
|
|
60
|
+
}
|
|
61
|
+
const result = await service.kickFromGroup(args.chatId, args.mids);
|
|
62
|
+
log.info('kick_member.done', {
|
|
63
|
+
chatId: args.chatId,
|
|
64
|
+
count: args.mids.length,
|
|
65
|
+
});
|
|
66
|
+
return jsonResult(result);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Handle `leave_group` — REALLY makes THIS account leave a real LINE group now
|
|
70
|
+
* (TalkService deleteSelfFromChat). The account loses access to the group.
|
|
71
|
+
*
|
|
72
|
+
* @param service - Resumed LineProtocolService.
|
|
73
|
+
* @param args - Tool arguments.
|
|
74
|
+
* @returns MCP tool result.
|
|
75
|
+
*/
|
|
76
|
+
export async function handleLeaveGroup(service, args) {
|
|
77
|
+
if (!args.chatId) {
|
|
78
|
+
return toolError('chatId is required.');
|
|
79
|
+
}
|
|
80
|
+
const result = await service.leaveGroup(args.chatId);
|
|
81
|
+
log.info('leave_group.done', { chatId: args.chatId });
|
|
82
|
+
return jsonResult(result);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Handle `create_group` — REALLY creates a new LINE group/room now with the
|
|
86
|
+
* given members (TalkService createChat). `chatType` 0 = group (invitees must
|
|
87
|
+
* accept), 1 = room (members added directly); defaults to 1.
|
|
88
|
+
*
|
|
89
|
+
* @param service - Resumed LineProtocolService.
|
|
90
|
+
* @param args - Tool arguments.
|
|
91
|
+
* @returns MCP tool result.
|
|
92
|
+
*/
|
|
93
|
+
export async function handleCreateGroup(service, args) {
|
|
94
|
+
if (!args.name) {
|
|
95
|
+
return toolError('name is required.');
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(args.mids) || args.mids.length === 0) {
|
|
98
|
+
return toolError('mids must be a non-empty array of member MIDs.');
|
|
99
|
+
}
|
|
100
|
+
const result = await service.createGroup(args.name, args.mids, args.chatType);
|
|
101
|
+
log.info('create_group.done', {
|
|
102
|
+
name: args.name,
|
|
103
|
+
count: args.mids.length,
|
|
104
|
+
chatId: result?.chatId ?? null,
|
|
105
|
+
});
|
|
106
|
+
return jsonResult(result);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Handle `accept_invitation` — REALLY accepts a group/chat invitation for THIS
|
|
110
|
+
* account now (TalkService acceptChatInvitation); the account joins the chat.
|
|
111
|
+
*
|
|
112
|
+
* @param service - Resumed LineProtocolService.
|
|
113
|
+
* @param args - Tool arguments.
|
|
114
|
+
* @returns MCP tool result.
|
|
115
|
+
*/
|
|
116
|
+
export async function handleAcceptInvitation(service, args) {
|
|
117
|
+
if (!args.chatId) {
|
|
118
|
+
return toolError('chatId is required.');
|
|
119
|
+
}
|
|
120
|
+
const result = await service.acceptInvitation(args.chatId);
|
|
121
|
+
log.info('accept_invitation.done', { chatId: args.chatId });
|
|
122
|
+
return jsonResult(result);
|
|
123
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel re-exporting every Yomi MCP handler and shared helper — the single
|
|
3
|
+
* import source for server.ts.
|
|
4
|
+
*/
|
|
5
|
+
export * from './contacts.js';
|
|
6
|
+
export * from './conversations.js';
|
|
7
|
+
export * from './groups.js';
|
|
8
|
+
export * from './login.js';
|
|
9
|
+
export * from './media.js';
|
|
10
|
+
export * from './message-actions.js';
|
|
11
|
+
export * from './scope.js';
|
|
12
|
+
export * from './search.js';
|
|
13
|
+
export * from './send.js';
|
|
14
|
+
export * from './shared.js';
|
|
15
|
+
export * from './stickers.js';
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yomi MCP `login`/`login_complete` handlers.
|
|
3
|
+
*
|
|
4
|
+
* Split out of handlers.ts purely to keep that file (the general tool
|
|
5
|
+
* handler surface) under the project's 500-scc-line module cap; behavior
|
|
6
|
+
* owned here is otherwise unchanged in spirit from what previously lived
|
|
7
|
+
* inline there.
|
|
8
|
+
*
|
|
9
|
+
* Two login paths exist, dispatched by client capability:
|
|
10
|
+
*
|
|
11
|
+
* - Elicitation clients: the original single-call flow — elicit phone/
|
|
12
|
+
* region, elicit PIN acknowledgement, block to completion. Unchanged.
|
|
13
|
+
*
|
|
14
|
+
* - Non-elicitation clients (e.g. Claude Desktop, confirmed empirically —
|
|
15
|
+
* it does not support MCP elicitation): the PIN has nowhere to go if we
|
|
16
|
+
* block, since it is produced mid-flow and stderr is invisible to the
|
|
17
|
+
* human, but a tool RESULT is visible. So the flow is split across two
|
|
18
|
+
* tool calls: `login` starts the passwordless flow and returns the PIN
|
|
19
|
+
* as soon as LINE issues it (bounded wait), then `login_complete`
|
|
20
|
+
* awaits the same in-flight login to finish once the human has acted
|
|
21
|
+
* on their phone.
|
|
22
|
+
*
|
|
23
|
+
* Both paths call the ONE shared login sequence in ../cli/login.ts
|
|
24
|
+
* (`runPwlessLogin`) — neither duplicates it.
|
|
25
|
+
*/
|
|
26
|
+
import { runPwlessLogin } from '../../cli/login.js';
|
|
27
|
+
import { LINE_PIN_CODE_LIFETIME_MS, PAAK_AUTH_CLIENT_CEILING_MS, PIN_VERIFY_CLIENT_CEILING_MS, } from '../../line/auth/pwless/index.js';
|
|
28
|
+
import { createCliLogger } from '../../util/log.js';
|
|
29
|
+
import { supportsMcpApps } from '../ui/capability.js';
|
|
30
|
+
import { toolError } from './shared.js';
|
|
31
|
+
const log = createCliLogger('Yomi');
|
|
32
|
+
/** LINE region codes offered on the login elicitation form. */
|
|
33
|
+
const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
|
|
34
|
+
/**
|
|
35
|
+
* How long a live `pendingLogin` may be reused by a later `login` call for
|
|
36
|
+
* the same phone/region, before treating it as stale and starting fresh.
|
|
37
|
+
*
|
|
38
|
+
* This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
|
|
39
|
+
* an external fact from LINE Help Center, ~3 minutes) — NOT to how long
|
|
40
|
+
* yomi's client is willing to keep polling (~16 minutes). A login started
|
|
41
|
+
* 90 seconds ago is still very much alive and its PIN is still valid; the
|
|
42
|
+
* only thing that actually kills a pending login early is LINE's own code
|
|
43
|
+
* expiring, so that is the correct bound for "is this still worth reusing".
|
|
44
|
+
*/
|
|
45
|
+
const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
|
|
46
|
+
/**
|
|
47
|
+
* How long yomi's client keeps polling for the device-approval step once a
|
|
48
|
+
* PIN has been verified (or skipped via a stored certificate) — the real
|
|
49
|
+
* ceiling `login_complete` blocks for, used only to describe that wait to
|
|
50
|
+
* the human, not to bound anything server-side.
|
|
51
|
+
*/
|
|
52
|
+
const APPROVAL_WINDOW_MS = PAAK_AUTH_CLIENT_CEILING_MS;
|
|
53
|
+
/** How long `login` waits for LINE to issue a PIN before reporting a failure. */
|
|
54
|
+
const PIN_WAIT_TIMEOUT_MS = 20000;
|
|
55
|
+
/**
|
|
56
|
+
* Elicit `phone`/`region` from the human in one form, pre-filling whatever
|
|
57
|
+
* the caller already supplied.
|
|
58
|
+
*
|
|
59
|
+
* @param server - MCP Server instance (elicitation already capability-gated
|
|
60
|
+
* by the caller).
|
|
61
|
+
* @param phone - Phone already supplied by the tool call, if any.
|
|
62
|
+
* @param region - Region already supplied by the tool call, if any.
|
|
63
|
+
* @returns The collected phone/region, or `null` if the human declined/cancelled.
|
|
64
|
+
*/
|
|
65
|
+
async function elicitPhoneRegion(server, phone, region) {
|
|
66
|
+
const collected = await server.elicitInput({
|
|
67
|
+
message: 'Yomi needs your LINE phone number and region to start passwordless login.',
|
|
68
|
+
requestedSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
phone: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
title: 'Phone number',
|
|
74
|
+
description: 'E.164 form, e.g. +8869XXXXXXXX.',
|
|
75
|
+
...(phone ? { default: phone } : {}),
|
|
76
|
+
},
|
|
77
|
+
region: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
title: 'Region',
|
|
80
|
+
enum: LOGIN_REGIONS,
|
|
81
|
+
...(region ? { default: region } : {}),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required: ['phone', 'region'],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
if (collected.action !== 'accept') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const collectedPhone = String(collected.content?.phone ?? '');
|
|
91
|
+
const collectedRegion = String(collected.content?.region ?? '');
|
|
92
|
+
if (!collectedPhone || !collectedRegion) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return { phone: collectedPhone, region: collectedRegion };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Elicit acknowledgement of the login PIN — fire-and-forget from the
|
|
99
|
+
* caller's perspective (must not block `startPwlessLogin`, which is
|
|
100
|
+
* already running concurrently and waiting on phone biometric approval).
|
|
101
|
+
*
|
|
102
|
+
* @param server - MCP Server instance.
|
|
103
|
+
* @param pin - The PIN LINE just issued.
|
|
104
|
+
* @param onDeclined - Called if the human explicitly declines/cancels the
|
|
105
|
+
* acknowledgement, so a later login failure can be reported as
|
|
106
|
+
* "cancelled by user" instead of a bare protocol error.
|
|
107
|
+
*/
|
|
108
|
+
function elicitPinAcknowledgement(server, pin, onDeclined) {
|
|
109
|
+
server
|
|
110
|
+
.elicitInput({
|
|
111
|
+
message: `LINE PIN: ${pin}\n\nEnter this PIN in the LINE app on your primary phone, then approve ` +
|
|
112
|
+
'the new device from there. Acknowledge below once you have done this.',
|
|
113
|
+
requestedSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
entered: {
|
|
117
|
+
type: 'boolean',
|
|
118
|
+
title: 'I entered the PIN and approved the device on my phone',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
.then((result) => {
|
|
124
|
+
if (result.action !== 'accept') {
|
|
125
|
+
onDeclined();
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
log.warn('login.pin_ack_failed', {
|
|
130
|
+
error: error?.message ?? String(error),
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Handle `login` for a client that supports MCP elicitation — drive the
|
|
136
|
+
* passwordless flow entirely through elicitation, with no out-of-band
|
|
137
|
+
* channel required. Phone/region are elicited when missing, and the PIN is
|
|
138
|
+
* surfaced through a second elicitation fired without blocking
|
|
139
|
+
* `startPwlessLogin`, which keeps running while the human acts on their
|
|
140
|
+
* phone. See ../cli/login.ts for the shared login sequence.
|
|
141
|
+
*
|
|
142
|
+
* @param server - MCP Server instance (for elicitInput).
|
|
143
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
144
|
+
* @param args - Tool arguments.
|
|
145
|
+
* @returns MCP tool result describing the logged-in profile.
|
|
146
|
+
*/
|
|
147
|
+
async function handleLoginElicitation(server, service, args) {
|
|
148
|
+
let { phone, region } = args;
|
|
149
|
+
if (!phone || !region) {
|
|
150
|
+
const collected = await elicitPhoneRegion(server, phone, region);
|
|
151
|
+
if (!collected) {
|
|
152
|
+
return toolError('Login was cancelled by the user.');
|
|
153
|
+
}
|
|
154
|
+
;
|
|
155
|
+
({ phone, region } = collected);
|
|
156
|
+
}
|
|
157
|
+
let pinDeclined = false;
|
|
158
|
+
const onPin = (pin) => {
|
|
159
|
+
log.warn('login.pin', { action: 'elicit_acknowledgement' });
|
|
160
|
+
elicitPinAcknowledgement(server, pin, () => {
|
|
161
|
+
pinDeclined = true;
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const onBiometric = () => {
|
|
165
|
+
log.warn('login.waiting_biometric', {
|
|
166
|
+
action: 'approve the new device on your phone',
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
const { mid, displayName } = await runPwlessLogin(service, phone, region, {
|
|
171
|
+
onPin,
|
|
172
|
+
onWaitingBiometric: onBiometric,
|
|
173
|
+
});
|
|
174
|
+
log.info('login.complete', { mid });
|
|
175
|
+
return {
|
|
176
|
+
content: [
|
|
177
|
+
{
|
|
178
|
+
type: 'text',
|
|
179
|
+
text: JSON.stringify({ loggedIn: true, mid, displayName }, null, 2),
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (pinDeclined) {
|
|
186
|
+
return toolError('Login was cancelled by the user.');
|
|
187
|
+
}
|
|
188
|
+
return toolError(error?.message ?? String(error));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let pendingLogin = null;
|
|
192
|
+
/**
|
|
193
|
+
* Start a new passwordless login in the background and register it as the
|
|
194
|
+
* one in-flight pending login.
|
|
195
|
+
*
|
|
196
|
+
* The stored promise is the actual `runPwlessLogin` promise, so
|
|
197
|
+
* `login_complete` can await it and see the real success/failure. A
|
|
198
|
+
* `.catch()` is attached immediately (its result discarded, not stored) so
|
|
199
|
+
* an abandoned login — one nobody ever calls `login_complete` for — never
|
|
200
|
+
* surfaces as an unhandled rejection; this does not affect what
|
|
201
|
+
* `pendingLogin.promise` itself resolves/rejects with.
|
|
202
|
+
*
|
|
203
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
204
|
+
* @param phone - Phone number in E.164 form.
|
|
205
|
+
* @param region - Region code.
|
|
206
|
+
* @returns The newly registered pending-login record.
|
|
207
|
+
*/
|
|
208
|
+
function startPendingLogin(service, phone, region) {
|
|
209
|
+
let resolvePinReady = () => { };
|
|
210
|
+
const pinReady = new Promise((resolve) => {
|
|
211
|
+
resolvePinReady = resolve;
|
|
212
|
+
});
|
|
213
|
+
const pending = {
|
|
214
|
+
phone,
|
|
215
|
+
region,
|
|
216
|
+
pin: null,
|
|
217
|
+
certSkippedPin: false,
|
|
218
|
+
startedAt: Date.now(),
|
|
219
|
+
pinReady,
|
|
220
|
+
};
|
|
221
|
+
const onPin = (pin) => {
|
|
222
|
+
pending.pin = pin;
|
|
223
|
+
resolvePinReady();
|
|
224
|
+
};
|
|
225
|
+
const onBiometric = () => {
|
|
226
|
+
log.warn('login.waiting_biometric', {
|
|
227
|
+
action: 'approve the new device on your phone',
|
|
228
|
+
});
|
|
229
|
+
// Reaching biometric wait with no PIN ever set means a valid stored
|
|
230
|
+
// certificate skipped the PIN step entirely (see LinePwlessLogin.login,
|
|
231
|
+
// step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
|
|
232
|
+
// its full timeout waiting for a PIN that will never arrive.
|
|
233
|
+
if (!pending.pin) {
|
|
234
|
+
pending.certSkippedPin = true;
|
|
235
|
+
resolvePinReady();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const promise = runPwlessLogin(service, phone, region, {
|
|
239
|
+
onPin,
|
|
240
|
+
onWaitingBiometric: onBiometric,
|
|
241
|
+
});
|
|
242
|
+
promise.catch(() => {
|
|
243
|
+
// Swallow here only to prevent an unhandled-rejection warning for a
|
|
244
|
+
// login nobody completes; the real error still surfaces to whoever
|
|
245
|
+
// awaits `pending.promise` (login_complete) below.
|
|
246
|
+
});
|
|
247
|
+
pending.promise = promise;
|
|
248
|
+
return pending;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Wait for a pending login's PIN to arrive, bounded by a timeout.
|
|
252
|
+
*
|
|
253
|
+
* @param pending - The pending login to wait on.
|
|
254
|
+
* @param timeoutMs - Maximum time to wait.
|
|
255
|
+
* @returns The PIN, or `null` if it did not arrive within `timeoutMs`.
|
|
256
|
+
*/
|
|
257
|
+
async function waitForPin(pending, timeoutMs) {
|
|
258
|
+
if (pending.pin) {
|
|
259
|
+
return pending.pin;
|
|
260
|
+
}
|
|
261
|
+
const outcome = await Promise.race([
|
|
262
|
+
pending.pinReady.then(() => pending.pin ?? null),
|
|
263
|
+
new Promise((resolve) => {
|
|
264
|
+
setTimeout(() => resolve(null), timeoutMs);
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
return outcome;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Handle `login` for a client that does NOT support MCP elicitation
|
|
271
|
+
* (confirmed empirically for Claude Desktop). Splits the flow into two
|
|
272
|
+
* tool calls since the PIN cannot be prompted for in-band: this call
|
|
273
|
+
* starts (or reuses) the passwordless flow and returns the PIN as soon as
|
|
274
|
+
* LINE issues it; `login_complete` finishes it once the human has acted.
|
|
275
|
+
*
|
|
276
|
+
* The MCP Apps view (./ui/login-app.ts) has never been observed to render on
|
|
277
|
+
* any deployed client (see that file's header) — resources/read succeeds
|
|
278
|
+
* but the host never completes `ui/initialize`. The MODEL is therefore
|
|
279
|
+
* always the driver of this flow, regardless of `supportsUi`: the plain
|
|
280
|
+
* `content` text below is fully actionable on its own in every case,
|
|
281
|
+
* identical in substance whether or not the client declares MCP Apps
|
|
282
|
+
* support. The only thing `supportsUi` still changes is:
|
|
283
|
+
* - missing phone/region: a UI-capable client gets `need_credentials`
|
|
284
|
+
* structuredContent (so a view CAN render a form if it ever does) plus
|
|
285
|
+
* text telling the model to ask the human directly and call `login`
|
|
286
|
+
* again — never text telling it to wait for a form. A non-UI client
|
|
287
|
+
* gets today's `toolError` naming the missing fields, unchanged.
|
|
288
|
+
* - `pin`/`cert` stages: the model is ALWAYS told to call `login_complete`
|
|
289
|
+
* immediately, in both cases. The view (./ui/login-app-view.ts) no
|
|
290
|
+
* longer calls `login_complete` on its own — display-only now — so
|
|
291
|
+
* there is exactly one driver and nothing to race.
|
|
292
|
+
*
|
|
293
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
294
|
+
* @param args - Tool arguments.
|
|
295
|
+
* @param supportsUi - Whether the connected client also negotiated MCP Apps
|
|
296
|
+
* support (../ui/capability.ts's `supportsMcpApps`) — only changes whether
|
|
297
|
+
* `structuredContent`/form-hint text is included, never whether the model is
|
|
298
|
+
* told to act.
|
|
299
|
+
* @returns MCP tool result carrying the PIN and next step, or an honest error.
|
|
300
|
+
*/
|
|
301
|
+
async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
302
|
+
let { phone, region } = args;
|
|
303
|
+
if (!phone) {
|
|
304
|
+
phone = (await service.credentialStore?.get?.('line_phone')) ?? undefined;
|
|
305
|
+
}
|
|
306
|
+
if (!region) {
|
|
307
|
+
region = (await service.credentialStore?.get?.('line_region')) ?? undefined;
|
|
308
|
+
}
|
|
309
|
+
// Build the "which fields are missing" text purely for the message, but
|
|
310
|
+
// return inside a `!phone || !region` guard so TypeScript narrows both to
|
|
311
|
+
// `string` for the rest of the function (no assertion needed).
|
|
312
|
+
if (!phone || !region) {
|
|
313
|
+
if (supportsUi) {
|
|
314
|
+
const needCredentialsContent = {
|
|
315
|
+
stage: 'need_credentials',
|
|
316
|
+
pin: null,
|
|
317
|
+
regions: LOGIN_REGIONS,
|
|
318
|
+
};
|
|
319
|
+
return {
|
|
320
|
+
content: [
|
|
321
|
+
{
|
|
322
|
+
type: 'text',
|
|
323
|
+
text: 'Ask the human for their LINE phone number (E.164 form, e.g. +8869XXXXXXXX) and region ' +
|
|
324
|
+
`(one of ${LOGIN_REGIONS.join(', ')}), then call \`login\` again with those as arguments. ` +
|
|
325
|
+
'A login form may also appear for the human to fill in directly — if they use it, the ' +
|
|
326
|
+
'login proceeds on its own and there is nothing further for you to do — but do not wait ' +
|
|
327
|
+
'for that; if no form appears, asking the human directly is how this login makes progress.',
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
structuredContent: needCredentialsContent,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const missing = [];
|
|
334
|
+
if (!phone)
|
|
335
|
+
missing.push('phone');
|
|
336
|
+
if (!region)
|
|
337
|
+
missing.push('region');
|
|
338
|
+
return toolError(`Missing ${missing.join(' and ')} for login. This MCP client does not support elicitation, so ` +
|
|
339
|
+
`${missing.join('/')} must be supplied as a tool argument, or persisted by running ` +
|
|
340
|
+
'`npx @rikaidev/yomi login` once in a terminal.');
|
|
341
|
+
}
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const active = pendingLogin && now - pendingLogin.startedAt < PIN_WINDOW_MS
|
|
344
|
+
? pendingLogin
|
|
345
|
+
: null;
|
|
346
|
+
if (active && (active.phone !== phone || active.region !== region)) {
|
|
347
|
+
// A live login for a DIFFERENT number is still in flight. Never hand
|
|
348
|
+
// back its PIN under this call's identity, and never silently start a
|
|
349
|
+
// second flow on top of it. Name nothing sensitive.
|
|
350
|
+
return toolError('A login for a different phone number is still in progress. Call `login_complete` to finish it, ' +
|
|
351
|
+
'or wait for its login code to expire (LINE gives the human about 3 minutes to act on it), ' +
|
|
352
|
+
'before starting a login for another number.');
|
|
353
|
+
}
|
|
354
|
+
// Reuse the in-flight login only when it matches this number; otherwise
|
|
355
|
+
// start a fresh one. `pending` is non-null either way, so no narrowing of
|
|
356
|
+
// the module-level `pendingLogin` is needed downstream.
|
|
357
|
+
const pending = active ?? startPendingLogin(service, phone, region);
|
|
358
|
+
pendingLogin = pending;
|
|
359
|
+
const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
|
|
360
|
+
if (!pin) {
|
|
361
|
+
if (pending.certSkippedPin) {
|
|
362
|
+
// A valid stored login certificate skipped the PIN step entirely — the
|
|
363
|
+
// flow is already past it and waiting on phone approval of this device.
|
|
364
|
+
// Call `login_complete` right now; there is no PIN to wait for.
|
|
365
|
+
log.warn('login.pin', { action: 'skipped_cert_valid' });
|
|
366
|
+
const certStructuredContent = {
|
|
367
|
+
stage: 'cert',
|
|
368
|
+
pin: null,
|
|
369
|
+
clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
|
|
370
|
+
};
|
|
371
|
+
const certSteps = 'No PIN needed — LINE recognized a stored login certificate from a previous login, ' +
|
|
372
|
+
'so the PIN step was skipped.\n\n' +
|
|
373
|
+
'0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 ' +
|
|
374
|
+
'enabled — without it LINE will not offer this device a sign-in prompt at all.\n' +
|
|
375
|
+
'1. Bring the LINE app to the foreground on your primary phone — it should prompt you ' +
|
|
376
|
+
'to approve this new device signing in.\n' +
|
|
377
|
+
'2. Approve the device from there.\n\n';
|
|
378
|
+
const certText = certSteps +
|
|
379
|
+
'Call the `login_complete` tool (no arguments) IMMEDIATELY now — do not wait for ' +
|
|
380
|
+
'confirmation that the approval happened first. `login_complete` blocks by itself while ' +
|
|
381
|
+
`you approve (up to about ${Math.round(APPROVAL_WINDOW_MS / 60000)} minutes) and returns ` +
|
|
382
|
+
'your profile once it succeeds.';
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: 'text', text: certText }],
|
|
385
|
+
structuredContent: certStructuredContent,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return toolError('LINE has not issued a login PIN within 20s. The attempt is still running in the background ' +
|
|
389
|
+
'(do not worry, nothing was lost) — call `login` again shortly to check for the PIN.');
|
|
390
|
+
}
|
|
391
|
+
log.warn('login.pin', { action: 'returned_in_tool_result' });
|
|
392
|
+
const pinCodeLifetimeSeconds = Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000);
|
|
393
|
+
const clientPinPollCeilingMinutes = Math.round(PIN_VERIFY_CLIENT_CEILING_MS / 60000);
|
|
394
|
+
const clientApprovalPollCeilingMinutes = Math.round(APPROVAL_WINDOW_MS / 60000);
|
|
395
|
+
const pinStructuredContent = {
|
|
396
|
+
stage: 'pin',
|
|
397
|
+
pin,
|
|
398
|
+
pinCodeLifetimeSeconds,
|
|
399
|
+
clientPinPollCeilingSeconds: Math.round(PIN_VERIFY_CLIENT_CEILING_MS / 1000),
|
|
400
|
+
clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
|
|
401
|
+
};
|
|
402
|
+
const pinSteps = 'LINE PIN:\n\n' +
|
|
403
|
+
` ${pin}\n\n` +
|
|
404
|
+
'0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 enabled — ' +
|
|
405
|
+
'without it LINE will not offer this device a sign-in prompt at all.\n' +
|
|
406
|
+
'1. Open LINE on the primary phone. If a verification prompt does not appear immediately, ' +
|
|
407
|
+
'bringing the app to the foreground surfaces it.\n' +
|
|
408
|
+
`2. Enter this PIN and tick the device that is signing in, then tap 「用戶確認」 — LINE gives ` +
|
|
409
|
+
`you ${pinCodeLifetimeSeconds} seconds (about 3 minutes) from when the code was shown to do this.\n` +
|
|
410
|
+
'3. LINE will then ask that same phone to approve this new device signing in — approve it there.\n\n';
|
|
411
|
+
const pinText = pinSteps +
|
|
412
|
+
`Call the \`login_complete\` tool (no arguments) IMMEDIATELY now — do not wait until you have ` +
|
|
413
|
+
'entered the PIN or approved the device. `login_complete` blocks by itself through both of ' +
|
|
414
|
+
`those steps: it keeps waiting up to about ${clientPinPollCeilingMinutes} minutes for step 2 and ` +
|
|
415
|
+
`up to about ${clientApprovalPollCeilingMinutes} more minutes for step 3, well beyond LINE's own ` +
|
|
416
|
+
`${pinCodeLifetimeSeconds}-second code deadline, so a slow phone is never the failure — missing ` +
|
|
417
|
+
'that 3-minute deadline is what actually kills the code.\n\n' +
|
|
418
|
+
'(This PIN step is skipped on future logins once a login certificate has been stored.)';
|
|
419
|
+
return {
|
|
420
|
+
content: [{ type: 'text', text: pinText }],
|
|
421
|
+
structuredContent: pinStructuredContent,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Handle `login` — dispatches to the elicitation flow when the connected
|
|
426
|
+
* client supports it, otherwise the two-phase flow that finishes via
|
|
427
|
+
* `login_complete`.
|
|
428
|
+
*
|
|
429
|
+
* @param server - MCP Server instance (for elicitInput/getClientCapabilities).
|
|
430
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
431
|
+
* @param args - Tool arguments.
|
|
432
|
+
* @returns MCP tool result.
|
|
433
|
+
*/
|
|
434
|
+
export async function handleLogin(server, service, args) {
|
|
435
|
+
if (server.getClientCapabilities()?.elicitation) {
|
|
436
|
+
return handleLoginElicitation(server, service, args);
|
|
437
|
+
}
|
|
438
|
+
return handleLoginNoElicitation(service, args, supportsMcpApps(server.getClientCapabilities()));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Handle `login_complete` — finish a login started by a prior `login` call
|
|
442
|
+
* on a non-elicitation client. Awaits the same in-flight `runPwlessLogin`
|
|
443
|
+
* promise `login` started, so the real success/failure surfaces here.
|
|
444
|
+
* Never touches credentials on the failure path.
|
|
445
|
+
*
|
|
446
|
+
* @returns MCP tool result describing the logged-in profile, or an honest error.
|
|
447
|
+
*/
|
|
448
|
+
export async function handleLoginComplete() {
|
|
449
|
+
if (!pendingLogin) {
|
|
450
|
+
return toolError('No login is pending. Call `login` first to start a passwordless login. ' +
|
|
451
|
+
'(If you already called `login`, a previous attempt may have finished or its PIN code may have ' +
|
|
452
|
+
`expired — LINE only gives the human about ${Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000)} seconds ` +
|
|
453
|
+
'(about 3 minutes) to act on a shown code. Calling `login` again is safe.)');
|
|
454
|
+
}
|
|
455
|
+
const pending = pendingLogin;
|
|
456
|
+
try {
|
|
457
|
+
const { mid, displayName } = await pending.promise;
|
|
458
|
+
pendingLogin = null;
|
|
459
|
+
log.info('login.complete', { mid });
|
|
460
|
+
return {
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: 'text',
|
|
464
|
+
text: JSON.stringify({ loggedIn: true, mid, displayName }, null, 2),
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
pendingLogin = null;
|
|
471
|
+
return toolError(error?.message ?? String(error));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { fetchLineMessageImage, fetchLineMessageMedia, LineMediaAccessError, resolveLineMediaType, } from '../media.js';
|
|
2
|
+
import { toolError } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Handle `get_message_image` — download and decrypt one image message (legacy
|
|
5
|
+
* alias of get_message_media restricted to images).
|
|
6
|
+
*
|
|
7
|
+
* @param service - Resumed LineProtocolService.
|
|
8
|
+
* @param args - Tool arguments.
|
|
9
|
+
* @returns MCP tool result.
|
|
10
|
+
*/
|
|
11
|
+
export async function handleGetMessageImage(service, args) {
|
|
12
|
+
if (!args.chatId || !args.messageId) {
|
|
13
|
+
return toolError('chatId and messageId are required.');
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const { bytes, mimeType } = await fetchLineMessageImage(service, args.chatId, args.messageId, args.preview ?? false);
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof LineMediaAccessError) {
|
|
25
|
+
return toolError(error.message);
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Handle `get_message_media` — download and decrypt any downloadable LINE
|
|
32
|
+
* media message (image, video, audio, or file). Returns MCP `image`/`audio`
|
|
33
|
+
* content for those kinds, or an embedded `resource` blob (with filename in
|
|
34
|
+
* the URI when known) for video/file. Non-downloadable content types (plain
|
|
35
|
+
* text, sticker refs, unsupported) return an honest error naming the type —
|
|
36
|
+
* never fabricated bytes.
|
|
37
|
+
*
|
|
38
|
+
* @param service - Resumed LineProtocolService.
|
|
39
|
+
* @param args - Tool arguments.
|
|
40
|
+
* @returns MCP tool result.
|
|
41
|
+
*/
|
|
42
|
+
export async function handleGetMessageMedia(service, args) {
|
|
43
|
+
if (!args.chatId || !args.messageId) {
|
|
44
|
+
return toolError('chatId and messageId are required.');
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const { bytes, contentType, fileName, mimeType } = await fetchLineMessageMedia(service, args.chatId, args.messageId, args.preview ?? false);
|
|
48
|
+
const mediaType = resolveLineMediaType(contentType);
|
|
49
|
+
const data = bytes.toString('base64');
|
|
50
|
+
if (mediaType === 'image') {
|
|
51
|
+
return { content: [{ type: 'image', data, mimeType }] };
|
|
52
|
+
}
|
|
53
|
+
if (mediaType === 'audio') {
|
|
54
|
+
return { content: [{ type: 'audio', data, mimeType }] };
|
|
55
|
+
}
|
|
56
|
+
const uri = `line-media://${args.chatId}/${args.messageId}${fileName ? `/${encodeURIComponent(fileName)}` : ''}`;
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{ type: 'resource', resource: { blob: data, mimeType, uri } },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof LineMediaAccessError) {
|
|
65
|
+
return toolError(error.message);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|