24 KiB
试看领取「多账号选择 + 一步一动作」重构 — 分批执行指南
状态:3 批已全部完成并部署生产(300daca / 6c9b8d7 / 1c651e7,182 tests 全绿,2026-08-31)。 本文件是完整实施蓝图,逐批独立 git commit + typecheck/test 门禁,每批结束仓库可运行。
触发(用户 2026-08-31 提出):
- 同一个 Telegram 账号在 bot 里创建了多个账号时,再领免费产品只能加进第一个账号,无法选择。
- 第一次从网站深链进 bot 领免费品,文案一次要求「ok + username」,顾客易误解。
用户拍板(勿再议):
- 问题 1:多个账号 → 弹清单选;inline 按钮 cap 8 个;超过 8 个时不发按钮,改文字列出清单并请顾客回复要加入的 username。
- 问题 2:按本文件「一步一动作」方案——每步只让顾客做一件事(点一下 / 打一个用户名)。
一、根因(已定位,勿重查)
问题 1 — 多账号无法选择,三处都在 bot/src/integrations/nocodb/provision.ts:
findActiveByTelegramId()查telegram_id后(r.list || [])[0]只取第一条,其余账号被丢弃。probeExisting()只返回单个{id, username},确认句永远只报第一个账号。provisionTrial()复用路径再调一次findActiveByTelegramId→ 即使选了别的账号也会被[0]覆盖。
问题 2 — 一次要求两件事,三处:
commands/index.tsstartSkuText()文案写「回复ok username:abc123」(一次要 ok + username 两件事)。ASK_USERNAME_TEXT()同样写「回复ok username:abc123」。parseTrialConsent()只认「ok/同意」前缀或username:标签,裸abc123不认,逼用户打ok abc123。
按钮键盘 startSkuKeyboard 其实已经「一次点一下」,但文字在教用户一次打两个东西,两者打架。
二、总设计
数据层(provision.ts)
- 新增
TrialRequest.customerId?: number:指定复用账号的Customers.Id,命中时跳过 telegram_id/username 查找直接 grant。 - 新增
findAllActiveByTelegramId():返回该 telegram_id 下全部 active 账号(不再[0])。 - 新增
findCustomerById(id):按Id+status=active精确查一条。 probeExisting()返回类型从{id,username}|null改为{id,username}[](0/1/N 由 bot 层分支)。
状态机(bot.ts pendingTrials)
ask-username : 已知 SKU,等用户名(裸用户名 / label 行 / 同意词 → recall 记忆)
confirm-reuse : 探测到【1 个】既有账号,等「可以(加到它)/ 新账号 xxx」
choose-account : 探测到【多个】账号,等「点某账号按钮 / 回用户名 / 新账号 xxx」 ← 新增
confirm-new : 用户选了新账号,等新 username
choose-account:accounts.length ≤ 8→ 按钮键盘(每账号一个按钮trial:acct:<id>+ 「开新账号」);> 8→ 无按钮,文字编号清单 + 请回复用户名。- 复用路径(confirm-reuse 的「可以」/ reuse 按钮 / choose-account 选号)统一改传
customerId,不再靠provisionTrial内部[0]。
文案(commands/index.ts)
startSkuText/ASK_USERNAME_TEXT/ASK_NEW_ACCOUNT_USERNAME_TEXT重写为一步一动作;startSkuKeyboard按钮标签改「领取 / 换新用户名」。
三、分批执行
每批独立 commit,门禁 =
cd bot && npm run typecheck && npm test(第 2、3 批另加npm run build)。 依赖顺序:第 1 批(数据层)先行;第 2、3 批相互独立。
✅ 第 1 批 — provision.ts 数据层(多账号支撑,纯增量 + 类型调整)
文件:bot/src/integrations/nocodb/provision.ts、bot/src/channels/telegram/bot.ts(仅 3 行机械改)、bot/tests/provision.test.ts
改动清单:
-
TrialRequest加字段:/** 指定复用账号的 Customers.Id;命中时跳过 telegram_id/username 查找。 */ customerId?: number; -
provisionTrial()在const existingByTg = ...之前插入(账号解析段最前):// 指定账号复用(第 2 批多账号选择会传 customerId):直接按 Id grant, // 跳过 telegram_id/username 的「取第一个」逻辑。 if (req.customerId) { const target = await this.findCustomerById(req.customerId); if (!target) { return { ok: false, message: "Account not found — please try again." }; } return this.grantToExisting(target, product, fmt(now), fmt(expires), sku); } -
probeExisting()返回类型改列表(保持「tg 优先 → 显式 username」口径不变):async probeExisting(req: { telegramUserId: string; username?: string; }): Promise<{ id: number; username: string }[]> { const byTg = await this.findAllActiveByTelegramId(req.telegramUserId); if (byTg.length > 0) { return byTg.map((r) => ({ id: r.Id, username: String(r.username ?? "") })); } if (req.username) { const un = normalizeUsername(req.username, req.telegramUserId); if (un.ok && !un.autoGenerated) { const byName = await this.findActiveByUsername(un.username); if (byName) { return [{ id: byName.Id, username: String(byName.username ?? "") }]; } } } return []; } -
新增两个 helper(放在
findActiveByTelegramId附近):/** 该 telegram_id 下的全部 active 账号(多账号选择用,不再只取第一个)。 */ private async findAllActiveByTelegramId( tgId: string, ): Promise<(NcRow & { username?: string })[]> { const tid = await this.tableId("Customers"); const r = await this.api<{ list: NcRow[] }>( "GET", `/api/v2/tables/${tid}/records?where=${encodeURIComponent( `(telegram_id,eq,${tgId})~and(status,eq,active)`, )}&limit=25`, ); return r.list || []; } /** 按 Customers.Id 精确查一条(customerId 复用路径;带 active 过滤防加给已停账号)。 */ private async findCustomerById( id: number, ): Promise<(NcRow & { username?: string }) | null> { const tid = await this.tableId("Customers"); const r = await this.api<{ list: NcRow[] }>( "GET", `/api/v2/tables/${tid}/records?where=${encodeURIComponent( `(Id,eq,${id})~and(status,eq,active)`, )}&limit=1`, ); return (r.list || [])[0] ?? null; } -
bot.tsrunTrialFlow探测段(唯一引用probeExisting处)机械改,仍只取[0],多账号 UI 留第 2 批:const accounts = await provisioner.probeExisting({ telegramUserId: tgId, username: opts.username, }); if (accounts.length > 0) { const existing = accounts[0]; // 多账号选择 UI 在第 2 批 pendingTrials.set(tgId, { sku: trialOpts.sku, at: Date.now(), mode: "confirm-reuse", existingUsername: existing.username, }); // …原 confirmReuseText + confirmReuseKeyboard 回复不变… }
测试改动(provision.test.ts):
- 改 4 个现有
probeExisting断言的返回形状:{ id: 13, username: "lover" }→[{ id: 13, username: "lover" }]null→[](两处:自动假名、两者未命中)Lover规范化命中那例同理改[{…}]。
- 新增:
probeExisting 同 telegram_id 返回全部账号:customersByTg给[{Id:13,lover},{Id:14,abc}]→ 期望[{13,lover},{14,abc}]。provisionTrial + customerId 加到指定账号(不是 [0]):customersByTg给两个账号、products给 FREECXM10,provisionTrial({sku, telegramUserId, customerId: 14})→ok/existed/addedProduct,CP 插入nc_jitu___Customers_id === 14,无新 Customers POST。provisionTrial + customerId 查无账号报错:customers空 +customerId: 999→ok:false,无任何 POST。- (可选)
findCustomerById走~and(status,eq,active),用现有multi连接符回归断言思路复用即可。
注意:
stubNocoDB的 where 分派里,(Id,eq,14)不含telegram_id/username子串 → 落到else返回opts.customers。所以 customerId 测试把目标账号放进customers即可命中;若要更精确可加一个(Id,eq,…)分支,非必需。
门禁:npm run typecheck && npm test(第 1 批后全绿)。
✅ 第 2 批 — bot.ts 状态机 + 多账号选择键盘
文件:bot/src/channels/telegram/bot.ts、bot/src/channels/telegram/commands/index.ts、bot/tests/commands.test.ts
改动清单:
-
commands/index.ts新增(放在confirmReuseKeyboard之后、TRIAL_CB相关区):/** 多账号选择的 callback_data 前缀与构造器(不要放进 TRIAL_CB —— 其契约测试 Object.values 全为字符串)。 */ export const TRIAL_ACCT_PREFIX = "trial:acct:"; export const trialAcctCb = (id: number): string => `${TRIAL_ACCT_PREFIX}${id}`; /** ≤8 账号用按钮;简短提示 + 文字 fallback 说明。 */ export function chooseAccountText( accounts: { id: number; username: string }[], lang: LanguageKey = "zh", ): string { if (lang === "en") { return `🤔 You have multiple accounts — pick which one to add this product to 👇\n\n(or reply with the username)`; } if (lang === "ms") { return `🤔 Anda ada beberapa akaun — pilih yang mana untuk menambah produk ini 👇\n\n(atau balas nama pengguna)`; } return `🤔 检测到你有多个账号,请选择要把产品加到哪个 👇\n\n(也可以直接回复用户名)`; } /** >8 账号:无按钮,文字编号清单 + 请回复用户名。 */ export function chooseAccountTooManyText( accounts: { id: number; username: string }[], lang: LanguageKey = "zh", ): string { const list = accounts .map((a, i) => `${i + 1}. <code>${a.username}</code>`) .join("\n"); if (lang === "en") { return `🤔 You have ${accounts.length} accounts — reply with the username to add to:\n\n${list}`; } if (lang === "ms") { return `🤔 Anda ada ${accounts.length} akaun — balas nama pengguna untuk ditambah:\n\n${list}`; } return `🤔 检测到你有 ${accounts.length} 个账号,请回复要加入的用户名:\n\n${list}`; } /** 每账号一个按钮(cap 8)+ 「开新账号」。 */ export function chooseAccountKeyboard( accounts: { id: number; username: string }[], lang: LanguageKey = "zh", ): InlineKeyboard { const kb = new InlineKeyboard(); for (const a of accounts.slice(0, 8)) { kb.text(`👤 ${a.username}`, trialAcctCb(a.id)).row(); } if (lang === "en") kb.text("✏️ Open new account", TRIAL_CB.newUsername); else if (lang === "ms") kb.text("✏️ Buka akaun baharu", TRIAL_CB.newUsername); else kb.text("✏️ 开新账号", TRIAL_CB.newUsername); return kb; }并在
bot.ts的 import 列表补chooseAccountText、chooseAccountTooManyText、chooseAccountKeyboard、TRIAL_ACCT_PREFIX。 -
bot.tsPendingTrial类型:type PendingTrial = | { sku: string; at: number; mode: "ask-username" } | { sku: string; at: number; mode: "confirm-reuse"; existingId: number } | { sku: string; at: number; mode: "choose-account"; accounts: { id: number; username: string }[] } | { sku: string; at: number; mode: "confirm-new" };(
confirm-reuse用existingId替换原来的existingUsername——display 用户名在进入该 mode 时已渲染,grant 只需 id。) -
runTrialFlowopts 加customerId?: number;探测段改三分支:if (!opts.confirmReuse && !opts.forceNew && !opts.customerId) { const accounts = await provisioner.probeExisting({ telegramUserId: tgId, username: opts.username, }); if (accounts.length === 1) { pendingTrials.set(tgId, { sku: trialOpts.sku, at: Date.now(), mode: "confirm-reuse", existingId: accounts[0].id, }); await ctx.reply(confirmReuseText(accounts[0].username, lang), { parse_mode: "HTML", reply_markup: confirmReuseKeyboard(lang), }); return; } if (accounts.length > 1) { pendingTrials.set(tgId, { sku: trialOpts.sku, at: Date.now(), mode: "choose-account", accounts, }); const buttons = accounts.length <= 8; await ctx.reply( buttons ? chooseAccountText(accounts, lang) : chooseAccountTooManyText(accounts, lang), { parse_mode: "HTML", reply_markup: buttons ? chooseAccountKeyboard(accounts, lang) : undefined }, ); return; } // 0 → 落到 ask-username } -
username 解析段:
customerId时跳过 memory recall / ask:let username = opts.username; if (!opts.customerId && !username && !opts.forceNew) username = await trialMemory.recallUsername(tgId); if (!opts.customerId && !username) { pendingTrials.set(tgId, { sku: trialOpts.sku, at: Date.now(), mode: "ask-username" }); await ctx.reply(opts.forceNew ? ASK_NEW_ACCOUNT_USERNAME_TEXT(lang) : ASK_USERNAME_TEXT(lang), { parse_mode: "HTML" }); return; }provisionTrial调用处透传customerId: opts.customerId。 -
message:text分派重构(在confirm-reuse与confirm-new块之后、原「2) 深链同意流」之前,插入新分支;并把原同意流改成ask-username专属):// 1c) 多账号选择:新账号 xxx → 新建;回用户名命中清单 → 加到该账号 if (provisioner && pendingFresh && pending.mode === "choose-account") { const decision = parseAccountDecision(text); if (decision?.action === "new") { logger.info({ tgId, sku: pending.sku }, "Choose-account: new account"); await runTrialFlow(ctx, { sku: pending.sku, username: decision.username, forceNew: true }); return; } const username = extractUsername(text); if (username) { const match = pending.accounts.find((a) => a.username === username.toLowerCase()); if (match) { logger.info({ tgId, sku: pending.sku, username }, "Choose-account: reuse by username"); await runTrialFlow(ctx, { sku: pending.sku, customerId: match.id, confirmReuse: true }); return; } // username 不在清单 → 掉落普通管线(不吞闲聊/输错) } } // 2) ask-username:裸用户名 / label 行 → provision;只回同意词 → recall 记忆 if (provisioner && pendingFresh && pending.mode === "ask-username") { const username = extractUsername(text); if (username) { logger.info({ tgId, sku: pending.sku }, "Ask-username: bare username -> provision"); await runTrialFlow(ctx, { sku: pending.sku, username }); return; } const consent = parseTrialConsent(text); if (consent.consented) { logger.info({ tgId, sku: pending.sku }, "Ask-username: consent -> recall username"); await runTrialFlow(ctx, { sku: pending.sku, username: consent.username }); return; } }⚠ 原「2) 深链同意流」块(
parseTrialConsent那个)整体替换成上面的ask-username块——注意它原来不 gate 在 mode,现必须 gate 在pending.mode === "ask-username",否则会吞掉 choose-account 的回复。 -
confirm-reuse复用两处改传customerId:message:textdecision?.action === "reuse":await runTrialFlow(ctx, { sku: pending.sku, customerId: pending.existingId, confirmReuse: true });- 回调
TRIAL_CB.reuse:await runTrialFlow(ctx, { sku: pending.sku, customerId: pending.existingId, confirmReuse: true });
-
回调分派新增
trial:acct:<id>分支(在if (data.startsWith("trial:"))内、switch之前):if (data.startsWith(TRIAL_ACCT_PREFIX)) { const id = Number(data.slice(TRIAL_ACCT_PREFIX.length)); if (provisioner && fresh && pending && pending.mode === "choose-account") { const acct = pending.accounts.find((a) => a.id === id); if (acct) { await stripButtons(processing); logger.info({ tgId, sku: pending.sku, username: acct.username }, "Choose-account button -> reuse"); await runTrialFlow(ctx, { sku: pending.sku, customerId: acct.id, confirmReuse: true }); } } await ctx.answerCallbackQuery().catch(() => {}); return; }
测试改动(commands.test.ts):
TRIAL_CB契约测试保持不变(TRIAL_ACCT_PREFIX不进TRIAL_CB)。- 新增:
trialAcctCb(13) === "trial:acct:13"、TRIAL_ACCT_PREFIX === "trial:acct:"。chooseAccountText三语:zh 含「多个账号」、en 含「multiple accounts」、ms 含「beberapa akaun」;各自不含其他语言残留。chooseAccountTooManyText:三语各含完整编号清单(1. <code>lover</code>/2. <code>abc</code>)。chooseAccountKeyboard:读kb.inline_keyboard,断言每账号一行按钮{text: "👤 lover", callback_data: "trial:acct:13"},末行「✏️ 开新账号」=TRIAL_CB.newUsername;cap 8(传 10 个账号只出 8 行 + 1 行开新账号)。
门禁:npm run typecheck && npm test && npm run build。
✅ 第 3 批 — 文案「一步一动作」重写
文件:bot/src/channels/telegram/commands/index.ts、bot/tests/commands.test.ts
改动清单:
-
startSkuText()重写(删「回复ok username:abc123」与「already claimed? reply ok」):export function startSkuText(entry: CatalogEntry, lang: LanguageKey = "zh"): string { const { sku, name } = entry; if (lang === "en") { return `🎁 <b>${name}</b> (SKU <code>${sku}</code>)\n\nThis is a free trial preview. I've noted the product you want — tap below to claim 👇`; } if (lang === "ms") { return `🎁 <b>${name}</b> (SKU <code>${sku}</code>)\n\nIni produk percubaan percuma. Saya telah mencatat produk ini — ketik di bawah untuk menuntut 👇`; } return `🎁 <b>${name}</b>(SKU <code>${sku}</code>)\n\n这是免费试看产品。我已记下你要领取的产品,点击下方按钮领取 👇`; } -
ASK_USERNAME_TEXT()重写(只问用户名):export function ASK_USERNAME_TEXT(lang: LanguageKey = "zh"): string { if (lang === "en") { return `🎁 Please reply with your username (3-32 lowercase letters or digits only), e.g. <code>abc123</code>`; } if (lang === "ms") { return `🎁 Sila balas nama pengguna anda (3-32 huruf kecil atau nombor sahaja), contoh <code>abc123</code>`; } return `🎁 请回复你的用户名(只用小写字母和数字,3-32 位),例如 <code>abc123</code>`; } -
ASK_NEW_ACCOUNT_USERNAME_TEXT()重写(只问新用户名):export function ASK_NEW_ACCOUNT_USERNAME_TEXT(lang: LanguageKey = "zh"): string { if (lang === "en") { return `📝 Sure, new account! Reply the new username (3-32 lowercase letters or digits only), e.g. <code>abc123</code>`; } if (lang === "ms") { return `📝 Baik, akaun baharu! Balas nama pengguna baharu (3-32 huruf kecil atau nombor sahaja), contoh <code>abc123</code>`; } return `📝 好的,开新账号!请回复新账号的用户名(只用小写字母和数字,3-32 位),例如 <code>abc123</code>`; } -
startSkuKeyboard()按钮标签改「领取 / 换新用户名」:- zh:
✅ 领取/✏️ 换新用户名 - en:
✅ Claim/✏️ New username - ms:
✅ Tuntut/✏️ Username baharu
- zh:
测试改动(commands.test.ts):
- 「deep-link guidance」测试(原断言
同意 用户名:abc123/ok username:abc123/reuse your username):改为断言 不含ok username:abc123/同意 用户名:abc123/setuju username:abc123;含免费试看、SKU、以及领取(zh)/claim(en)/menuntut(ms)。 ASK_USERNAME_TEXT测试:断言含请回复你的用户名+abc123,不含ok username:abc123。ASK_NEW_ACCOUNT_USERNAME_TEXT测试:断言含开新账号+abc123,不含新账号 用户名:abc123。startSkuKeyboard测试:三语标签断言更新为✅ 领取/✅ Claim/✅ Tuntut+✏️ 换新用户名/✏️ New username/✏️ Username baharu。
门禁:npm run typecheck && npm test && npm run build。
四、部署与验收(第 3 批全绿后)
- 本地门禁:
cd bot && npm run typecheck && npm test && npm run build。 git add三个批次的改动 + commit +git push origin main(git.hoelee.com)。⚠ push 前先git status/git log核对 HEAD(仓库多 agent 并发)。- 部署:
DSM_PWD=<pwd> python scripts/deploy_bot_dsm.py(DSM_HOST可覆盖 NAS IP;密码走 env,不落 repo/聊天)。- 详见 skill
digikedai-bot「Deploy」段;DSM 非交互 PATH 里 docker 用完整路径/usr/local/bin/docker。
- 详见 skill
- 验证(照 skill):
docker logs digikedai-bot --since 2m→ Database ready + webhook registered + HTTP listening。curl -s https://bot.digikedai.com/health→{"status":"ok"}。- getWebhookInfo 走容器内 node fetch(宿主 curl 对有效 token 也回 404):
sudo /usr/local/bin/docker exec digikedai-bot node -e 'fetch("https://api.telegram.org/bot"+process.env.BOT_TOKEN+"/getWebhookInfo").then(r=>r.text()).then(t=>console.log(t))'→pending_update_count:0、无last_error_message。 - migrations:
docker exec mem0-postgres psql -U mem0 -d bot -tAc 'SELECT version FROM schema_migrations ORDER BY version'→ 1..7(无新迁移,本批不碰表结构)。
- 手机端到端冒烟(用户实测,务必覆盖):
- 多账号:一个已有多账号的 TG 号深链领新品 → 弹账号清单按钮 → 点第 2 个账号 → 「新产品已加入账号」且登录后确实在第二个账号下。
- 多账号 >8(可用测试数据造 >8 账号,或临时放宽 cap 验证文字路径)→ 无按钮、文字清单 → 回复用户名 → 加到对应账号。
- 第一次进 bot 深链 → 只显示「领取」按钮 → 点领取 → 只问用户名 → 回
abc123(裸用户名)→ 开通。 - 回「同意」不带用户名 → recall 记忆里的老 username → 加入既有账号。
五、入档收尾(第 3 批验收通过后)
PROJECT_STATE.md新增「四·补·五」节,把本文件作为执行蓝图挂上,逐批状态行更新为 ✅ + commit hash。bot/README.md文档索引表加一行:docs/TRIAL_ACCOUNT_SELECTION_REDESIGN.md | Multi-account trial claim + one-action flow redesign。- 更新 skill
digikedai-bot(skill_managepatch):试看领取段补「多账号选择(choose-account / trial:acct: / cap 8 → 文字清单)+ 一步一动作文案」要点;把本文件加入references/。 git commit文档同步。
六、坑位提醒(来自 skill digikedai-bot,执行时务必遵守)
- callback 分派用
bot.on("callback_query:data", …),不是bot.callbackQuery()(grammY 没有这方法,会 TS 报错)。每支路末尾answerCallbackQuery()幂等;editMessageText用"message is not modified"守卫 + reply fallback。 TRIAL_CB是as const纯字符串对象,别往里面塞函数(契约测试Object.values全startsWith("trial:"))——trialAcctCb用独立常量TRIAL_ACCT_PREFIX。InlineKeyboard无toJSON(),测试读公开属性kb.inline_keyboard。- NocoDB v2 多条件 where 用
~and(不是,AND,),新findAllActiveByTelegramId/findCustomerById都带~and(status,eq,active),照抄勿改连接符。 \b是 ASCII 边界,CJK 同意词别用它;parseTrialConsent/parseAccountDecision的同意词边界维持现状((?:\s|$|[::…]))。- 镜像只打包
dist/:新模板/常量都在src/(TS 编译进去),别在运行期读 JSON/data 文件。 - 改
commands/index.ts/bot.ts(中文串 + 引号)时 patch 若报Escape-drift:说明过度转义了,把串内\"改回"重发。 - 开工前先
git show HEAD:<file>核对权威版,read_file 可能返回过期缓存(多 agent 并发)。 - username 匹配先 lowercase:
extractUsername返回原样大小写(ABC123),probe 存的 username 是 lowercase,accounts.find(a => a.username === username.toLowerCase())必须 lower。