@kin-tio/cli 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.
- package/.env.example +46 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +202 -0
- package/README.md +150 -0
- package/README.zh-CN.md +79 -0
- package/THIRD_PARTY_NOTICES +31 -0
- package/assets/ilink-login-card.png +0 -0
- package/bin/kintio.js +3 -0
- package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
- package/dist/cli.js +3 -0
- package/dist/daemon.js +28 -0
- package/dist/index.js +70 -0
- package/dist/mcp-relay.js +11 -0
- package/dist/src/agent/runtime.js +1 -0
- package/dist/src/app.js +34 -0
- package/dist/src/cli.js +578 -0
- package/dist/src/config.js +237 -0
- package/dist/src/domain/message.js +23 -0
- package/dist/src/domain/send-contract.js +205 -0
- package/dist/src/domain/wecom-message.js +281 -0
- package/dist/src/ilink/executor.js +306 -0
- package/dist/src/ilink/inbound-image.js +310 -0
- package/dist/src/ilink/listener.js +306 -0
- package/dist/src/ilink/login-manager.js +198 -0
- package/dist/src/ilink/login-store.js +197 -0
- package/dist/src/ilink/media-gateway.js +83 -0
- package/dist/src/ilink/media.js +267 -0
- package/dist/src/ilink/message.js +247 -0
- package/dist/src/ilink/protocol/client.js +464 -0
- package/dist/src/ilink/protocol/types.js +35 -0
- package/dist/src/ilink/qr.js +109 -0
- package/dist/src/ilink/secret-box.js +143 -0
- package/dist/src/ilink/sqlite-store.js +1194 -0
- package/dist/src/ilink/store-types.js +63 -0
- package/dist/src/lib/image-format.js +23 -0
- package/dist/src/lib/path-identity.js +38 -0
- package/dist/src/lib/private-directory.js +51 -0
- package/dist/src/lib/text.js +19 -0
- package/dist/src/lib/wecom-crypto.js +74 -0
- package/dist/src/lib/xml.js +8 -0
- package/dist/src/mcp/conversation-memory-server.js +179 -0
- package/dist/src/mcp/ilink-server.js +158 -0
- package/dist/src/mcp/ipc-host.js +275 -0
- package/dist/src/mcp/ipc-protocol.js +226 -0
- package/dist/src/mcp/stdio-relay.js +122 -0
- package/dist/src/mcp/wechat-kf-executor.js +295 -0
- package/dist/src/mcp/wechat-kf-server.js +208 -0
- package/dist/src/routes/wecom.js +89 -0
- package/dist/src/runtime/daemon-protocol.js +202 -0
- package/dist/src/runtime/managed-skill.js +49 -0
- package/dist/src/runtime/native-daemon.js +325 -0
- package/dist/src/runtime/single-instance-lock.js +167 -0
- package/dist/src/runtime.js +503 -0
- package/dist/src/services/codex-agent.js +542 -0
- package/dist/src/services/codex-app-server.js +436 -0
- package/dist/src/services/conversation-processor.js +762 -0
- package/dist/src/services/image-stager.js +49 -0
- package/dist/src/services/media-gateway.js +83 -0
- package/dist/src/services/wecom-api.js +311 -0
- package/dist/src/services/wecom-sync.js +316 -0
- package/dist/src/state/persistence.js +124 -0
- package/dist/src/state/sqlite-store.js +3102 -0
- package/dist/src/supervisor.js +212 -0
- package/dist/src/types.js +1 -0
- package/dist/src/version.js +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { IlinkLoginStore } from './login-store.js';
|
|
2
|
+
import { IlinkClient, IlinkProtocolError, normalizeIlinkBaseUrl, } from './protocol/client.js';
|
|
3
|
+
import { renderIlinkQrPng } from './qr.js';
|
|
4
|
+
import { IlinkSecretBox } from './secret-box.js';
|
|
5
|
+
import { IlinkSqliteStore } from './sqlite-store.js';
|
|
6
|
+
import { createIlinkAccountKey } from './store-types.js';
|
|
7
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
8
|
+
function sleep(milliseconds, signal) {
|
|
9
|
+
if (signal.aborted)
|
|
10
|
+
return Promise.reject(signal.reason);
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const done = () => {
|
|
13
|
+
signal.removeEventListener('abort', abort);
|
|
14
|
+
resolve();
|
|
15
|
+
};
|
|
16
|
+
const timer = setTimeout(done, milliseconds);
|
|
17
|
+
const abort = () => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
signal.removeEventListener('abort', abort);
|
|
20
|
+
reject(signal.reason);
|
|
21
|
+
};
|
|
22
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
23
|
+
timer.unref();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export class IlinkLoginManager {
|
|
27
|
+
#offers;
|
|
28
|
+
#accounts;
|
|
29
|
+
#secrets;
|
|
30
|
+
#client;
|
|
31
|
+
#logger;
|
|
32
|
+
#maxAccounts;
|
|
33
|
+
#clock;
|
|
34
|
+
#onAccountsChanged;
|
|
35
|
+
#sleep;
|
|
36
|
+
#running = new Map();
|
|
37
|
+
#closed = false;
|
|
38
|
+
constructor({ offers, accounts, secretBox, client = new IlinkClient(), maxAccounts = 20, clock = Date.now, onAccountsChanged = () => undefined, logger = console, sleep: sleepFunction = sleep, }) {
|
|
39
|
+
this.#offers = offers;
|
|
40
|
+
this.#accounts = accounts;
|
|
41
|
+
this.#secrets = secretBox;
|
|
42
|
+
this.#client = client;
|
|
43
|
+
this.#maxAccounts = maxAccounts;
|
|
44
|
+
this.#clock = clock;
|
|
45
|
+
this.#onAccountsChanged = onAccountsChanged;
|
|
46
|
+
this.#logger = logger;
|
|
47
|
+
this.#sleep = sleepFunction;
|
|
48
|
+
}
|
|
49
|
+
async start() {
|
|
50
|
+
if (this.#closed)
|
|
51
|
+
throw new Error('iLink login manager is closed');
|
|
52
|
+
for (const offer of this.#offers.listActive())
|
|
53
|
+
this.#startPolling(offer);
|
|
54
|
+
}
|
|
55
|
+
async offer(sessionToken) {
|
|
56
|
+
if (this.#closed)
|
|
57
|
+
throw new Error('iLink login manager is closed');
|
|
58
|
+
if (this.#offers.findForSession(sessionToken)) {
|
|
59
|
+
throw new Error('An iLink login offer is already pending');
|
|
60
|
+
}
|
|
61
|
+
if (this.#accounts.listActiveAccounts().length +
|
|
62
|
+
this.#offers.listActive().length >= this.#maxAccounts) {
|
|
63
|
+
throw new Error('iLink account limit reached');
|
|
64
|
+
}
|
|
65
|
+
const localTokens = this.#accounts.listActiveAccountsWithSecrets()
|
|
66
|
+
.slice(-10)
|
|
67
|
+
.reverse()
|
|
68
|
+
.map(({ account, secret }) => this.#secrets.open(secret.sealedBotToken, {
|
|
69
|
+
secretKind: 'bot_token',
|
|
70
|
+
accountId: account.accountKey,
|
|
71
|
+
peerId: account.ownerPeerId,
|
|
72
|
+
generation: secret.accountGeneration,
|
|
73
|
+
}));
|
|
74
|
+
const created = await this.#client.createQr({ local_token_list: localTokens });
|
|
75
|
+
if (this.#accounts.listActiveAccounts().length +
|
|
76
|
+
this.#offers.listActive().length >= this.#maxAccounts) {
|
|
77
|
+
throw new Error('iLink account limit reached');
|
|
78
|
+
}
|
|
79
|
+
const offer = this.#offers.create({
|
|
80
|
+
sessionToken,
|
|
81
|
+
qrCode: created.qrcode,
|
|
82
|
+
apiBaseUrl: normalizeIlinkBaseUrl('https://ilinkai.weixin.qq.com/'),
|
|
83
|
+
});
|
|
84
|
+
let png;
|
|
85
|
+
try {
|
|
86
|
+
png = await renderIlinkQrPng(created.qrcode_img_content);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
this.#offers.finish(offer.offerId, 'failed');
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
this.#startPolling(offer);
|
|
93
|
+
return { offerId: offer.offerId, png };
|
|
94
|
+
}
|
|
95
|
+
cancel(offerId) {
|
|
96
|
+
this.#running.get(offerId)?.controller.abort();
|
|
97
|
+
this.#offers.finish(offerId, 'cancelled');
|
|
98
|
+
}
|
|
99
|
+
#startPolling(offer) {
|
|
100
|
+
if (this.#running.has(offer.offerId) || this.#closed)
|
|
101
|
+
return;
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const task = this.#poll(offer, controller.signal).finally(() => {
|
|
104
|
+
if (this.#running.get(offer.offerId)?.controller === controller) {
|
|
105
|
+
this.#running.delete(offer.offerId);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
this.#running.set(offer.offerId, { controller, task });
|
|
109
|
+
}
|
|
110
|
+
async #poll(initial, signal) {
|
|
111
|
+
let offer = initial;
|
|
112
|
+
while (!this.#closed && !signal.aborted && Number(this.#clock()) < offer.expiresAt) {
|
|
113
|
+
try {
|
|
114
|
+
const result = await this.#client.getQrStatus({ qrcode: offer.qrCode }, { signal, baseUrl: offer.apiBaseUrl });
|
|
115
|
+
if (signal.aborted || !this.#offers.isActive(offer.offerId))
|
|
116
|
+
return;
|
|
117
|
+
if (result.status === 'confirmed') {
|
|
118
|
+
try {
|
|
119
|
+
await this.#confirm(offer, result);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
this.#offers.finish(offer.offerId, 'failed');
|
|
123
|
+
this.#logger.warn?.('[ilink-login] confirmed account activation failed');
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (result.status === 'binded_redirect') {
|
|
128
|
+
this.#offers.finish(offer.offerId, 'cancelled');
|
|
129
|
+
this.#logger.info?.('[ilink-login] account is already connected');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (result.status === 'scaned_but_redirect' && result.redirect_host) {
|
|
133
|
+
offer = this.#offers.update(offer.offerId, {
|
|
134
|
+
status: 'waiting',
|
|
135
|
+
apiBaseUrl: this.#client.resolveRedirectBaseUrl(result.redirect_host),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else if (result.status === 'scaned') {
|
|
139
|
+
offer = this.#offers.update(offer.offerId, { status: 'scanned' });
|
|
140
|
+
}
|
|
141
|
+
else if (result.status !== 'wait') {
|
|
142
|
+
this.#offers.finish(offer.offerId, result.status === 'expired' ? 'expired' : 'failed');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await this.#sleep(POLL_INTERVAL_MS, signal);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (signal.aborted || this.#closed)
|
|
149
|
+
return;
|
|
150
|
+
if (error instanceof IlinkProtocolError && error.kind === 'configuration') {
|
|
151
|
+
this.#offers.finish(offer.offerId, 'failed');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.#logger.warn?.('[ilink-login] status poll failed; retrying');
|
|
155
|
+
await this.#sleep(POLL_INTERVAL_MS, signal).catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
this.#offers.finish(offer.offerId, 'expired');
|
|
159
|
+
}
|
|
160
|
+
async #confirm(offer, result) {
|
|
161
|
+
const providerAccountId = String(result.ilink_bot_id || '');
|
|
162
|
+
const ownerPeerId = String(result.ilink_user_id || '');
|
|
163
|
+
const token = String(result.bot_token || '');
|
|
164
|
+
if (!providerAccountId || !ownerPeerId || !token) {
|
|
165
|
+
throw new Error('Confirmed iLink login is missing credentials');
|
|
166
|
+
}
|
|
167
|
+
const accountKey = createIlinkAccountKey(providerAccountId);
|
|
168
|
+
const existing = this.#accounts.getAccount(accountKey);
|
|
169
|
+
const generation = existing ? existing.generation + 1 : 1;
|
|
170
|
+
const encryptedBotToken = this.#secrets.seal(token, {
|
|
171
|
+
secretKind: 'bot_token',
|
|
172
|
+
accountId: accountKey,
|
|
173
|
+
peerId: ownerPeerId,
|
|
174
|
+
generation,
|
|
175
|
+
});
|
|
176
|
+
const baseUrl = normalizeIlinkBaseUrl(result.baseurl || offer.apiBaseUrl);
|
|
177
|
+
this.#accounts.confirmEnrollment({
|
|
178
|
+
offerId: offer.offerId,
|
|
179
|
+
accountGeneration: generation,
|
|
180
|
+
maxAccounts: this.#maxAccounts,
|
|
181
|
+
providerAccountId,
|
|
182
|
+
ownerPeerId,
|
|
183
|
+
baseUrl,
|
|
184
|
+
encryptedBotToken,
|
|
185
|
+
now: Number(this.#clock()),
|
|
186
|
+
});
|
|
187
|
+
await this.#onAccountsChanged();
|
|
188
|
+
}
|
|
189
|
+
async close() {
|
|
190
|
+
if (this.#closed)
|
|
191
|
+
return;
|
|
192
|
+
this.#closed = true;
|
|
193
|
+
for (const running of this.#running.values())
|
|
194
|
+
running.controller.abort();
|
|
195
|
+
await Promise.allSettled([...this.#running.values()].map(({ task }) => task));
|
|
196
|
+
this.#running.clear();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { normalizeIlinkBaseUrl } from './protocol/client.js';
|
|
3
|
+
import { IlinkSecretBox } from './secret-box.js';
|
|
4
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
|
5
|
+
const MAX_TTL_MS = 10 * 60 * 1_000;
|
|
6
|
+
function sha256(value) {
|
|
7
|
+
return createHash('sha256').update(value).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
function secretGeneration(offerId) {
|
|
10
|
+
return Number.parseInt(sha256(offerId).slice(0, 12), 16);
|
|
11
|
+
}
|
|
12
|
+
function rowAs(value) {
|
|
13
|
+
return value === undefined ? undefined : value;
|
|
14
|
+
}
|
|
15
|
+
function mapped(row) {
|
|
16
|
+
return {
|
|
17
|
+
offerId: row.offer_id,
|
|
18
|
+
sourceMessageKey: row.source_message_key,
|
|
19
|
+
sourceOpenKfId: row.source_open_kfid,
|
|
20
|
+
sourceExternalUserId: row.source_external_userid,
|
|
21
|
+
apiBaseUrl: row.api_base_url,
|
|
22
|
+
status: row.status,
|
|
23
|
+
expiresAt: Number(row.expires_at),
|
|
24
|
+
lastPolledAt: Number(row.last_polled_at),
|
|
25
|
+
createdAt: Number(row.created_at),
|
|
26
|
+
updatedAt: Number(row.updated_at),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export class IlinkLoginStore {
|
|
30
|
+
#store;
|
|
31
|
+
#database;
|
|
32
|
+
#secrets;
|
|
33
|
+
#clock;
|
|
34
|
+
constructor({ store, database, secretBox, clock = Date.now, }) {
|
|
35
|
+
this.#store = store;
|
|
36
|
+
this.#database = database;
|
|
37
|
+
this.#secrets = secretBox;
|
|
38
|
+
this.#clock = clock;
|
|
39
|
+
}
|
|
40
|
+
#transaction(operation) {
|
|
41
|
+
if (this.#database.isTransaction)
|
|
42
|
+
return operation();
|
|
43
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
44
|
+
try {
|
|
45
|
+
const result = operation();
|
|
46
|
+
this.#database.exec('COMMIT');
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
this.#database.exec('ROLLBACK');
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
#row(offerId) {
|
|
55
|
+
return rowAs(this.#database.prepare(`
|
|
56
|
+
SELECT * FROM ilink_login_offers WHERE offer_id = ?
|
|
57
|
+
`).get(offerId));
|
|
58
|
+
}
|
|
59
|
+
#runtime(row) {
|
|
60
|
+
return {
|
|
61
|
+
...mapped(row),
|
|
62
|
+
qrCode: this.#secrets.open({
|
|
63
|
+
nonce: row.nonce,
|
|
64
|
+
ciphertext: row.ciphertext,
|
|
65
|
+
authTag: row.auth_tag,
|
|
66
|
+
}, {
|
|
67
|
+
secretKind: 'qr_token',
|
|
68
|
+
accountId: row.source_open_kfid,
|
|
69
|
+
peerId: row.source_external_userid,
|
|
70
|
+
generation: row.secret_generation,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
#expire(now = Number(this.#clock())) {
|
|
75
|
+
const expired = this.#database.prepare(`
|
|
76
|
+
SELECT * FROM ilink_login_offers
|
|
77
|
+
WHERE status IN ('waiting', 'scanned') AND expires_at <= ?
|
|
78
|
+
`).all(now);
|
|
79
|
+
for (const row of expired)
|
|
80
|
+
this.#finishRow(row, 'expired', '', now);
|
|
81
|
+
}
|
|
82
|
+
#finishRow(row, result, accountKey, now) {
|
|
83
|
+
this.#database.prepare(`
|
|
84
|
+
INSERT INTO ilink_enrollment_audit (
|
|
85
|
+
offer_id, source_message_key, source_open_kfid,
|
|
86
|
+
source_external_userid, account_key, result, offered_at, completed_at
|
|
87
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
88
|
+
ON CONFLICT(offer_id) DO NOTHING
|
|
89
|
+
`).run(row.offer_id, row.source_message_key, row.source_open_kfid, row.source_external_userid, accountKey, result, row.created_at, now);
|
|
90
|
+
this.#database.prepare(`
|
|
91
|
+
DELETE FROM ilink_login_offers WHERE offer_id = ?
|
|
92
|
+
`).run(row.offer_id);
|
|
93
|
+
}
|
|
94
|
+
findForSession(sessionToken) {
|
|
95
|
+
const session = this.#store.getAgentSession(sessionToken);
|
|
96
|
+
if (session.channel !== 'wechat_kf')
|
|
97
|
+
throw new Error('Wrong channel for iLink offer');
|
|
98
|
+
return this.#transaction(() => {
|
|
99
|
+
this.#expire();
|
|
100
|
+
const row = rowAs(this.#database.prepare(`
|
|
101
|
+
SELECT * FROM ilink_login_offers
|
|
102
|
+
WHERE source_open_kfid = ? AND source_external_userid = ?
|
|
103
|
+
AND status IN ('waiting', 'scanned')
|
|
104
|
+
`).get(session.accountKey, session.peerId));
|
|
105
|
+
return row ? mapped(row) : undefined;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
create({ sessionToken, qrCode, apiBaseUrl, ttlMs = DEFAULT_TTL_MS, }) {
|
|
109
|
+
const session = this.#store.getAgentSession(sessionToken);
|
|
110
|
+
if (session.channel !== 'wechat_kf')
|
|
111
|
+
throw new Error('Wrong channel for iLink offer');
|
|
112
|
+
if (!qrCode || Buffer.byteLength(qrCode, 'utf8') > 8_192) {
|
|
113
|
+
throw new Error('Invalid iLink QR token');
|
|
114
|
+
}
|
|
115
|
+
const lifetime = Math.max(1_000, Math.min(Number(ttlMs) || 0, MAX_TTL_MS));
|
|
116
|
+
const offerId = `qo_${randomBytes(20).toString('base64url')}`;
|
|
117
|
+
const generation = secretGeneration(offerId);
|
|
118
|
+
const sealed = this.#secrets.seal(qrCode, {
|
|
119
|
+
secretKind: 'qr_token',
|
|
120
|
+
accountId: session.accountKey,
|
|
121
|
+
peerId: session.peerId,
|
|
122
|
+
generation,
|
|
123
|
+
});
|
|
124
|
+
const now = Number(this.#clock());
|
|
125
|
+
return this.#transaction(() => {
|
|
126
|
+
this.#expire(now);
|
|
127
|
+
const existing = this.#database.prepare(`
|
|
128
|
+
SELECT 1 FROM ilink_login_offers
|
|
129
|
+
WHERE source_open_kfid = ? AND source_external_userid = ?
|
|
130
|
+
AND status IN ('waiting', 'scanned')
|
|
131
|
+
`).get(session.accountKey, session.peerId);
|
|
132
|
+
if (existing)
|
|
133
|
+
throw new Error('An iLink login offer is already pending');
|
|
134
|
+
this.#database.prepare(`
|
|
135
|
+
INSERT INTO ilink_login_offers (
|
|
136
|
+
offer_id, source_message_key, source_open_kfid,
|
|
137
|
+
source_external_userid, secret_generation,
|
|
138
|
+
nonce, ciphertext, auth_tag, api_base_url, status,
|
|
139
|
+
expires_at, last_polled_at, created_at, updated_at
|
|
140
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, 0, ?, ?)
|
|
141
|
+
`).run(offerId, session.messageKey, session.accountKey, session.peerId, generation, sealed.nonce, sealed.ciphertext, sealed.authTag, normalizeIlinkBaseUrl(apiBaseUrl), now + lifetime, now, now);
|
|
142
|
+
return this.#runtime(this.#row(offerId));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
listActive() {
|
|
146
|
+
return this.#transaction(() => {
|
|
147
|
+
this.#expire();
|
|
148
|
+
return this.#database.prepare(`
|
|
149
|
+
SELECT * FROM ilink_login_offers
|
|
150
|
+
WHERE status IN ('waiting', 'scanned') ORDER BY created_at
|
|
151
|
+
`).all().map((row) => this.#runtime(row));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
isActive(offerId) {
|
|
155
|
+
return Boolean(this.#database.prepare(`
|
|
156
|
+
SELECT 1 FROM ilink_login_offers
|
|
157
|
+
WHERE offer_id = ? AND status IN ('waiting', 'scanned')
|
|
158
|
+
AND expires_at > ?
|
|
159
|
+
`).get(offerId, Number(this.#clock())));
|
|
160
|
+
}
|
|
161
|
+
update(offerId, input) {
|
|
162
|
+
const now = Number(this.#clock());
|
|
163
|
+
return this.#transaction(() => {
|
|
164
|
+
this.#expire(now);
|
|
165
|
+
const current = this.#row(offerId);
|
|
166
|
+
if (!current)
|
|
167
|
+
throw new Error('Unknown or expired iLink login offer');
|
|
168
|
+
this.#database.prepare(`
|
|
169
|
+
UPDATE ilink_login_offers
|
|
170
|
+
SET status = ?, api_base_url = ?, last_polled_at = ?, updated_at = ?
|
|
171
|
+
WHERE offer_id = ? AND status IN ('waiting', 'scanned')
|
|
172
|
+
`).run(input.status, input.apiBaseUrl
|
|
173
|
+
? normalizeIlinkBaseUrl(input.apiBaseUrl)
|
|
174
|
+
: current.api_base_url, now, now, offerId);
|
|
175
|
+
return this.#runtime(this.#row(offerId));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
finish(offerId, result = 'cancelled', accountKey = '') {
|
|
179
|
+
return this.#transaction(() => {
|
|
180
|
+
const row = this.#row(offerId);
|
|
181
|
+
if (!row)
|
|
182
|
+
return false;
|
|
183
|
+
this.#finishRow(row, result, accountKey, Number(this.#clock()));
|
|
184
|
+
return true;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
cleanup(auditMaxAgeMs = 30 * 24 * 60 * 60 * 1_000) {
|
|
188
|
+
const maximumAge = Math.max(1, Number(auditMaxAgeMs) || 0);
|
|
189
|
+
return this.#transaction(() => {
|
|
190
|
+
const now = Number(this.#clock());
|
|
191
|
+
this.#expire(now);
|
|
192
|
+
return Number(this.#database.prepare(`
|
|
193
|
+
DELETE FROM ilink_enrollment_audit WHERE completed_at < ?
|
|
194
|
+
`).run(now - maximumAge).changes);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { downloadIlinkInboundImage, } from './inbound-image.js';
|
|
2
|
+
import { IlinkSecretBox } from './secret-box.js';
|
|
3
|
+
import { IlinkSqliteStore } from './sqlite-store.js';
|
|
4
|
+
const MAX_IMAGES_PER_TURN = 4;
|
|
5
|
+
const MAX_AGGREGATE_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
function imagePosition(mediaId) {
|
|
7
|
+
const match = /^ilink:(0|[1-9]\d?)$/u.exec(mediaId);
|
|
8
|
+
if (!match)
|
|
9
|
+
throw new Error('Invalid iLink image reference');
|
|
10
|
+
return Number(match[1]);
|
|
11
|
+
}
|
|
12
|
+
export class IlinkMediaGateway {
|
|
13
|
+
#store;
|
|
14
|
+
#secrets;
|
|
15
|
+
#download;
|
|
16
|
+
constructor({ store, secretBox, download = (imageItem) => downloadIlinkInboundImage(imageItem), }) {
|
|
17
|
+
this.#store = store;
|
|
18
|
+
this.#secrets = secretBox;
|
|
19
|
+
this.#download = download;
|
|
20
|
+
}
|
|
21
|
+
async resolveReference({ messageKey, mediaId, }) {
|
|
22
|
+
const stored = this.#store.getInboundImageSecret(messageKey, imagePosition(mediaId));
|
|
23
|
+
if (!stored)
|
|
24
|
+
throw new Error('iLink image reference is unavailable');
|
|
25
|
+
const plaintext = this.#secrets.open(stored.sealedLocator, {
|
|
26
|
+
secretKind: 'media_locator',
|
|
27
|
+
accountId: stored.accountKey,
|
|
28
|
+
peerId: stored.peerId,
|
|
29
|
+
generation: stored.secretGeneration,
|
|
30
|
+
});
|
|
31
|
+
let locator;
|
|
32
|
+
try {
|
|
33
|
+
locator = JSON.parse(plaintext);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error('iLink image locator is invalid');
|
|
37
|
+
}
|
|
38
|
+
if (!locator || typeof locator !== 'object' || Array.isArray(locator)) {
|
|
39
|
+
throw new Error('iLink image locator is invalid');
|
|
40
|
+
}
|
|
41
|
+
const { downloadUrl, aesKey } = locator;
|
|
42
|
+
if (typeof downloadUrl !== 'string' || typeof aesKey !== 'string' ||
|
|
43
|
+
!/^[A-Za-z0-9_-]{22}$/u.test(aesKey)) {
|
|
44
|
+
throw new Error('iLink image locator is invalid');
|
|
45
|
+
}
|
|
46
|
+
const key = Buffer.from(aesKey, 'base64url');
|
|
47
|
+
if (key.length !== 16 || key.toString('base64url') !== aesKey) {
|
|
48
|
+
key.fill(0);
|
|
49
|
+
throw new Error('iLink image locator is invalid');
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const resolved = await this.#download({
|
|
53
|
+
media: {
|
|
54
|
+
full_url: downloadUrl,
|
|
55
|
+
aes_key: key.toString('base64'),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return { kind: 'image', ...resolved };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
key.fill(0);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async resolveForCodex(message) {
|
|
65
|
+
if (message.attachments.length > MAX_IMAGES_PER_TURN) {
|
|
66
|
+
throw new Error('Too many iLink images in one turn');
|
|
67
|
+
}
|
|
68
|
+
const resolved = [];
|
|
69
|
+
let total = 0;
|
|
70
|
+
for (const attachment of message.attachments) {
|
|
71
|
+
const image = await this.resolveReference({
|
|
72
|
+
messageKey: message.messageKey,
|
|
73
|
+
mediaId: attachment.mediaId,
|
|
74
|
+
});
|
|
75
|
+
total += image.bytes.length;
|
|
76
|
+
if (total > MAX_AGGREGATE_IMAGE_BYTES) {
|
|
77
|
+
throw new Error('iLink image aggregate exceeds the turn limit');
|
|
78
|
+
}
|
|
79
|
+
resolved.push(image);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
}
|