@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.
Files changed (86) hide show
  1. package/README.md +46 -4
  2. package/dist/ambient/ambient-event-graph.js +172 -0
  3. package/dist/ambient/ambient-observer.js +71 -0
  4. package/dist/ambient/event-graph-reducer.js +300 -0
  5. package/dist/ambient/ingestion-adapter.js +203 -0
  6. package/dist/ambient/journal/crypto.js +58 -0
  7. package/dist/ambient/journal/journal-store.js +284 -0
  8. package/dist/ambient/journal/types.js +7 -0
  9. package/dist/ambient/summaries/retriever.js +55 -0
  10. package/dist/ambient/summaries/summary-engine.js +128 -0
  11. package/dist/ambient/summaries/types.js +4 -0
  12. package/dist/attention/policy.js +101 -0
  13. package/dist/auth/credential-store.js +47 -17
  14. package/dist/auth/keychain.js +7 -54
  15. package/dist/cli/query.js +875 -0
  16. package/dist/cli/work-item.js +24 -0
  17. package/dist/insight/relationships.js +1 -1
  18. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  19. package/dist/line/auth/pwless/index.js +1 -1
  20. package/dist/line/client/auth-service/client.js +7 -8
  21. package/dist/line/client/buddy-service/client.js +73 -0
  22. package/dist/line/client/index.js +28 -3
  23. package/dist/line/client/obs-media-client.js +38 -0
  24. package/dist/line/client/parsers.js +1 -0
  25. package/dist/line/client/square-service/client.js +298 -0
  26. package/dist/line/client/sticker-meta.js +32 -0
  27. package/dist/line/client/sync-service/client.js +31 -3
  28. package/dist/line/client/talk-service/command-client.js +9 -1
  29. package/dist/line/client/talk-service/contact-query.js +8 -1
  30. package/dist/line/client/talk-service/message-box-query.js +17 -0
  31. package/dist/line/client/talk-service/message-id-query.js +27 -3
  32. package/dist/line/client/talk-service/query-client.js +32 -26
  33. package/dist/line/client/talk-service/requests.js +10 -11
  34. package/dist/line/core/auth-session-runtime.js +17 -2
  35. package/dist/line/core/chat-event.js +35 -0
  36. package/dist/line/core/chat-runtime-service.js +18 -23
  37. package/dist/line/core/config.js +1 -0
  38. package/dist/line/core/directory-runtime-service.js +10 -2
  39. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  40. package/dist/line/core/flex-message.js +453 -0
  41. package/dist/line/core/mention.js +1 -1
  42. package/dist/line/core/message-preview.js +60 -0
  43. package/dist/line/core/message-query-service.js +0 -20
  44. package/dist/line/core/service.js +1 -3
  45. package/dist/mcp/handlers/contacts.js +12 -6
  46. package/dist/mcp/handlers/conversations.js +91 -4
  47. package/dist/mcp/handlers/login.js +1 -1
  48. package/dist/mcp/handlers/message-actions.js +1 -1
  49. package/dist/mcp/handlers/search.js +1 -1
  50. package/dist/mcp/handlers/send.js +8 -11
  51. package/dist/mcp/handlers/shared.js +1 -1
  52. package/dist/mcp/integrator.js +168 -0
  53. package/dist/mcp/media.js +135 -4
  54. package/dist/mcp/names.js +78 -6
  55. package/dist/mcp/phi-guard.js +2 -2
  56. package/dist/search/bigram.js +2 -2
  57. package/dist/search/collector.js +1 -1
  58. package/dist/search/default-embedder.js +12 -3
  59. package/dist/search/embedder.js +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/work-item/adapter.js +1 -0
  62. package/dist/work-item/destination-contracts.js +1 -0
  63. package/dist/work-item/event-context.js +73 -0
  64. package/dist/work-item/gold-contract.js +101 -0
  65. package/dist/work-item/gold-coverage.js +96 -0
  66. package/dist/work-item/gold-derivation.js +67 -0
  67. package/dist/work-item/gold-split.js +143 -0
  68. package/dist/work-item/index.js +23 -0
  69. package/dist/work-item/line-adapter.js +111 -0
  70. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  71. package/dist/work-item/local-provider.js +38 -0
  72. package/dist/work-item/local-work-event-provider.js +56 -0
  73. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  74. package/dist/work-item/provider.js +21 -0
  75. package/dist/work-item/registry.js +19 -0
  76. package/dist/work-item/service.js +85 -0
  77. package/dist/work-item/store.js +97 -0
  78. package/dist/work-item/types.js +79 -0
  79. package/dist/work-item/work-event-graph-engine.js +63 -0
  80. package/dist/work-item/work-event-inference.js +267 -0
  81. package/dist/work-item/work-event-matcher.js +105 -0
  82. package/dist/work-item/work-event-model-manifest.js +39 -0
  83. package/dist/work-item/work-event-store.js +125 -0
  84. package/dist/work-item/work-event.js +123 -0
  85. package/package.json +26 -2
  86. 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
- const lastMessage = box.lastMessages?.[box.lastMessages.length - 1] || null;
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, decryptedLastMessage?.text ?? null),
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 允許自其他裝置登入 prerequisite (and collects
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 台東 misses the chat that literally says 台東" bug),
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
- displayName = service.resolveName(args.contactMid) || '';
248
+ const contact = await service.getContact(args.contactMid);
249
+ displayName = contact?.displayName ?? '';
244
250
  }
245
251
  catch {
246
- displayName = '';
247
- }
248
- if (!displayName) {
249
- try {
250
- const contact = await service.getContact(args.contactMid);
251
- displayName = contact?.displayName ?? '';
252
- }
253
- catch {
254
- // Leave empty — LINE resolves the name from the mid on the recipient side.
255
- }
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`); it does not depend on inboxd for anything.
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: dataPayload.fileName || null,
183
+ fileName,
120
184
  keyMaterial: dataPayload.keyMaterial || null,
121
- mimeType: resolveMimeType(mediaInfo.extension || null, Number(message?.contentType || 0)),
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
- throw new MissingDecryptMaterialError(`message ${messageId} is missing OBS object metadata (OID/SID)`);
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,