feat: v2.18.0 Telegram 签到(Docker 专属)+ Docker 适配 + 镜像改推 Docker Hub
- 新增 Telegram 签到站点(MTProto 用户身份,Docker 版专属;Workers 上明确提示) - docker/telegram-login.mjs:交互式登录拿 Session - Dockerfile 构建时 npm install telegram 依赖 - docker-compose 默认 host 网络模式(IPv6,给 NodeSeek 直连用) - .github/workflows/docker.yml:push master 自动构建多架构镜像并推送到 nameguoguo/daily-checkin-panel(Docker Hub) - README/详细手册/CHANGELOG 同步更新(含 NodeSeek 反馈帖链接)
This commit is contained in:
1 parent
f97392a359
commit
f00eb407e2
19 files changed
+1137
-5
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# 构建上下文瘦身:镜像里不需要这些
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
node_modules
|
||||
data/
|
||||
test/
|
||||
tools/
|
||||
.vscode
|
||||
*.md
|
||||
LICENSE
|
||||
@@ -0,0 +1,13 @@
|
||||
# 复制为 .env 后填写:cp .env.example .env
|
||||
|
||||
# 可选,但强烈建议设置:账号凭据的加密密钥。
|
||||
# 生成:openssl rand -base64 32
|
||||
# 不填也行:面板首次使用时会自动生成一把存进数据库(和 Cloudflare 上不配 Secret 一个行为)。
|
||||
# 警告:一旦投入使用就不要改(改了已存账号凭据解不开);备份好 ./data 目录。
|
||||
ENCRYPT_KEY=
|
||||
|
||||
# 可选:面板端口(默认 8787)
|
||||
PORT=8787
|
||||
|
||||
# 可选:时区,只影响容器日志时间显示;签到时间走面板「设置」页
|
||||
TZ=Asia/Shanghai
|
||||
@@ -0,0 +1,64 @@
|
||||
# 每次 push 到 master(或打 v* 标签)后,自动构建 Docker 镜像并推送到 Docker Hub。
|
||||
# 之后服务器上只需 `docker compose pull && docker compose up -d`,不需要人工 docker push。
|
||||
#
|
||||
# 镜像地址:nameguoguo/daily-checkin-panel
|
||||
# 标签:latest(master)、v2.18.0 / v2.18 这种语义化标签(打 tag 时)、sha(每次提交)
|
||||
#
|
||||
# 需要的仓库密钥(GitHub 仓库 → Settings → Secrets and variables → Actions → New repository secret):
|
||||
# DOCKERHUB_USERNAME = 你的 Docker Hub 用户名(nameguoguo)
|
||||
# DOCKERHUB_TOKEN = Docker Hub → Account Settings → Security → Access Tokens → 新建(Read & Write)
|
||||
# 密钥配好之前 workflow 会跳过推送(只构建不推送,避免红叉)。
|
||||
|
||||
name: 构建并发布 Docker 镜像
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 拉代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 准备多架构构建
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: 准备 buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 登录 Docker Hub
|
||||
if: secrets.DOCKERHUB_TOKEN != ''
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: 生成镜像标签
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: nameguoguo/daily-checkin-panel
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=,format=short
|
||||
|
||||
- name: 构建并推送(amd64 + arm64,NAS/树莓派也能跑)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# 没配密钥时只构建不推送(密钥是用户在 GitHub 网页上配的,见文件头说明)
|
||||
push: ${{ secrets.DOCKERHUB_TOKEN != '' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -14,6 +14,17 @@
|
||||
### 修复
|
||||
-
|
||||
|
||||
## [2.18.0] - 2026-09-30
|
||||
|
||||
### 新增
|
||||
- Docker 运行方式:一套代码两种跑法,`docker compose up -d` 即可在本地/NAS/服务器上跑面板。`docker/server.mjs` 把 Worker 的 fetch/scheduled 原样跑在 Node 24 上,`docker/adapter.mjs` 用 Node 内置 `node:sqlite` 实现 D1 兼容层(prepare/bind/run/all/first/batch,batch 事务性),静态资源走 `public/` 目录并沿用 `public/_headers` 头规则与 SPA 回退。数据全在 `./data/checkin.sqlite` 一个文件里,备份拷走就行。ENCRYPT_KEY 可选(不填面板自动生成存库,`openssl rand -base64 32` 生成)。CI(`.github/workflows/docker.yml`)在每次 push 后自动构建多架构镜像(amd64+arm64)推送到 Docker Hub(`nameguoguo/daily-checkin-panel`),不需要人工 `docker push`;服务器上 `docker compose pull && docker compose up -d` 更新,或加 `--profile auto-update` 让 watchtower 全自动更新。说明见 `docker/README.md`。
|
||||
- Telegram 签到(**Docker 版专属**):用 MTProto(gramjs)以「你的 Telegram 账号」身份,给指定的 TG 机器人/群发签到指令(如 `/checkin`),读 bot 回执判定成功/已签到。Cloudflare Workers 没有 TCP socket 跑不了这个,所以新站 `src/sites/telegram.js` 声明了 `requiresNode`,runner 在 Workers 上会直接给明确提示("需要 Docker 版")而不空转重试。首次用 `docker exec -it <容器> node docker/telegram-login.mjs` 交互登录一次拿 Session(api_id/api_hash 去 my.telegram.org 申请),粘到面板账号里即可。Dockerfile 构建时 `npm install` 装 `telegram` 依赖(CI 自检不需要装,模块里是动态 import)。步骤见 `docker/README.md「Telegram 签到」`。
|
||||
- Docker 默认改用 host 网络模式:容器直接用主机的网络栈,拿到 IPv6 出口。之前默认的 bridge 是纯 IPv4,而 NodeSeek 会把无 IPv6 的请求踢到 IPv6 提示页,导致 Docker 直连签到也失败(和 CF 机房一样)。Mac/Windows 的 Docker Desktop 不支持 host 模式,请用 Linux 主机;改回 bridge 的方法写在 `docker-compose.yml` 注释和 `docker/README.md「网络模式」` 里。
|
||||
- README 文档表新增 [NodeSeek 使用反馈收集帖](https://www.nodeseek.com/post-956355-1#1)。
|
||||
|
||||
### 修复
|
||||
-
|
||||
|
||||
## [2.17.1] - 2026-09-30
|
||||
|
||||
### 新增
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# 每日签到面板 — Docker 镜像(Node 运行时,不依赖 Cloudflare)
|
||||
#
|
||||
# 一套代码两种跑法:Cloudflare Workers(wrangler deploy)和 Docker(本镜像),
|
||||
# 面板代码 src/ 完全共用,差异由 docker/adapter.mjs 抹平。
|
||||
|
||||
FROM node:24-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# telegram(gramjs)是 Telegram 签到(MTProto 用户身份)要用的;
|
||||
# 其他站点零依赖,node:sqlite 是 Node 24 内置模块。
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
COPY docker/ ./docker/
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8787 \
|
||||
DB_PATH=/data/checkin.sqlite
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8787
|
||||
|
||||
# /api/status 无需登录,适合做健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:8787/api/status').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "docker/server.mjs"]
|
||||
@@ -157,6 +157,7 @@ node deploy.mjs
|
||||
| [docs/详细手册.md](docs/详细手册.md) | 手动部署、执行模式、内置站点、社区适配、FAQ、备份与结构 |
|
||||
| [SECURITY.md](SECURITY.md) / [CONTRIBUTING.md](CONTRIBUTING.md) | 安全说明 / 怎么贡献一个站点适配 |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 每一版改了什么 |
|
||||
| [NodeSeek 使用反馈收集帖](https://www.nodeseek.com/post-956355-1#1) | 用户反馈集中收集(遇到签到问题先来这里看看/留言) |
|
||||
|
||||
```bash
|
||||
node tools/verify.mjs # 自检:语法 + HTML 配对 + DOM 引用 + 全部单测(CI 跑的是它)
|
||||
@@ -165,7 +166,7 @@ node tools/release.mjs minor # 发版:自检 → 升版本号 → 写 CHANGELO
|
||||
```
|
||||
|
||||
`.github/workflows/` 里:`ci.yml` 每次推送/PR 自检;`release.yml` 推 `v*` tag 自动发 Release;
|
||||
`deploy.yml` 是可选手动部署(默认只 dry-run)。零第三方依赖,所以 CI 不需要 `npm install`。
|
||||
`deploy.yml` 是可选手动部署(默认只 dry-run)。自检不需要 `npm install`(`telegram` 依赖只在站点模块里动态 import,只有 Docker 镜像构建时才装)。
|
||||
|
||||
```
|
||||
wrangler.toml 部署配置(数据库 id、定时触发器) deploy.mjs 一键部署脚本
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 每日签到面板(Docker 版)
|
||||
# 用法:cp .env.example .env → 填好 ENCRYPT_KEY → docker compose up -d
|
||||
|
||||
services:
|
||||
panel:
|
||||
build: .
|
||||
# CI 在每次 push 后自动构建并推送到 Docker Hub(见 .github/workflows/docker.yml),
|
||||
# 所以这里既能本地构建,也能 docker compose pull 直接拉远端镜像。
|
||||
image: nameguoguo/daily-checkin-panel:latest
|
||||
container_name: daily-checkin-panel
|
||||
# 主机网络模式:容器直接用主机的网络栈 ——
|
||||
# ① NodeSeek 这类站点要求 IPv6,直连签到需要容器的 IPv6 出口,
|
||||
# 而 Docker 默认 bridge 网络是纯 IPv4(配了 daemon IPv6 的除外);
|
||||
# ② 家用 NAS/软路由上最简单,不用再映射端口。
|
||||
# 注意:Mac/Windows 的 Docker Desktop 不支持 host 模式(会被忽略),
|
||||
# 请用 Linux 主机(NAS/软路由/小主机)。如需改回 bridge,注释掉下面这行,
|
||||
# 并取消 ports 的注释(同时要在 Docker daemon 里开 IPv6,否则 NodeSeek 直连会失败)。
|
||||
network_mode: "host"
|
||||
# ports:
|
||||
# - "${PORT:-8787}:8787"
|
||||
environment:
|
||||
# 不填也行(面板会自动生成存库);填了就用你的(openssl rand -base64 32)
|
||||
ENCRYPT_KEY: ${ENCRYPT_KEY:-}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
# 数据(SQLite + WAL)全在这个目录里,备份拷走就行
|
||||
- ./data:/data
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
restart: unless-stopped
|
||||
|
||||
# 全自动更新(可选):每 5 分钟检查 GHCR,有新镜像就拉取并重启 panel。
|
||||
# watchtower 只会更新打了 enable 标签的容器,不会动你机器上别的容器。
|
||||
# 启用:docker compose --profile auto-update up -d
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: daily-checkin-panel-watchtower
|
||||
command: --interval 300 --cleanup --label-enable
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
profiles: [auto-update]
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,106 @@
|
||||
# Docker 运行方式
|
||||
|
||||
同一套代码,两种跑法:
|
||||
|
||||
| 方式 | 命令 | 数据 | 定时 |
|
||||
|---|---|---|---|
|
||||
| Cloudflare Workers | `wrangler deploy` | D1(云端) | Cron 每分钟 |
|
||||
| Docker(本页) | `docker compose up -d` | SQLite(本地 `./data`) | 容器内每分钟 |
|
||||
|
||||
面板代码 `src/` 完全共用,差异由 `docker/adapter.mjs` 抹平(D1 接口 → Node 内置 `node:sqlite`,静态资源 → `public/` 目录)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 可选:填 ENCRYPT_KEY(openssl rand -base64 32 生成;不填面板会自动生成存库)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
打开 `http://你的IP:8787`,首次会让你设置管理密码,之后和 Cloudflare 版用法完全一样。
|
||||
|
||||
## 配置说明(.env)
|
||||
|
||||
- `ENCRYPT_KEY`:账号凭据的加密密钥,32 字节 base64(`openssl rand -base64 32`)。
|
||||
不填也行,面板首次使用时会自动生成一把存进数据库。**投入使用后不要改**,改了旧凭据解不开。
|
||||
- `PORT`:面板端口,默认 8787。
|
||||
- `TZ`:时区,只影响容器日志时间;签到时间走面板「设置」页。
|
||||
|
||||
## 数据与备份
|
||||
|
||||
所有数据(账号、日志、设置、自动生成的加密密钥)都在 `./data/checkin.sqlite` 这一个文件里(+WAL 临时文件)。备份直接拷走 `data/` 目录就行;迁到别的机器也是拷这个目录。
|
||||
|
||||
## 更新(git push 后自动)
|
||||
|
||||
CI(`.github/workflows/docker.yml`)在每次 push 到 master 后自动构建多架构镜像(amd64+arm64,NAS/树莓派能用)并推送到 Docker Hub(`nameguoguo/daily-checkin-panel`),打 tag 会额外出版本号标签。**不需要人工 `docker push`**。
|
||||
|
||||
服务器上更新,三选一:
|
||||
|
||||
```bash
|
||||
# 1. 手动(推荐):拉新镜像并重启
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# 2. 全自动:watchtower 每 5 分钟检查一次,有新镜像自动更新(只动本面板容器)
|
||||
docker compose --profile auto-update up -d
|
||||
```
|
||||
|
||||
## 网络模式(为什么默认 host)
|
||||
|
||||
`docker-compose.yml` 默认用 `network_mode: "host"`(容器直接用主机的网络栈),两个原因:
|
||||
|
||||
1. **NodeSeek 这类站点要求 IPv6**:直连签到需要容器的 IPv6 出口,而 Docker 默认的 bridge 网络是纯 IPv4(除非你单独给 Docker daemon 开了 IPv6)。host 模式下容器直接走主机的 IPv6,和你在家用浏览器签到是同一个网络。
|
||||
2. 家用 NAS/软路由上最省事,不用再映射端口。
|
||||
|
||||
注意:Mac/Windows 的 Docker Desktop 不支持 host 模式(会被忽略),请用 Linux 主机。如需改回 bridge,注释掉 compose 里那行并取消 `ports` 的注释,同时要在 Docker daemon 里开 IPv6(`/etc/docker/daemon.json` 加 `"ipv6": true`),否则 NodeSeek 直连签到会被踢到 IPv6 提示页而失败。
|
||||
|
||||
## Telegram 签到(Docker 版专属)
|
||||
|
||||
给指定的 TG 机器人/群发签到指令(如机场群的 `/checkin` 领流量)。**只能跑在 Docker 版**:它用 MTProto 以「你的 Telegram 账号」身份登录,需要 TCP 长连接,Cloudflare Workers 没有 TCP,跑不了。在 Workers 上建这个账号会直接提示换 Docker 版。
|
||||
|
||||
用之前先装依赖(镜像构建时已自动装好,`package.json` 里有 `telegram` 一项)。
|
||||
|
||||
### 一次性:申请 API 凭据 + 登录拿 Session
|
||||
|
||||
1. 去 https://my.telegram.org 用手机号登录 → **API development tools** → 建一个应用,拿到 `api_id`(数字)和 `api_hash`。
|
||||
2. 在跑容器的机器上执行(`-it` 是必须的,要交互输入):
|
||||
|
||||
```bash
|
||||
docker exec -it daily-checkin-panel node docker/telegram-login.mjs
|
||||
```
|
||||
|
||||
按提示输入 `api_id` / `api_hash` / 手机号(带国家码,如 `+86` 开头)/ 手机收到的验证码 / 两步验证密码(没设直接回车)。成功后会输出一段 **Session** 字符串。
|
||||
|
||||
3. 面板里「添加账号」选 **Telegram 签到**,填:
|
||||
- `API ID` / `API Hash`:第 1 步拿到的;
|
||||
- `Session`:第 2 步输出的那段(很长,完整粘贴);
|
||||
- `签到目标`:`@机器人用户名` 或群链接(如 `@checkin_bot`);
|
||||
- `签到指令`:默认 `/checkin`;
|
||||
- `成功关键词` / `已签到关键词`:用来判断 bot 回执,默认 `签到成功` / `已签到`(多个用 `|` 分隔)。
|
||||
|
||||
之后每天到点,面板就以你的账号身份进群发指令,bot 的回执会原样记在「网站反馈」里。
|
||||
|
||||
安全提醒:Session 等于你的 Telegram 登录态,面板里是加密存的,但**不要截图发给别人**,也不要提交到 git(本来也不在仓库里)。
|
||||
|
||||
## 扩展配合
|
||||
|
||||
扩展里的「面板地址」填 `http://你的IP:8787`(或域名)。注意两点:
|
||||
|
||||
1. 扩展要求面板地址是 **https**,回环地址(localhost/127.0.0.1)除外。局域网用 `http://192.168.x.x:8787` 时扩展会拒绝保存,这是扩展的安全规则。解法:反向代理加 https(下面有 Caddy 示例),或在跑容器的本机用 `http://localhost:8787`。
|
||||
2. 「📥 下载扩展」按钮注入的是你当前访问的地址,用什么地址打开面板,扩展里就带出什么地址。
|
||||
|
||||
## 反向代理(可选,Caddy 示例)
|
||||
|
||||
```caddy
|
||||
checkin.example.com {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
```
|
||||
|
||||
Caddy 自动申请 Let's Encrypt 证书,配好后扩展填 `https://checkin.example.com` 即可。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **端口被占用**:改 `.env` 里 `PORT`,如 `PORT=8080`。
|
||||
- **忘记管理密码**:删 `./data/checkin.sqlite` 重来(所有账号数据一起没,慎用),或进库改 `settings` 表(不建议)。
|
||||
- **ENCRYPT_KEY 丢了/改了**:已存账号的凭据解不开,只能删库重建。备份好 `./data`。
|
||||
- **定时没跑**:面板「设置」页看「自动执行」心跳;容器日志 `docker compose logs -f` 看 `[cron]`。
|
||||
@@ -0,0 +1,184 @@
|
||||
// Docker 运行时适配层:把 Worker 依赖的 Cloudflare 环境(D1 / ASSETS)
|
||||
// 用 Node.js 内置能力实现出来。面板代码(src/)一行不用改。
|
||||
//
|
||||
// env.DB → node:sqlite 上的 D1 兼容层(prepare/bind/run/all/first/batch)
|
||||
// env.ASSETS → public/ 目录 + public/_headers 头规则 + SPA 回退
|
||||
//
|
||||
// 注意:接口形状刻意对齐 D1(返回值带 success/meta,first() 无行返回 null),
|
||||
// 以后即使换回 Cloudflare 也不会有行为差。
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
|
||||
// D1 只接受 null/number/string/ArrayBuffer 等;JS 的 undefined/boolean
|
||||
// 在 node:sqlite 里会炸,转成 SQLite 能懂的样子(D1 侧本来也只传这几种)。
|
||||
function toSqliteValue(v) {
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
return v;
|
||||
}
|
||||
|
||||
class D1Statement {
|
||||
constructor(sqlite, sql) {
|
||||
this.sqlite = sqlite;
|
||||
this.sql = sql;
|
||||
this.params = [];
|
||||
}
|
||||
bind(...params) {
|
||||
this.params = params.map(toSqliteValue);
|
||||
return this;
|
||||
}
|
||||
run() {
|
||||
const r = this.sqlite.prepare(this.sql).run(...this.params);
|
||||
const out = {
|
||||
success: true,
|
||||
meta: {
|
||||
changes: Number(r.changes),
|
||||
last_row_id: Number(r.lastInsertRowid),
|
||||
rows_read: 0,
|
||||
rows_written: Number(r.changes),
|
||||
},
|
||||
};
|
||||
return out;
|
||||
}
|
||||
all() {
|
||||
const rows = this.sqlite.prepare(this.sql).all(...this.params);
|
||||
return {
|
||||
success: true,
|
||||
results: rows,
|
||||
meta: { changes: 0, last_row_id: 0, rows_read: rows.length, rows_written: 0 },
|
||||
};
|
||||
}
|
||||
first() {
|
||||
const row = this.sqlite.prepare(this.sql).get(...this.params);
|
||||
return row === undefined ? null : row;
|
||||
}
|
||||
}
|
||||
|
||||
// path 为 ':memory:' 时走内存库(单测用);否则落盘。
|
||||
export function createD1(path) {
|
||||
const sqlite = new DatabaseSync(path);
|
||||
// WAL:定时任务和 HTTP 请求可能交错写,WAL 比回滚日志更扛并发。
|
||||
try { sqlite.exec('PRAGMA journal_mode = WAL;'); } catch { /* 内存库无所谓 */ }
|
||||
return {
|
||||
prepare: (sql) => new D1Statement(sqlite, sql),
|
||||
// D1 的 batch 是事务性的:要么全成功,要么全回滚。
|
||||
batch(statements) {
|
||||
sqlite.exec('BEGIN');
|
||||
try {
|
||||
const out = statements.map((s) => s.run());
|
||||
sqlite.exec('COMMIT');
|
||||
return out;
|
||||
} catch (e) {
|
||||
try { sqlite.exec('ROLLBACK'); } catch { /* 忽略 */ }
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
exec: (sql) => {
|
||||
sqlite.exec(sql);
|
||||
return { success: true };
|
||||
},
|
||||
// 容器退出时用得到;平时不用调。
|
||||
close: () => sqlite.close(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- public/_headers 解析 ----
|
||||
// 格式:一段一个路径(精确路径,或 /* 前缀通配),下面缩进行是「头: 值」。
|
||||
// 注释行 # 开头,空行分隔。
|
||||
export function parseHeadersFile(text) {
|
||||
const rules = [];
|
||||
let cur = null;
|
||||
for (const raw of String(text).split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
if (!/^\s/.test(raw) && !line.includes(':')) {
|
||||
cur = { pattern: line, headers: [] };
|
||||
rules.push(cur);
|
||||
} else if (cur && line.includes(':')) {
|
||||
const i = line.indexOf(':');
|
||||
cur.headers.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function matchHeaders(rules, pathname) {
|
||||
const out = [];
|
||||
for (const r of rules) {
|
||||
if (r.pattern.endsWith('/*')) {
|
||||
if (pathname.startsWith(r.pattern.slice(0, -1))) out.push(...r.headers);
|
||||
} else if (pathname === r.pattern) {
|
||||
out.push(...r.headers);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
'.zip': 'application/zip',
|
||||
};
|
||||
|
||||
export function mimeFor(filename) {
|
||||
const i = filename.lastIndexOf('.');
|
||||
const ext = i >= 0 ? filename.slice(i).toLowerCase() : '';
|
||||
return MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// 对齐 Cloudflare Workers Static Assets 的行为:
|
||||
// 文件存在就 serve;目录 → index.html;都不存在 → SPA 回退到 index.html(200)。
|
||||
// (wrangler.toml 里 not_found_handling = "single-page-application")
|
||||
export function createAssets(publicDir) {
|
||||
const root = resolve(publicDir);
|
||||
let rules = [];
|
||||
try {
|
||||
rules = parseHeadersFile(readFileSync(join(root, '_headers'), 'utf8'));
|
||||
} catch { /* 没有 _headers 就不加头 */ }
|
||||
|
||||
return {
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
let pathname;
|
||||
try { pathname = decodeURIComponent(url.pathname); } catch { pathname = url.pathname; }
|
||||
const rel = pathname.replace(/^\/+/, '');
|
||||
const file = resolve(root, rel);
|
||||
// 防目录穿越:解析完必须还在 public 里
|
||||
if (file !== root && !file.startsWith(root + sep)) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
let st = null;
|
||||
try { st = statSync(file); } catch { /* 不存在 */ }
|
||||
let target;
|
||||
if (st && st.isDirectory()) target = join(file, 'index.html');
|
||||
else if (st) target = file;
|
||||
else target = join(root, 'index.html'); // SPA 回退
|
||||
let body;
|
||||
try { body = readFileSync(target); } catch {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
const headers = new Headers({ 'Content-Type': mimeFor(target) });
|
||||
for (const [k, v] of matchHeaders(rules, pathname)) headers.set(k, v);
|
||||
return new Response(body, { status: 200, headers });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Docker 入口:把 Cloudflare Worker(src/index.js 的 fetch / scheduled)跑在 Node.js 上。
|
||||
//
|
||||
// HTTP 请求 → worker.fetch(req, env, ctx)
|
||||
// 每分钟定时 → worker.scheduled({ cron }, env, ctx)(对齐 wrangler.toml 的 crons)
|
||||
//
|
||||
// 环境变量:
|
||||
// PORT 监听端口(默认 8787)
|
||||
// HOST 监听地址(默认 0.0.0.0)
|
||||
// DB_PATH SQLite 文件路径(默认 /data/checkin.sqlite,compose 里挂了 volume)
|
||||
// ENCRYPT_KEY 必填:账号凭据加密密钥,投入使用后绝不能改(openssl rand -hex 32)
|
||||
// EXTERNAL_API_KEY 选填:兼容老 Cloudflare Secret 的外部 API Key
|
||||
// TZ 时区(默认 Asia/Shanghai,只影响日志时间显示;签到时间走面板设置)
|
||||
|
||||
import http from 'node:http';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import worker from '../src/index.js';
|
||||
import { createD1, createAssets } from './adapter.mjs';
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DB_PATH = process.env.DB_PATH || '/data/checkin.sqlite';
|
||||
const PUBLIC_DIR = new URL('../public', import.meta.url).pathname;
|
||||
|
||||
if (process.env.ENCRYPT_KEY) {
|
||||
// 格式不对时面板会在第一次加解密时报一句能看懂的话,这里先预检,启动就拦下来。
|
||||
const m = String(process.env.ENCRYPT_KEY).replace(/\s+/g, '');
|
||||
let ok = false;
|
||||
try { ok = Buffer.from(m, 'base64').length === 32 && /^[A-Za-z0-9+/=]+$/.test(m); } catch { ok = false; }
|
||||
if (!ok) {
|
||||
console.error('❌ ENCRYPT_KEY 格式不对:必须是 32 字节的 base64(openssl rand -base64 32 生成)。');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
// 和 Cloudflare 上的 env 对齐:只有这四个绑定/变量。
|
||||
const env = {
|
||||
DB: createD1(DB_PATH),
|
||||
ASSETS: createAssets(PUBLIC_DIR),
|
||||
ENCRYPT_KEY: process.env.ENCRYPT_KEY,
|
||||
};
|
||||
if (process.env.EXTERNAL_API_KEY) env.EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY;
|
||||
|
||||
// waitUntil:Worker 里用来「响应先回、善后慢慢做」。
|
||||
// 这里顺手记下来,关机时等它们收尾完再关数据库,避免写一半被掐掉。
|
||||
const pending = new Set();
|
||||
const ctx = {
|
||||
waitUntil: (p) => {
|
||||
const pr = Promise.resolve(p);
|
||||
pending.add(pr);
|
||||
pr.catch((e) => console.error('[waitUntil]', e)).finally(() => pending.delete(pr));
|
||||
},
|
||||
};
|
||||
|
||||
const server = http.createServer(async (nodeReq, nodeRes) => {
|
||||
try {
|
||||
const chunks = [];
|
||||
for await (const c of nodeReq) chunks.push(c);
|
||||
const body = Buffer.concat(chunks);
|
||||
const host = nodeReq.headers.host || `127.0.0.1:${PORT}`;
|
||||
// 扩展下载包里的面板地址就是从这里的 origin 注入的,所以 Host 必须透传正确。
|
||||
const url = `http://${host}${nodeReq.url}`;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(nodeReq.headers)) {
|
||||
if (v === undefined) continue;
|
||||
if (Array.isArray(v)) v.forEach((x) => headers.append(k, x));
|
||||
else headers.set(k, v);
|
||||
}
|
||||
const init = { method: nodeReq.method, headers };
|
||||
if (body.length > 0 && nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') init.body = body;
|
||||
|
||||
const wres = await worker.fetch(new Request(url, init), env, ctx);
|
||||
|
||||
// set-cookie 可能有多条,entries() 会把它拼坏,单独处理。
|
||||
const outHeaders = {};
|
||||
wres.headers.forEach((v, k) => { outHeaders[k] = v; });
|
||||
const setCookies = typeof wres.headers.getSetCookie === 'function' ? wres.headers.getSetCookie() : [];
|
||||
if (setCookies.length > 0) outHeaders['set-cookie'] = setCookies;
|
||||
nodeRes.writeHead(wres.status, outHeaders);
|
||||
if (wres.body) nodeRes.end(Buffer.from(await wres.arrayBuffer()));
|
||||
else nodeRes.end();
|
||||
} catch (e) {
|
||||
console.error('[http]', e);
|
||||
if (!nodeRes.headersSent) nodeRes.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
nodeRes.end('服务异常');
|
||||
}
|
||||
});
|
||||
|
||||
// 定时:对齐到下一分钟整点再跑,和 Cloudflare Cron 一样每分钟触发一次。
|
||||
// 面板自己的 shouldRun 有幂等(sched_last_map),即使某次延迟也不会重复签到。
|
||||
function armCron() {
|
||||
const next = Math.ceil((Date.now() + 1) / 60000) * 60000;
|
||||
setTimeout(async () => {
|
||||
armCron();
|
||||
try {
|
||||
await worker.scheduled({ cron: '* * * * *', scheduledTime: Date.now() }, env, ctx);
|
||||
} catch (e) {
|
||||
console.error('[cron]', e);
|
||||
}
|
||||
}, Math.max(0, next - Date.now()));
|
||||
}
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`✅ 签到面板已启动:http://${HOST === '0.0.0.0' ? '本机IP' : HOST}:${PORT}`);
|
||||
console.log(` 数据文件:${DB_PATH}`);
|
||||
if (process.env.ENCRYPT_KEY) console.log(' 凭据加密:使用环境变量 ENCRYPT_KEY(投入使用后不要改)');
|
||||
else console.log(' 凭据加密:未设置 ENCRYPT_KEY,首次使用时自动生成并存入数据库(备份好 /data 目录)');
|
||||
console.log(' 定时任务:每分钟(和 Cloudflare Cron 一致)');
|
||||
armCron();
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`\n${signal},正在关闭…`);
|
||||
server.close(async () => {
|
||||
// 等 cron/善后任务收尾(最多 8 秒),再关数据库
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.allSettled([...pending]),
|
||||
new Promise((r) => setTimeout(r, 8000)),
|
||||
]);
|
||||
} catch { /* 忽略 */ }
|
||||
try { env.DB.close(); } catch { /* 忽略 */ }
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(0), 15000).unref();
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,56 @@
|
||||
// Telegram 登录脚本(一次性):交互式登录你的 TG 账号,输出 Session 字符串。
|
||||
//
|
||||
// 用法(在跑容器的机器上,-it 必须,否则没法交互输入):
|
||||
// docker exec -it daily-checkin-panel node docker/telegram-login.mjs
|
||||
//
|
||||
// 流程:输入 api_id / api_hash(my.telegram.org 申请)→ 手机号 → 手机验证码 →
|
||||
// 两步验证密码(没设直接回车)→ 输出 Session。
|
||||
// 把输出的那段 Session 完整粘到面板「Telegram 签到」账号的 Session 栏即可。
|
||||
// Session 等于你的 TG 登录态,不要截图外传。
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q) => rl.question(q);
|
||||
|
||||
try {
|
||||
const apiId = Number((await ask('API ID(my.telegram.org 上的数字):')).trim());
|
||||
const apiHash = (await ask('API Hash:')).trim();
|
||||
const phone = (await ask('手机号(带国家码,如 +8613800000000):')).trim();
|
||||
if (!apiId || !apiHash || !phone) {
|
||||
console.error('API ID / API Hash / 手机号都要填,退出。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let TelegramClient, StringSession;
|
||||
try {
|
||||
({ TelegramClient } = await import('telegram'));
|
||||
({ StringSession } = await import('telegram/sessions/index.js'));
|
||||
} catch {
|
||||
console.error('缺少 telegram 依赖,请先在容器里执行:npm install telegram');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TelegramClient(new StringSession(''), apiId, apiHash, {
|
||||
connectionRetries: 5,
|
||||
});
|
||||
console.log('正在连接 Telegram……(手机会收到验证码)');
|
||||
await client.start({
|
||||
phoneNumber: async () => phone,
|
||||
phoneCode: async () => (await ask('手机验证码:')).trim(),
|
||||
password: async () => (await ask('两步验证密码(没设直接回车):')).trim(),
|
||||
onError: (err) => console.log('提示:' + (err && err.message ? err.message : err)),
|
||||
});
|
||||
|
||||
const session = client.session.save();
|
||||
console.log('\n================ 登录成功 ================');
|
||||
console.log('把下面这段 Session 完整粘到面板「Telegram 签到」账号的 Session 栏:\n');
|
||||
console.log(session);
|
||||
console.log('\n==========================================');
|
||||
await client.disconnect();
|
||||
} catch (e) {
|
||||
console.error('登录失败:' + ((e && e.message) || e));
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
@@ -167,6 +167,7 @@ npx wrangler@latest deploy # ④ 部署,打印
|
||||
| Discuz 每日签到 | 论坛地址 + Cookie | 通用模块,适用于装 `k_misign` 插件的论坛 |
|
||||
| 天翼云盘 | 189 手机号 + 密码 | 登录前自动检测验证码;需要时先在官方 App/网页登录一次(关掉设备锁) |
|
||||
| 夸克网盘 | `kps` / `sign` / `vcode` | 手机抓包获取,约 2 个月过期 |
|
||||
| Telegram 签到 | API ID + API Hash + Session + 签到目标 | **Docker 版专属**(MTProto 要 TCP,Workers 跑不了)。给指定 TG 机器人/群发签到指令,Session 用 `docker/telegram-login.mjs` 登录一次拿到 |
|
||||
| 自定义 HTTP | URL/方法/头/体 + 判定规则 | 万能模块,支持多步录制。**没内置的站用它录一遍,再导出成社区配置分享** |
|
||||
|
||||
---
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "daily-checkin-panel",
|
||||
"version": "2.17.1",
|
||||
"version": "2.18.0",
|
||||
"private": true,
|
||||
"description": "跑在 Cloudflare Workers 上的自动签到面板:网页后台管账号 + D1 存储 + 定时签到 + 推送通知",
|
||||
"type": "module",
|
||||
@@ -16,5 +16,8 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"telegram": "^2.26.22"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,27 @@ export { applyCookieRefresh };
|
||||
// 执行路线的对外名字(面板「网站反馈」里会带上,排障时一眼能看出这次请求从哪个网络出去)
|
||||
export const ROUTE_NAME = { server: 'CF 直连', relay: '本地网络' };
|
||||
|
||||
// 是否跑在 Node.js 上(Docker 版 / 本地 node)。
|
||||
// Cloudflare Workers 里没有 process.versions.node(且 navigator.userAgent 带 Cloudflare-Workers),
|
||||
// 需要 TCP 长连接的站点(如 Telegram 的 MTProto)只能在这里返回 true 的环境跑。
|
||||
export function isNodeRuntime() {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && /cloudflare-workers/i.test(navigator.userAgent || '')) return false;
|
||||
return typeof process !== 'undefined' && !!(process.versions && process.versions.node);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 纯函数:要不要因为「需要 Node.js」拦截这个站点。onNode 由调用方传 isNodeRuntime(),
|
||||
// 抽出来是为了单测能确定性地覆盖 Workers/Node 两种分支。
|
||||
export function needNodeSkip(site, onNode) {
|
||||
if (site && site.requiresNode && !onNode) {
|
||||
return `${site.name}:需要 Docker 版才能跑(要 TCP 长连接,Cloudflare Workers 没有)。请用 docker compose 部署后再启用此账号,步骤见 docker/README.md「Telegram 签到」。`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 把一条路线的失败压成几个字,给面板上那一行用。
|
||||
//
|
||||
// 为什么要压:面板「网站反馈」列一行只有两百多像素宽,把 `HTTP 403 Forbidden
|
||||
@@ -199,6 +220,23 @@ export async function runAccount(env, account) {
|
||||
try {
|
||||
const site = getSite(account.site, customSites);
|
||||
if (!site) throw new Error('未知站点:' + account.site);
|
||||
// 需要 Node.js 的站点(如 Telegram 签到用的 MTProto 要 TCP 长连接):
|
||||
// Cloudflare Workers 没有 TCP,进路线解析没有意义 —— 直接记一条说清楚的失败,
|
||||
// 不重试(retryable: false),免得每 15 分钟空转一次还写一堆看不懂的日志。
|
||||
const needNodeMsg = needNodeSkip(site, isNodeRuntime());
|
||||
if (needNodeMsg) {
|
||||
const now = Date.now();
|
||||
const duration = now - t0;
|
||||
await db
|
||||
.prepare('INSERT INTO runs(account_id, site, name, status, message, detail, duration_ms, created_at) VALUES(?,?,?,?,?,?,?,?)')
|
||||
.bind(account.id, account.site, account.name, 'fail', needNodeMsg, '', duration, now)
|
||||
.run();
|
||||
await db
|
||||
.prepare('UPDATE accounts SET last_status=?, last_msg=?, last_run_at=?, meta=?, updated_at=? WHERE id=?')
|
||||
.bind('fail', needNodeMsg, now, JSON.stringify(meta), now, account.id)
|
||||
.run();
|
||||
return { status: 'fail', message: needNodeMsg, duration_ms: duration, retryable: false };
|
||||
}
|
||||
// 先把凭据解出来:路线默认值可能是**按站点地址**决定的(见下面的 executionFor),
|
||||
// 同一个站点模块可能管着多个域名,默认路线不一定相同。
|
||||
const creds = await decryptJSON(env, db, account.creds);
|
||||
|
||||
+6
-1
@@ -13,8 +13,9 @@ import { kanxue } from './kanxue.js';
|
||||
import { wuaipojie } from './wuaipojie.js';
|
||||
import { v2board } from './v2board.js';
|
||||
import { hutue } from './hutue.js';
|
||||
import { telegram } from './telegram.js';
|
||||
|
||||
export const SITES = [quark, cloud189, nodeseek, akile, v2ex, misign, kanxue, wuaipojie, v2board, hutue, httpTask];
|
||||
export const SITES = [quark, cloud189, nodeseek, akile, v2ex, misign, kanxue, wuaipojie, v2board, hutue, telegram, httpTask];
|
||||
|
||||
// 内置站点 + 社区导入的站点(声明式配置,见 src/community.js)
|
||||
// custom 由调用方从 D1 读出后传进来(保持本文件无副作用,测试也好写)。
|
||||
@@ -44,6 +45,7 @@ const LOGIN_HINTS = {
|
||||
wuaipojie: { kind: 'cookie', captcha: 'slider', url: 'https://www.52pojie.cn/member.php?mod=logging&action=login', note: '需先过一次网宿滑块安全验证,再登录' },
|
||||
v2board: { kind: 'password', captcha: 'maybe', url: '', note: '用机场域名打开登录页' },
|
||||
hutue: { kind: 'cookie', captcha: 'maybe', url: '', note: '登录后页面右侧会出现签到悬浮窗,用扩展抓 Cookie 即可' },
|
||||
telegram: { kind: 'none', captcha: 'none', url: 'https://my.telegram.org', note: 'Docker 版专属:先去 my.telegram.org 建应用,再用 docker/telegram-login.mjs 登录拿 Session' },
|
||||
http: { kind: 'cookie', captcha: 'maybe', url: '', note: '' },
|
||||
};
|
||||
|
||||
@@ -60,6 +62,9 @@ export function siteMeta(custom = []) {
|
||||
toggles: s.toggles || [],
|
||||
execution: s.execution || 'server', // 默认执行模式
|
||||
domain: s.domain || '', // 浏览器执行时的目标域名
|
||||
// 需要 Node.js 才能跑(如 Telegram 的 MTProto 要 TCP 长连接):
|
||||
// runner 在 Cloudflare Workers 上会直接给明确提示,不空转重试
|
||||
requiresNode: !!s.requiresNode,
|
||||
// 站点自己的「一天」以哪个时区算(空 = 跟面板设置一致)。
|
||||
// 面板状态列的「已签到/未签到」用它判定,站点日界不同时才不会出现假签到。
|
||||
dayTz: s.dayTz || '',
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Telegram 群/机器人签到 —— Docker 版专属。
|
||||
//
|
||||
// 原理:用 MTProto(gramjs)以「你的 Telegram 账号」身份登录,给指定的
|
||||
// bot/群发签到指令(如 /checkin),读 bot 的回执判定成功还是已签到。
|
||||
//
|
||||
// 为什么只能跑 Docker:MTProto 需要 TCP 长连接,Cloudflare Workers 没有
|
||||
// TCP socket,在 Workers 上这个模块根本连不上。所以模块声明了 requiresNode,
|
||||
// runner 在 Workers 上会直接跳过并提示换 Docker 版(不会每 15 分钟空重试)。
|
||||
//
|
||||
// 首次使用(一次性):
|
||||
// 1. https://my.telegram.org → API development tools → 建应用 → 拿到 api_id / api_hash
|
||||
// 2. docker exec -it <容器名> node docker/telegram-login.mjs ← 按提示登录,输出 Session
|
||||
// 3. 面板「添加账号」选 Telegram 签到,把 Session 粘进去
|
||||
// 详细步骤见 docker/README.md「Telegram 签到」一节。
|
||||
|
||||
// 等待回执的超时(秒):bot 一般几秒内就回,45 秒没回就当这次没戏
|
||||
const REPLY_TIMEOUT_MS = 45000;
|
||||
|
||||
function splitKeywords(raw, fallback) {
|
||||
const list = String(raw || '')
|
||||
.split('|')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return list.length ? list : fallback;
|
||||
}
|
||||
|
||||
function hitKeywords(text, keywords) {
|
||||
const t = String(text || '');
|
||||
return keywords.some((k) => k && t.includes(k));
|
||||
}
|
||||
|
||||
// 按 bot 回执判结论。返回 { ok, message, detail },message 是一句话结论。
|
||||
export function judgeTelegramReply(text, creds) {
|
||||
const doneKw = splitKeywords(creds.done_keywords, ['已签到', '已经签到', '重复签到', 'already']);
|
||||
const okKw = splitKeywords(creds.ok_keywords, ['签到成功', '成功', '领取成功']);
|
||||
const detail = `TG 回执:${String(text || '').slice(0, 300)}`;
|
||||
if (hitKeywords(text, doneKw)) {
|
||||
return { ok: true, message: '今日已签到', detail };
|
||||
}
|
||||
if (hitKeywords(text, okKw)) {
|
||||
return { ok: true, message: '签到成功', detail };
|
||||
}
|
||||
const err = new Error(`签到结果未知:${String(text || '').slice(0, 80) || 'bot 没有回复可识别的内容'}`);
|
||||
err.detail = detail;
|
||||
err.judged = true; // 已经是最终结论,run() 里不要再包一层
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 默认客户端工厂:真连 Telegram(gramjs)。动态 import ——
|
||||
// 顶层静态 import 'telegram' 会让 Cloudflare Workers 在加载站点注册表时就炸,
|
||||
// 而这个模块在 Workers 上只是「被注册、从不被执行」。
|
||||
async function defaultTgFactory({ apiId, apiHash, session }) {
|
||||
const [{ TelegramClient }, { StringSession }, { NewMessage }] = await Promise.all([
|
||||
import('telegram'),
|
||||
import('telegram/sessions/index.js'),
|
||||
import('telegram/events/index.js'),
|
||||
]);
|
||||
const client = new TelegramClient(new StringSession(session), apiId, apiHash, {
|
||||
connectionRetries: 3,
|
||||
});
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
disconnect: () => client.disconnect(),
|
||||
// 先挂好回执监听再发指令:回来的是「发指令之后、对方发来的第一条文字消息」
|
||||
sendAndWaitReply: (target, message, timeoutMs) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (fn, val) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
try { client.removeEventHandler(handler); } catch { /* 忽略 */ }
|
||||
fn(val);
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error(`等待签到回执超时(${Math.round(timeoutMs / 1000)}s),对方可能没回复`)),
|
||||
timeoutMs
|
||||
);
|
||||
const handler = async (event) => {
|
||||
const msg = event && event.message;
|
||||
if (!msg || msg.out) return; // 只要对方发来的(过滤掉自己发出去的指令回显)
|
||||
const text = msg.text || msg.message || '';
|
||||
if (!text) return;
|
||||
finish(resolve, String(text));
|
||||
};
|
||||
client.addEventHandler(handler, new NewMessage({}));
|
||||
client.sendMessage(target, { message }).catch((e) => finish(reject, e));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const telegram = {
|
||||
id: 'telegram',
|
||||
name: 'Telegram 签到',
|
||||
desc: '给指定 TG 机器人/群发签到指令。Docker 版专属(MTProto 要 TCP,Workers 跑不了)。',
|
||||
// 在 Docker 里走本机直连即可;Workers 上会被 runner 提前拦截(requiresNode)
|
||||
execution: 'server',
|
||||
// 需要 Node.js(TCP 长连接):runner 在非 Node 环境直接给明确提示,不空转重试
|
||||
requiresNode: true,
|
||||
domain: 'telegram.org',
|
||||
fields: [
|
||||
{
|
||||
key: 'api_id',
|
||||
label: 'API ID',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'my.telegram.org 申请到的数字 ID',
|
||||
},
|
||||
{
|
||||
key: 'api_hash',
|
||||
label: 'API Hash',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'my.telegram.org 申请到的 hash',
|
||||
},
|
||||
{
|
||||
key: 'session',
|
||||
label: 'Session',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
placeholder: 'docker exec -it 容器名 node docker/telegram-login.mjs 登录后输出的那段',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: '签到目标',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '@机器人用户名 或 群链接,如 @checkin_bot',
|
||||
},
|
||||
{
|
||||
key: 'command',
|
||||
label: '签到指令',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '/checkin(默认)',
|
||||
},
|
||||
{
|
||||
key: 'ok_keywords',
|
||||
label: '成功关键词',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '签到成功(多个用 | 分隔)',
|
||||
},
|
||||
{
|
||||
key: 'done_keywords',
|
||||
label: '已签到关键词',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '已签到(多个用 | 分隔)',
|
||||
},
|
||||
],
|
||||
tips: 'Docker 版专属:先去 my.telegram.org 建应用拿 api_id/api_hash,再用 docker/telegram-login.mjs 登录一次拿到 Session,粘到上面。Session 等于你的 TG 登录态,面板加密存储,不要外传。',
|
||||
login: {
|
||||
kind: 'none',
|
||||
captcha: 'none',
|
||||
url: 'https://my.telegram.org',
|
||||
note: '先去 my.telegram.org 建应用(拿 api_id/api_hash),再用登录脚本拿 Session',
|
||||
},
|
||||
|
||||
async run(creds, ctx) {
|
||||
const apiId = Number(creds.api_id);
|
||||
const apiHash = String(creds.api_hash || '').trim();
|
||||
const session = String(creds.session || '').trim();
|
||||
const target = String(creds.target || '').trim();
|
||||
const command = String(creds.command || '').trim() || '/checkin';
|
||||
if (!apiId || !apiHash) {
|
||||
const err = new Error('请先填好 API ID / API Hash(my.telegram.org 申请)');
|
||||
err.detail = '';
|
||||
throw err;
|
||||
}
|
||||
if (!session) {
|
||||
const err = new Error('请先填 Session:docker exec -it 容器名 node docker/telegram-login.mjs 登录后会输出');
|
||||
err.detail = '';
|
||||
throw err;
|
||||
}
|
||||
if (!target) {
|
||||
const err = new Error('请填写签到目标(@机器人用户名 或 群链接)');
|
||||
err.detail = '';
|
||||
throw err;
|
||||
}
|
||||
// 测试注入点:单测传 ctx.tgFactory 伪造客户端,避免真连 Telegram
|
||||
const factory = (ctx && ctx.tgFactory) || defaultTgFactory;
|
||||
let client;
|
||||
try {
|
||||
client = await factory({ apiId, apiHash, session });
|
||||
} catch (e) {
|
||||
const err = new Error(`Telegram 客户端初始化失败:${e.message || e}(Docker 镜像里是否装了 telegram 依赖?)`);
|
||||
err.detail = String((e && e.stack) || e).slice(0, 300);
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await client.connect();
|
||||
const reply = await client.sendAndWaitReply(target, command, REPLY_TIMEOUT_MS);
|
||||
return judgeTelegramReply(reply, creds);
|
||||
} catch (e) {
|
||||
if (e && e.judged) throw e; // judgeTelegramReply 已经给了最终结论,原样抛出
|
||||
// 连接/授权类错误给明确指引
|
||||
const m = String((e && e.message) || e || '');
|
||||
if (/auth|unauthor|SESSION_REVOKED|expired/i.test(m)) {
|
||||
const err = new Error('TG 登录已失效,请用 docker/telegram-login.mjs 重新登录拿新的 Session');
|
||||
err.detail = m.slice(0, 300);
|
||||
throw err;
|
||||
}
|
||||
const err = new Error(`TG 签到失败:${m.slice(0, 80)}`);
|
||||
err.detail = m.slice(0, 300);
|
||||
throw err;
|
||||
} finally {
|
||||
try { await client.disconnect(); } catch { /* 忽略 */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
// · 测试会断言 package.json 的 version 和它一致(改了这里忘了改那边会直接报红)
|
||||
// 扩展的版本在 public/ext-src/manifest.json 里(它有自己的发布节奏:改过扩展代码,
|
||||
// 用户就得重新下载 + 在 chrome://extensions 点一次「重新加载」,所以必须单独能看出来)。
|
||||
export const PANEL_VERSION = '2.17.1';
|
||||
export const PANEL_VERSION = '2.18.0';
|
||||
@@ -0,0 +1,90 @@
|
||||
// docker/adapter.mjs 的单测:D1 兼容层 + _headers 解析 + MIME。
|
||||
// 用法:node test/docker.test.mjs(tools/verify.mjs 会自动跑)
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { createD1, parseHeadersFile, matchHeaders, mimeFor } from '../docker/adapter.mjs';
|
||||
|
||||
test('D1:建表/插入/查询/更新/删除', () => {
|
||||
const db = createD1(':memory:');
|
||||
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, n INTEGER)');
|
||||
const r1 = db.prepare('INSERT INTO t (name, n) VALUES (?, ?)').bind('a', 1).run();
|
||||
assert.equal(r1.success, true);
|
||||
assert.equal(r1.meta.last_row_id, 1);
|
||||
const r2 = db.prepare('INSERT INTO t (name, n) VALUES (?, ?)').bind('b', 2).run();
|
||||
assert.equal(r2.meta.last_row_id, 2);
|
||||
|
||||
const all = db.prepare('SELECT * FROM t ORDER BY id').all();
|
||||
assert.equal(all.results.length, 2);
|
||||
assert.equal(all.results[0].name, 'a');
|
||||
|
||||
const one = db.prepare('SELECT name FROM t WHERE id = ?').bind(2).first();
|
||||
assert.equal(one.name, 'b');
|
||||
|
||||
const none = db.prepare('SELECT name FROM t WHERE id = ?').bind(99).first();
|
||||
assert.equal(none, null); // D1 无行返回 null,不是 undefined
|
||||
|
||||
const up = db.prepare('UPDATE t SET n = ? WHERE id = ?').bind(9, 1).run();
|
||||
assert.equal(up.meta.changes, 1);
|
||||
db.prepare('DELETE FROM t WHERE id = ?').bind(2).run();
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM t').first().c, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('D1:undefined/boolean 转义成 SQLite 能接受的值', () => {
|
||||
const db = createD1(':memory:');
|
||||
db.exec('CREATE TABLE t (a TEXT, b INTEGER)');
|
||||
db.prepare('INSERT INTO t (a, b) VALUES (?, ?)').bind(undefined, true).run();
|
||||
const row = db.prepare('SELECT * FROM t').first();
|
||||
assert.equal(row.a, null);
|
||||
assert.equal(row.b, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('D1:batch 是事务性的,失败整体回滚', () => {
|
||||
const db = createD1(':memory:');
|
||||
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT UNIQUE)');
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').bind('keep').run();
|
||||
const stmts = [
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').bind('ok1'),
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').bind('keep'), // UNIQUE 冲突
|
||||
];
|
||||
assert.throws(() => db.batch(stmts));
|
||||
// ok1 也被回滚了,只剩原来那条
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM t').first().c, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('D1:batch 成功返回每条 run 的结果', () => {
|
||||
const db = createD1(':memory:');
|
||||
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
|
||||
const out = db.batch([
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').bind('x'),
|
||||
db.prepare('INSERT INTO t (v) VALUES (?)').bind('y'),
|
||||
]);
|
||||
assert.equal(out.length, 2);
|
||||
assert.ok(out.every((r) => r.success));
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM t').first().c, 2);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('_headers:通配 + 精确路径都能匹配', () => {
|
||||
const rules = parseHeadersFile([
|
||||
'# 注释',
|
||||
'/*',
|
||||
' X-Frame-Options: DENY',
|
||||
'',
|
||||
'/a.zip',
|
||||
' Cache-Control: no-store',
|
||||
].join('\n'));
|
||||
const root = matchHeaders(rules, '/index.html');
|
||||
assert.ok(root.some(([k, v]) => k === 'X-Frame-Options' && v === 'DENY'));
|
||||
assert.equal(matchHeaders(rules, '/a.zip').length, 2);
|
||||
assert.equal(matchHeaders(rules, '/b.zip').length, 1); // 只有通配
|
||||
});
|
||||
|
||||
test('mimeFor:常见后缀', () => {
|
||||
assert.equal(mimeFor('a.html'), 'text/html; charset=utf-8');
|
||||
assert.equal(mimeFor('a.JS'), 'text/javascript; charset=utf-8');
|
||||
assert.equal(mimeFor('a.svg'), 'image/svg+xml');
|
||||
assert.equal(mimeFor('a.unknownext'), 'application/octet-stream');
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Telegram 签到模块测试:node test/telegram.test.mjs(纯 mock,不连 Telegram)
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { telegram, judgeTelegramReply } from '../src/sites/telegram.js';
|
||||
import { nodeseek } from '../src/sites/nodeseek.js';
|
||||
import { SITES, siteMeta } from '../src/sites/index.js';
|
||||
import { isNodeRuntime, needNodeSkip } from '../src/runner.js';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
let n = 0;
|
||||
const t = async (name, fn) => { await fn(); n++; console.log('ok -', name); };
|
||||
|
||||
// 伪造的 TG 客户端:按剧本回 reply,或抛 error
|
||||
function fakeFactory({ reply, error }) {
|
||||
return async () => ({
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
sendAndWaitReply: async (target, message) => {
|
||||
assert.equal(target, '@checkin_bot');
|
||||
assert.equal(message, '/checkin');
|
||||
if (error) throw error;
|
||||
return reply;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const creds = {
|
||||
api_id: '12345',
|
||||
api_hash: 'hash',
|
||||
session: 'sess',
|
||||
target: '@checkin_bot',
|
||||
command: '/checkin',
|
||||
};
|
||||
|
||||
await t('站点已注册:requiresNode=true,siteMeta 透出', () => {
|
||||
assert.ok(SITES.find((s) => s.id === 'telegram'), 'SITES 里要有 telegram');
|
||||
const meta = siteMeta().find((s) => s.id === 'telegram');
|
||||
assert.equal(meta.requiresNode, true);
|
||||
assert.ok(meta.fields.find((f) => f.key === 'session'), '要有 Session 字段');
|
||||
assert.ok(meta.fields.find((f) => f.key === 'target'), '要有签到目标字段');
|
||||
});
|
||||
|
||||
await t('isNodeRuntime:node 下为 true', () => {
|
||||
assert.equal(isNodeRuntime(), true);
|
||||
});
|
||||
|
||||
await t('needNodeSkip:Workers 下拦截 telegram,Node 下放行,普通站点不受影响', () => {
|
||||
const onWorkers = needNodeSkip(telegram, false);
|
||||
assert.match(onWorkers, /需要 Docker 版/);
|
||||
assert.match(onWorkers, /Telegram 签到/);
|
||||
assert.equal(needNodeSkip(telegram, true), null);
|
||||
assert.equal(needNodeSkip(nodeseek, false), null);
|
||||
});
|
||||
|
||||
await t('回执判定:成功关键词', () => {
|
||||
const r = judgeTelegramReply('签到成功,获得 10GB 流量', creds);
|
||||
assert.equal(r.ok, true);
|
||||
assert.match(r.message, /签到成功/);
|
||||
assert.match(r.detail, /TG 回执/);
|
||||
});
|
||||
|
||||
await t('回执判定:已签到', () => {
|
||||
const r = judgeTelegramReply('你今天已经签到过了', creds);
|
||||
assert.equal(r.ok, true);
|
||||
assert.match(r.message, /已签到/);
|
||||
});
|
||||
|
||||
await t('回执判定:自定义关键词', () => {
|
||||
const r = judgeTelegramReply('checkin done', { ...creds, ok_keywords: 'done|finished' });
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
await t('回执判定:未知回执抛错且带 judged 标记(run 里不再包第二层)', () => {
|
||||
assert.throws(
|
||||
() => judgeTelegramReply('哈哈哈', creds),
|
||||
(e) => {
|
||||
assert.equal(e.judged, true);
|
||||
assert.match(e.message, /签到结果未知/);
|
||||
assert.match(e.detail, /哈哈哈/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await t('run:成功', async () => {
|
||||
const r = await telegram.run(creds, { tgFactory: fakeFactory({ reply: '签到成功' }) });
|
||||
assert.equal(r.ok, true);
|
||||
assert.match(r.message, /签到成功/);
|
||||
});
|
||||
|
||||
await t('run:未知回执原样抛出(不包"TG 签到失败"第二层)', async () => {
|
||||
await assert.rejects(
|
||||
telegram.run(creds, { tgFactory: fakeFactory({ reply: '哈哈哈' }) }),
|
||||
(e) => {
|
||||
assert.match(e.message, /^签到结果未知/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await t('run:缺 Session 给明确指引', async () => {
|
||||
await assert.rejects(telegram.run({ ...creds, session: '' }, {}), /Session/);
|
||||
});
|
||||
|
||||
await t('run:缺签到目标给明确指引', async () => {
|
||||
await assert.rejects(telegram.run({ ...creds, target: '' }, {}), /签到目标/);
|
||||
});
|
||||
|
||||
await t('run:依赖缺失给明确指引', async () => {
|
||||
const badFactory = async () => {
|
||||
throw new Error("Cannot find package 'telegram'");
|
||||
};
|
||||
await assert.rejects(telegram.run(creds, { tgFactory: badFactory }), /telegram 依赖/);
|
||||
});
|
||||
|
||||
await t('run:授权失效给重登指引', async () => {
|
||||
const authFactory = async () => ({
|
||||
connect: async () => {
|
||||
throw new Error('AUTH_KEY_UNREGISTERED');
|
||||
},
|
||||
disconnect: async () => {},
|
||||
sendAndWaitReply: async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
});
|
||||
await assert.rejects(telegram.run(creds, { tgFactory: authFactory }), /重新登录/);
|
||||
});
|
||||
|
||||
await t('docker-compose:IPv6 修复——默认 host 网络模式', () => {
|
||||
const yml = readFileSync(join(root, 'docker-compose.yml'), 'utf8');
|
||||
assert.match(yml, /network_mode:\s*["']?host["']?/);
|
||||
});
|
||||
|
||||
console.log(`\n全部 ${n} 组通过`);
|
||||
Reference in new issue
Block a user