#!/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@180.159.29.143'; 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-intent.mjs', `${REMOTE_ROOT}/schedule-intent.mjs`], ['schedule-utterance-normalize.mjs', `${REMOTE_ROOT}/schedule-utterance-normalize.mjs`], ['schedule-service.mjs', `${REMOTE_ROOT}/schedule-service.mjs`], ['wechat-subscribe-morning-llm.mjs', `${REMOTE_ROOT}/wechat-subscribe-morning-llm.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); }