// 发布 / 新 issue → Telegram 群通知。把 GitHub Markdown 转成 Telegram 支持的 HTML 再发, // 失败则回退纯文本,保证必达。无服务器,跑在 GitHub Actions(telegram-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, '&').replace(//g, '>'); // 行内 Markdown → Telegram HTML(在已 HTML-escape 的文本上运行) function inlineMd(s) { s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, u) => `${t}`); s = s.replace(/\*\*([^*]+?)\*\*/g, '$1'); s = s.replace(/__([^_]+?)__/g, '$1'); s = s.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, '$1$2'); return s; } // GitHub Markdown → Telegram HTML。Telegram 不支持标题/表格/列表标记,转成粗体/·/• 等。 function mdToHtml(md) { const blocks = []; md = md.replace(/```[^\n]*\n([\s\S]*?)```/g, (_, code) => { blocks.push('
' + esc(code.replace(/\n$/, '')) + '
'); return `B${blocks.length - 1}`; }); const codes = []; md = md.replace(/`([^`\n]+)`/g, (_, c) => { codes.push('' + esc(c) + ''); 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('' + inlineMd(esc(m[1])) + ''); } 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(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/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 tgCall(method, params) { const r = await fetch(`https://api.telegram.org/bot${TG}/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(params), }); return r.json(); } const send = (text, mode) => tgCall('sendMessage', { chat_id: CHAT, text, parse_mode: mode || undefined, disable_web_page_preview: false }); // 发布通知自动置顶:置顶新消息,并取消「上一个 release」的置顶。 // 用 getChat 读出群当前置顶消息(即上一个 release),无需任何持久化存储。 // 失败不影响通知本身(需机器人是群管理员且有「置顶消息」权限)。 async function pinAndUnpinPrev(newId) { // 先读当前置顶(= 上一个 release 的置顶消息),必须在置顶新消息之前 const chat = await tgCall('getChat', { chat_id: CHAT }); const prevPinned = chat?.result?.pinned_message?.message_id; // 置顶新消息(静音,避免二次提醒——消息本身已经提醒过) const pin = await tgCall('pinChatMessage', { chat_id: CHAT, message_id: newId, disable_notification: true }); if (!pin.ok) { console.error('置顶失败(机器人需为群管理员且有「置顶消息」权限):', JSON.stringify(pin)); return; } console.log('已置顶新发布消息'); if (prevPinned && prevPinned !== newId) { const unpin = await tgCall('unpinChatMessage', { chat_id: CHAT, message_id: prevPinned }); console.log(unpin.ok ? '已取消上一个 release 的置顶' : '取消旧置顶失败:' + JSON.stringify(unpin)); } } (async () => { // PR 的评论也走 issue_comment 事件;只通知真正的 issue 回复(C_PR 非空 = PR 评论,跳过) if (EVENT === 'issue_comment' && (process.env.C_PR || '').trim()) { console.log('PR 评论,跳过'); return; } 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 = `🐛 新反馈 Issue #${esc(num)}:${esc(title)}\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 if (EVENT === 'issue_comment') { // issue 新回复:简短推送 const num = process.env.C_NUM || ''; const title = process.env.C_TITLE || ''; const url = process.env.C_URL || ''; const user = process.env.C_USER || ''; const body = (process.env.C_BODY || '').slice(0, 400); htmlText = `💬 Issue #${esc(num)} 新回复 · ${esc(title)}\n👤 ${esc(user)}\n\n${mdToHtml(body)}\n\n🔗 ${esc(url)}`; plainText = `💬 Issue #${num} 新回复 · ${title}\n👤 ${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 = `${esc(titleTxt)}\n\n${mdToHtml(body)}\n\n🔗 ${esc(url)}\n⬆️ 升级: docker compose pull && docker compose up -d`; plainText = `${titleTxt}\n\n${body}\n\n🔗 ${url}\n⬆️ 升级: docker compose pull && docker compose up -d`; } // 发送(HTML,失败回退纯文本) let sent = await send(htmlText, 'HTML'); if (sent.ok) { console.log('sent (HTML)'); } else { console.error('HTML 发送失败,回退纯文本:', JSON.stringify(sent)); sent = await send(plainText.length > 4000 ? plainText.slice(0, 4000) : plainText); if (!sent.ok) { console.error('纯文本也失败:', JSON.stringify(sent)); process.exit(1); } console.log('sent (plain fallback)'); } // 发布通知(release 事件或手动触发)自动置顶 + 取消上一个 release 的置顶;issue / 回复通知不置顶。 if (EVENT !== 'issues' && EVENT !== 'issue_comment' && sent.result?.message_id) { try { await pinAndUnpinPrev(sent.result.message_id); } catch (e) { console.error('置顶流程出错(已忽略,不影响通知):', e?.message || e); } } })().catch((e) => { console.error(e); process.exit(1); });