e42417bd6e
Enable daily morning greeting on reply 1 (with custom time, modify, and cancel), random greeting delivery, M发现 in subscribe welcome, and optional LLM parsing when rules miss. Also fix WeChat MP draft/config error passthrough and add Tang E2E scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
304 lines
8.3 KiB
JavaScript
304 lines
8.3 KiB
JavaScript
import { fetch as undiciFetch } from 'undici';
|
|
import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs';
|
|
|
|
const TABLE = 'h5_user_wechat_mp_configs';
|
|
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
|
const ACCOUNT_TYPES = new Set(['service', 'subscription']);
|
|
|
|
function normalizeAccountType(value, fallback = 'service') {
|
|
const normalized = String(value ?? '').trim().toLowerCase();
|
|
return ACCOUNT_TYPES.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function resolveEncryptionKey(env = process.env) {
|
|
return (
|
|
env.H5_SETTINGS_ENCRYPTION_KEY
|
|
?? env.TKMIND_SERVER__SECRET_KEY
|
|
?? 'local-dev-secret'
|
|
);
|
|
}
|
|
|
|
async function readJsonResponse(response) {
|
|
const text = await response.text().catch(() => '');
|
|
if (!response.ok) {
|
|
throw new Error(text || `upstream ${response.status}`);
|
|
}
|
|
return text ? JSON.parse(text) : null;
|
|
}
|
|
|
|
async function ensureTable(pool) {
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
user_id CHAR(36) PRIMARY KEY,
|
|
app_id VARCHAR(64) NOT NULL,
|
|
account_type ENUM('service', 'subscription') NOT NULL DEFAULT 'service',
|
|
label VARCHAR(64) NULL,
|
|
author VARCHAR(16) NULL,
|
|
secret_ciphertext TEXT NOT NULL,
|
|
secret_iv VARCHAR(32) NOT NULL,
|
|
secret_tag VARCHAR(32) NOT NULL,
|
|
verified_at BIGINT NULL,
|
|
created_at BIGINT NOT NULL,
|
|
updated_at BIGINT NOT NULL,
|
|
KEY idx_wechat_mp_config_app (app_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
`);
|
|
}
|
|
|
|
function mapRow(row) {
|
|
if (!row) return null;
|
|
return {
|
|
userId: row.user_id,
|
|
appId: row.app_id,
|
|
accountType: row.account_type,
|
|
label: row.label ?? '',
|
|
author: row.author ?? '',
|
|
secretCiphertext: row.secret_ciphertext,
|
|
secretIv: row.secret_iv,
|
|
secretTag: row.secret_tag,
|
|
verifiedAt: row.verified_at ? Number(row.verified_at) : null,
|
|
createdAt: Number(row.created_at ?? 0),
|
|
updatedAt: Number(row.updated_at ?? 0),
|
|
};
|
|
}
|
|
|
|
function toPublicConfig(row, env = process.env) {
|
|
if (!row) {
|
|
return {
|
|
configured: false,
|
|
appId: '',
|
|
accountType: 'service',
|
|
label: '',
|
|
author: '',
|
|
appSecretMasked: '',
|
|
verifiedAt: null,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
let appSecretMasked = '';
|
|
try {
|
|
const secret = decryptSecret(
|
|
{
|
|
ciphertext: row.secretCiphertext,
|
|
iv: row.secretIv,
|
|
tag: row.secretTag,
|
|
},
|
|
resolveEncryptionKey(env),
|
|
);
|
|
appSecretMasked = maskApiKey(secret);
|
|
} catch {
|
|
appSecretMasked = '********';
|
|
}
|
|
return {
|
|
configured: true,
|
|
appId: row.appId,
|
|
accountType: row.accountType,
|
|
label: row.label,
|
|
author: row.author,
|
|
appSecretMasked,
|
|
verifiedAt: row.verifiedAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
export async function fetchWechatMpAccessToken(
|
|
{ appId, appSecret },
|
|
{ tokenUrl = DEFAULT_TOKEN_URL, wechatFetch = undiciFetch } = {},
|
|
) {
|
|
const payload = await readJsonResponse(
|
|
await wechatFetch(tokenUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
grant_type: 'client_credential',
|
|
appid: appId,
|
|
secret: appSecret,
|
|
force_refresh: false,
|
|
}),
|
|
}),
|
|
);
|
|
if (!payload?.access_token) {
|
|
throw Object.assign(new Error(payload?.errmsg || '获取微信 access_token 失败'), {
|
|
code: 'invalid_wechat_mp_config',
|
|
});
|
|
}
|
|
return {
|
|
accessToken: String(payload.access_token),
|
|
expiresIn: Number(payload.expires_in ?? 7200),
|
|
};
|
|
}
|
|
|
|
export function createMindSpaceWechatMpConfigService(
|
|
pool,
|
|
{
|
|
env = process.env,
|
|
tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL,
|
|
wechatFetch = undiciFetch,
|
|
} = {},
|
|
) {
|
|
let ensurePromise = null;
|
|
|
|
async function ensureReady() {
|
|
if (!ensurePromise) {
|
|
ensurePromise = ensureTable(pool);
|
|
}
|
|
await ensurePromise;
|
|
}
|
|
|
|
async function readRow(userId) {
|
|
await ensureReady();
|
|
const [rows] = await pool.query(
|
|
`SELECT user_id, app_id, account_type, label, author,
|
|
secret_ciphertext, secret_iv, secret_tag,
|
|
verified_at, created_at, updated_at
|
|
FROM ${TABLE}
|
|
WHERE user_id = ?
|
|
LIMIT 1`,
|
|
[userId],
|
|
);
|
|
return mapRow(rows[0] ?? null);
|
|
}
|
|
|
|
function readCredentials(row) {
|
|
if (!row) return null;
|
|
const appSecret = decryptSecret(
|
|
{
|
|
ciphertext: row.secretCiphertext,
|
|
iv: row.secretIv,
|
|
tag: row.secretTag,
|
|
},
|
|
resolveEncryptionKey(env),
|
|
);
|
|
if (!appSecret) {
|
|
throw new Error('公众号密钥解密失败,请重新保存配置');
|
|
}
|
|
return {
|
|
appId: row.appId,
|
|
appSecret,
|
|
accountType: row.accountType,
|
|
author: row.author,
|
|
label: row.label,
|
|
};
|
|
}
|
|
|
|
return {
|
|
async getConfig(userId) {
|
|
return toPublicConfig(await readRow(userId), env);
|
|
},
|
|
|
|
async getCredentials(userId) {
|
|
return readCredentials(await readRow(userId));
|
|
},
|
|
|
|
async upsertConfig(
|
|
userId,
|
|
{
|
|
appId,
|
|
appSecret,
|
|
accountType,
|
|
label,
|
|
author,
|
|
verify = true,
|
|
} = {},
|
|
) {
|
|
await ensureReady();
|
|
const normalizedAppId = String(appId ?? '').trim();
|
|
if (!normalizedAppId) {
|
|
throw Object.assign(new Error('请填写 AppID'), { code: 'invalid_wechat_mp_config' });
|
|
}
|
|
|
|
const existing = await readRow(userId);
|
|
const nextSecret = String(appSecret ?? '').trim();
|
|
let encrypted = null;
|
|
if (nextSecret) {
|
|
encrypted = encryptSecret(nextSecret, resolveEncryptionKey(env));
|
|
} else if (existing) {
|
|
encrypted = {
|
|
ciphertext: existing.secretCiphertext,
|
|
iv: existing.secretIv,
|
|
tag: existing.secretTag,
|
|
};
|
|
} else {
|
|
throw Object.assign(new Error('请填写 AppSecret'), { code: 'invalid_wechat_mp_config' });
|
|
}
|
|
|
|
const credentials = {
|
|
appId: normalizedAppId,
|
|
appSecret: nextSecret || decryptSecret(
|
|
{
|
|
ciphertext: encrypted.ciphertext,
|
|
iv: encrypted.iv,
|
|
tag: encrypted.tag,
|
|
},
|
|
resolveEncryptionKey(env),
|
|
),
|
|
};
|
|
|
|
let verifiedAt = existing?.verifiedAt ?? null;
|
|
if (verify) {
|
|
await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch });
|
|
verifiedAt = Date.now();
|
|
}
|
|
|
|
const now = Date.now();
|
|
await pool.query(
|
|
`INSERT INTO ${TABLE}
|
|
(user_id, app_id, account_type, label, author,
|
|
secret_ciphertext, secret_iv, secret_tag,
|
|
verified_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
app_id = VALUES(app_id),
|
|
account_type = VALUES(account_type),
|
|
label = VALUES(label),
|
|
author = VALUES(author),
|
|
secret_ciphertext = VALUES(secret_ciphertext),
|
|
secret_iv = VALUES(secret_iv),
|
|
secret_tag = VALUES(secret_tag),
|
|
verified_at = VALUES(verified_at),
|
|
updated_at = VALUES(updated_at)`,
|
|
[
|
|
userId,
|
|
normalizedAppId,
|
|
normalizeAccountType(accountType, existing?.accountType ?? 'service'),
|
|
String(label ?? existing?.label ?? '').trim().slice(0, 64) || null,
|
|
String(author ?? existing?.author ?? '').trim().slice(0, 8) || null,
|
|
encrypted.ciphertext,
|
|
encrypted.iv,
|
|
encrypted.tag,
|
|
verifiedAt,
|
|
existing?.createdAt ?? now,
|
|
now,
|
|
],
|
|
);
|
|
return this.getConfig(userId);
|
|
},
|
|
|
|
async verifyConfig(userId) {
|
|
const row = await readRow(userId);
|
|
if (!row) {
|
|
throw Object.assign(new Error('尚未配置公众号凭证'), { code: 'wechat_mp_not_configured' });
|
|
}
|
|
const credentials = readCredentials(row);
|
|
await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch });
|
|
const now = Date.now();
|
|
await pool.query(
|
|
`UPDATE ${TABLE} SET verified_at = ?, updated_at = ? WHERE user_id = ?`,
|
|
[now, now, userId],
|
|
);
|
|
return this.getConfig(userId);
|
|
},
|
|
|
|
async deleteConfig(userId) {
|
|
await ensureReady();
|
|
await pool.query(`DELETE FROM ${TABLE} WHERE user_id = ?`, [userId]);
|
|
return { ok: true };
|
|
},
|
|
};
|
|
}
|
|
|
|
export const mindspaceWechatMpConfigInternals = {
|
|
normalizeAccountType,
|
|
toPublicConfig,
|
|
};
|