// 发布 / 新 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);
});