@rikaidev/yomi 0.2.6

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