@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
@@ -0,0 +1,24 @@
1
+ import { InMemoryWorkItemStore, LineWorkItemAdapter, LocalReviewProvider, WorkItemProviderRegistry, WorkItemService, } from '../work-item/index.js';
2
+ /**
3
+ * Read-only/create-draft bridge for the desktop and CLI callers. It never
4
+ * sends to a destination; callers must explicitly select a provider and
5
+ * approve the draft before delivery.
6
+ */
7
+ export async function cliCreateWorkItemDraftJson(input, options = {}) {
8
+ const service = new WorkItemService(new InMemoryWorkItemStore());
9
+ return service.create(new LineWorkItemAdapter(), input, options);
10
+ }
11
+ /** Execute only local Work Item transitions; no external provider is registered. */
12
+ export async function cliWorkItemOperationJson(operation, draft) {
13
+ const store = new InMemoryWorkItemStore();
14
+ const provider = new LocalReviewProvider();
15
+ const registry = new WorkItemProviderRegistry().register(provider);
16
+ const service = new WorkItemService(store, registry);
17
+ await store.save(draft);
18
+ if (operation === 'local-review') {
19
+ return service.deliverByProviderId(draft.id, 'local.review');
20
+ }
21
+ return service.transition(draft.id, {
22
+ type: operation === 'preview' ? 'preview' : 'approve',
23
+ });
24
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The relationship layer: who you talk to, how, and how often — the 對象/關係
2
+ * The relationship layer: who you talk to, how, and how often — the contact/relationship
3
3
  * axis of the analysis, built purely from interaction structure (no message
4
4
  * content). This is unsupervised context for the agent's final judgement, not a
5
5
  * judgement itself.
@@ -65,6 +65,18 @@ export async function performPwlessLogin(service, phone, region, loggingInState,
65
65
  });
66
66
  }
67
67
  service.profile = await service.client.getProfile();
68
+ if (service.profile) {
69
+ if (service.profile.displayName) {
70
+ await service.credentialStore.set('displayName', service.profile.displayName);
71
+ }
72
+ if (service.profile.picturePath) {
73
+ await service.credentialStore.set('picturePath', service.profile.picturePath);
74
+ await service.credentialStore.set('pictureUrl', `https://obs.line-scdn.net/${service.profile.picturePath}`);
75
+ }
76
+ if (service.profile.statusMessage) {
77
+ await service.credentialStore.set('statusMessage', service.profile.statusMessage);
78
+ }
79
+ }
68
80
  // Display name only — do not print the mid alongside it.
69
81
  log?.info?.('profile.ok', {
70
82
  displayName: service.profile?.displayName ?? null,
@@ -49,7 +49,7 @@ export const PAAK_AUTH_CLIENT_CEILING_MS = PWLESS_POLL_TIMEOUT_MS * PAAK_AUTH_PO
49
49
  * This is an EXTERNAL FACT, not derived from any of our code or protocol
50
50
  * traffic. Source: LINE Help Center (zh-Hant),
51
51
  * https://help.line.me/line/IOSSecondary/?contentId=20018574&lang=zh-Hant
52
- * — on the primary device, tick the device signing in and tap 「用戶確認」
52
+ * — on the primary device, select the device signing in and confirm the user
53
53
  * within 3 minutes of the code being displayed.
54
54
  */
55
55
  export const LINE_PIN_CODE_LIFETIME_MS = 3 * 60 * 1000;
@@ -39,6 +39,9 @@ function unwrapAuthSuccess(method, result) {
39
39
  }
40
40
  return result.fields?.[0] ?? true;
41
41
  }
42
+ function stringifyAuthValue(value) {
43
+ return (JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? item.toString() : item) ?? String(value));
44
+ }
42
45
  /**
43
46
  * Create the auth capability bound to one LINE client runtime.
44
47
  *
@@ -68,7 +71,7 @@ export function createAuthClient(runtime) {
68
71
  }
69
72
  if (result.fields?.[1] && typeof result.fields[1] === 'object') {
70
73
  const exc = result.fields[1];
71
- const msg = typeof exc[2] === 'string' ? exc[2] : JSON.stringify(exc);
74
+ const msg = typeof exc[2] === 'string' ? exc[2] : stringifyAuthValue(exc);
72
75
  console.error(`[LINE] ${method} exception: ${msg}`);
73
76
  }
74
77
  return result;
@@ -83,10 +86,7 @@ export function createAuthClient(runtime) {
83
86
  // stderr-only logger — stdout is the MCP JSON-RPC channel and this runs
84
87
  // inside the stdio server (token refresh on resume/rotation).
85
88
  const lineLog = getLineClientLog(runtime);
86
- lineLog.info('auth.refresh.call', {
87
- auth: `${String(runtime.authToken || '').slice(0, 16)}...`,
88
- refresh: `${String(refreshToken || '').slice(0, 12)}...`,
89
- });
89
+ lineLog.info('auth.refresh.call');
90
90
  const result = await runtime.sendAuth('refresh', [[12, 1, [[11, 1, refreshToken]]]], LINE_APP_CONFIG.tokenRefreshPath);
91
91
  const resp = result.fields?.[0];
92
92
  if (resp?.[1]) {
@@ -95,7 +95,6 @@ export function createAuthClient(runtime) {
95
95
  const durationUntilRefreshInSec = typeof resp[2] === 'bigint' ? Number(resp[2]) : resp[2] || 0;
96
96
  const tokenIssueTimeEpochSec = typeof resp[3] === 'bigint' ? Number(resp[3]) : resp[3] || null;
97
97
  lineLog.info('auth.refresh.success', {
98
- auth: `${String(resp[1]).slice(0, 16)}...`,
99
98
  next_in_hours: Math.round(durationUntilRefreshInSec / 3600),
100
99
  });
101
100
  return {
@@ -104,8 +103,8 @@ export function createAuthClient(runtime) {
104
103
  tokenIssueTimeEpochSec,
105
104
  };
106
105
  }
107
- console.error('[LINE] refresh() exception payload:', JSON.stringify(result, (_key, val) => typeof val === 'bigint' ? val.toString() : val));
108
- throw new Error(`Token refresh failed: ${JSON.stringify(result, (_key, val) => (typeof val === 'bigint' ? val.toString() : val))}`);
106
+ lineLog.warn('auth.refresh.rejected');
107
+ throw new Error('Token refresh was rejected by LINE');
109
108
  },
110
109
  /**
111
110
  * Report a freshly rotated access token back to LINE's auth service.
@@ -0,0 +1,73 @@
1
+ export function createBuddyClient(runtime) {
2
+ return {
3
+ async getBuddyRichMenu(buddyMid) {
4
+ const detail = await runtime.sendBinary('/BUDDY3', 'getBuddyDetailWithPersonal', [
5
+ [11, 1, buddyMid],
6
+ [14, 2, [8, [0, 1, 2]]],
7
+ ]);
8
+ const buddy = detail.fields?.[0]?.[1];
9
+ const personal = detail.fields?.[0]?.[2];
10
+ const richMenuId = personal?.[1];
11
+ if (!richMenuId)
12
+ return null;
13
+ const chatBar = await runtime.sendBinary('/BUDDY3', 'getBuddyChatBarV2', [
14
+ [
15
+ 12,
16
+ 1,
17
+ [
18
+ [11, 1, buddyMid],
19
+ [10, 2, buddy?.[21] || 0],
20
+ [11, 3, richMenuId],
21
+ ],
22
+ ],
23
+ ]);
24
+ const rich = (chatBar.fields?.[0]?.[1] || [])
25
+ .map((item) => item?.[1])
26
+ .find((item) => typeof item?.[2] === 'string');
27
+ if (!rich)
28
+ return null;
29
+ let body;
30
+ try {
31
+ body = JSON.parse(rich[2]);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ const canvas = body?.canvas;
37
+ const scene = body?.scenes?.[canvas?.initialScene];
38
+ const image = Object.values(body?.images || {})[0];
39
+ if (!canvas || !scene || !image?.imageUrl)
40
+ return null;
41
+ const areas = (scene.listeners || []).flatMap((listener) => {
42
+ const [x, y, width, height] = listener?.params || [];
43
+ const action = body?.actions?.[listener?.action];
44
+ if (![x, y, width, height].every(Number.isFinite) || !action)
45
+ return [];
46
+ return [
47
+ {
48
+ x,
49
+ y,
50
+ width,
51
+ height,
52
+ type: String(action.type || ''),
53
+ uri: typeof action.params?.linkUri === 'string'
54
+ ? action.params.linkUri
55
+ : null,
56
+ text: typeof action.params?.text === 'string'
57
+ ? action.params.text
58
+ : null,
59
+ },
60
+ ];
61
+ });
62
+ return {
63
+ label: String(rich[1] || '選單'),
64
+ richMenuId: String(richMenuId),
65
+ selected: Boolean(rich[3]),
66
+ width: Number(canvas.width),
67
+ height: Number(canvas.height),
68
+ imageUrl: String(image.imageUrl),
69
+ areas,
70
+ };
71
+ },
72
+ };
73
+ }
@@ -20,11 +20,14 @@ import { createCliLogger } from '../../util/log.js';
20
20
  import { LINE_APP_CONFIG } from '../core/config.js';
21
21
  import { encodeCallMessage } from '../core/thrift/index.js';
22
22
  import { createAuthClient } from './auth-service/client.js';
23
+ import { createBuddyClient } from './buddy-service/client.js';
23
24
  import { createE2EEClient } from './e2ee-service/client.js';
24
25
  import { parseOperation } from './parsers.js';
25
26
  import { createShopClient } from './shop-service/client.js';
27
+ import { createSquareClient } from './square-service/client.js';
26
28
  import { createSyncClient } from './sync-service/client.js';
27
29
  import { createTalkCommandClient } from './talk-service/command-client.js';
30
+ import { decodeBinaryResponseMessage, encodeBinaryCallMessage, postBinary, } from './talk-service/message-id-query.js';
28
31
  import { createTalkQueryClient } from './talk-service/query-client.js';
29
32
  import { sendRequest } from './transport.js';
30
33
  const TALK_EXCEPTION_CODE_NAMES = {
@@ -128,11 +131,17 @@ export class LineClient extends EventEmitter {
128
131
  openAuthSession;
129
132
  issueV3TokenForPrimary;
130
133
  restoreE2EEKeyBackup;
131
- downloadMessageContent;
132
- downloadMessageContentPreview;
133
134
  logoutZ;
134
135
  getOwnedStickerPackages;
136
+ getJoinedSquareChats;
137
+ getSquareChatMessages;
138
+ getSquareChatStatus;
139
+ getSquareChatLatestMessage;
140
+ markSquareChatRead;
141
+ sendSquareMessage;
142
+ getBuddyRichMenu;
135
143
  updateChatName;
144
+ updateContactNotificationDisabled;
136
145
  inviteIntoChat;
137
146
  deleteOtherFromChat;
138
147
  deleteSelfFromChat;
@@ -157,7 +166,7 @@ export class LineClient extends EventEmitter {
157
166
  this.seq = 1;
158
167
  this.logger = config.logger || createCliLogger('LINE');
159
168
  this.startupFlowLogger = config.startupFlowLogger || null;
160
- Object.assign(this, createAuthClient(this), createTalkQueryClient(this), createTalkCommandClient(this), createSyncClient(this), createE2EEClient(this), createShopClient(this));
169
+ Object.assign(this, createAuthClient(this), createBuddyClient(this), createTalkQueryClient(this), createTalkCommandClient(this), createSyncClient(this), createE2EEClient(this), createShopClient(this), createSquareClient(this));
161
170
  }
162
171
  /**
163
172
  * Send a TCompact Thrift call to an arbitrary LINE service endpoint path.
@@ -201,6 +210,22 @@ export class LineClient extends EventEmitter {
201
210
  async sendTalk(method, args) {
202
211
  return this.sendCompact(LINE_APP_CONFIG.talkPath, method, args);
203
212
  }
213
+ async sendBinary(path, method, args) {
214
+ const body = encodeBinaryCallMessage(method, this.seq++, args);
215
+ const response = await postBinary(this.host, path, body, this.authToken);
216
+ if (response.nextToken) {
217
+ this.authToken = response.nextToken;
218
+ this.emit('tokenRotated', response.nextToken);
219
+ }
220
+ if (!response.body.length) {
221
+ throw new Error(`${method}(${path}) returned an empty response`);
222
+ }
223
+ const decoded = decodeBinaryResponseMessage(response.body);
224
+ if (decoded.fields?.[1]) {
225
+ throw new Error(`${method}(${path}) failed: ${decoded.fields[1]?.[2] || JSON.stringify(decoded.fields[1])}`);
226
+ }
227
+ return decoded;
228
+ }
204
229
  /**
205
230
  * Parse a raw Thrift operation struct into a domain object.
206
231
  *
@@ -9,6 +9,44 @@ const OBS_HOST = 'obs.line-apps.com';
9
9
  // (LINE_GW_HOST_DOMAIN). obs.line-apps.com returns 400 for the /oa/ upload
10
10
  // route; only the gateway accepts it, and only with an encrypted access token.
11
11
  const OBS_UPLOAD_HOST = 'gwz.line.naver.jp';
12
+ /**
13
+ * Download media sent by a legacy/non-E2EE Talk client.
14
+ *
15
+ * These messages do not carry the OID/SID pair used by the newer E2EE OBS
16
+ * object format. LINE's self-client protocol exposes them under the message
17
+ * object route instead:
18
+ * GET https://obs.line-apps.com/r/talk/m/{messageId}[\/preview]
19
+ *
20
+ * This is deliberately not implemented as a TalkService call. There is no
21
+ * `downloadMessageContent*` method on the current `/S4` service; sending such
22
+ * a Thrift call yields `Invalid method name`.
23
+ */
24
+ export async function downloadLineMessageData(client, options) {
25
+ const messageId = String(options.messageId || '').trim();
26
+ if (!messageId) {
27
+ throw new Error('LINE legacy media download requires a message id');
28
+ }
29
+ const suffix = options.preview ? '/preview' : '';
30
+ const namespace = options.square ? 'g2' : 'talk';
31
+ const requestUrl = `https://${OBS_HOST}/r/${namespace}/m/${encodeURIComponent(messageId)}${suffix}`;
32
+ const response = await fetch(requestUrl, {
33
+ method: 'GET',
34
+ headers: {
35
+ Accept: 'application/json, text/plain, */*',
36
+ 'User-Agent': LINE_APP_CONFIG.userAgent,
37
+ 'X-Line-Access': client.authToken,
38
+ 'X-Line-Application': LINE_APP_CONFIG.lineApp,
39
+ },
40
+ });
41
+ const bytes = Buffer.from(await response.arrayBuffer());
42
+ if (!response.ok || bytes.length === 0) {
43
+ throw new Error(`LINE legacy media download failed: status=${response.status} bytes=${bytes.length}`);
44
+ }
45
+ return {
46
+ bytes,
47
+ mimeType: response.headers.get('content-type')?.split(';', 1)[0] || null,
48
+ };
49
+ }
12
50
  /**
13
51
  * Build LINE talk metadata required for OBS media downloads.
14
52
  *
@@ -26,6 +26,7 @@ export function parseMessage(msg) {
26
26
  ? Number(msg[7])
27
27
  : (msg[6] ?? msg[7]),
28
28
  text: msg[10] || null,
29
+ contentPreview: msg[17] || null,
29
30
  contentType: msg[15] || 0,
30
31
  contentMetadata: msg[18] || {},
31
32
  chunks: msg[20] || null,
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Read-only LINE OpenChat (SquareService) capability.
3
+ *
4
+ * OpenChat is a separate service from TalkService: joined chats use `t...`
5
+ * MIDs and never appear in TalkService message boxes. Keep the protocol
6
+ * boundary explicit so callers cannot accidentally treat Square messages as
7
+ * ordinary group messages.
8
+ */
9
+ import { LINE_APP_CONFIG } from '../../core/config.js';
10
+ import { i32Field, i64Field, mapField, setField, stringField, structField, } from '../../core/thrift/index.js';
11
+ function mapEntries(value) {
12
+ if (value instanceof Map)
13
+ return Array.from(value.entries());
14
+ if (value && typeof value === 'object')
15
+ return Object.entries(value);
16
+ return [];
17
+ }
18
+ /** LINE currently emits `m...` SquareChat MIDs; older schemas used `t...`. */
19
+ export function isSquareChatMid(value) {
20
+ return value.startsWith('m') || value.startsWith('t');
21
+ }
22
+ function normalizeJoinedChat(chat, status, member) {
23
+ const id = String(chat?.[1] || '');
24
+ if (!isSquareChatMid(id))
25
+ return null;
26
+ const lastSquareMessage = status?.[3];
27
+ return {
28
+ id,
29
+ squareMid: chat?.[2] ? String(chat[2]) : null,
30
+ memberMid: member?.[1] ? String(member[1]) : null,
31
+ name: String(chat?.[4] || 'LINE 社群'),
32
+ pictureHash: chat?.[5] ? String(chat[5]) : null,
33
+ unreadCount: Number(status?.[5]?.[2] || 0),
34
+ lastMessage: lastSquareMessage?.[1] || null,
35
+ lastSenderName: status?.[4] ? String(status[4]) : null,
36
+ isMuted: member?.[5] === false,
37
+ };
38
+ }
39
+ function eventMessage(event) {
40
+ const payload = event?.[4];
41
+ const received = payload?.[1];
42
+ const sent = payload?.[2];
43
+ const mutated = payload?.[41];
44
+ const notification = payload?.[30];
45
+ const envelope = received || sent || mutated || notification;
46
+ const squareMessage = envelope?.[2];
47
+ const message = squareMessage?.[1];
48
+ if (!message)
49
+ return null;
50
+ const chatId = String(envelope?.[1] || message?.[2] || '');
51
+ const id = String(message?.[4] || '');
52
+ if (!chatId || !id)
53
+ return null;
54
+ const fromMid = String(message?.[1] || '');
55
+ return {
56
+ id,
57
+ chatId,
58
+ fromMid,
59
+ fromName: fromMid ? `成員 ${fromMid.slice(-4)}` : '匿名成員',
60
+ fromPictureUrl: null,
61
+ isSelf: Boolean(sent),
62
+ text: typeof message?.[10] === 'string' ? message[10] : '',
63
+ createdTime: Number(message?.[6] || message?.[5] || event?.[2] || 0),
64
+ contentType: Number(message?.[15] || 0),
65
+ contentMetadata: message?.[18] && typeof message[18] === 'object' ? message[18] : {},
66
+ threadMid: squareMessage?.[6]?.[1]
67
+ ? String(squareMessage[6][1])
68
+ : envelope?.[7]
69
+ ? String(envelope[7])
70
+ : null,
71
+ isThreadRoot: Boolean(squareMessage?.[6]?.[2]),
72
+ };
73
+ }
74
+ export function createSquareClient(runtime) {
75
+ const memberProfiles = new Map();
76
+ async function resolveMember(squareMemberMid) {
77
+ const cached = memberProfiles.get(squareMemberMid);
78
+ if (cached)
79
+ return cached;
80
+ try {
81
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'getSquareMember', [structField(1, [stringField(1, squareMemberMid)])]);
82
+ const member = result.fields?.[0]?.[1];
83
+ const displayName = String(member?.[3] || '').trim();
84
+ const pictureHash = String(member?.[4] || '').trim();
85
+ if (displayName) {
86
+ const profile = {
87
+ displayName,
88
+ pictureUrl: pictureHash
89
+ ? `https://obs.line-scdn.net/${pictureHash.replace(/^\/+/, '')}`
90
+ : null,
91
+ };
92
+ memberProfiles.set(squareMemberMid, profile);
93
+ return profile;
94
+ }
95
+ }
96
+ catch (error) {
97
+ if (process.env.YOMI_DEBUG_SQUARE_MEMBER === '1') {
98
+ console.error('[square] getSquareMember failed', error instanceof Error ? error.message : String(error));
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+ async function resolveMembers(squareMemberMids) {
104
+ const unresolved = squareMemberMids.filter((mid) => !memberProfiles.has(mid));
105
+ if (unresolved.length === 0)
106
+ return;
107
+ try {
108
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'getSquareMembers', [structField(1, [setField(2, 11, unresolved)])]);
109
+ const raw = result.fields?.[0]?.[1];
110
+ const candidates = raw && typeof raw?.[1] === 'string'
111
+ ? [raw]
112
+ : mapEntries(raw).map(([, member]) => member);
113
+ for (const member of candidates) {
114
+ const mid = String(member?.[1] || '');
115
+ const displayName = String(member?.[3] || '').trim();
116
+ const pictureHash = String(member?.[4] || '').trim();
117
+ if (!mid || !displayName)
118
+ continue;
119
+ memberProfiles.set(mid, {
120
+ displayName,
121
+ pictureUrl: pictureHash
122
+ ? `https://obs.line-scdn.net/${pictureHash.replace(/^\/+/, '')}`
123
+ : null,
124
+ });
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (process.env.YOMI_DEBUG_SQUARE_MEMBER === '1') {
129
+ console.error('[square] getSquareMembers failed', error instanceof Error ? error.message : String(error));
130
+ }
131
+ }
132
+ }
133
+ return {
134
+ async getJoinedSquareChats(limit = 100) {
135
+ const chats = [];
136
+ let continuationToken;
137
+ const seenTokens = [];
138
+ try {
139
+ for (;;) {
140
+ const request = [i32Field(3, Math.max(1, Math.min(limit, 100)))];
141
+ if (continuationToken)
142
+ request.unshift(stringField(2, continuationToken));
143
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'getJoinedSquareChats', [structField(1, request)]);
144
+ const response = result.fields?.[0];
145
+ const statuses = mapEntries(response?.[3]);
146
+ const members = mapEntries(response?.[2]);
147
+ for (const raw of Array.isArray(response?.[1]) ? response[1] : []) {
148
+ const id = String(raw?.[1] || '');
149
+ const chat = normalizeJoinedChat(raw, statuses.find(([key]) => key === id)?.[1], members.find(([key]) => key === id)?.[1]);
150
+ if (chat)
151
+ chats.push(chat);
152
+ }
153
+ const next = response?.[4] ? String(response[4]) : '';
154
+ if (!next || seenTokens.includes(next) || chats.length >= limit)
155
+ break;
156
+ seenTokens.push(next);
157
+ continuationToken = next;
158
+ }
159
+ }
160
+ catch (error) {
161
+ // LINE's Desktop identity currently answers getJoinedSquareChats with
162
+ // `501 NO XLT`. A bootstrap fetchMyEvents(subscriptionId=0) returns
163
+ // the authoritative CREATE_SQUARE_CHAT_MEMBER snapshots instead.
164
+ chats.length = 0;
165
+ let eventContinuation;
166
+ const seenEventTokens = [];
167
+ for (;;) {
168
+ const request = [i64Field(1, 0), i32Field(3, 100)];
169
+ if (eventContinuation)
170
+ request.push(stringField(4, eventContinuation));
171
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'fetchMyEvents', [structField(1, request)]);
172
+ const response = result.fields?.[0];
173
+ for (const event of Array.isArray(response?.[2]) ? response[2] : []) {
174
+ const createdMembership = event?.[4]?.[17];
175
+ const chat = normalizeJoinedChat(createdMembership?.[1], createdMembership?.[2], createdMembership?.[3]);
176
+ if (chat)
177
+ chats.push(chat);
178
+ }
179
+ const next = response?.[4] ? String(response[4]) : '';
180
+ if (!next || seenEventTokens.includes(next) || chats.length >= limit)
181
+ break;
182
+ seenEventTokens.push(next);
183
+ eventContinuation = next;
184
+ }
185
+ if (chats.length === 0)
186
+ throw error;
187
+ }
188
+ return chats
189
+ .filter((chat, index) => chats.findIndex((item) => item.id === chat.id) === index)
190
+ .slice(0, limit);
191
+ },
192
+ async getSquareChatMessages(chatId, limit = 40, threadMid) {
193
+ if (!isSquareChatMid(chatId))
194
+ throw new Error('Invalid OpenChat MID');
195
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'fetchSquareChatEvents', [
196
+ structField(1, [
197
+ i64Field(1, 0),
198
+ stringField(2, chatId),
199
+ i32Field(4, Math.max(1, Math.min(limit, 100))),
200
+ i32Field(5, 1),
201
+ i32Field(6, 1),
202
+ i32Field(8, 1),
203
+ ...(threadMid ? [stringField(9, threadMid)] : []),
204
+ ]),
205
+ ]);
206
+ const events = result.fields?.[0]?.[2];
207
+ const messages = [];
208
+ for (const event of Array.isArray(events) ? events : []) {
209
+ const message = eventMessage(event);
210
+ if (!message || message.chatId !== chatId)
211
+ continue;
212
+ const existingIndex = messages.findIndex((item) => item.id === message.id);
213
+ if (existingIndex >= 0)
214
+ messages[existingIndex] = message;
215
+ else
216
+ messages.push(message);
217
+ }
218
+ const senderMids = [
219
+ ...new Set(messages.map((message) => message.fromMid).filter(Boolean)),
220
+ ];
221
+ await resolveMembers(senderMids);
222
+ await Promise.all(senderMids.map(async (mid) => {
223
+ const profile = await resolveMember(mid);
224
+ if (!profile)
225
+ return;
226
+ for (const message of messages) {
227
+ if (message.fromMid !== mid)
228
+ continue;
229
+ message.fromName = profile.displayName;
230
+ message.fromPictureUrl = profile.pictureUrl;
231
+ }
232
+ }));
233
+ return messages
234
+ .sort((a, b) => a.createdTime - b.createdTime)
235
+ .slice(-limit);
236
+ },
237
+ async getSquareChatStatus(chatId) {
238
+ if (!isSquareChatMid(chatId))
239
+ throw new Error('Invalid OpenChat MID');
240
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'getSquareChatStatus', [structField(1, [stringField(2, chatId)])]);
241
+ return result.fields?.[0]?.[1] || null;
242
+ },
243
+ async getSquareChatLatestMessage(chatId) {
244
+ if (!isSquareChatMid(chatId))
245
+ throw new Error('Invalid OpenChat MID');
246
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'getSquareChatStatus', [structField(1, [stringField(2, chatId)])]);
247
+ const status = result.fields?.[0]?.[1];
248
+ const raw = status?.[3]?.[1];
249
+ if (!raw?.[4])
250
+ return null;
251
+ const fromMid = String(raw?.[1] || '');
252
+ const profile = fromMid ? await resolveMember(fromMid) : null;
253
+ return {
254
+ id: String(raw[4]),
255
+ chatId,
256
+ fromMid,
257
+ fromName: profile?.displayName || String(status?.[4] || '') || '匿名成員',
258
+ fromPictureUrl: profile?.pictureUrl || null,
259
+ isSelf: false,
260
+ text: typeof raw?.[10] === 'string' ? raw[10] : '',
261
+ createdTime: Number(raw?.[6] || raw?.[5] || 0),
262
+ contentType: Number(raw?.[15] || 0),
263
+ contentMetadata: raw?.[18] && typeof raw[18] === 'object' ? raw[18] : {},
264
+ threadMid: null,
265
+ isThreadRoot: false,
266
+ };
267
+ },
268
+ async markSquareChatRead(chatId, messageId) {
269
+ if (!isSquareChatMid(chatId))
270
+ throw new Error('Invalid OpenChat MID');
271
+ if (!messageId)
272
+ throw new Error('OpenChat message id required');
273
+ await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'markAsRead', [
274
+ structField(1, [stringField(2, chatId), stringField(4, messageId)]),
275
+ ]);
276
+ },
277
+ async sendSquareMessage(chatId, text, contentMetadata = {}) {
278
+ if (!isSquareChatMid(chatId))
279
+ throw new Error('Invalid OpenChat MID');
280
+ const messageFields = [
281
+ stringField(2, chatId),
282
+ stringField(10, text),
283
+ i32Field(15, 0),
284
+ ];
285
+ if (Object.keys(contentMetadata).length > 0) {
286
+ messageFields.push(mapField(18, 11, 11, contentMetadata));
287
+ }
288
+ const result = await runtime.sendCompact(LINE_APP_CONFIG.squarePath, 'sendMessage', [
289
+ structField(1, [
290
+ i32Field(1, runtime.seq++),
291
+ stringField(2, chatId),
292
+ structField(3, [structField(1, messageFields), i64Field(4, 4)]),
293
+ ]),
294
+ ]);
295
+ return result.fields?.[0]?.[1] || null;
296
+ },
297
+ };
298
+ }
@@ -8,6 +8,30 @@
8
8
  * package the caller wants to expand into sendable sticker ids.
9
9
  */
10
10
  const CDN = 'https://stickershop.line-scdn.net/stickershop/v1/product';
11
+ // Metadata is public and immutable for the lifetime of a picker session. Keep
12
+ // one in-flight/result promise per package so opening the picker or changing
13
+ // locale does not refetch the same package repeatedly.
14
+ const packageMetaCache = new Map();
15
+ /** Pick the title users expect for the app's current language. */
16
+ export function localizedStickerTitle(titles, language = 'en', fallback = '') {
17
+ const values = titles ?? {};
18
+ const normalized = language.replace('_', '-').toLowerCase();
19
+ const preferred = normalized.startsWith('zh')
20
+ ? ['zh-Hant', 'zh_TW', 'zh-TW', 'zh']
21
+ : normalized.startsWith('ja')
22
+ ? ['ja']
23
+ : ['en'];
24
+ for (const key of preferred) {
25
+ const value = values[key];
26
+ if (typeof value === 'string' && value.trim())
27
+ return value.trim();
28
+ }
29
+ const english = values.en;
30
+ if (typeof english === 'string' && english.trim())
31
+ return english.trim();
32
+ const first = Object.values(values).find((value) => typeof value === 'string' && value.trim());
33
+ return first?.trim() || fallback;
34
+ }
11
35
  /**
12
36
  * Fetch one sticker package's public metadata (title + individual sticker ids).
13
37
  *
@@ -31,6 +55,14 @@ export async function fetchStickerPackageMeta(packageId) {
31
55
  .filter((id) => id !== null),
32
56
  };
33
57
  }
58
+ export function fetchStickerPackageMetaCached(packageId) {
59
+ const cached = packageMetaCache.get(packageId);
60
+ if (cached)
61
+ return cached;
62
+ const request = fetchStickerPackageMeta(packageId);
63
+ packageMetaCache.set(packageId, request);
64
+ return request;
65
+ }
34
66
  /**
35
67
  * Fetch one sticker's PNG preview image from the public sticker CDN.
36
68
  *