@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,237 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parseEnv } from 'node:util';
|
|
6
|
+
import { isPathInside } from './lib/path-identity.js';
|
|
7
|
+
export function resolveProjectRoot(moduleUrl) {
|
|
8
|
+
const moduleDirectory = path.dirname(fileURLToPath(moduleUrl));
|
|
9
|
+
const parentDirectory = path.dirname(moduleDirectory);
|
|
10
|
+
return path.basename(parentDirectory) === 'dist'
|
|
11
|
+
? path.dirname(parentDirectory)
|
|
12
|
+
: parentDirectory;
|
|
13
|
+
}
|
|
14
|
+
export const KINTIO_PACKAGE_ROOT = resolveProjectRoot(import.meta.url);
|
|
15
|
+
export const FORCE_ABORT_TIMEOUT_MS = 5_000;
|
|
16
|
+
const MAX_SHUTDOWN_TIMEOUT_MS = 120_000;
|
|
17
|
+
export const WORKER_GRACEFUL_TIMEOUT_MS = MAX_SHUTDOWN_TIMEOUT_MS + FORCE_ABORT_TIMEOUT_MS + 2_000;
|
|
18
|
+
export const DAEMON_STOP_TIMEOUT_MS = WORKER_GRACEFUL_TIMEOUT_MS + 5_000 + 3_000;
|
|
19
|
+
export function parseStartTimeout(value) {
|
|
20
|
+
const timeout = Number(value || 30_000);
|
|
21
|
+
if (!Number.isInteger(timeout) || timeout < 1_000 || timeout > 120_000) {
|
|
22
|
+
throw new Error('KINTIO_START_TIMEOUT_MS must be an integer between 1000 and 120000');
|
|
23
|
+
}
|
|
24
|
+
return timeout;
|
|
25
|
+
}
|
|
26
|
+
function resolveInstanceRoot(environment = process.env, fallback = path.join(os.homedir(), '.kintio')) {
|
|
27
|
+
return path.resolve(environment.KINTIO_HOME || fallback);
|
|
28
|
+
}
|
|
29
|
+
export function resolveStateFiles(environment = process.env, root = resolveInstanceRoot(environment), fileExists = fs.existsSync) {
|
|
30
|
+
const configuredDatabaseFile = environment.KINTIO_DB_FILE ||
|
|
31
|
+
environment.TALKFERRY_DB_FILE ||
|
|
32
|
+
environment.HARNESS_DB_FILE ||
|
|
33
|
+
environment.WECOM_DB_FILE;
|
|
34
|
+
const defaultDatabaseFile = path.join(root, 'data/kintio.sqlite');
|
|
35
|
+
const talkFerryDatabaseFile = path.join(root, 'data/talkferry.sqlite');
|
|
36
|
+
const legacyDatabaseFile = path.join(root, 'data/wecom.sqlite');
|
|
37
|
+
const existingDefaultFiles = [
|
|
38
|
+
defaultDatabaseFile,
|
|
39
|
+
talkFerryDatabaseFile,
|
|
40
|
+
legacyDatabaseFile,
|
|
41
|
+
].filter(fileExists);
|
|
42
|
+
if (!configuredDatabaseFile && existingDefaultFiles.length > 1) {
|
|
43
|
+
const names = existingDefaultFiles.map((file) => path.relative(root, file)).join(', ');
|
|
44
|
+
throw new Error(`Multiple default state databases exist (${names}); set KINTIO_DB_FILE explicitly`);
|
|
45
|
+
}
|
|
46
|
+
const databaseFile = path.resolve(root, configuredDatabaseFile || existingDefaultFiles[0] || defaultDatabaseFile);
|
|
47
|
+
const originalDatabaseSelection = databaseFile === legacyDatabaseFile ||
|
|
48
|
+
(!environment.KINTIO_DB_FILE &&
|
|
49
|
+
!environment.TALKFERRY_DB_FILE &&
|
|
50
|
+
Boolean(environment.HARNESS_DB_FILE || environment.WECOM_DB_FILE));
|
|
51
|
+
const talkFerryDatabaseSelection = !originalDatabaseSelection && (databaseFile === talkFerryDatabaseFile ||
|
|
52
|
+
(!environment.KINTIO_DB_FILE && Boolean(environment.TALKFERRY_DB_FILE)));
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
databaseFile,
|
|
55
|
+
lockFile: path.join(path.dirname(databaseFile), originalDatabaseSelection
|
|
56
|
+
? 'wecom.lock'
|
|
57
|
+
: talkFerryDatabaseSelection
|
|
58
|
+
? 'talkferry.lock'
|
|
59
|
+
: 'kintio.lock'),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function loadEnvironmentFile(filePath, environment) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = parseEnv(fs.readFileSync(filePath, 'utf8'));
|
|
65
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
66
|
+
const key = process.platform === 'win32' ? name.toUpperCase() : name;
|
|
67
|
+
if (environment[key] === undefined)
|
|
68
|
+
environment[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function copyEnvironment(environment) {
|
|
78
|
+
if (process.platform !== 'win32')
|
|
79
|
+
return { ...environment };
|
|
80
|
+
return Object.fromEntries(Object.entries(environment).map(([name, value]) => [name.toUpperCase(), value]));
|
|
81
|
+
}
|
|
82
|
+
function parsePort(value) {
|
|
83
|
+
const port = Number(value ?? 8888);
|
|
84
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
85
|
+
throw new Error('PORT must be an integer between 1 and 65535');
|
|
86
|
+
}
|
|
87
|
+
return port;
|
|
88
|
+
}
|
|
89
|
+
function parseBoolean(value, fallback) {
|
|
90
|
+
if (value === undefined || value === '') {
|
|
91
|
+
return fallback;
|
|
92
|
+
}
|
|
93
|
+
if (/^(1|true|yes|on)$/i.test(value)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (/^(0|false|no|off)$/i.test(value)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Invalid boolean value: ${value}`);
|
|
100
|
+
}
|
|
101
|
+
function parsePositiveInteger(value, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
102
|
+
const parsed = Number(value ?? fallback);
|
|
103
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
104
|
+
throw new Error(`${name} must be a positive integer`);
|
|
105
|
+
}
|
|
106
|
+
if (parsed > maximum)
|
|
107
|
+
throw new Error(`${name} must not exceed ${maximum}`);
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
function parseAllowedUserIds(value) {
|
|
111
|
+
const parsed = String(value || '')
|
|
112
|
+
.split(',')
|
|
113
|
+
.map((item) => item.trim())
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
if (parsed.includes('*')) {
|
|
116
|
+
throw new Error('WECOM_ALLOWED_USER_IDS does not support wildcard entries');
|
|
117
|
+
}
|
|
118
|
+
return Object.freeze(parsed);
|
|
119
|
+
}
|
|
120
|
+
function parseBoundedText(value, fallback, name, maxBytes) {
|
|
121
|
+
const parsed = value === undefined ? fallback : String(value);
|
|
122
|
+
if (Buffer.byteLength(parsed, 'utf8') > maxBytes) {
|
|
123
|
+
throw new Error(`${name} must not exceed ${maxBytes} UTF-8 bytes`);
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
export function createConfig(environment = process.env, root, platform = process.platform) {
|
|
128
|
+
environment = copyEnvironment(environment);
|
|
129
|
+
const instanceRoot = path.resolve(root || resolveInstanceRoot(environment));
|
|
130
|
+
const port = parsePort(environment.PORT);
|
|
131
|
+
const callbackToken = environment.WECOM_CALLBACK_TOKEN || '';
|
|
132
|
+
const encodingAesKey = environment.WECOM_ENCODING_AES_KEY || '';
|
|
133
|
+
const corpId = (environment.WECOM_CORP_ID || '').trim();
|
|
134
|
+
const kfSecret = (environment.WECOM_KF_SECRET || '').trim();
|
|
135
|
+
if (Boolean(callbackToken) !== Boolean(encodingAesKey)) {
|
|
136
|
+
throw new Error('WECOM_CALLBACK_TOKEN and WECOM_ENCODING_AES_KEY must be configured together');
|
|
137
|
+
}
|
|
138
|
+
if (callbackToken && !/^[A-Za-z0-9]{1,32}$/.test(callbackToken)) {
|
|
139
|
+
throw new Error('WECOM_CALLBACK_TOKEN must contain 1 to 32 letters or digits');
|
|
140
|
+
}
|
|
141
|
+
if (encodingAesKey && !/^[A-Za-z0-9]{43}$/.test(encodingAesKey)) {
|
|
142
|
+
throw new Error('WECOM_ENCODING_AES_KEY must contain 43 letters or digits');
|
|
143
|
+
}
|
|
144
|
+
if (Boolean(corpId) !== Boolean(kfSecret)) {
|
|
145
|
+
throw new Error('WECOM_CORP_ID and WECOM_KF_SECRET must be configured together');
|
|
146
|
+
}
|
|
147
|
+
const apiEnabled = Boolean(corpId && kfSecret);
|
|
148
|
+
if (apiEnabled && !String(environment.ILINK_ENABLED ?? '').trim()) {
|
|
149
|
+
throw new Error('ILINK_ENABLED must be explicitly true or false when WeChat KF API is enabled');
|
|
150
|
+
}
|
|
151
|
+
const ilinkEnabled = parseBoolean(environment.ILINK_ENABLED, false);
|
|
152
|
+
const codexEnabled = parseBoolean(environment.CODEX_ENABLED, apiEnabled || ilinkEnabled);
|
|
153
|
+
const ilinkStorageKey = String(environment.ILINK_STORAGE_KEY || '').trim();
|
|
154
|
+
if (ilinkStorageKey && !/^[A-Za-z0-9_-]{43}$/u.test(ilinkStorageKey)) {
|
|
155
|
+
throw new Error('ILINK_STORAGE_KEY must be a canonical 32-byte base64url value');
|
|
156
|
+
}
|
|
157
|
+
const { databaseFile, lockFile } = resolveStateFiles(environment, instanceRoot);
|
|
158
|
+
const codexWorkingDirectory = path.resolve(instanceRoot, environment.CODEX_WORKING_DIRECTORY ||
|
|
159
|
+
'codex-workspace');
|
|
160
|
+
const ilinkStorageKeyFile = path.resolve(instanceRoot, environment.ILINK_STORAGE_KEY_FILE ||
|
|
161
|
+
path.join(path.dirname(databaseFile), 'ilink-storage.key'));
|
|
162
|
+
const codexImageTempDirectory = path.resolve(instanceRoot, environment.CODEX_IMAGE_TMP_DIR ||
|
|
163
|
+
'data/codex-input');
|
|
164
|
+
if (platform === 'win32') {
|
|
165
|
+
for (const [name, filePath] of [
|
|
166
|
+
['KINTIO_DB_FILE', databaseFile],
|
|
167
|
+
['Kintio state lock', lockFile],
|
|
168
|
+
['ILINK_STORAGE_KEY_FILE', ilinkStorageKeyFile],
|
|
169
|
+
['CODEX_IMAGE_TMP_DIR', codexImageTempDirectory],
|
|
170
|
+
]) {
|
|
171
|
+
if (!isPathInside(instanceRoot, filePath)) {
|
|
172
|
+
throw new Error(`${name} must stay inside KINTIO_HOME on Windows`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const apiTimeoutMs = parsePositiveInteger(environment.WECOM_API_TIMEOUT_MS, 10_000, 'WECOM_API_TIMEOUT_MS', 120_000);
|
|
177
|
+
const shutdownTimeoutMs = parsePositiveInteger(environment.SHUTDOWN_TIMEOUT_MS, 10_000, 'SHUTDOWN_TIMEOUT_MS', MAX_SHUTDOWN_TIMEOUT_MS);
|
|
178
|
+
if (shutdownTimeoutMs < 1_000) {
|
|
179
|
+
throw new Error('SHUTDOWN_TIMEOUT_MS must be at least 1000');
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
port,
|
|
183
|
+
wecom: Object.freeze({
|
|
184
|
+
callbackToken,
|
|
185
|
+
encodingAesKey,
|
|
186
|
+
expectedReceiveId: environment.WECOM_RECEIVE_ID || corpId,
|
|
187
|
+
api: Object.freeze({
|
|
188
|
+
enabled: apiEnabled,
|
|
189
|
+
corpId,
|
|
190
|
+
kfSecret,
|
|
191
|
+
baseUrl: environment.WECOM_API_BASE_URL || 'https://qyapi.weixin.qq.com',
|
|
192
|
+
timeoutMs: apiTimeoutMs,
|
|
193
|
+
observeMs: parsePositiveInteger(environment.WECOM_MCP_OBSERVE_MS, 5_000, 'WECOM_MCP_OBSERVE_MS', 20_000),
|
|
194
|
+
}),
|
|
195
|
+
allowedUserIds: parseAllowedUserIds(environment.WECOM_ALLOWED_USER_IDS),
|
|
196
|
+
authorization: Object.freeze({
|
|
197
|
+
trigger: parseBoundedText(environment.WECOM_AUTH_TRIGGER, '', 'WECOM_AUTH_TRIGGER', 128),
|
|
198
|
+
requiredConsecutive: parsePositiveInteger(environment.WECOM_AUTH_TRIGGER_COUNT, 3, 'WECOM_AUTH_TRIGGER_COUNT'),
|
|
199
|
+
confirmationText: parseBoundedText(environment.WECOM_AUTH_CONFIRMATION, 'Code accepted. You can continue the conversation.', 'WECOM_AUTH_CONFIRMATION', 2048),
|
|
200
|
+
}),
|
|
201
|
+
}),
|
|
202
|
+
ilink: Object.freeze({
|
|
203
|
+
enabled: ilinkEnabled,
|
|
204
|
+
storageKey: ilinkStorageKey,
|
|
205
|
+
storageKeyFile: ilinkStorageKeyFile,
|
|
206
|
+
baseUrl: environment.ILINK_BASE_URL || 'https://ilinkai.weixin.qq.com/',
|
|
207
|
+
apiTimeoutMs: parsePositiveInteger(environment.ILINK_API_TIMEOUT_MS, 15_000, 'ILINK_API_TIMEOUT_MS', 120_000),
|
|
208
|
+
longPollTimeoutMs: parsePositiveInteger(environment.ILINK_LONG_POLL_TIMEOUT_MS, 35_000, 'ILINK_LONG_POLL_TIMEOUT_MS', 120_000),
|
|
209
|
+
maxAccounts: parsePositiveInteger(environment.ILINK_MAX_ACCOUNTS, 20, 'ILINK_MAX_ACCOUNTS', 1_000),
|
|
210
|
+
}),
|
|
211
|
+
state: Object.freeze({
|
|
212
|
+
databaseFile,
|
|
213
|
+
lockFile,
|
|
214
|
+
shutdownTimeoutMs,
|
|
215
|
+
}),
|
|
216
|
+
codex: Object.freeze({
|
|
217
|
+
enabled: codexEnabled,
|
|
218
|
+
imageTempDirectory: codexImageTempDirectory,
|
|
219
|
+
workingDirectory: codexWorkingDirectory,
|
|
220
|
+
generatedImageDirectory: path.join(codexWorkingDirectory, 'generated_images'),
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
export function loadConfig(options = {}) {
|
|
225
|
+
const environment = copyEnvironment(options.environment || process.env);
|
|
226
|
+
const configuredEnvFile = options.envFile || environment.KINTIO_CONFIG_FILE;
|
|
227
|
+
const defaultRoot = path.join(path.resolve(options.homeDirectory || os.homedir()), '.kintio');
|
|
228
|
+
const initialRoot = path.resolve(options.root ||
|
|
229
|
+
environment.KINTIO_HOME ||
|
|
230
|
+
(configuredEnvFile
|
|
231
|
+
? path.dirname(path.resolve(configuredEnvFile))
|
|
232
|
+
: defaultRoot));
|
|
233
|
+
const envFile = path.resolve(configuredEnvFile || path.join(initialRoot, '.env'));
|
|
234
|
+
loadEnvironmentFile(envFile, environment);
|
|
235
|
+
const root = path.resolve(options.root || environment.KINTIO_HOME || initialRoot);
|
|
236
|
+
return createConfig(environment, root);
|
|
237
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const MESSAGE_ORIGINS = {
|
|
2
|
+
CUSTOMER: 'customer',
|
|
3
|
+
SYSTEM: 'system',
|
|
4
|
+
UNKNOWN: 'unknown',
|
|
5
|
+
};
|
|
6
|
+
export const COMMON_MESSAGE_TYPES = {
|
|
7
|
+
TEXT: 'text',
|
|
8
|
+
EVENT: 'event',
|
|
9
|
+
UNKNOWN: 'unknown',
|
|
10
|
+
};
|
|
11
|
+
export function isProcessableCustomerMessage(message) {
|
|
12
|
+
return message.origin === MESSAGE_ORIGINS.CUSTOMER &&
|
|
13
|
+
Boolean(message.type.trim()) &&
|
|
14
|
+
message.type !== COMMON_MESSAGE_TYPES.EVENT &&
|
|
15
|
+
message.type !== COMMON_MESSAGE_TYPES.UNKNOWN;
|
|
16
|
+
}
|
|
17
|
+
export function isSystemEvent(message) {
|
|
18
|
+
return message.origin === MESSAGE_ORIGINS.SYSTEM &&
|
|
19
|
+
message.type === COMMON_MESSAGE_TYPES.EVENT;
|
|
20
|
+
}
|
|
21
|
+
export function renderMessageForAgent(message) {
|
|
22
|
+
return String(message.summary || message.text || '[Channel message: no readable summary]');
|
|
23
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
const SEND_TYPES = [
|
|
3
|
+
'text',
|
|
4
|
+
'image',
|
|
5
|
+
'link',
|
|
6
|
+
'miniprogram',
|
|
7
|
+
'location',
|
|
8
|
+
];
|
|
9
|
+
export const SEND_TOOL_NAMES = [
|
|
10
|
+
'send_text',
|
|
11
|
+
'send_image',
|
|
12
|
+
'send_link',
|
|
13
|
+
'send_miniprogram',
|
|
14
|
+
'send_location',
|
|
15
|
+
];
|
|
16
|
+
const TOOL_TYPES = new Map(SEND_TOOL_NAMES.map((name, index) => [name, SEND_TYPES[index]]));
|
|
17
|
+
const MEDIA_REFERENCE = /^(?:media|artifact):(?:0|[1-9]\d?)$/u;
|
|
18
|
+
const MAX_TEXT_BYTES = 2048;
|
|
19
|
+
export class SendContractError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(message, code = 'invalid_send_intent') {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'SendContractError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function fail(message, code) {
|
|
28
|
+
throw new SendContractError(message, code);
|
|
29
|
+
}
|
|
30
|
+
function requiredText(value, label, maxBytes) {
|
|
31
|
+
const result = String(value ?? '').trim();
|
|
32
|
+
if (!result)
|
|
33
|
+
fail(`${label} cannot be empty`);
|
|
34
|
+
if (Buffer.byteLength(result, 'utf8') > maxBytes) {
|
|
35
|
+
fail(`${label} exceeds ${maxBytes} UTF-8 bytes`);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function optionalText(value, label, maxBytes) {
|
|
40
|
+
const result = String(value ?? '').trim();
|
|
41
|
+
if (Buffer.byteLength(result, 'utf8') > maxBytes) {
|
|
42
|
+
fail(`${label} exceeds ${maxBytes} UTF-8 bytes`);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function privateIpv4(hostname) {
|
|
47
|
+
const octets = hostname.split('.').map(Number);
|
|
48
|
+
if (octets.length !== 4 || octets.some((item) => !Number.isInteger(item))) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const a = octets[0];
|
|
52
|
+
const b = octets[1];
|
|
53
|
+
return (a === 0 ||
|
|
54
|
+
a === 10 ||
|
|
55
|
+
a === 127 ||
|
|
56
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
57
|
+
(a === 169 && b === 254) ||
|
|
58
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
59
|
+
(a === 192 && b === 0) ||
|
|
60
|
+
(a === 192 && b === 168) ||
|
|
61
|
+
(a === 198 && [18, 19, 51].includes(b)) ||
|
|
62
|
+
(a === 203 && b === 0) ||
|
|
63
|
+
a >= 224);
|
|
64
|
+
}
|
|
65
|
+
function privateIpv6(hostname) {
|
|
66
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, '');
|
|
67
|
+
if (normalized === '::' || normalized === '::1')
|
|
68
|
+
return true;
|
|
69
|
+
if (/^(?:fc|fd|fe[89ab])/u.test(normalized))
|
|
70
|
+
return true;
|
|
71
|
+
if (normalized.startsWith('::ffff:'))
|
|
72
|
+
return true;
|
|
73
|
+
const mapped = normalized.match(/(?:^|:)ffff:(\d+\.\d+\.\d+\.\d+)$/u);
|
|
74
|
+
return mapped?.[1] ? privateIpv4(mapped[1]) : false;
|
|
75
|
+
}
|
|
76
|
+
function publicHttpUrl(value, label) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(String(value || ''));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
fail(`${label} must be a valid public HTTP(S) URL`);
|
|
83
|
+
}
|
|
84
|
+
const hostname = url.hostname.toLowerCase();
|
|
85
|
+
const ipVersion = isIP(hostname.replace(/^\[|\]$/gu, ''));
|
|
86
|
+
const forbiddenName = !ipVersion &&
|
|
87
|
+
(!hostname.includes('.') ||
|
|
88
|
+
hostname === 'localhost' ||
|
|
89
|
+
hostname.endsWith('.localhost') ||
|
|
90
|
+
hostname.endsWith('.local') ||
|
|
91
|
+
hostname.endsWith('.internal'));
|
|
92
|
+
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
93
|
+
url.username ||
|
|
94
|
+
url.password ||
|
|
95
|
+
forbiddenName ||
|
|
96
|
+
(ipVersion === 4 && privateIpv4(hostname)) ||
|
|
97
|
+
(ipVersion === 6 && privateIpv6(hostname))) {
|
|
98
|
+
fail(`${label} must be a valid public HTTP(S) URL`);
|
|
99
|
+
}
|
|
100
|
+
if (Buffer.byteLength(url.toString(), 'utf8') > 2048) {
|
|
101
|
+
fail(`${label} exceeds 2048 UTF-8 bytes`);
|
|
102
|
+
}
|
|
103
|
+
return url.toString();
|
|
104
|
+
}
|
|
105
|
+
export function normalizeMediaCatalog(mediaCatalog = []) {
|
|
106
|
+
if (!Array.isArray(mediaCatalog) || mediaCatalog.length > 100) {
|
|
107
|
+
fail('Media catalog must contain at most 100 entries', 'invalid_media_catalog');
|
|
108
|
+
}
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
return Object.freeze(mediaCatalog.map((item) => {
|
|
111
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
112
|
+
fail('Media catalog entries must be objects', 'invalid_media_catalog');
|
|
113
|
+
}
|
|
114
|
+
const record = item;
|
|
115
|
+
const extra = Object.keys(record).filter((key) => !['ref', 'kind'].includes(key));
|
|
116
|
+
if (extra.length) {
|
|
117
|
+
fail(`Media catalog must not expose ${extra.join(', ')}`, 'unsafe_media_catalog');
|
|
118
|
+
}
|
|
119
|
+
const ref = String(record.ref || '');
|
|
120
|
+
const kind = String(record.kind || '');
|
|
121
|
+
if (!MEDIA_REFERENCE.test(ref) || kind !== 'image' || seen.has(ref)) {
|
|
122
|
+
fail('Media catalog requires unique media:N image references', 'invalid_media_catalog');
|
|
123
|
+
}
|
|
124
|
+
seen.add(ref);
|
|
125
|
+
return Object.freeze({ ref, kind: 'image' });
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
function normalizeText(input) {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
type: 'text',
|
|
131
|
+
content: requiredText(input?.content, 'Text content', MAX_TEXT_BYTES),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function normalizeImage(input, mediaCatalog, allowUnboundMediaReference) {
|
|
135
|
+
const mediaRef = String(input?.mediaRef || '');
|
|
136
|
+
if (!MEDIA_REFERENCE.test(mediaRef)) {
|
|
137
|
+
fail('Image reference must use media:N', 'invalid_media_reference');
|
|
138
|
+
}
|
|
139
|
+
if (!allowUnboundMediaReference &&
|
|
140
|
+
!mediaCatalog.some((entry) => entry.ref === mediaRef)) {
|
|
141
|
+
fail('Image reference is not available in this turn', 'invalid_media_reference');
|
|
142
|
+
}
|
|
143
|
+
return Object.freeze({ type: 'image', mediaRef });
|
|
144
|
+
}
|
|
145
|
+
function normalizeLink(input) {
|
|
146
|
+
return Object.freeze({
|
|
147
|
+
type: 'link',
|
|
148
|
+
title: requiredText(input?.title, 'Link title', 128),
|
|
149
|
+
description: optionalText(input?.description, 'Link description', 512),
|
|
150
|
+
url: publicHttpUrl(input?.url, 'Link URL'),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function normalizeMiniProgram(input) {
|
|
154
|
+
const appId = String(input?.appId || '').trim();
|
|
155
|
+
if (!/^wx[A-Za-z0-9]{16}$/u.test(appId)) {
|
|
156
|
+
fail('Mini-program appId must use wx followed by 16 letters or digits');
|
|
157
|
+
}
|
|
158
|
+
const pagePath = requiredText(input?.pagePath, 'Mini-program pagePath', 1024);
|
|
159
|
+
if (/^[a-z][a-z\d+.-]*:\/\//iu.test(pagePath) ||
|
|
160
|
+
pagePath.includes('..') ||
|
|
161
|
+
/[\u0000-\u001f\u007f]/u.test(pagePath)) {
|
|
162
|
+
fail('Mini-program pagePath is invalid');
|
|
163
|
+
}
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
type: 'miniprogram',
|
|
166
|
+
appId,
|
|
167
|
+
title: requiredText(input?.title, 'Mini-program title', 64),
|
|
168
|
+
pagePath,
|
|
169
|
+
sourceUrl: publicHttpUrl(input?.sourceUrl, 'Mini-program source URL'),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function normalizeLocation(input) {
|
|
173
|
+
const latitude = Number(input?.latitude);
|
|
174
|
+
const longitude = Number(input?.longitude);
|
|
175
|
+
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
|
176
|
+
fail('Location latitude must be between -90 and 90');
|
|
177
|
+
}
|
|
178
|
+
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
179
|
+
fail('Location longitude must be between -180 and 180');
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
type: 'location',
|
|
183
|
+
name: requiredText(input?.name, 'Location name', 128),
|
|
184
|
+
address: requiredText(input?.address, 'Location address', 512),
|
|
185
|
+
latitude,
|
|
186
|
+
longitude,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export function normalizeSendIntent(typeOrToolName, input, options = {}) {
|
|
190
|
+
const type = TOOL_TYPES.get(typeOrToolName) || String(typeOrToolName || '');
|
|
191
|
+
if (!SEND_TYPES.includes(type)) {
|
|
192
|
+
fail(`Unsupported send type: ${type || 'unknown'}`, 'unsupported_send_type');
|
|
193
|
+
}
|
|
194
|
+
const mediaCatalog = normalizeMediaCatalog(options.mediaCatalog || []);
|
|
195
|
+
if (type === 'text')
|
|
196
|
+
return normalizeText(input);
|
|
197
|
+
if (type === 'image') {
|
|
198
|
+
return normalizeImage(input, mediaCatalog, options.allowUnboundMediaReference === true);
|
|
199
|
+
}
|
|
200
|
+
if (type === 'link')
|
|
201
|
+
return normalizeLink(input);
|
|
202
|
+
if (type === 'miniprogram')
|
|
203
|
+
return normalizeMiniProgram(input);
|
|
204
|
+
return normalizeLocation(input);
|
|
205
|
+
}
|