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:
hani committed 2026-09-30 12:13:57 +08:00
1 parent f97392a359
commit f00eb407e2
19 files changed
+1137 -5

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# 构建上下文瘦身:镜像里不需要这些
.git
.github
.gitignore
node_modules
data/
test/
tools/
.vscode
*.md
LICENSE
+13
View File
@@ -0,0 +1,13 @@
# 复制为 .env 后填写:cp .env.example .env
# 可选,但强烈建议设置:账号凭据的加密密钥。
# 生成:openssl rand -base64 32
# 不填也行:面板首次使用时会自动生成一把存进数据库(和 Cloudflare 上不配 Secret 一个行为)。
# 警告:一旦投入使用就不要改(改了已存账号凭据解不开);备份好 ./data 目录。
ENCRYPT_KEY=
# 可选:面板端口(默认 8787)
PORT=8787
# 可选:时区,只影响容器日志时间显示;签到时间走面板「设置」页
TZ=Asia/Shanghai
+64
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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"]
+2 -1
View File
@@ -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 一键部署脚本
+42
View File
@@ -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
+106
View File
@@ -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]`。
+184
View File
@@ -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 });
},
};
}
+130
View File
@@ -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'));
+56
View File
@@ -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();
}
+1
View File
@@ -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
View File
@@ -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"
}
}
+38
View File
@@ -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
View File
@@ -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 || '',
+211
View File
@@ -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
View File
@@ -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';
+90
View File
@@ -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');
});
+137
View File
@@ -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} 组通过`);