diff --git a/.github/scripts/telegram-bot.mjs b/.github/scripts/telegram-bot.mjs new file mode 100644 index 0000000..8728f1d --- /dev/null +++ b/.github/scripts/telegram-bot.mjs @@ -0,0 +1,136 @@ +// Telegram 命令机器人(轮询版)——跑在 GitHub Actions cron 上,无需任何服务器。 +// +// 工作方式:每次运行调用 getUpdates 拉取「自上次确认以来」的待处理更新,逐条处理命令并回复, +// 最后用 offset=最后一条+1 再调一次 getUpdates 向 Telegram「确认」(这些更新随即被服务端清掉, +// 下次不再返回)。Telegram 自己保存 offset 状态(未确认的更新保留 24h),因此本脚本无需任何持久化存储。 +// +// 支持私聊与群组(群组里以 /命令 形式发送即可,命令不受 bot 隐私模式影响)。 +// 命令:/help /releases /release /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 — 某版本详情(省略 = 最新)', + '/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 看某版详情'); + } + + 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); +}); diff --git a/.github/workflows/telegram-bot.yml b/.github/workflows/telegram-bot.yml new file mode 100644 index 0000000..c51f543 --- /dev/null +++ b/.github/workflows/telegram-bot.yml @@ -0,0 +1,40 @@ +name: telegram-bot + +# Telegram 命令机器人(轮询版)——无需服务器,仅靠 GitHub Actions cron。 +# 私聊 / 群组都可用命令查询:/help /releases /release /issues /issue <编号>。 +# +# 启用(一次性): +# 1) 已配置 telegram-notify 用到的 TELEGRAM_BOT_TOKEN(Secret)即可复用; +# 2) 仓库 Settings → Secrets and variables → Actions → Variables 新建 +# TELEGRAM_BOT_ENABLED = true (未设为 true 则本工作流不运行,避免空跑) +# 3) 把机器人拉进群组 / 在私聊里 /start。 +# +# 局限:cron 最小 5 分钟且可能再延后 → 命令非实时;GitHub 会在仓库 60 天无活动时暂停定时任务。 +# 想立即处理一次:Actions → telegram-bot → Run workflow(workflow_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 diff --git a/doc/发布到GHCR.md b/doc/发布到GHCR.md index 9e36fad..4ca01d7 100644 --- a/doc/发布到GHCR.md +++ b/doc/发布到GHCR.md @@ -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 `、`/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 端点)。 diff --git a/panel/web/src/AppShell.tsx b/panel/web/src/AppShell.tsx index 71f49ea..15b1bf5 100644 --- a/panel/web/src/AppShell.tsx +++ b/panel/web/src/AppShell.tsx @@ -272,12 +272,20 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
{instances.map((inst) => { const st = statusOf(inst); + const meta = inst.wechat.installed + ? `微信 ${inst.wechat.version || ''}`.trim() + : inst.runtime === 'running' + ? '待下载安装微信' + : ''; return ( diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 6a0af94..7b6d087 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -12,6 +12,25 @@ const MenuIcon = ( ); +// 折叠菜单的展开箭头 +const CaretIcon = ( + + + +); + +// 友好空状态:圆形图标 + 标题 + 说明 + 可选引导按钮(沿用首页 .empty-state 样式) +function EmptyState({ icon, title, sub, action }: { icon: string; title: string; sub?: string; action?: JSX.Element }) { + return ( +
+
{icon}
+
{title}
+ {sub &&
{sub}
} + {action &&
{action}
} +
+ ); +} + 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: ()
{instances.length === 0 ? ( -
-
暂无实例
-
+ setCreatingInst(true)}> + + 新建微信实例 + + } + /> ) : (
{instances.map((inst) => ( @@ -243,9 +269,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{subs.length === 0 ? ( -
-
暂无子账号
-
+ setCreatingUser(true)}> + + 新建子账号 + + } + /> ) : (
{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,41 +885,62 @@ function InstanceAdminCard({ )}
-
- {!offline && ( - - )} - - {!offline && ( - - )} - {!offline && ( - - )} - - - - - -
+ + + {menuOpen && ( +
+
+
运维
+
+ {!offline && ( + + )} + + {!offline && ( + + )} + {!offline && ( + + )} +
+
+
+
设置
+
+ + + + +
+
+
+
+ +
+
+
+ )} )} diff --git a/panel/web/src/styles.css b/panel/web/src/styles.css index 24e20af..329d367 100644 --- a/panel/web/src/styles.css +++ b/panel/web/src/styles.css @@ -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); } /* —— 内嵌实例视图 —— */