@rikaidev/yomi 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +81 -0
- package/PRIVACY.md +39 -0
- package/README.md +659 -0
- package/dist/auth/credential-store.js +395 -0
- package/dist/auth/keychain.js +256 -0
- package/dist/cli/login.js +142 -0
- package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
- package/dist/line/auth/protocol/index.js +3 -0
- package/dist/line/auth/protocol/login-metadata.js +159 -0
- package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
- package/dist/line/auth/pwless/index.js +163 -0
- package/dist/line/auth/pwless/steps.js +213 -0
- package/dist/line/client/auth-service/client.js +240 -0
- package/dist/line/client/e2ee-service/client.js +69 -0
- package/dist/line/client/e2ee-service/requests.js +92 -0
- package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
- package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
- package/dist/line/client/index.js +213 -0
- package/dist/line/client/obs-media-client.js +140 -0
- package/dist/line/client/parsers.js +69 -0
- package/dist/line/client/shop-service/client.js +63 -0
- package/dist/line/client/sticker-meta.js +46 -0
- package/dist/line/client/sync-service/client.js +317 -0
- package/dist/line/client/talk-service/chat-query.js +122 -0
- package/dist/line/client/talk-service/command-client.js +242 -0
- package/dist/line/client/talk-service/contact-query.js +77 -0
- package/dist/line/client/talk-service/message-box-query.js +137 -0
- package/dist/line/client/talk-service/message-id-query.js +241 -0
- package/dist/line/client/talk-service/query-client.js +219 -0
- package/dist/line/client/talk-service/requests.js +383 -0
- package/dist/line/client/transport.js +138 -0
- package/dist/line/core/auth-session-logout.js +55 -0
- package/dist/line/core/auth-session-runtime.js +231 -0
- package/dist/line/core/auth-session-service.js +85 -0
- package/dist/line/core/chat-runtime-service.js +343 -0
- package/dist/line/core/client-runtime.js +17 -0
- package/dist/line/core/config.js +67 -0
- package/dist/line/core/constants.js +56 -0
- package/dist/line/core/contact-query-service.js +20 -0
- package/dist/line/core/directory-runtime-service.js +83 -0
- package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
- package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
- package/dist/line/core/e2ee/index.js +6 -0
- package/dist/line/core/e2ee/keys/decrypt.js +110 -0
- package/dist/line/core/e2ee/keys/encrypt.js +117 -0
- package/dist/line/core/e2ee/keys/group-key.js +260 -0
- package/dist/line/core/e2ee/keys/key-manager.js +231 -0
- package/dist/line/core/e2ee/keys/key-payload.js +130 -0
- package/dist/line/core/e2ee/keys/key-types.js +1 -0
- package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
- package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
- package/dist/line/core/e2ee/keys/peer-key.js +191 -0
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
- package/dist/line/core/e2ee/types.js +4 -0
- package/dist/line/core/group-query-service.js +20 -0
- package/dist/line/core/media-decrypt.js +78 -0
- package/dist/line/core/media-encrypt.js +97 -0
- package/dist/line/core/mention.js +89 -0
- package/dist/line/core/message-command-service.js +454 -0
- package/dist/line/core/message-query-service.js +161 -0
- package/dist/line/core/service.js +134 -0
- package/dist/line/core/session-state/index.js +243 -0
- package/dist/line/core/session-state/persist.js +192 -0
- package/dist/line/core/store.js +8 -0
- package/dist/line/core/thrift/binary/binary-reader.js +151 -0
- package/dist/line/core/thrift/binary/binary-writer.js +157 -0
- package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
- package/dist/line/core/thrift/compact/compact-reader.js +180 -0
- package/dist/line/core/thrift/compact/compact-writer.js +161 -0
- package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
- package/dist/line/core/thrift/fields/builders.js +124 -0
- package/dist/line/core/thrift/index.js +12 -0
- package/dist/line/core/thrift/message/decoder.js +55 -0
- package/dist/line/core/thrift/message/encoder.js +25 -0
- package/dist/line/core/thrift/primitives/varint.js +64 -0
- package/dist/line/core/thrift/struct/reader.js +172 -0
- package/dist/line/core/thrift/struct/writer.js +189 -0
- package/dist/line/core/thrift/types.js +23 -0
- package/dist/line/core/video-thumbnail.js +104 -0
- package/dist/mcp/handlers/contacts.js +166 -0
- package/dist/mcp/handlers/conversations.js +193 -0
- package/dist/mcp/handlers/groups.js +123 -0
- package/dist/mcp/handlers/index.js +15 -0
- package/dist/mcp/handlers/login.js +473 -0
- package/dist/mcp/handlers/media.js +69 -0
- package/dist/mcp/handlers/message-actions.js +62 -0
- package/dist/mcp/handlers/scope.js +120 -0
- package/dist/mcp/handlers/search.js +196 -0
- package/dist/mcp/handlers/send.js +523 -0
- package/dist/mcp/handlers/shared.js +84 -0
- package/dist/mcp/handlers/stickers.js +87 -0
- package/dist/mcp/media.js +228 -0
- package/dist/mcp/names.js +133 -0
- package/dist/mcp/phi-guard.js +105 -0
- package/dist/mcp/policy.js +34 -0
- package/dist/mcp/server.js +301 -0
- package/dist/mcp/tools.js +761 -0
- package/dist/mcp/ui/capability.js +26 -0
- package/dist/mcp/ui/login-app-protocol.js +118 -0
- package/dist/mcp/ui/login-app-styles.js +142 -0
- package/dist/mcp/ui/login-app-view.js +361 -0
- package/dist/mcp/ui/login-app.js +72 -0
- package/dist/mcp/ui/login-structured-content.js +1 -0
- package/dist/mcp/ui/resource.js +35 -0
- package/dist/mcp/ui/tools-with-ui.js +40 -0
- package/dist/search/bigram.js +86 -0
- package/dist/search/capture.js +140 -0
- package/dist/search/collector.js +176 -0
- package/dist/search/default-embedder.js +101 -0
- package/dist/search/embedder.js +13 -0
- package/dist/search/scope.js +142 -0
- package/dist/search/sqlite.js +141 -0
- package/dist/search/store.js +469 -0
- package/dist/util/data-dir.js +55 -0
- package/dist/util/log.js +86 -0
- package/dist/version.js +7 -0
- package/package.json +63 -0
- package/run.mjs +71 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Protocol Service - Yomi's read-only session/query core.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates session resume (with auto token refresh) and E2EE
|
|
5
|
+
* decryption. Login and continuous polling live in the vendored auth
|
|
6
|
+
* modules but are never invoked by Yomi's MCP server — see README.md.
|
|
7
|
+
*/
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { CredentialStore } from '../../auth/credential-store.js';
|
|
10
|
+
import { yomiDataPath } from '../../util/data-dir.js';
|
|
11
|
+
import { createCliLogger } from '../../util/log.js';
|
|
12
|
+
import { createAuthSessionService } from './auth-session-service.js';
|
|
13
|
+
import { createChatRuntimeService } from './chat-runtime-service.js';
|
|
14
|
+
import { createDirectoryRuntimeService } from './directory-runtime-service.js';
|
|
15
|
+
import { E2EEKeyManager } from './e2ee/index.js';
|
|
16
|
+
import { LineSessionState } from './session-state/index.js';
|
|
17
|
+
export const STATE = {
|
|
18
|
+
DISCONNECTED: 'disconnected',
|
|
19
|
+
LOGGING_IN: 'logging_in',
|
|
20
|
+
CONNECTED: 'connected',
|
|
21
|
+
POLLING: 'polling',
|
|
22
|
+
ERROR: 'error',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* High-level LINE service coordinating all aspects of the protocol.
|
|
26
|
+
* Used by Adapter and Agent layers.
|
|
27
|
+
*/
|
|
28
|
+
export class LineProtocolService extends EventEmitter {
|
|
29
|
+
options;
|
|
30
|
+
state;
|
|
31
|
+
client;
|
|
32
|
+
profile;
|
|
33
|
+
startupFlowLogger;
|
|
34
|
+
logger;
|
|
35
|
+
credentialStore;
|
|
36
|
+
sessionState;
|
|
37
|
+
nameCache;
|
|
38
|
+
chatCache;
|
|
39
|
+
e2eeManager;
|
|
40
|
+
e2eeWarning;
|
|
41
|
+
loginRequired;
|
|
42
|
+
// Why loginRequired was set. 'revoked' = LINE invalidated the token (a
|
|
43
|
+
// competing login elsewhere); 'expired' = the token aged out and the silent
|
|
44
|
+
// refresh failed. Both recover via `login`, but they are different stories to
|
|
45
|
+
// tell the user, so the cause has to survive the trip to the MCP gate.
|
|
46
|
+
// Always cleared back to null wherever loginRequired goes false.
|
|
47
|
+
loginReason;
|
|
48
|
+
recentFetchState;
|
|
49
|
+
// The following are mixed in at construction time via Object.assign from
|
|
50
|
+
// createAuthSessionService / createDirectoryRuntimeService /
|
|
51
|
+
// createChatRuntimeService (see constructor below) — TS cannot see that
|
|
52
|
+
// assignment, so these use definite-assignment assertions. Yomi's MCP
|
|
53
|
+
// server calls resumeSession/sendMessage/getRecentMessages/
|
|
54
|
+
// getPreviousMessages/download* only; it never calls startPwlessLogin
|
|
55
|
+
// (still present for compile completeness).
|
|
56
|
+
startPwlessLogin;
|
|
57
|
+
logout;
|
|
58
|
+
invalidateSession;
|
|
59
|
+
tryRefreshToken;
|
|
60
|
+
resumeSession;
|
|
61
|
+
resolveName;
|
|
62
|
+
getContact;
|
|
63
|
+
getGroup;
|
|
64
|
+
listStickerPackages;
|
|
65
|
+
searchStickerPackages;
|
|
66
|
+
sendMessage;
|
|
67
|
+
sendImage;
|
|
68
|
+
sendFile;
|
|
69
|
+
sendAudio;
|
|
70
|
+
sendVideo;
|
|
71
|
+
sendContact;
|
|
72
|
+
sendSticker;
|
|
73
|
+
sendLocation;
|
|
74
|
+
getRecentMessages;
|
|
75
|
+
getPreviousMessages;
|
|
76
|
+
downloadMessageContent;
|
|
77
|
+
downloadMessageContentPreview;
|
|
78
|
+
markChatRead;
|
|
79
|
+
renameGroup;
|
|
80
|
+
inviteToGroup;
|
|
81
|
+
kickFromGroup;
|
|
82
|
+
leaveGroup;
|
|
83
|
+
createGroup;
|
|
84
|
+
reactToMessage;
|
|
85
|
+
cancelReaction;
|
|
86
|
+
unsendMessage;
|
|
87
|
+
addFriend;
|
|
88
|
+
blockContact;
|
|
89
|
+
unblockContact;
|
|
90
|
+
acceptInvitation;
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
super();
|
|
93
|
+
this.options = { autoStart: true, pollInterval: 10000, ...options };
|
|
94
|
+
this.state = STATE.DISCONNECTED;
|
|
95
|
+
this.client = null;
|
|
96
|
+
this.profile = null;
|
|
97
|
+
this.logger = options.logger || createCliLogger('LINE');
|
|
98
|
+
this.startupFlowLogger = null;
|
|
99
|
+
this.credentialStore =
|
|
100
|
+
options.credentialStore ||
|
|
101
|
+
new CredentialStore('line',
|
|
102
|
+
// Absolute, per-user (see ../../util/data-dir.ts). This was the
|
|
103
|
+
// relative string 'data/line-credentials.json', which CredentialStore
|
|
104
|
+
// also derives the E2EE cache path from — so under Claude Desktop, whose
|
|
105
|
+
// cwd is `/`, the cache aimed at /data/ and silently never persisted.
|
|
106
|
+
this.options.credentialPath ?? yomiDataPath('line-credentials.json'));
|
|
107
|
+
this.sessionState = new LineSessionState(this.credentialStore);
|
|
108
|
+
this.nameCache = new Map();
|
|
109
|
+
this.chatCache = new Map();
|
|
110
|
+
this.e2eeManager = options.e2eeManager || new E2EEKeyManager();
|
|
111
|
+
this.e2eeWarning = false;
|
|
112
|
+
this.loginRequired = false;
|
|
113
|
+
this.loginReason = null;
|
|
114
|
+
this.recentFetchState = new Map();
|
|
115
|
+
this.e2eeManager.setRuntime({
|
|
116
|
+
getClient: () => this.client,
|
|
117
|
+
getStore: () => this.credentialStore,
|
|
118
|
+
getProfileMid: () => this.profile?.mid,
|
|
119
|
+
emitWarning: (payload) => {
|
|
120
|
+
this.e2eeWarning = payload.active;
|
|
121
|
+
this.emit('e2eeWarning', payload);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
Object.assign(this, createAuthSessionService(this, STATE), createDirectoryRuntimeService(this), createChatRuntimeService(this));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Set the current service state and emit an event.
|
|
128
|
+
* @param newState - The new state to set.
|
|
129
|
+
*/
|
|
130
|
+
setState(newState) {
|
|
131
|
+
this.state = newState;
|
|
132
|
+
this.emit('state', newState);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE session state manager.
|
|
3
|
+
*
|
|
4
|
+
* Acts as the single state owner for LINE auth/session persistence. All
|
|
5
|
+
* token/revision writes should flow through this class instead of writing
|
|
6
|
+
* directly to the credential store from scattered call sites.
|
|
7
|
+
*/
|
|
8
|
+
import { loadStoredSessionFields, parseStoredNumber, persistLoginBootstrap, persistLoginCredentials, persistLoginCryptoMaterial, } from './persist.js';
|
|
9
|
+
/**
|
|
10
|
+
* Own persisted and in-memory LINE session state.
|
|
11
|
+
*/
|
|
12
|
+
export class LineSessionState {
|
|
13
|
+
credentialStore;
|
|
14
|
+
client;
|
|
15
|
+
authToken;
|
|
16
|
+
refreshToken;
|
|
17
|
+
tokenIssueTimeEpochSec;
|
|
18
|
+
durationUntilRefreshInSec;
|
|
19
|
+
revision;
|
|
20
|
+
globalRevision;
|
|
21
|
+
individualRevision;
|
|
22
|
+
mid;
|
|
23
|
+
/**
|
|
24
|
+
* Create the LINE session state manager.
|
|
25
|
+
* @param credentialStore - Persistent credential store for LINE session data
|
|
26
|
+
*/
|
|
27
|
+
constructor(credentialStore) {
|
|
28
|
+
this.credentialStore = credentialStore;
|
|
29
|
+
this.client = null;
|
|
30
|
+
this.authToken = null;
|
|
31
|
+
this.refreshToken = null;
|
|
32
|
+
this.tokenIssueTimeEpochSec = null;
|
|
33
|
+
this.durationUntilRefreshInSec = null;
|
|
34
|
+
this.revision = -1;
|
|
35
|
+
this.globalRevision = 0;
|
|
36
|
+
this.individualRevision = 0;
|
|
37
|
+
this.mid = null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Bind the active runtime client to the current session snapshot.
|
|
41
|
+
* @param client - Active LINE client instance
|
|
42
|
+
*/
|
|
43
|
+
bindClient(client) {
|
|
44
|
+
this.client = client;
|
|
45
|
+
if (this.authToken) {
|
|
46
|
+
client.authToken = this.authToken;
|
|
47
|
+
}
|
|
48
|
+
client.revision = this.revision;
|
|
49
|
+
client.globalRevision = this.globalRevision;
|
|
50
|
+
client.individualRevision = this.individualRevision;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Load persisted LINE session state from the credential store.
|
|
54
|
+
* @returns Promise that resolves when state has been hydrated
|
|
55
|
+
*/
|
|
56
|
+
async loadFromStore() {
|
|
57
|
+
const stored = await loadStoredSessionFields(this.credentialStore);
|
|
58
|
+
this.authToken = stored.authToken;
|
|
59
|
+
this.refreshToken = stored.refreshToken;
|
|
60
|
+
this.mid = stored.mid;
|
|
61
|
+
this.tokenIssueTimeEpochSec = parseStoredNumber(stored.tokenIssueTimeEpochSec, null);
|
|
62
|
+
this.durationUntilRefreshInSec = parseStoredNumber(stored.durationUntilRefreshInSec, null);
|
|
63
|
+
this.revision = parseStoredNumber(stored.revision, -1) ?? -1;
|
|
64
|
+
this.globalRevision = parseStoredNumber(stored.globalRevision, 0) ?? 0;
|
|
65
|
+
this.individualRevision =
|
|
66
|
+
parseStoredNumber(stored.individualRevision, 0) ?? 0;
|
|
67
|
+
if (this.client) {
|
|
68
|
+
this.bindClient(this.client);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Initialize session state from a fresh login result.
|
|
73
|
+
* @param loginResult - Login payload returned by the LINE auth flow
|
|
74
|
+
* @returns Promise that resolves when state has been persisted
|
|
75
|
+
*/
|
|
76
|
+
async initializeFromLogin(loginResult) {
|
|
77
|
+
this.authToken = loginResult.authToken ?? this.authToken;
|
|
78
|
+
this.refreshToken = loginResult.refreshToken ?? this.refreshToken;
|
|
79
|
+
this.mid = loginResult.mid ?? this.mid;
|
|
80
|
+
this.tokenIssueTimeEpochSec =
|
|
81
|
+
loginResult.tokenIssueTimeEpochSec ?? this.tokenIssueTimeEpochSec;
|
|
82
|
+
this.durationUntilRefreshInSec =
|
|
83
|
+
loginResult.durationUntilRefreshInSec ?? this.durationUntilRefreshInSec;
|
|
84
|
+
await persistLoginCredentials(this.credentialStore, loginResult);
|
|
85
|
+
await persistLoginCryptoMaterial(this.credentialStore, loginResult);
|
|
86
|
+
await persistLoginBootstrap(this.credentialStore, loginResult);
|
|
87
|
+
// Read-back verification: a write that is never read back is an
|
|
88
|
+
// unverified claim, not a fact. `persistLoginCredentials` above can
|
|
89
|
+
// report success while the underlying store still drops the key (a
|
|
90
|
+
// keychain write failure swallowed by `CredentialStore.persistBlob`'s
|
|
91
|
+
// catch, a race with another writer, etc.) — the only way to know the
|
|
92
|
+
// session actually survives a restart is to read it back through the
|
|
93
|
+
// same store the next process boot will use.
|
|
94
|
+
const persistedAuthToken = await this.credentialStore.get('line_auth_token');
|
|
95
|
+
const persistedMid = await this.credentialStore.get('line_mid');
|
|
96
|
+
if (!persistedAuthToken || !persistedMid) {
|
|
97
|
+
throw new Error(`LineSessionState.initializeFromLogin: login succeeded but session did not persist ` +
|
|
98
|
+
`(line_auth_token=${persistedAuthToken ? 'present' : 'MISSING'}, ` +
|
|
99
|
+
`line_mid=${persistedMid ? 'present' : 'MISSING'}). ` +
|
|
100
|
+
'The next restart would require a fresh phone-PIN login.');
|
|
101
|
+
}
|
|
102
|
+
if (this.client) {
|
|
103
|
+
this.bindClient(this.client);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Apply a refreshed auth token result to the session.
|
|
108
|
+
* @param refreshResult - Refresh payload containing a new auth token
|
|
109
|
+
* @returns Promise that resolves when the new token is persisted
|
|
110
|
+
*/
|
|
111
|
+
async applyRefreshResult(refreshResult) {
|
|
112
|
+
this.authToken = refreshResult.authToken;
|
|
113
|
+
this.tokenIssueTimeEpochSec =
|
|
114
|
+
refreshResult.tokenIssueTimeEpochSec ?? this.tokenIssueTimeEpochSec;
|
|
115
|
+
this.durationUntilRefreshInSec =
|
|
116
|
+
refreshResult.durationUntilRefreshInSec ?? this.durationUntilRefreshInSec;
|
|
117
|
+
await this.credentialStore.set('line_auth_token', refreshResult.authToken);
|
|
118
|
+
if (refreshResult.tokenIssueTimeEpochSec != null) {
|
|
119
|
+
await this.credentialStore.set('line_token_issue_time_epoch_sec', String(refreshResult.tokenIssueTimeEpochSec));
|
|
120
|
+
}
|
|
121
|
+
if (refreshResult.durationUntilRefreshInSec != null) {
|
|
122
|
+
await this.credentialStore.set('line_token_duration_until_refresh_sec', String(refreshResult.durationUntilRefreshInSec));
|
|
123
|
+
}
|
|
124
|
+
if (this.client) {
|
|
125
|
+
this.client.authToken = refreshResult.authToken;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Apply an auth token observed at runtime, such as token rotation.
|
|
130
|
+
* @param authToken - Latest auth token
|
|
131
|
+
* @returns Promise that resolves when the token is persisted
|
|
132
|
+
*/
|
|
133
|
+
async applyRuntimeToken(authToken) {
|
|
134
|
+
this.authToken = authToken;
|
|
135
|
+
await this.credentialStore.set('line_auth_token', authToken);
|
|
136
|
+
if (this.client) {
|
|
137
|
+
this.client.authToken = authToken;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Persist the latest LINE sync revisions.
|
|
142
|
+
* @param revision - Main operation revision
|
|
143
|
+
* @param globalRevision - Global sync revision
|
|
144
|
+
* @param individualRevision - Individual sync revision
|
|
145
|
+
* @returns Promise that resolves when revisions are persisted
|
|
146
|
+
*/
|
|
147
|
+
async setRevisions(revision, globalRevision, individualRevision) {
|
|
148
|
+
this.revision = revision;
|
|
149
|
+
await this.credentialStore.set('line_revision', String(revision));
|
|
150
|
+
if (globalRevision != null) {
|
|
151
|
+
this.globalRevision = globalRevision;
|
|
152
|
+
await this.credentialStore.set('line_global_revision', String(globalRevision));
|
|
153
|
+
}
|
|
154
|
+
if (individualRevision != null) {
|
|
155
|
+
this.individualRevision = individualRevision;
|
|
156
|
+
await this.credentialStore.set('line_individual_revision', String(individualRevision));
|
|
157
|
+
}
|
|
158
|
+
if (this.client) {
|
|
159
|
+
this.client.revision = revision;
|
|
160
|
+
this.client.globalRevision = this.globalRevision;
|
|
161
|
+
this.client.individualRevision = this.individualRevision;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Persist decrypted E2EE self keys for future session restore.
|
|
166
|
+
* @param keys - Decrypted E2EE self keys
|
|
167
|
+
* @returns Promise that resolves when keys are persisted
|
|
168
|
+
*/
|
|
169
|
+
async saveE2EEKeys(keys) {
|
|
170
|
+
// Only the `line_e2ee_keys` blob is ever read back (auth-session-runtime
|
|
171
|
+
// restore). The former per-keyId and per-mid entries had no reader, so they
|
|
172
|
+
// only churned the OS keychain and emitted one store-complete debug log per
|
|
173
|
+
// key on every login/token refresh — dropped.
|
|
174
|
+
await this.credentialStore.set('line_e2ee_keys', JSON.stringify(keys));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Load persisted recent-fetch checkpoints for warm history planning.
|
|
178
|
+
*
|
|
179
|
+
* @returns Recent fetch checkpoint map.
|
|
180
|
+
*/
|
|
181
|
+
async loadRecentFetchState() {
|
|
182
|
+
const raw = await this.credentialStore.get('line_recent_fetch_state');
|
|
183
|
+
if (!raw) {
|
|
184
|
+
return new Map();
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(raw);
|
|
188
|
+
const entries = Object.entries(parsed || {}).map(([chatId, state]) => [
|
|
189
|
+
chatId,
|
|
190
|
+
{
|
|
191
|
+
lastCheckedAt: Number(state?.lastCheckedAt || 0),
|
|
192
|
+
lastDeliveredMessageId: state?.lastDeliveredMessageId
|
|
193
|
+
? String(state.lastDeliveredMessageId)
|
|
194
|
+
: null,
|
|
195
|
+
lastDeliveredTime: Number(state?.lastDeliveredTime || 0),
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
return new Map(entries);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return new Map();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Persist recent-fetch checkpoints for warm history planning.
|
|
206
|
+
*
|
|
207
|
+
* @param recentFetchState - Current recent fetch checkpoint map.
|
|
208
|
+
* @returns Promise that resolves when checkpoints are persisted.
|
|
209
|
+
*/
|
|
210
|
+
async saveRecentFetchState(recentFetchState) {
|
|
211
|
+
const payload = Object.fromEntries(Array.from(recentFetchState.entries())
|
|
212
|
+
.sort((left, right) => right[1].lastCheckedAt - left[1].lastCheckedAt)
|
|
213
|
+
.slice(0, 500));
|
|
214
|
+
await this.credentialStore.set('line_recent_fetch_state', JSON.stringify(payload));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Clear auth credentials for the current LINE session.
|
|
218
|
+
*
|
|
219
|
+
* This intentionally preserves non-auth LINE state such as saved phone,
|
|
220
|
+
* E2EE key material, peer/group key caches, and recent-fetch checkpoints.
|
|
221
|
+
* Those fields may belong to a freshly logged-in sibling process or be useful
|
|
222
|
+
* for in-session E2EE recovery after a token failure.
|
|
223
|
+
*
|
|
224
|
+
* @returns Promise that resolves when credentials are removed
|
|
225
|
+
*/
|
|
226
|
+
async clearAuth() {
|
|
227
|
+
this.authToken = null;
|
|
228
|
+
this.refreshToken = null;
|
|
229
|
+
this.tokenIssueTimeEpochSec = null;
|
|
230
|
+
this.durationUntilRefreshInSec = null;
|
|
231
|
+
this.mid = null;
|
|
232
|
+
this.revision = -1;
|
|
233
|
+
this.globalRevision = 0;
|
|
234
|
+
this.individualRevision = 0;
|
|
235
|
+
await this.credentialStore.delete('line_auth_token');
|
|
236
|
+
await this.credentialStore.delete('line_refresh_token');
|
|
237
|
+
await this.credentialStore.delete('line_token_issue_time_epoch_sec');
|
|
238
|
+
await this.credentialStore.delete('line_token_duration_until_refresh_sec');
|
|
239
|
+
await this.credentialStore.delete('line_revision');
|
|
240
|
+
await this.credentialStore.delete('line_global_revision');
|
|
241
|
+
await this.credentialStore.delete('line_individual_revision');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence helpers for LINE session state.
|
|
3
|
+
* Read/write individual credential fields from the credential store.
|
|
4
|
+
*/
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { createCliLogger } from '../../../util/log.js';
|
|
7
|
+
import { extractE2EEInfo } from '../../auth/protocol/login-metadata.js';
|
|
8
|
+
const persistLog = createCliLogger('SESSION-PERSIST');
|
|
9
|
+
/**
|
|
10
|
+
* Normalize binary credential values before persistence.
|
|
11
|
+
* @param value - Raw credential value.
|
|
12
|
+
* @returns Persistable string value.
|
|
13
|
+
*/
|
|
14
|
+
export function toPersistedKeyValue(value) {
|
|
15
|
+
if (!value) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return Buffer.isBuffer(value) ? value.toString('base64') : String(value);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse an optional numeric credential value.
|
|
22
|
+
* @param value - Raw persisted value.
|
|
23
|
+
* @param fallback - Fallback when the value is missing.
|
|
24
|
+
* @returns Parsed number or fallback.
|
|
25
|
+
*/
|
|
26
|
+
export function parseStoredNumber(value, fallback) {
|
|
27
|
+
return value ? Number(value) : fallback;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Extract persisted token/revision fields from the credential store.
|
|
31
|
+
* @param credentialStore - Persistent LINE credential store.
|
|
32
|
+
* @returns Hydrated session fields.
|
|
33
|
+
*/
|
|
34
|
+
export async function loadStoredSessionFields(credentialStore) {
|
|
35
|
+
const authToken = await credentialStore.get('line_auth_token');
|
|
36
|
+
const refreshToken = await credentialStore.get('line_refresh_token');
|
|
37
|
+
const mid = await credentialStore.get('line_mid');
|
|
38
|
+
const tokenIssueTimeEpochSec = await credentialStore.get('line_token_issue_time_epoch_sec');
|
|
39
|
+
const durationUntilRefreshInSec = await credentialStore.get('line_token_duration_until_refresh_sec');
|
|
40
|
+
const revision = await credentialStore.get('line_revision');
|
|
41
|
+
const globalRevision = await credentialStore.get('line_global_revision');
|
|
42
|
+
const individualRevision = await credentialStore.get('line_individual_revision');
|
|
43
|
+
return {
|
|
44
|
+
authToken,
|
|
45
|
+
refreshToken,
|
|
46
|
+
mid,
|
|
47
|
+
tokenIssueTimeEpochSec,
|
|
48
|
+
durationUntilRefreshInSec,
|
|
49
|
+
revision,
|
|
50
|
+
globalRevision,
|
|
51
|
+
individualRevision,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Persist LINE auth/session fields provided by a fresh login result.
|
|
56
|
+
*
|
|
57
|
+
* `authToken` is never optional: a login result without it is not a
|
|
58
|
+
* usable session, so it throws instead of writing a half-persisted
|
|
59
|
+
* credential set. The other fields are genuinely optional (a field the
|
|
60
|
+
* LINE login response legitimately omits must not throw), but every
|
|
61
|
+
* call logs exactly which keys were written and which were skipped so
|
|
62
|
+
* an unexpected omission is visible immediately instead of surfacing as
|
|
63
|
+
* "session lost" three hours later.
|
|
64
|
+
*
|
|
65
|
+
* @param credentialStore - Persistent LINE credential store.
|
|
66
|
+
* @param loginResult - Login payload returned by the auth flow.
|
|
67
|
+
*/
|
|
68
|
+
export async function persistLoginCredentials(credentialStore, loginResult) {
|
|
69
|
+
if (!loginResult.authToken) {
|
|
70
|
+
throw new Error('persistLoginCredentials: loginResult.authToken is missing — cannot persist a session without an auth token');
|
|
71
|
+
}
|
|
72
|
+
const written = [];
|
|
73
|
+
const skipped = [];
|
|
74
|
+
await credentialStore.set('line_auth_token', loginResult.authToken);
|
|
75
|
+
written.push('line_auth_token');
|
|
76
|
+
if (loginResult.refreshToken) {
|
|
77
|
+
await credentialStore.set('line_refresh_token', loginResult.refreshToken);
|
|
78
|
+
written.push('line_refresh_token');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
skipped.push('line_refresh_token');
|
|
82
|
+
}
|
|
83
|
+
if (loginResult.certificate) {
|
|
84
|
+
await credentialStore.set('line_certificate', loginResult.certificate);
|
|
85
|
+
written.push('line_certificate');
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
skipped.push('line_certificate');
|
|
89
|
+
}
|
|
90
|
+
if (loginResult.mid) {
|
|
91
|
+
await credentialStore.set('line_mid', loginResult.mid);
|
|
92
|
+
written.push('line_mid');
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
skipped.push('line_mid');
|
|
96
|
+
}
|
|
97
|
+
if (loginResult.tokenIssueTimeEpochSec != null) {
|
|
98
|
+
await credentialStore.set('line_token_issue_time_epoch_sec', String(loginResult.tokenIssueTimeEpochSec));
|
|
99
|
+
written.push('line_token_issue_time_epoch_sec');
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
skipped.push('line_token_issue_time_epoch_sec');
|
|
103
|
+
}
|
|
104
|
+
if (loginResult.durationUntilRefreshInSec != null) {
|
|
105
|
+
await credentialStore.set('line_token_duration_until_refresh_sec', String(loginResult.durationUntilRefreshInSec));
|
|
106
|
+
written.push('line_token_duration_until_refresh_sec');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
skipped.push('line_token_duration_until_refresh_sec');
|
|
110
|
+
}
|
|
111
|
+
persistLog.info('login-credentials-persisted', {
|
|
112
|
+
written: written.join(','),
|
|
113
|
+
skipped: skipped.join(',') || 'none',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Persist LINE NaCl/E2EE bootstrap credentials when present.
|
|
118
|
+
*
|
|
119
|
+
* All three fields are optional here (a fresh login may or may not carry
|
|
120
|
+
* new crypto bootstrap material), so a missing field never throws — but
|
|
121
|
+
* every call logs exactly which keys were written and which were skipped.
|
|
122
|
+
*
|
|
123
|
+
* @param credentialStore - Persistent LINE credential store.
|
|
124
|
+
* @param loginResult - Login payload returned by the auth flow.
|
|
125
|
+
*/
|
|
126
|
+
export async function persistLoginCryptoMaterial(credentialStore, loginResult) {
|
|
127
|
+
const secretKey = toPersistedKeyValue(loginResult.secretKey);
|
|
128
|
+
const publicKey = toPersistedKeyValue(loginResult.publicKey);
|
|
129
|
+
const written = [];
|
|
130
|
+
const skipped = [];
|
|
131
|
+
if (secretKey) {
|
|
132
|
+
await credentialStore.set('line_nacl_secret_key', secretKey);
|
|
133
|
+
written.push('line_nacl_secret_key');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
skipped.push('line_nacl_secret_key');
|
|
137
|
+
}
|
|
138
|
+
if (publicKey) {
|
|
139
|
+
await credentialStore.set('line_nacl_public_key', publicKey);
|
|
140
|
+
written.push('line_nacl_public_key');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
skipped.push('line_nacl_public_key');
|
|
144
|
+
}
|
|
145
|
+
if (loginResult.nonce) {
|
|
146
|
+
await credentialStore.set('line_nonce', loginResult.nonce);
|
|
147
|
+
written.push('line_nonce');
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
skipped.push('line_nonce');
|
|
151
|
+
}
|
|
152
|
+
persistLog.info('login-crypto-material-persisted', {
|
|
153
|
+
written: written.join(',') || 'none',
|
|
154
|
+
skipped: skipped.join(',') || 'none',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Persist the E2EE bootstrap payload extracted from login metadata.
|
|
159
|
+
*
|
|
160
|
+
* This field is optional (a login result may carry no E2EE bootstrap
|
|
161
|
+
* metadata at all — e.g. a device that already has keys), so a missing
|
|
162
|
+
* field never throws — but every call logs whether the key was written
|
|
163
|
+
* or skipped, and why, so an unexpected omission is visible immediately.
|
|
164
|
+
*
|
|
165
|
+
* @param credentialStore - Persistent LINE credential store.
|
|
166
|
+
* @param loginResult - Login payload returned by the auth flow.
|
|
167
|
+
*/
|
|
168
|
+
export async function persistLoginBootstrap(credentialStore, loginResult) {
|
|
169
|
+
const e2eeInfo = extractE2EEInfo(loginResult.e2eeInfo || loginResult.metaData || null);
|
|
170
|
+
const secretKey = toPersistedKeyValue(loginResult.secretKey);
|
|
171
|
+
if (!e2eeInfo?.encryptedKeyChain ||
|
|
172
|
+
!e2eeInfo?.serverPublicKey ||
|
|
173
|
+
!secretKey) {
|
|
174
|
+
persistLog.info('login-bootstrap-persisted', {
|
|
175
|
+
written: 'none',
|
|
176
|
+
skipped: 'line_e2ee_bootstrap',
|
|
177
|
+
reason: 'no encryptedKeyChain/serverPublicKey/secretKey in login result',
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await credentialStore.set('line_e2ee_bootstrap', JSON.stringify({
|
|
182
|
+
encryptedKeyChain: e2eeInfo.encryptedKeyChain.toString('base64'),
|
|
183
|
+
secretKey,
|
|
184
|
+
serverPublicKey: e2eeInfo.serverPublicKey.toString('base64'),
|
|
185
|
+
keyId: e2eeInfo.keyId,
|
|
186
|
+
e2eeVersion: e2eeInfo.e2eeVersion,
|
|
187
|
+
}));
|
|
188
|
+
persistLog.info('login-bootstrap-persisted', {
|
|
189
|
+
written: 'line_e2ee_bootstrap',
|
|
190
|
+
skipped: 'none',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Credential Stores — re-exports
|
|
3
|
+
*
|
|
4
|
+
* Both stores live in Yomi's vendored auth/credential-store module.
|
|
5
|
+
* This module re-exports them under their original names for
|
|
6
|
+
* compatibility with the LINE protocol core.
|
|
7
|
+
*/
|
|
8
|
+
export { CredentialStore as FileCredentialStore, InMemoryStore, } from '../../auth/credential-store.js';
|