Files
memind/mindspace-wechat-mp-config.mjs
T
john e42417bd6e feat(wechat): add subscribe morning reminder, plaza welcome, and LLM fallback
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>
2026-09-10 17:32:10 +08:00

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,
};