Skip to content

Commit 481f327

Browse files
committed
Extract Telegram login and three one-shot check-ins
0 parents  commit 481f327

30 files changed

Lines changed: 1993 additions & 0 deletions

‎.gitattributes‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* text=auto eol=lf

‎.gitignore‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.venv/
2+
__pycache__/
3+
*.py[cod]
4+
*.egg-info/
5+
.pytest_cache/
6+
.ruff_cache/
7+
build/
8+
dist/
9+
config.toml
10+
config.*.toml
11+
!config.example.toml
12+
data/
13+
*.session*
14+
*.login
15+
*.log
16+
.env*
17+
!*.example

‎LICENSE‎

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

‎NOTICE.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# 来源与范围
2+
3+
本项目遵循 GPL-3.0,参考并抽离自 [Embykeeper](https://github.com/emby-keeper/emby-keeper),
4+
源码基准提交为 `facccab7001758377a4b8e332d42f162df68e83d`。
5+
保留原作者的许可;三份站点定义来自其 `embykeeper/telegram/checkiner/`,
6+
通用流程依据 Template A 行为重新缩减实现。
7+
8+
当前仅保留:惊蛰(jingzhe)、非越助手(sfcju)、Saturday(saturday)。
9+
非越助手沿用已有部署中的 `fyemby_bot` 地址;可通过站点配置覆盖机器人用户名。
10+
登录采用原项目的 Telethon 首次登录 → Pyrogram 会话转换方式,以及相同的客户端身份默认值。
11+
不包含媒体播放、认证机器人、通知机器人、AI 回答、自动注册、自动水群、内部定时器。

‎README.md‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# my-signer
2+
3+
登录 Telegram,执行一次签到,退出。由青龙控制定时。
4+
5+
当前内置三个站点,共用同一个 Template A 流程:
6+
7+
| 配置名 | 机器人 | 图片验证码 |
8+
| --- | --- | --- |
9+
| `jingzhe` | `@JingzheProbot` | 本地 OCR |
10+
| `sfcju` | `@fyemby_bot` | 不识别图片 |
11+
| `saturday` | `@saturday_lite_bot` | 本地 OCR |
12+
13+
流程:发送 `/start` → 点击“签到/簽到” → 如有图片验证码则本地识别 → 判断成功或今日已签到。
14+
支持按钮回调结果、新消息和编辑消息。失败返回非零退出码,结果保存在 `data/last-run.json`。
15+
不会联系 Embykeeper 的认证/通知 Bot,不包含观看保活、AI、自动注册、自动水群或内部定时器。
16+
17+
## 安装
18+
19+
Python 3.11 或更高版本:
20+
21+
```bash
22+
git clone https://github.com/jiazaiwanbi/my-signer.git
23+
cd my-signer
24+
python3 -m venv .venv
25+
.venv/bin/pip install -e '.[ocr]'
26+
cp config.example.toml config.toml
27+
```
28+
29+
修改 `config.toml`,填写手机号和自己的 [Telegram API 凭据](https://my.telegram.org/apps)。
30+
先在 Telegram 中完成三个站点的注册或绑定,再进行签到。
31+
32+
## 登录和运行
33+
34+
```bash
35+
# 首次登录:交互式终端输入验证码和两步验证密码
36+
.venv/bin/python run.py login --account main
37+
38+
# 验证已有会话,不发送签到消息
39+
.venv/bin/python run.py status
40+
41+
# 执行一次后退出;没有子命令时默认执行 run
42+
.venv/bin/python run.py
43+
44+
# 只运行一个站点
45+
.venv/bin/python run.py run --sites sfcju
46+
```
47+
48+
配置文件默认是项目目录下的 `config.toml`。指定其他位置:
49+
50+
```bash
51+
.venv/bin/python run.py --config /path/to/config.toml run
52+
```
53+
54+
## 从原青龙 Embykeeper 迁移
55+
56+
**先停止并禁用原任务**,确认没有 Embykeeper 进程,然后在同一台服务器内执行:
57+
58+
```bash
59+
.venv/bin/python run.py migrate-embykeeper --source /ql/data/scripts/embykeeper
60+
.venv/bin/python run.py status
61+
```
62+
63+
迁移只在本机读取原配置和 `data/cache.json`,保留原会话的授权密钥及 API 凭据,不发起新登录,
64+
不修改原项目。目标已有配置或会话时会拒绝覆盖。迁移文件均在 Git 忽略范围内。
65+
66+
## 青龙
67+
68+
首次登录可通过 `docker exec -it ql-web-1 bash` 进入容器,再执行上述命令。
69+
在青龙中配置:
70+
71+
```text
72+
名称:my-signer
73+
命令:/ql/data/scripts/my-signer/.venv/bin/python /ql/data/scripts/my-signer/run.py
74+
定时:0 8 * * *
75+
允许多实例:关闭
76+
```
77+
78+
程序有文件互斥锁,重叠运行退出码为 `75`;每次运行完即断开连接,不在后台等待明天。
79+
站点失败退出码为 `1`,全部成功或已签到时为 `0`。
80+
81+
## 添加同类机器人
82+
83+
无需新增 Python 文件,在 `sites` 中加入 `"templ_a<ExampleBot>"`,按需添加:
84+
85+
```toml
86+
[checkiner."templ_a<ExampleBot>"]
87+
command = "/start"
88+
button_keywords = ["签到", "簽到"]
89+
use_captcha = true
90+
success_pattern = "签到成功"
91+
# 图片标题匹配时才识别;不填写则识别收到的图片
92+
# captcha_pattern = "请输入验证码"
93+
```
94+
95+
这是相同交互流程的通用器;网页验证码、答题或其他交互需要另写适配。
96+
不认识的返回内容会等待本轮超时,不会猜测回答或擅自访问外部服务。
97+
98+
内置站点的机器人地址变更时,可以在对应站点配置中设置 `bot_username`,不必修改源码。
99+
100+
## Telegram 会话处理
101+
102+
原项目 [Issue #67](https://github.com/emby-keeper/emby-keeper/issues/67) 记录过全设备登出问题,
103+
并改为 Telethon 首次登录。本项目保留这条登录路径、会话格式转换和客户端身份默认值。
104+
签到使用 Pyrogram 兼容分支 Kurigram,复用同一个授权,每个账号只建一个连接;退出时只断开连接。
105+
运行期间不重新登录、不调用登出或终止其他设备的方法。会话失效会停止,只有显式 `login --replace` 才创建新会话。
106+
107+
同一数据目录的运行互斥;配置中重复授权密钥也会被拒绝。
108+
不要在原项目、新项目或不同服务器上同时使用同一个会话。
109+
[Telegram 文档](https://core.telegram.org/api/errors#406-not-acceptable) 说明重复主连接可能触发 `AUTH_KEY_DUPLICATED` 并使该授权失效。
110+
这些处理不能保证 Telegram 永不触发自身的风控。
111+
112+
手机号、API hash、会话和运行日志请保存在本机,仓库只包含示例。
113+
114+
## 测试
115+
116+
```bash
117+
.venv/bin/pip install -e '.[dev]'
118+
.venv/bin/python -m pytest
119+
```
120+
121+
自动测试使用模拟机器人,覆盖按钮回调、消息结果、本地验证码、超时清理、互斥锁、会话转换和迁移。
122+
123+
## 许可
124+
125+
GPL-3.0,来源和修改范围见 [NOTICE.md](NOTICE.md)。

‎config.example.toml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
state_dir = "data"
2+
sites = ["jingzhe", "sfcju", "saturday"]
3+
4+
[checkiner]
5+
timeout = 240
6+
retries = 4
7+
concurrency = 1
8+
max_duration = 900
9+
10+
[[accounts]]
11+
name = "main"
12+
phone = "+12345678900"
13+
# 在 https://my.telegram.org/apps 创建自己的应用后填写:
14+
api_id = 12345
15+
api_hash = "00000000000000000000000000000000"
16+
17+
# 如需代理,取消注释:
18+
# [proxy]
19+
# scheme = "socks5"
20+
# hostname = "127.0.0.1"
21+
# port = 1080
22+
23+
# 同类机器人可以直接加到 sites,例如:"templ_a<ExampleBot>"
24+
# [checkiner."templ_a<ExampleBot>"]
25+
# command = "/start"
26+
# button_keywords = ["签到", "簽到"]
27+
# use_captcha = true
28+
# success_pattern = "签到成功"

‎my_signer/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Telegram login and one-shot check-ins."""
2+
__version__ = "0.1.0"

‎my_signer/__main__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .cli import main
2+
3+
main()

‎my_signer/cli.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import argparse
2+
import asyncio
3+
from pathlib import Path
4+
import sys
5+
6+
from loguru import logger
7+
8+
from .config import Settings
9+
from .locking import state_lock, AlreadyRunning
10+
from .registry import inventory
11+
12+
13+
def parser():
14+
root = argparse.ArgumentParser(description="Telegram 登录与单次签到;定时由青龙控制。")
15+
root.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[1] / "config.toml")
16+
sub = root.add_subparsers(dest="command")
17+
sub.add_parser("list-sites", help="列出内置签到器")
18+
run = sub.add_parser("run", help="签到一次后退出(默认)")
19+
run.add_argument("--sites", help="本次站点,逗号分隔;也支持 templ_a<bot_username>")
20+
run.add_argument("--account", help="只运行一个账号")
21+
status = sub.add_parser("status", help="只检查现有会话,不发送签到消息")
22+
status.add_argument("--account")
23+
login = sub.add_parser("login", help="使用 Telethon 交互式首次登录")
24+
login.add_argument("--account", required=True)
25+
login.add_argument("--replace", action="store_true", help="明确创建一个新会话并备份原会话")
26+
migration = sub.add_parser("migrate-embykeeper", help="在本机读取原配置和会话;先停用旧任务")
27+
migration.add_argument("--source", type=Path, required=True)
28+
return root
29+
30+
31+
async def execute(args, settings, accounts):
32+
from .sessions import connected, login, check_unique_sessions
33+
if args.command == "login":
34+
await login(settings, accounts[0], args.replace)
35+
return 0
36+
if args.command == "status":
37+
check_unique_sessions(settings, accounts)
38+
for account in accounts:
39+
async with connected(settings, account):
40+
logger.info("{}: 现有 Telegram 会话有效。", account.name)
41+
return 0
42+
from .runner import run
43+
override = [s.strip() for s in args.sites.split(",") if s.strip()] if getattr(args, "sites", None) else None
44+
return await run(settings, accounts, override)
45+
46+
47+
def main(argv=None):
48+
logger.remove()
49+
logger.add(sys.stdout, format="{time:YYYY-MM-DD HH:mm:ss} | {level: <7} | {message}", colorize=False)
50+
args = parser().parse_args(argv)
51+
try:
52+
if args.command == "list-sites":
53+
for item in inventory():
54+
print(item["id"])
55+
raise SystemExit(0)
56+
if args.command == "migrate-embykeeper":
57+
from .migrate import migrate
58+
count = migrate(args.source, args.config)
59+
logger.info("已迁移 {} 个账号的配置和会话;没有发起新登录。", count)
60+
raise SystemExit(0)
61+
settings = Settings.load(args.config)
62+
accounts = [a for a in settings.accounts if a.enabled and
63+
(not getattr(args, "account", None) or a.name == args.account)]
64+
if not accounts:
65+
raise ValueError("没有匹配的启用账号。")
66+
with state_lock(settings.state_dir):
67+
code = asyncio.run(execute(args, settings, accounts))
68+
raise SystemExit(code)
69+
except AlreadyRunning as exc:
70+
logger.warning("{}", exc)
71+
raise SystemExit(75)
72+
except KeyboardInterrupt:
73+
logger.warning("任务被中断。")
74+
raise SystemExit(130)
75+
except Exception as exc:
76+
# No traceback locals, session strings, phone numbers or credentials in normal logs.
77+
logger.error("{}: {}", type(exc).__name__, exc)
78+
raise SystemExit(1)

‎my_signer/config.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from pathlib import Path
2+
from types import SimpleNamespace
3+
import tomllib
4+
5+
from pydantic import BaseModel, ConfigDict, Field, field_validator
6+
7+
8+
class StrictModel(BaseModel):
9+
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
10+
11+
12+
class Proxy(StrictModel):
13+
scheme: str = "socks5"
14+
hostname: str
15+
port: int = Field(gt=0, le=65535)
16+
username: str | None = None
17+
password: str | None = None
18+
19+
@field_validator("scheme")
20+
@classmethod
21+
def valid_scheme(cls, value):
22+
if value not in {"socks5", "http"}:
23+
raise ValueError("proxy.scheme must be socks5 or http")
24+
return value
25+
26+
def pyrogram(self):
27+
return self.model_dump(exclude_none=True)
28+
29+
def url(self):
30+
from urllib.parse import quote
31+
auth = ""
32+
if self.username:
33+
auth = quote(self.username, safe="") + ":" + quote(self.password or "", safe="") + "@"
34+
return f"{self.scheme}://{auth}{self.hostname}:{self.port}"
35+
36+
37+
class Account(StrictModel):
38+
name: str = Field(pattern=r"^[A-Za-z0-9_-]+$")
39+
phone: str = Field(pattern=r"^\+\d{7,15}$")
40+
api_id: int = Field(gt=0)
41+
api_hash: str = Field(pattern=r"^[a-fA-F0-9]{32}$")
42+
enabled: bool = True
43+
sites: list[str] | None = None
44+
# Keep the identity used when creating/importing the authorization.
45+
device_model: str = "A320MH"
46+
system_version: str = "4.16.30-vxEmby"
47+
app_version: str = "7.6.1"
48+
49+
50+
class Checkiner(BaseModel):
51+
model_config = ConfigDict(extra="allow")
52+
timeout: int = Field(default=240, ge=10, le=3600)
53+
retries: int = Field(default=4, ge=1, le=10)
54+
concurrency: int = Field(default=1, ge=1, le=8)
55+
max_duration: int = Field(default=900, ge=30, le=7200)
56+
57+
def get_site_config(self, name):
58+
return self.model_extra.get(name, {})
59+
60+
61+
class Settings(StrictModel):
62+
state_dir: Path = Path("data")
63+
sites: list[str] = Field(default_factory=list)
64+
accounts: list[Account] = Field(default_factory=list)
65+
checkiner: Checkiner = Field(default_factory=Checkiner)
66+
proxy: Proxy | None = None
67+
68+
@classmethod
69+
def load(cls, path):
70+
path = Path(path).resolve()
71+
obj = cls.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
72+
if not obj.state_dir.is_absolute():
73+
obj.state_dir = path.parent / obj.state_dir
74+
names = [a.name for a in obj.accounts]
75+
if len(names) != len(set(names)):
76+
raise ValueError("Duplicate account names")
77+
config.basedir = obj.state_dir
78+
config.checkiner = obj.checkiner
79+
config.proxy = obj.proxy
80+
return obj
81+
82+
83+
# Small runtime settings object used by extracted adapters; no scheduler or watchers.
84+
config = SimpleNamespace(
85+
basedir=Path("data"), checkiner=Checkiner(), proxy=None,
86+
nofail=True,
87+
)

0 commit comments

Comments
 (0)