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>
This commit is contained in:
Gloridust
2026-06-13 00:29:02 +08:00
Unverified
parent a24977dd1f
commit 54ed841a68
6 changed files with 387 additions and 42 deletions
+136
View File
@@ -0,0 +1,136 @@
// 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);
});
+40
View File
@@ -0,0 +1,40 @@
name: telegram-bot
# Telegram 命令机器人(轮询版)——无需服务器,仅靠 GitHub Actions cron。
# 私聊 / 群组都可用命令查询:/help /releases /release <tag> /issues /issue <编号>。
#
# 启用(一次性):
# 1) 已配置 telegram-notify 用到的 TELEGRAM_BOT_TOKENSecret)即可复用;
# 2) 仓库 Settings → Secrets and variables → Actions → Variables 新建
# TELEGRAM_BOT_ENABLED = true (未设为 true 则本工作流不运行,避免空跑)
# 3) 把机器人拉进群组 / 在私聊里 /start。
#
# 局限:cron 最小 5 分钟且可能再延后 → 命令非实时;GitHub 会在仓库 60 天无活动时暂停定时任务。
# 想立即处理一次:Actions → telegram-bot → Run workflowworkflow_dispatch)。
on:
schedule:
- cron: '*/5 * * * *'
workflow_dispatch: {}
permissions:
contents: read
issues: read
# 避免轮询任务并发重叠重复处理;新触发取消正在跑的(残留未确认更新下次重处理,无副作用)
concurrency:
group: telegram-bot
cancel-in-progress: true
jobs:
poll:
runs-on: ubuntu-latest
if: ${{ vars.TELEGRAM_BOT_ENABLED == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Poll Telegram & handle commands
env:
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: node .github/scripts/telegram-bot.mjs
+17
View File
@@ -92,3 +92,20 @@ docker buildx build --platform linux/amd64,linux/arm64 \
- **Secrets** 标签 → `TELEGRAM_BOT_TOKEN` = [@BotFather](https://t.me/BotFather) 给的 token。
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
---
## Telegram 命令机器人(可选,免服务器,轮询版)
除了发布通知,仓库还内置 [.github/workflows/telegram-bot.yml](../.github/workflows/telegram-bot.yml) + [.github/scripts/telegram-bot.mjs](../.github/scripts/telegram-bot.mjs):让机器人在**私聊 / 群组**里响应命令,跑在 GitHub Actions cron 上,**不需要服务器**。
命令:`/help``/releases``/release <tag>``/issues``/issue <编号>`
启用:
1. 复用上面的 `TELEGRAM_BOT_TOKEN`Secret);
2. 仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `TELEGRAM_BOT_ENABLED = true`(不设为 `true` 则该工作流不运行);
3. 把机器人拉进群组,或在私聊里 `/start`
> **原理**:每 5 分钟(cron 最小间隔)调用 `getUpdates` 拉取待处理命令、回复、再用 `offset` 向 Telegram 确认(Telegram 自己保存 offset**无需任何持久化存储**)。
> **局限**:命令**非实时**——受 cron 最小 5 分钟 + GitHub 排队延迟影响;且 GitHub 会在仓库 60 天无活动时暂停定时任务(去 Actions 页手动重启即可)。想立即处理一次:Actions → telegram-bot → **Run workflow**。要真正实时,得改用 webhook(需一个 serverless 端点)。
+8
View File
@@ -272,12 +272,20 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
<div className="inst-grid">
{instances.map((inst) => {
const st = statusOf(inst);
const meta = inst.wechat.installed
? `微信 ${inst.wechat.version || ''}`.trim()
: inst.runtime === 'running'
? '待下载安装微信'
: '';
return (
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
<span className="home-card-main">
<span className="home-card-name">{inst.name}</span>
<span className="home-card-meta">
<span className={'home-card-st ' + st.cls}> {st.text}</span>
{meta && <span className="home-card-ver">{meta}</span>}
</span>
</span>
<span className="enter-arrow"></span>
</button>
+66 -11
View File
@@ -12,6 +12,25 @@ const MenuIcon = (
</svg>
);
// 折叠菜单的展开箭头
const CaretIcon = (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
);
// 友好空状态:圆形图标 + 标题 + 说明 + 可选引导按钮(沿用首页 .empty-state 样式)
function EmptyState({ icon, title, sub, action }: { icon: string; title: string; sub?: string; action?: JSX.Element }) {
return (
<div className="empty-state">
<div className="empty-blob">{icon}</div>
<div className="empty-title">{title}</div>
{sub && <div className="empty-sub">{sub}</div>}
{action && <div className="empty-action">{action}</div>}
</div>
);
}
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
const nav = useNavigate();
const { user } = useAuth();
@@ -210,9 +229,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
</button>
</div>
{instances.length === 0 ? (
<div className="list">
<div className="muted small" style={{ padding: '14px 16px' }}></div>
</div>
<EmptyState
icon="🖥️"
title="还没有微信实例"
sub="新建一个实例,进入后扫码登录即可在浏览器里用微信"
action={
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
</button>
}
/>
) : (
<div className="inst-grid">
{instances.map((inst) => (
@@ -243,9 +269,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
</button>
</div>
{subs.length === 0 ? (
<div className="list">
<div className="muted small" style={{ padding: '14px 16px' }}></div>
</div>
<EmptyState
icon="👥"
title="还没有子账号"
sub="子账号是登录这套面板的身份,可按账号分配能访问哪些实例"
action={
<button className="btn btn-primary" onClick={() => setCreatingUser(true)}>
</button>
}
/>
) : (
<div className="inst-grid">
{subs.map((u) => (
@@ -800,6 +833,7 @@ function InstanceAdminCard({
const installed = wx.installed && wx.phase !== 'downloading';
const offline = inst.runtime !== 'running';
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
const [menuOpen, setMenuOpen] = useState(false); // 「管理」折叠菜单是否展开
let badge: { text: string; cls: string };
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
@@ -851,13 +885,22 @@ function InstanceAdminCard({
)}
</div>
<div className="inst-admin-links">
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
<span></span>
<span className="inst-menu-caret">{CaretIcon}</span>
</button>
{menuOpen && (
<div className="inst-menu">
<div className="inst-menu-group">
<div className="inst-menu-label"></div>
<div className="inst-menu-items">
{!offline && (
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
{installed ? '更新微信' : '下载安装'}
</button>
)}
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
</button>
{!offline && (
@@ -870,22 +913,34 @@ function InstanceAdminCard({
</button>
)}
</div>
</div>
<div className="inst-menu-group">
<div className="inst-menu-label"></div>
<div className="inst-menu-items">
<button className="btn-text" onClick={onRename}>
</button>
<button className="btn-text" onClick={onAssign}>
</button>
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志">
</button>
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
</button>
</div>
</div>
<div className="inst-menu-group inst-menu-danger">
<div className="inst-menu-items">
<button className="btn-text danger" onClick={onDelete}>
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
+89
View File
@@ -305,6 +305,67 @@ button {
background: rgba(var(--danger-rgb) / 0.12);
}
/* 实例卡片操作:「管理」分类折叠菜单(默认收起,点开按运维/设置/危险分组展开文字操作) */
.inst-menu-toggle {
margin-top: 10px;
width: 100%;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border: none;
border-radius: 12px;
background: var(--trough);
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
transition: background 0.15s;
}
.inst-menu-toggle:hover {
background: var(--base);
}
.inst-menu-caret {
display: inline-flex;
color: var(--muted);
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.inst-menu-toggle.open .inst-menu-caret {
transform: rotate(180deg);
}
.inst-menu {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
animation: inst-menu-in 0.16s ease;
}
@keyframes inst-menu-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.inst-menu-label {
font-size: 11px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
margin: 2px 4px 2px;
}
.inst-menu-items {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.inst-menu-danger {
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid rgba(var(--shadow) / 0.08);
}
.error {
color: var(--danger);
font-size: 14px;
@@ -845,6 +906,8 @@ button {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 22px;
/* 各卡片按自身内容高度,避免某张展开「管理」菜单时同行其它卡片被拉等高(显得也展开了) */
align-items: start;
}
.inst-card {
position: relative;
@@ -978,6 +1041,9 @@ button {
text-align: center;
padding: 30px 16px 36px;
}
.empty-action {
margin-top: 16px;
}
.empty-blob {
width: 96px;
height: 96px;
@@ -1467,6 +1533,10 @@ button {
background: var(--sheen);
pointer-events: none;
}
.home-card:hover {
transform: translateY(-2px);
box-shadow: var(--crease-accent);
}
.home-card:active {
transform: scale(0.985);
}
@@ -1500,10 +1570,29 @@ button {
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.home-card-st {
font-size: 12px;
font-weight: 600;
background: none !important;
flex: none;
}
.home-card-ver {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card-ver::before {
content: '·';
margin-right: 8px;
color: var(--muted);
}
/* —— 内嵌实例视图 —— */