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>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const HOST = 'john@58.38.22.103';
|
||||
const REMOTE_ROOT = '/Users/john/Project/Memind';
|
||||
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
|
||||
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
|
||||
const remoteScript = `
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
|
||||
const now = Date.now();
|
||||
|
||||
const [items] = await pool.query(
|
||||
\`SELECT id FROM h5_schedule_items
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'\`,
|
||||
['${TANG}'],
|
||||
);
|
||||
const itemIds = items.map((row) => row.id);
|
||||
if (itemIds.length) {
|
||||
await pool.query(
|
||||
\`DELETE FROM h5_schedule_reminders WHERE user_id = ? AND item_id IN (\${itemIds.map(() => '?').join(',')})\`,
|
||||
['${TANG}', ...itemIds],
|
||||
);
|
||||
await pool.query(
|
||||
\`UPDATE h5_schedule_items SET status = 'cancelled', deleted_at = ?, updated_at = ?
|
||||
WHERE user_id = ? AND id IN (\${itemIds.map(() => '?').join(',')})\`,
|
||||
[now, now, '${TANG}', ...itemIds],
|
||||
);
|
||||
}
|
||||
|
||||
const [ident] = await pool.query(
|
||||
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
|
||||
['${TANG}', appId],
|
||||
);
|
||||
const openid = ident?.[0]?.openid;
|
||||
if (openid) {
|
||||
await pool.query(
|
||||
'DELETE FROM h5_wechat_subscribe_morning_pending WHERE app_id = ? AND openid = ?',
|
||||
[appId, openid],
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
clearedItems: itemIds.length,
|
||||
clearedPending: Boolean(openid),
|
||||
}, null, 2));
|
||||
await pool.end();
|
||||
`.trim();
|
||||
|
||||
const local = path.join(root, '.tmp-reset-tang-morning.mjs');
|
||||
fs.writeFileSync(local, remoteScript);
|
||||
execSync(`scp -q ${local} ${HOST}:${REMOTE_ROOT}/.tmp-reset-tang-morning.mjs`, { stdio: 'inherit' });
|
||||
execSync(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} .tmp-reset-tang-morning.mjs && rm -f .tmp-reset-tang-morning.mjs'`, { stdio: 'inherit' });
|
||||
fs.unlinkSync(local);
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const HOST = 'john@58.38.22.103';
|
||||
const REMOTE_ROOT = '/Users/john/Project/Memind';
|
||||
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
|
||||
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
|
||||
const remoteScript = `
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
import {
|
||||
createSubscribeMorningReminderPendingStore,
|
||||
handleSubscribeMorningReminderTurn,
|
||||
subscribeMorningReminderSchedule,
|
||||
} from './wechat/subscribe-morning-reminder.mjs';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
|
||||
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
|
||||
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
|
||||
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||
const { hour, minute } = subscribeMorningReminderSchedule({ env: process.env });
|
||||
|
||||
const [ident] = await pool.query(
|
||||
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
|
||||
['${TANG}', appId],
|
||||
);
|
||||
const openid = ident?.[0]?.openid;
|
||||
if (!openid) throw new Error('openid missing');
|
||||
|
||||
const pendingStore = createSubscribeMorningReminderPendingStore({ mysqlPool: pool });
|
||||
const scheduleService = createScheduleService(pool, { defaultTimezone: timezone });
|
||||
await pendingStore.setPending({ appId, openid });
|
||||
|
||||
async function sendWechatText(text) {
|
||||
const tokenPayload = await (
|
||||
await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
|
||||
})
|
||||
).json();
|
||||
const sendPayload = await (
|
||||
await fetch(
|
||||
'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token='
|
||||
+ encodeURIComponent(tokenPayload.access_token),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
|
||||
},
|
||||
)
|
||||
).json();
|
||||
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
|
||||
}
|
||||
|
||||
const reply = await handleSubscribeMorningReminderTurn({
|
||||
appId,
|
||||
openid,
|
||||
text: '1',
|
||||
pendingStore,
|
||||
scheduleService,
|
||||
boundUser: { userId: '${TANG}' },
|
||||
timezone,
|
||||
hour,
|
||||
minute,
|
||||
sourceMessageId: 'e2e-retry',
|
||||
});
|
||||
if (reply) await sendWechatText(reply);
|
||||
|
||||
const [items] = await pool.query(
|
||||
\`SELECT id, title, metadata_json FROM h5_schedule_items
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'
|
||||
ORDER BY created_at DESC LIMIT 1\`,
|
||||
['${TANG}'],
|
||||
);
|
||||
const [reminders] = items[0]
|
||||
? await pool.query(
|
||||
'SELECT id, remind_at, channel, status FROM h5_schedule_reminders WHERE item_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[items[0].id],
|
||||
)
|
||||
: [[]];
|
||||
|
||||
console.log(JSON.stringify({ ok: Boolean(items[0]), reply, item: items[0] ?? null, reminder: reminders[0] ?? null }, null, 2));
|
||||
await pool.end();
|
||||
`.trim();
|
||||
|
||||
execSync(`scp -q ${path.join(root, 'wechat/subscribe-morning-reminder.mjs')} ${HOST}:${REMOTE_ROOT}/wechat/subscribe-morning-reminder.mjs`, { stdio: 'inherit' });
|
||||
const local = path.join(root, '.tmp-retry-tang-morning.mjs');
|
||||
fs.writeFileSync(local, remoteScript);
|
||||
execSync(`scp -q ${local} ${HOST}:${REMOTE_ROOT}/.tmp-retry-tang-morning.mjs`, { stdio: 'inherit' });
|
||||
execSync(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} .tmp-retry-tang-morning.mjs && rm -f .tmp-retry-tang-morning.mjs'`, { stdio: 'inherit' });
|
||||
fs.unlinkSync(local);
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 103 唐用户:推送新关注欢迎语 → 等待回复 1 → 验证/补跑早安提醒设置。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/run-tang-subscribe-morning-e2e-103.mjs
|
||||
* node scripts/run-tang-subscribe-morning-e2e-103.mjs --wait-seconds 600
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const HOST = 'john@58.38.22.103';
|
||||
const REMOTE_ROOT = '/Users/john/Project/Memind';
|
||||
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
|
||||
const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
|
||||
function parseWaitSeconds(argv) {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--wait-seconds') {
|
||||
return Math.max(30, Number(argv[i + 1] ?? 600) || 600);
|
||||
}
|
||||
}
|
||||
return 600;
|
||||
}
|
||||
|
||||
function sh(cmd) {
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function scp(localRel, remoteAbs) {
|
||||
sh(`scp -q ${path.join(root, localRel)} ${HOST}:${remoteAbs}`);
|
||||
}
|
||||
|
||||
const waitSeconds = parseWaitSeconds(process.argv.slice(2));
|
||||
const remoteRunner = `${REMOTE_ROOT}/.tmp-run-tang-subscribe-morning-e2e.mjs`;
|
||||
|
||||
const remoteScript = `
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { buildSubscribeWelcomeText } from './wechat/handlers/sync-replies.mjs';
|
||||
import {
|
||||
createSubscribeMorningReminderPendingStore,
|
||||
handleSubscribeMorningReminderTurn,
|
||||
isSubscribeMorningConfirmReply,
|
||||
subscribeMorningReminderSchedule,
|
||||
} from './wechat/subscribe-morning-reminder.mjs';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
|
||||
const TANG_USER_ID = ${JSON.stringify(TANG_USER_ID)};
|
||||
const WAIT_SECONDS = ${waitSeconds};
|
||||
const POLL_MS = 5000;
|
||||
|
||||
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
|
||||
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
|
||||
const publicBaseUrl = (process.env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\\/$/, '');
|
||||
const bindPath = process.env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
|
||||
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||
const { hour, minute } = subscribeMorningReminderSchedule({ env: process.env });
|
||||
|
||||
if (!appId || !appSecret) throw new Error('missing wechat credentials');
|
||||
|
||||
async function fetchAccessToken() {
|
||||
const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
|
||||
});
|
||||
const tokenPayload = await tokenRes.json();
|
||||
if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload));
|
||||
return tokenPayload.access_token;
|
||||
}
|
||||
|
||||
async function sendWechatText(openid, text) {
|
||||
const accessToken = await fetchAccessToken();
|
||||
const sendRes = await fetch(
|
||||
'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(accessToken),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
|
||||
},
|
||||
);
|
||||
const sendPayload = await sendRes.json();
|
||||
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
|
||||
return sendPayload;
|
||||
}
|
||||
|
||||
async function findMorningScheduleItem(userId) {
|
||||
const [rows] = await pool.query(
|
||||
\`SELECT id, title, metadata_json, status, created_at
|
||||
FROM h5_schedule_items
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = 'subscribe_morning_reminder'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1\`,
|
||||
[userId],
|
||||
);
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const [users] = await pool.query(
|
||||
'SELECT id, username, display_name FROM h5_users WHERE id = ? LIMIT 1',
|
||||
[TANG_USER_ID],
|
||||
);
|
||||
const user = users?.[0];
|
||||
if (!user) throw new Error('tang user not found');
|
||||
|
||||
const [ident] = await pool.query(
|
||||
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
|
||||
[TANG_USER_ID, appId],
|
||||
);
|
||||
const openid = ident?.[0]?.openid;
|
||||
if (!openid) throw new Error('tang not bound to wechat');
|
||||
|
||||
const pendingStore = createSubscribeMorningReminderPendingStore({ mysqlPool: pool });
|
||||
const scheduleService = createScheduleService(pool, { defaultTimezone: timezone });
|
||||
|
||||
const welcomeText = buildSubscribeWelcomeText({
|
||||
bindUrl: publicBaseUrl + bindPath,
|
||||
introUrl: process.env.H5_WECHAT_MP_SUBSCRIBE_INTRO_URL?.trim() || undefined,
|
||||
plazaHomeUrl: process.env.H5_WECHAT_MP_SUBSCRIBE_PLAZA_HOME_URL?.trim()
|
||||
|| (process.env.PLAZA_PUBLIC_BASE?.trim()
|
||||
? process.env.PLAZA_PUBLIC_BASE.trim().replace(/\\/$/, '') + '/plaza'
|
||||
: undefined),
|
||||
morningReminderEnabled: true,
|
||||
morningReminderHour: hour,
|
||||
morningReminderMinute: minute,
|
||||
});
|
||||
|
||||
await pendingStore.setPending({ appId, openid });
|
||||
await sendWechatText(openid, welcomeText);
|
||||
|
||||
const baselineAt = Date.now();
|
||||
console.log(JSON.stringify({
|
||||
phase: 'sent',
|
||||
userId: TANG_USER_ID,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
openid: openid.slice(0, 10) + '...',
|
||||
baselineAt,
|
||||
waitSeconds: WAIT_SECONDS,
|
||||
instruction: '请唐在微信里回复 1 确认每日早安提醒',
|
||||
welcomePreview: welcomeText.slice(0, 240) + (welcomeText.length > 240 ? '...' : ''),
|
||||
}, null, 2));
|
||||
|
||||
const seenMsgIds = new Set();
|
||||
const deadline = Date.now() + WAIT_SECONDS * 1000;
|
||||
let outcome = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const [rows] = await pool.query(
|
||||
\`SELECT msg_id, agent_text, display_text, created_at
|
||||
FROM h5_wechat_mp_message_details
|
||||
WHERE app_id = ? AND openid = ? AND msg_type = 'text' AND created_at >= ?
|
||||
ORDER BY created_at ASC\`,
|
||||
[appId, openid, baselineAt - 5000],
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const msgId = String(row.msg_id ?? '');
|
||||
if (!msgId || seenMsgIds.has(msgId)) continue;
|
||||
seenMsgIds.add(msgId);
|
||||
|
||||
const text = String(row.agent_text ?? row.display_text ?? '').trim();
|
||||
console.log(JSON.stringify({
|
||||
phase: 'inbound',
|
||||
msgId,
|
||||
text,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
|
||||
if (!isSubscribeMorningConfirmReply(text)) {
|
||||
console.log(JSON.stringify({ phase: 'ignored', reason: 'not_confirm_1', msgId, text }));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ phase: 'confirm_detected', msgId, text }));
|
||||
await sleep(15000);
|
||||
|
||||
let item = await findMorningScheduleItem(TANG_USER_ID);
|
||||
let handlerReply = null;
|
||||
let handlerSource = item ? 'production_or_existing' : 'e2e_fallback';
|
||||
|
||||
if (!item) {
|
||||
handlerReply = await handleSubscribeMorningReminderTurn({
|
||||
appId,
|
||||
openid,
|
||||
text,
|
||||
pendingStore,
|
||||
scheduleService,
|
||||
boundUser: { userId: TANG_USER_ID },
|
||||
timezone,
|
||||
hour,
|
||||
minute,
|
||||
bindUrl: publicBaseUrl + bindPath,
|
||||
sourceMessageId: msgId,
|
||||
});
|
||||
if (handlerReply) {
|
||||
await sendWechatText(openid, handlerReply);
|
||||
}
|
||||
item = await findMorningScheduleItem(TANG_USER_ID);
|
||||
}
|
||||
|
||||
const [reminders] = item
|
||||
? await pool.query(
|
||||
\`SELECT id, remind_at, channel, status
|
||||
FROM h5_schedule_reminders
|
||||
WHERE user_id = ? AND item_id = ?
|
||||
ORDER BY created_at DESC LIMIT 1\`,
|
||||
[TANG_USER_ID, item.id],
|
||||
)
|
||||
: [[]];
|
||||
|
||||
outcome = {
|
||||
ok: Boolean(item),
|
||||
handlerSource,
|
||||
handlerReply,
|
||||
scheduleItem: item
|
||||
? {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
metadata: item.metadata_json,
|
||||
}
|
||||
: null,
|
||||
reminder: reminders?.[0] ?? null,
|
||||
msgId,
|
||||
replyText: text,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (outcome) break;
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
|
||||
if (!outcome) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
phase: 'timeout',
|
||||
waitedSeconds: WAIT_SECONDS,
|
||||
message: '等待时间内未收到唐回复 1',
|
||||
}, null, 2));
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.log(JSON.stringify({ phase: 'result', ...outcome }, null, 2));
|
||||
if (!outcome.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
`.trim();
|
||||
|
||||
const filesToSync = [
|
||||
['schedule-time.mjs', `${REMOTE_ROOT}/schedule-time.mjs`],
|
||||
['schedule-service.mjs', `${REMOTE_ROOT}/schedule-service.mjs`],
|
||||
['wechat/morning-greeting-library.mjs', `${REMOTE_ROOT}/wechat/morning-greeting-library.mjs`],
|
||||
['wechat/subscribe-morning-reminder.mjs', `${REMOTE_ROOT}/wechat/subscribe-morning-reminder.mjs`],
|
||||
['wechat/handlers/sync-replies.mjs', `${REMOTE_ROOT}/wechat/handlers/sync-replies.mjs`],
|
||||
['wechat/user/display-name.mjs', `${REMOTE_ROOT}/wechat/user/display-name.mjs`],
|
||||
];
|
||||
|
||||
for (const [rel, remoteAbs] of filesToSync) {
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'mkdir -p $(dirname ${remoteAbs})'`);
|
||||
scp(rel, remoteAbs);
|
||||
}
|
||||
|
||||
const localRunner = path.join(root, '.tmp-run-tang-subscribe-morning-e2e.mjs');
|
||||
fs.writeFileSync(localRunner, remoteScript);
|
||||
scp('.tmp-run-tang-subscribe-morning-e2e.mjs', remoteRunner);
|
||||
try {
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${remoteRunner}; ec=$?; rm -f ${remoteRunner}; exit $ec'`);
|
||||
} finally {
|
||||
fs.unlinkSync(localRunner);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 向 103 生产「唐」微信用户推送一条早安随机话术预览。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/send-morning-greeting-preview-103.mjs
|
||||
* node scripts/send-morning-greeting-preview-103.mjs --user-id <uuid>
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const HOST = 'john@58.38.22.103';
|
||||
const REMOTE_ROOT = '/Users/john/Project/Memind';
|
||||
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
|
||||
const DEFAULT_TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
|
||||
function parseUserId(argv) {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--user-id') return String(argv[i + 1] ?? '').trim();
|
||||
}
|
||||
return DEFAULT_TANG_USER_ID;
|
||||
}
|
||||
|
||||
function sh(cmd) {
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
const userId = parseUserId(process.argv.slice(2));
|
||||
const remoteLib = `${REMOTE_ROOT}/wechat/morning-greeting-library.mjs`;
|
||||
const remoteRunner = `${REMOTE_ROOT}/.tmp-send-morning-greeting-preview.mjs`;
|
||||
|
||||
const remoteScript = `
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { formatMorningGreetingDeliveryText } from './wechat/morning-greeting-library.mjs';
|
||||
|
||||
const USER_ID = ${JSON.stringify(userId)};
|
||||
|
||||
process.loadEnvFile(path.join('${REMOTE_ROOT}', '.env'));
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
|
||||
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
|
||||
if (!appId || !appSecret) throw new Error('missing wechat credentials');
|
||||
|
||||
const [users] = await pool.query(
|
||||
'SELECT id, username, display_name FROM h5_users WHERE id = ? LIMIT 1',
|
||||
[USER_ID],
|
||||
);
|
||||
const user = users?.[0];
|
||||
if (!user) throw new Error('user not found: ' + USER_ID);
|
||||
|
||||
const [ident] = await pool.query(
|
||||
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
|
||||
[USER_ID, appId],
|
||||
);
|
||||
const openid = ident?.[0]?.openid;
|
||||
if (!openid) throw new Error('user not bound to wechat: ' + USER_ID);
|
||||
|
||||
const text = formatMorningGreetingDeliveryText({
|
||||
userId: USER_ID,
|
||||
timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||||
});
|
||||
|
||||
const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
|
||||
});
|
||||
const tokenPayload = await tokenRes.json();
|
||||
if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload));
|
||||
|
||||
const sendRes = await fetch(
|
||||
'https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(tokenPayload.access_token),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
|
||||
},
|
||||
);
|
||||
const sendPayload = await sendRes.json();
|
||||
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
userId: USER_ID,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
openid: openid.slice(0, 8) + '...',
|
||||
preview: text,
|
||||
}, null, 2));
|
||||
await pool.end();
|
||||
`.trim();
|
||||
|
||||
const localLib = path.join(root, 'wechat/morning-greeting-library.mjs');
|
||||
const localScheduleTime = path.join(root, 'schedule-time.mjs');
|
||||
const localRunner = path.join(root, '.tmp-send-morning-greeting-preview.mjs');
|
||||
fs.writeFileSync(localRunner, remoteScript);
|
||||
sh(`scp -q ${localLib} ${HOST}:${remoteLib}`);
|
||||
sh(`scp -q ${localScheduleTime} ${HOST}:${REMOTE_ROOT}/schedule-time.mjs`);
|
||||
sh(`scp -q ${localRunner} ${HOST}:${remoteRunner}`);
|
||||
try {
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${remoteRunner} && rm -f ${remoteRunner}'`);
|
||||
} finally {
|
||||
fs.unlinkSync(localRunner);
|
||||
}
|
||||
Reference in New Issue
Block a user