Files
WechatOnCloud/.github/scripts/tg-notify.mjs
T
GloridustandClaude Opus 4.8 8ac0016398 ci(telegram): render release/issue notes as Telegram HTML (not raw markdown)
GitHub-Flavored Markdown ≠ Telegram MarkdownV2, so the old plain-text send
showed literal ## ** | etc. New .github/scripts/tg-notify.mjs converts GFM
→ Telegram-safe HTML (<b>/<i>/<code>/<pre>/<a>; headers→bold, tables→· rows,
lists→•, quotes→▎), escapes <>&, and falls back to plain text if Telegram
rejects the HTML. Adds workflow_dispatch to telegram-notify so you can send
the latest release rendered for testing without cutting a new release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:41:54 +08:00

134 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 发布 / 新 issue → Telegram 群通知。把 GitHub Markdown 转成 Telegram 支持的 HTML 再发,
// 失败则回退纯文本,保证必达。无服务器,跑在 GitHub Actionstelegram-notify.yml)。
//
// 事件来源(env EVENT):
// release —— 发布事件,用 release 的 payload
// issues —— 新 issue 事件,用 issue 的 payload
// workflow_dispatch —— 手动触发,拉「最新 release」渲染发送(用于测试渲染效果)
const TG = process.env.TG_TOKEN;
const CHAT = process.env.TG_CHAT;
const EVENT = process.env.EVENT || '';
if (!TG || !CHAT) {
console.log('TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID 未配置,跳过');
process.exit(0);
}
const esc = (s) => (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 行内 Markdown → Telegram HTML(在已 HTML-escape 的文本上运行)
function inlineMd(s) {
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, u) => `<a href="${u.replace(/"/g, '&quot;')}">${t}</a>`);
s = s.replace(/\*\*([^*]+?)\*\*/g, '<b>$1</b>');
s = s.replace(/__([^_]+?)__/g, '<b>$1</b>');
s = s.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, '$1<i>$2</i>');
return s;
}
// GitHub Markdown → Telegram HTML。Telegram 不支持标题/表格/列表标记,转成粗体/·/• 等。
function mdToHtml(md) {
const blocks = [];
md = md.replace(/```[^\n]*\n([\s\S]*?)```/g, (_, code) => {
blocks.push('<pre>' + esc(code.replace(/\n$/, '')) + '</pre>');
return `B${blocks.length - 1}`;
});
const codes = [];
md = md.replace(/`([^`\n]+)`/g, (_, c) => {
codes.push('<code>' + esc(c) + '</code>');
return `C${codes.length - 1}`;
});
const out = [];
for (const line of md.split('\n')) {
// 表格分隔行(|---|---|)跳过
if (/^\s*\|[\s:|-]+\|\s*$/.test(line) && line.includes('-')) continue;
// 分割线
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) continue;
let m;
if ((m = line.match(/^\s{0,3}#{1,6}\s+(.*)$/))) {
out.push('<b>' + inlineMd(esc(m[1])) + '</b>');
} else if ((m = line.match(/^\s*>\s?(.*)$/))) {
out.push('▎ ' + inlineMd(esc(m[1])));
} else if ((m = line.match(/^(\s*)[-*]\s+(.*)$/))) {
out.push(m[1] + '• ' + inlineMd(esc(m[2])));
} else if ((m = line.match(/^(\s*)(\d+)\.\s+(.*)$/))) {
out.push(m[1] + m[2] + '. ' + inlineMd(esc(m[3])));
} else if (/^\s*\|.*\|\s*$/.test(line)) {
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => inlineMd(esc(c.trim())));
out.push(cells.filter(Boolean).join(' · '));
} else {
out.push(inlineMd(esc(line)));
}
}
let res = out.join('\n').replace(/C(\d+)/g, (_, i) => codes[+i]).replace(/B(\d+)/g, (_, i) => blocks[+i]);
return res.replace(/\n{3,}/g, '\n\n').trim();
}
const stripTags = (s) => s.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"');
async function ghLatestRelease() {
const repo = process.env.REPO;
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'woc-tg' };
if (process.env.GH_TOKEN) headers.authorization = `Bearer ${process.env.GH_TOKEN}`;
const r = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
if (!r.ok) throw new Error('latest release ' + r.status);
return r.json();
}
async function send(text, mode) {
const r = await fetch(`https://api.telegram.org/bot${TG}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: CHAT, text, parse_mode: mode || undefined, disable_web_page_preview: false }),
});
return r.json();
}
(async () => {
let htmlText;
let plainText;
if (EVENT === 'issues') {
const num = process.env.I_NUM || '';
const title = process.env.I_TITLE || '';
const url = process.env.I_URL || '';
const user = process.env.I_USER || '';
const body = (process.env.I_BODY || '').slice(0, 800);
htmlText = `🐛 <b>新反馈 Issue #${esc(num)}${esc(title)}</b>\n👤 by ${esc(user)}\n\n${mdToHtml(body)}\n\n🔗 ${esc(url)}`;
plainText = `🐛 新反馈 Issue #${num}${title}\n👤 by ${user}\n\n${body}\n\n🔗 ${url}`;
} else {
// release 或 手动触发(拉最新 release)
let tag = process.env.R_TAG || '';
let name = process.env.R_NAME || '';
let url = process.env.R_URL || '';
let body = process.env.R_BODY || '';
if (!tag) {
const rel = await ghLatestRelease();
tag = rel.tag_name || '';
name = rel.name || '';
url = rel.html_url || '';
body = rel.body || '';
}
body = body.slice(0, 3500);
const titleTxt = `🚀 云微 WechatOnCloud ${tag}${name && name !== tag ? ' — ' + name : ''} 已发布`;
htmlText = `<b>${esc(titleTxt)}</b>\n\n${mdToHtml(body)}\n\n🔗 ${esc(url)}\n⬆️ 升级: <code>docker compose pull &amp;&amp; docker compose up -d</code>`;
plainText = `${titleTxt}\n\n${body}\n\n🔗 ${url}\n⬆️ 升级: docker compose pull && docker compose up -d`;
}
const res = await send(htmlText, 'HTML');
if (res.ok) {
console.log('sent (HTML)');
return;
}
console.error('HTML 发送失败,回退纯文本:', JSON.stringify(res));
const p = await send(plainText.length > 4000 ? plainText.slice(0, 4000) : plainText);
if (!p.ok) {
console.error('纯文本也失败:', JSON.stringify(p));
process.exit(1);
}
console.log('sent (plain fallback)');
})().catch((e) => {
console.error(e);
process.exit(1);
});