Files
WechatOnCloud/.github/scripts/tg-notify.mjs
T
Gloridust ac7389e75f feat(ci/telegram): 发布通知自动置顶 + 取消上一个 release 的置顶
tg-notify.mjs:release/手动触发发完消息后,用 getChat 读群当前置顶(=上一个 release),
pinChatMessage 置顶新消息(静音,避免二次提醒)、unpinChatMessage 取消旧的——无需持久化存储。
issue 通知不置顶;置顶失败(机器人非管理员/无置顶权限)仅跳过、不影响通知本身。
文档补充:需把机器人设为群管理员并开启「置顶消息」权限。

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

166 lines
7.1 KiB
JavaScript
Raw 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 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 () => {
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`;
}
// 发送(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' && 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);
});