Files
Gloridust 167a80a0c3 feat(ci/telegram): issue 新回复也推送(简短)
telegram-notify 增订阅 issue_comment(created),tg-notify.mjs 加 issue_comment 分支:
💬 Issue #N 新回复 · 标题 / 评论人 / 评论摘要(≤400字) / 链接。PR 的评论也走此事件,按 C_PR 跳过;
回复通知不置顶(置顶仍仅限 release)。

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

181 lines
8.0 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 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 = `🐛 <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 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 = `💬 <b>Issue #${esc(num)} 新回复</b> · ${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 = `<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' && 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);
});