-
Notifications
You must be signed in to change notification settings - Fork 7.8k
fix(telegram): surface verification codes in mail previews #1034
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { describe, it } from "node:test"; | ||
|
|
||
| import { buildSearchableMailText, extractVerificationCode, htmlToPlainText } from "./verification_code.ts"; | ||
|
|
||
| describe("verification code extraction", () => { | ||
| it("extracts a ChatGPT code from HTML-only mail", () => { | ||
| const html = `<html><body><p>Your temporary ChatGPT login code is:</p><div style="font-size:32px">123456</div><p>This code expires in 10 minutes.</p></body></html>`; | ||
| const body = buildSearchableMailText("Your temporary ChatGPT login code", "", html); | ||
|
|
||
| assert.equal(extractVerificationCode(body), "123456"); | ||
| }); | ||
|
|
||
| it("extracts spaced and separated numeric codes", () => { | ||
| const text = "您的验证码为 1 2 3-4_5.6,请勿泄露。"; | ||
|
|
||
| assert.equal(extractVerificationCode(text), "123456"); | ||
| }); | ||
|
|
||
| it("supports 4 and 8 digit verification codes", () => { | ||
| assert.equal(extractVerificationCode("Login PIN: 0428. It expires soon."), "0428"); | ||
| assert.equal(extractVerificationCode("安全验证码:87654321,5分钟内有效"), "87654321"); | ||
| }); | ||
|
|
||
| it("prefers code-related context over unrelated numbers", () => { | ||
| const text = "订单 987654 于 2026-05-04 创建。验证码是 135790,请在 10 分钟内输入。"; | ||
|
|
||
| assert.equal(extractVerificationCode(text), "135790"); | ||
| }); | ||
|
|
||
| it("converts HTML entities and basic tags to readable text", () => { | ||
| const html = "<div>验证码:123456</div><br><span>请勿泄露 code</span>"; | ||
|
|
||
| assert.match(htmlToPlainText(html), /验证码:123456/); | ||
| assert.match(htmlToPlainText(html), /请勿泄露 code/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,141 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_CODE_CONTEXT_LENGTH = 120; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const htmlEntityMap: Record<string, string> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| amp: "&", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lt: "<", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gt: ">", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| quot: '"', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apos: "'", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nbsp: " ", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ensp: " ", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| emsp: " ", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| thinsp: " ", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| zwnj: "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| zwj: "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const decodeHtmlEntities = (value: string): string => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lowerCode = code.toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (lowerCode.startsWith("#x")) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const charCode = parseInt(lowerCode.slice(2), 16); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (lowerCode.startsWith("#")) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const charCode = parseInt(lowerCode.slice(1), 10); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return htmlEntityMap[lowerCode] ?? entity; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🛡️ 建议加入码点范围校验 export const decodeHtmlEntities = (value: string): string => {
return value.replace(/&(`#x`?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => {
const lowerCode = code.toLowerCase();
if (lowerCode.startsWith("#x")) {
const charCode = parseInt(lowerCode.slice(2), 16);
- return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
+ return Number.isFinite(charCode) && charCode >= 0 && charCode <= 0x10FFFF
+ ? String.fromCodePoint(charCode) : entity;
}
if (lowerCode.startsWith("#")) {
const charCode = parseInt(lowerCode.slice(1), 10);
- return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
+ return Number.isFinite(charCode) && charCode >= 0 && charCode <= 0x10FFFF
+ ? String.fromCodePoint(charCode) : entity;
}
return htmlEntityMap[lowerCode] ?? entity;
});
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const htmlToPlainText = (html: string): string => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return decodeHtmlEntities(html) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/<\s*br\s*\/?\s*>/gi, "\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/<\s*\/\s*(p|div|tr|td|th|li|h[1-6]|table|section|article)\s*>/gi, "\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/<[^>]+>/g, " ") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/[\u200B-\u200D\uFEFF]/g, "") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/[ \t\r\f\v]+/g, " ") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/\n\s+/g, "\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/\n{3,}/g, "\n\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const buildSearchableMailText = (subject?: string, text?: string, html?: string): string => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return [subject || "", text || "", html ? htmlToPlainText(html) : ""] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(part => part.trim().length > 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join("\n"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const compactCandidateCode = (value: string): string => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value.replace(/[\s\-_.::]/g, ""); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isLikelyDateOrTime = (code: string, context: string): boolean => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/^(19|20)\d{2}$/.test(code)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/^\d{8}$/.test(code) && /(?:date|日期|时间|time|expires?|过期|有效期)/i.test(context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/^\d{4}$/.test(code) && /[::]\s*\d{2}/.test(context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+55
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4 位验证码会因相邻的“code: ”等上下文被误判为时间,导致 -100 惩罚后被丢弃。 第 62 行使用 建议只在数字未紧邻 code 本身时判定为时间,例如把整段时间形如 🛠️ 建议修正- if (/^\d{4}$/.test(code) && /[::]\s*\d{2}/.test(context)) {
- return true;
- }
+ // 仅当 4 位代码本身呈现为 "HH:MM" 形式时才视为时间
+ if (/^\d{4}$/.test(code) && new RegExp(`${code[0]}${code[1]}[::]${code[2]}${code[3]}`).test(context)) {
+ return true;
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const distanceToNearestKeyword = (context: string, codeStartInContext: number): number => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keywordPattern = /(verification|verify|login|sign.?in|security|auth|authentication|one.?time|otp|passcode|code|pin|temporary|确认|验证|验证码|校验码|动态码|登录|登入|安全|口令|一次性|临时)/ig; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let nearest = Number.POSITIVE_INFINITY; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let match: RegExpExecArray | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while ((match = keywordPattern.exec(context)) !== null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keywordStart = match.index; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keywordEnd = match.index + match[0].length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const distance = codeStartInContext < keywordStart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? keywordStart - codeStartInContext | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : Math.max(0, codeStartInContext - keywordEnd); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nearest = Math.min(nearest, distance); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nearest; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const scoreCandidate = (code: string, context: string, index: number, codeStartInContext: number): number => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let score = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (code.length === 6) score += 30; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (code.length === 8) score += 20; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (code.length === 4) score += 10; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (code.length >= 5 && code.length <= 8) score += 10; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keywordDistance = distanceToNearestKeyword(context, codeStartInContext); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (Number.isFinite(keywordDistance)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score += Math.max(0, 90 - keywordDistance); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/(验证码|校验码|动态码|一次性代码|登录代码|安全代码)/.test(context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score += 20; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/(openai|chatgpt|google|github|telegram|microsoft|apple|discord|cloudflare)/i.test(context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score += 15; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/\b(code|pin|otp)\b\s*(?:is|:|:|-)/i.test(context) || /(?:验证码|代码|校验码|动态码)\s*(?:是|为|:|:)/.test(context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score += 55; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (/(?:expires?|expire|valid|有效|过期|分钟|minutes?|mins?)/i.test(context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score += 5; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isLikelyDateOrTime(code, context)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score -= 100; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| score -= Math.min(index / 100, 15); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return score; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const extractVerificationCode = (input: string): string => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const normalized = decodeHtmlEntities(input) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/[\u200B-\u200D\uFEFF]/g, "") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/[0-9]/g, char => String.fromCharCode(char.charCodeAt(0) - 0xFEE0)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const candidates: { code: string; score: number; index: number }[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const seen = new Set<string>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const codePattern = /(?<!\d)(?:\d[\s\-_.::]?){4,8}\d?(?!\d)/g; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let match: RegExpExecArray | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while ((match = codePattern.exec(normalized)) !== null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const code = compactCandidateCode(match[0]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!/^\d{4,8}$/.test(code) || seen.has(code)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const start = Math.max(0, match.index - MAX_CODE_CONTEXT_LENGTH); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const end = Math.min(normalized.length, match.index + match[0].length + MAX_CODE_CONTEXT_LENGTH); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const context = normalized.slice(start, end); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const score = scoreCandidate(code, context, match.index, match.index - start); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (score > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| candidates.push({ code, score, index: match.index }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| seen.add(code); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| candidates.sort((a, b) => b.score - a.score || a.index - b.index); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return candidates[0]?.code || ""; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
验证码标签硬编码中文,破坏多语言一致性。
文件中所有面向用户的文案(
TgMsgTooLongMsg/TgParseFailedViewInAppMsg/TgNoSenderMsg等)都走msgs: LocaleMessages,且 Bot 已通过/lang支持中英文切换。此处直接拼接验证码:,英文用户也会看到中文前缀,与现有 i18n 约定不一致。建议在LocaleMessages中新增对应 key(如TgVerificationCodeLabel),中文验证码、英文Verification code。♻️ 建议改为走 i18n
并在
worker/src/i18n中为中英文各加一条TgVerificationCodeLabel(如验证码/Verification code)。🤖 Prompt for AI Agents