Files
WechatOnCloud/.github/scripts/telegram-bot.mjs
T
Gloridust 54ed841a68 feat(admin/ci): 管理页折叠菜单 + 空状态/主页优化 + Telegram 命令机器人
管理页 UI/UX
- 实例卡片操作改为「管理」分类折叠菜单(默认收起,点开按 运维/设置/危险 分组的
  文字操作),替代之前难辨认的图标排;删除单独成组、红色,降低误点
- 修复展开一张卡片时同行其它卡片被 grid 拉等高(inst-grid align-items:start)
- 管理页空状态(无实例/无子账号)改为图标+标题+说明+引导按钮
- 主页实例卡片加副行(状态·微信版本)、悬停上浮高亮

Telegram 命令机器人(轮询版,纯 GitHub Actions,无服务器)
- .github/workflows/telegram-bot.yml + scripts/telegram-bot.mjs
- 私聊/群组命令:/help /releases /release <tag> /issues /issue <编号>
- cron 每 5 分钟 getUpdates,处理后用 offset 向 Telegram 确认,无需持久化存储
- 受 vars.TELEGRAM_BOT_ENABLED 开关;命令非实时(cron 限制),文档已说明

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

137 lines
5.3 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.
// Telegram 命令机器人(轮询版)——跑在 GitHub Actions cron 上,无需任何服务器。
//
// 工作方式:每次运行调用 getUpdates 拉取「自上次确认以来」的待处理更新,逐条处理命令并回复,
// 最后用 offset=最后一条+1 再调一次 getUpdates 向 Telegram「确认」(这些更新随即被服务端清掉,
// 下次不再返回)。Telegram 自己保存 offset 状态(未确认的更新保留 24h),因此本脚本无需任何持久化存储。
//
// 支持私聊与群组(群组里以 /命令 形式发送即可,命令不受 bot 隐私模式影响)。
// 命令:/help /releases /release <tag> /issues /issue <编号>
//
// 局限:受 GitHub cron 最小 5 分钟间隔限制,命令有延迟(非实时)。要实时需改用 webhook(需 serverless 端点)。
const TG = process.env.TG_TOKEN;
const GH = process.env.GH_TOKEN || '';
const REPO = process.env.REPO; // owner/repo
if (!TG) {
console.log('TELEGRAM_BOT_TOKEN 未配置,跳过');
process.exit(0);
}
const tgUrl = (method) => `https://api.telegram.org/bot${TG}/${method}`;
const ghHeaders = {
accept: 'application/vnd.github+json',
'user-agent': 'woc-telegram-bot',
...(GH ? { authorization: `Bearer ${GH}` } : {}),
};
async function tg(method, params) {
const r = await fetch(tgUrl(method), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(params),
});
return r.json();
}
async function gh(path) {
const r = await fetch(`https://api.github.com/repos/${REPO}${path}`, { headers: ghHeaders });
if (!r.ok) throw new Error(`GitHub ${path}${r.status}`);
return r.json();
}
const trunc = (s, n) => (s && s.length > n ? s.slice(0, n) + '…' : s || '');
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text, disable_web_page_preview: true });
const HELP = [
'🤖 云微 WechatOnCloud 机器人命令:',
'',
'/releases — 最近发布列表',
'/release <tag> — 某版本详情(省略 = 最新)',
'/issues — 打开中的 issue 列表',
'/issue <编号> — issue 详情',
'/help — 显示本帮助',
'',
'(轮询版,命令可能有几分钟延迟)',
].join('\n');
async function handle(cmd, arg, chatId) {
switch (cmd) {
case '/start':
case '/help':
return send(chatId, HELP);
case '/releases': {
const rels = await gh('/releases?per_page=8');
if (!rels.length) return send(chatId, '暂无 release');
const lines = rels.map(
(r) =>
`${r.tag_name}${r.name && r.name !== r.tag_name ? ' — ' + r.name : ''} (${(r.published_at || '').slice(0, 10)})`,
);
return send(chatId, '📦 最近发布:\n' + lines.join('\n') + '\n\n用 /release <tag> 看某版详情');
}
case '/release': {
const rel = arg ? await gh(`/releases/tags/${encodeURIComponent(arg)}`) : await gh('/releases/latest');
const title = `${rel.tag_name}${rel.name && rel.name !== rel.tag_name ? ' · ' + rel.name : ''}`;
return send(
chatId,
`📦 ${title}\n发布于 ${(rel.published_at || '').slice(0, 10)}\n\n${trunc(rel.body, 2500)}\n\n🔗 ${rel.html_url}`,
);
}
case '/issues': {
const items = (await gh('/issues?state=open&per_page=10&sort=updated')).filter((i) => !i.pull_request);
if (!items.length) return send(chatId, '🎉 当前没有打开的 issue');
const lines = items.map((i) => `• #${i.number} ${trunc(i.title, 60)}`);
return send(chatId, `🐛 打开中的 issue${items.length}):\n` + lines.join('\n') + '\n\n用 /issue <编号> 看详情');
}
case '/issue': {
if (!arg) return send(chatId, '用法:/issue <编号>');
const i = await gh(`/issues/${encodeURIComponent(arg)}`);
if (i.pull_request) return send(chatId, `#${arg} 是个 PR,不是 issue`);
return send(
chatId,
`🐛 #${i.number} ${i.title}\n状态:${i.state} · by ${i.user?.login}\n\n${trunc(i.body, 2500)}\n\n🔗 ${i.html_url}`,
);
}
default:
return; // 未知命令静默忽略,避免群里刷屏
}
}
(async () => {
// 短轮询拉取待处理更新(只要 message)
const res = await (
await fetch(tgUrl('getUpdates') + '?timeout=0&allowed_updates=' + encodeURIComponent('["message"]'))
).json();
if (!res.ok) {
console.error('getUpdates 失败:', JSON.stringify(res));
process.exit(res.error_code === 409 ? 0 : 1); // 409 = 设了 webhook,与轮询冲突,直接退出
}
const updates = res.result || [];
let maxId = 0;
for (const u of updates) {
maxId = Math.max(maxId, u.update_id);
const m = u.message;
if (!m || !m.text) continue;
const text = m.text.trim();
if (!text.startsWith('/')) continue;
const parts = text.split(/\s+/);
const cmd = parts[0].split('@')[0].toLowerCase(); // 去掉 @botname 后缀
const arg = parts.slice(1).join(' ').trim();
try {
await handle(cmd, arg, m.chat.id);
} catch (e) {
await send(m.chat.id, '⚠️ 出错了:' + (e?.message || e));
}
}
// 向 Telegram 确认已处理(清掉这些更新,下次不再返回)
if (maxId) {
await fetch(tgUrl('getUpdates') + `?offset=${maxId + 1}&timeout=0`);
}
console.log(`processed ${updates.length} update(s)`);
})().catch((e) => {
console.error(e);
process.exit(1);
});