本章目录
第 3 章
从零做一个完整的机器人
跑通 hello world 和做出一个真东西之间有道坎。这一章跨过去——做一个签到打卡机器人:记得住谁签过、攒了多少分、谁排第一,重启也不丢。
先把想法拆成动作
第 1 章结尾让你想过一件事:把你要做的机器人拆成「用户做什么 → 它回什么」。现在正式做一遍,因为这一步决定了你要写几个函数。
我们要做的是签到打卡:每天签一次拿 10 分,能查自己的分,能看群里排名。拆开是这样:
| 用户发 | 它要做什么 | 它回什么 |
|---|---|---|
/start | 什么都不做 | 说明有哪些命令 |
/checkin | 记下"这人今天签过了",加 10 分 今天签过就不能再加 | 签到成功 + 当前积分 或者「今天已经签过了」 |
/me | 查这个人的积分和天数 | 他的战绩 |
/rank | 查积分前十 | 一个榜单 |
数据存哪
「今天签过了」这件事得记下来,而且要在程序重启之后还在。三个选择:
| 方案 | 问题 |
|---|---|
| Python 字典 | 写起来最省事,但进程一停数据全没。Ctrl+C 一次,大家的积分归零。 |
| 存 JSON 文件 | 能持久化了,但每次都得整个文件读出来改完再写回去。两个请求同时进来,后写的会覆盖先写的。 |
| SQLite | Python 自带,不用装任何东西,一个文件就是一个数据库,而且天然处理好了并发写。 |
项目长这样
在第 2 章那个目录里继续做就行,虚拟环境和 .env 都能接着用。三个 Python 文件:
my-bot/├── .env # token(第 2 章建的)├── .gitignore├── bot.py # 入口:启动、装配├── handlers.py # 业务:四个命令└── storage.py # 数据:只跟 SQLite 打交道
为什么拆三个文件而不是全写一起?因为业务逻辑和数据操作混在一起之后就分不开了。第 5 章要把 SQLite 换掉的时候,你只需要动 storage.py,handlers.py 一行都不用改。
数据层
先写 storage.py。两张表:一张记「谁哪天签过」,一张记「谁有多少分」。
import sqlite3from contextlib import contextmanagerfrom pathlib import PathDB_PATH = Path(__file__).parent / "data.db"@contextmanagerdef connect(): conn = sqlite3.connect(DB_PATH) try: yield conn conn.commit() finally: conn.close()def init_db() -> None: with connect() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS checkins ( user_id INTEGER NOT NULL, day TEXT NOT NULL, PRIMARY KEY (user_id, day) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS users ( user_id INTEGER PRIMARY KEY, name TEXT NOT NULL, points INTEGER NOT NULL DEFAULT 0 ) """)def check_in(user_id: int, name: str, day: str, reward: int) -> int | None: """签到。成功返回签到后的总分;今天已经签过返回 None。""" with connect() as conn: try: conn.execute( "INSERT INTO checkins (user_id, day) VALUES (?, ?)", (user_id, day), ) except sqlite3.IntegrityError: return None # 主键撞了 —— 今天签过了 conn.execute( """ INSERT INTO users (user_id, name, points) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET points = points + excluded.points, name = excluded.name """, (user_id, name, reward), ) row = conn.execute( "SELECT points FROM users WHERE user_id = ?", (user_id,) ).fetchone() return row[0]def get_user(user_id: int): with connect() as conn: return conn.execute( "SELECT name, points FROM users WHERE user_id = ?", (user_id,) ).fetchone()def count_days(user_id: int) -> int: with connect() as conn: return conn.execute( "SELECT COUNT(*) FROM checkins WHERE user_id = ?", (user_id,) ).fetchone()[0]def top_users(limit: int = 10): with connect() as conn: return conn.execute( "SELECT name, points FROM users " "ORDER BY points DESC, user_id ASC LIMIT ?", (limit,), ).fetchall()
这里面有一处值得停下来看
防止重复签到,最直觉的写法是「先查有没有,没有再插入」:
已经签过了 = conn.execute("SELECT ... WHERE user_id=? AND day=?").fetchone()if not 已经签过了: conn.execute("INSERT ...") # ← 这两步之间有个缝
问题在于这是两步。用户手快连点两下 /checkin,两条消息几乎同时到,两次查询都说「没签过」,于是两次都插入、两次都加分。
上面的写法换了个思路:直接插,让数据库拒绝我。
PRIMARY KEY (user_id, day) # 建表时就声明:这一对不能重复try: conn.execute("INSERT INTO checkins ...")except sqlite3.IntegrityError: return None # 数据库替你挡住了
判断和写入合成了一个动作,缝就没了。这类"让数据库来保证,而不是自己先查再写"的写法,是并发场景下的基本功。
我用 8 个线程同时调 check_in() 测过:成功 1 次,积分正好 +10。换成「先查再插」那种写法,同样的测试会加 8 次。
第 6 章会把这类问题讲透,现在你只要记住这个手法。
业务层
handlers.py —— 四个命令,每个都很短,因为脏活都在 storage.py 里干完了。
from datetime import datefrom aiogram import Routerfrom aiogram.filters import Commandfrom aiogram.types import Messageimport storageREWARD = 10router = Router()@router.message(Command("start"))async def cmd_start(message: Message) -> None: await message.answer( "签到机器人\n\n" "/checkin 每天签到一次,拿 10 分\n" "/me 看我的积分\n" "/rank 看排行榜" )@router.message(Command("checkin"))async def cmd_checkin(message: Message) -> None: if message.from_user is None: return points = storage.check_in( user_id=message.from_user.id, name=message.from_user.full_name, day=date.today().isoformat(), reward=REWARD, ) if points is None: await message.answer("今天已经签过了,明天再来") return days = storage.count_days(message.from_user.id) await message.answer( f"签到成功 +{REWARD}\n累计 {days} 天,当前 {points} 分" )@router.message(Command("me"))async def cmd_me(message: Message) -> None: if message.from_user is None: return row = storage.get_user(message.from_user.id) if row is None: await message.answer("你还没签过到,发 /checkin 试试") return name, points = row days = storage.count_days(message.from_user.id) await message.answer(f"{name}\n积分 {points}\n签到 {days} 天")@router.message(Command("rank"))async def cmd_rank(message: Message) -> None: rows = storage.top_users(10) if not rows: await message.answer("还没有人签到") return lines = ["积分榜", ""] for i, (name, points) in enumerate(rows, start=1): lines.append(f"{i}. {name} — {points}") await message.answer("\n".join(lines))
两个细节:
if message.from_user is None | 频道发的消息没有发送者。不判断的话,某天有人把 bot 加进频道,程序就会崩。 |
回复用纯文本,没设 parse_mode | 用户昵称里可能有 *、_、< 这类字符。一旦用了 Markdown 或 HTML 格式,这些字符会让整条消息发送失败。第 7 章讲怎么正确处理。 |
入口
bot.py 跟第 2 章那个几乎一样,多了两行:建表、挂上 handlers。
import asyncioimport loggingimport osfrom aiogram import Bot, Dispatcherfrom aiogram.client.session.aiohttp import AiohttpSessionfrom dotenv import load_dotenvimport storagefrom handlers import routerload_dotenv()logging.basicConfig(level=logging.INFO)async def main() -> None: token = os.getenv("BOT_TOKEN") if not token: raise SystemExit("没读到 BOT_TOKEN,检查 .env 文件") storage.init_db() # 表不存在就建,已存在就跳过 proxy = os.getenv("PROXY_URL") session = AiohttpSession(proxy=proxy) if proxy else None bot = Bot(token=token, session=session) dp = Dispatcher() dp.include_router(router) me = await bot.get_me() logging.info("启动成功:@%s", me.username) await dp.start_polling(bot)if __name__ == "__main__": asyncio.run(main())
跑起来
python bot.py
然后在 Telegram 里按顺序试一遍,每一步都该有预期的反应:
| 你发 | 应该看到 |
|---|---|
/checkin | 签到成功 +10,累计 1 天,当前 10 分 |
/checkin 再来一次 | 今天已经签过了 ← 关键,说明去重生效 |
/me | 你的名字、10 分、1 天 |
/rank | 榜上只有你一个 |
Ctrl+C 然后重新 python bot.py,再发 /me | 积分还在 ← 说明真的存下来了 |
最后一行是这一章真正的验收标准。项目目录里现在多了个 data.db,你的数据就在里面。
把 handlers.py 里的 date.today().isoformat() 临时改成 "2026-01-01" 这样的固定字符串,重启,就相当于换了一天。测完记得改回去。
数据库是数据不是代码,不该提交。在 .gitignore 里加一行 data.db。
它能用了,但它有问题
你现在有一个真能用的机器人。但它离"能放心丢在群里跑一年"还差得远,而且差在哪里并不明显 —— 下面这些问题平时都看不出来。
| 什么时候会出事 | 会怎样 | 哪一章 |
|---|---|---|
| 群里有国外的人 | date.today() 用的是服务器时区。他本地早上八点签到,在服务器看来还是前一天,于是「今天已经签过了」。 | 第 5 章 |
| 你想加个多步骤功能 比如「兑换奖品」要先问兑什么、再问几个 | 现在的写法做不了。用户回的那句话,程序不知道是在回答哪个问题。 | 第 4 章 |
| 群里几百人一起签到 | 回复会开始丢。Telegram 对同一个群有发送频率上限,超了直接拒绝。 | 第 7 章 |
| 任何一个 handler 抛异常 | 现在没有全局错误处理。某条消息触发了 bug,日志里一堆红字,用户那边没有任何反馈。 | 第 7 章 |
| 你关掉电脑 | 机器人就死了。这是第 1 章讲过的事,第 10 章解决它。 | 第 10 章 |
这一章你做出了什么
- 一个真能用的签到机器人:签到、去重、积分、排行榜
- 数据存在 SQLite 里,重启不丢
- 三个文件分层:入口 / 业务 / 数据。以后换存储只动一个文件
- 学会了一个手法:让数据库拒绝重复,而不是自己先查再写
把固定的 10 分改成连续签到递增:连续第 N 天给 N×5 分,最多 50。
提示:users 表要多两列(streak 和 last_day),签到时判断 last_day 是不是昨天 —— 是就 +1,不是就重置成 1。
改完记得删掉 data.db 重新跑,因为表结构变了。
跑起来了、没跑起来、或者觉得哪段代码没讲清楚,都可以说一句。留不留联系方式都行。