Refactoring a messy Tieba bot
A small Tieba bot went from one muddy loop to a clearer split: discovery, judgement, action, state, and a safe migration.
Journal5 minEN
This bot did not get hard because of one big bug.
It got hard from many small, reasonable choices.
At first the runtime was a simple loop: fetch new threads and replies, filter, judge, maybe like, maybe reply, then save. That lasted longer than it should. Trouble started when new needs blurred what the loop was doing.
The question was no longer “does it run?” It was “can we still say why it did, or did not, do something?”
The code still runs. The meaning is already off.
The first pressure was meaning, not scale.
Action logic started simple: do not like twice, do not reply twice, record what you did. Later the records were not yes/no. They had a life: planned, done, failed, skipped, dry-run.
One “actions” table could no longer answer:
- already done, or only picked?
- if it failed, can we retry?
- does “skipped” count as done?
- is “planned” current state, or history?
A system can keep running after its meaning goes muddy. After that, safe change gets harder every week.
The fix was not a big framework. It was a split:
action_statefor what is true nowaction_eventsfor what happened
Small change. Easier to read. Clear that this is not “just a status field”.
Think like a state machine. You do not need an FSM library.
We needed named moves, not better flags.
Once actions meant planned, leased, done, failed, dead, and cooldown, the runtime was already a state machine, even if the code did not say so.
A yes/no model cannot cover:
- short downtime vs real failure
- retry vs dead
- current truth vs history
- who holds the lease vs finished work
We still did not need a heavy FSM library. Named states beat a pile of ifs.
Then a second problem showed up: too much lived in one place.
Hidden coupling
One round did four jobs:
- find new content
- judge what to do
- run side effects
- notify and save
That is fine at first. It breaks when those steps fail for different reasons. Fetch can fail. AI judge can fail. Action can fail on cooldown or the network.
Those are different failure types. They should not share one loop forever.
Cooldown made this obvious. When cooldown made discovery feel slow, action time was leaking into find time. That is a design smell.
Do not rewrite everything first
When upstream is unstable, a full rewrite is tempting. It was the wrong first move.
Better:
- cut the transport edge first
- move the read path earlier
- keep action on the old path for a while
- keep a working baseline while the new edge proves itself
Not flashy. It ages well. One real edge, without killing a running system.
Then the product question got clearer too.
Queue good targets. Do not rank the whole world.
One scan could find more than one good target. The “smart” idea: score all of them, always pick the best. That turns the product into a ranker.
That was not the point.
Better: if more than one target deserves a like or reply, put them in a queue and consume at a sane pace.
Shape:
- ingest / discovery
judge_queue- judge consumer
action_queue- action consumer
- state and events
SQLite was enough. No Kafka, Redis, or fake broker. We needed a durable local queue: pending, leased, done, dead-letter, retry, cooldown, crash recovery.
A solid local task table did the job.
Queues were not enough. Each edge still needed a reliability rule.
Transactions inside. Idempotency outside.
Transactions and idempotency are not rivals. They solve different things.
- Local table handoff: transaction first
- Outside side effects: idempotency first
Examples:
judge_queue → action_queueis one local atomic step- a real Tieba like/comment must stay safe if the process dies and retries
Local consistency and “safe to run twice” are not the same class of failure. A reliable system usually needs both.
Then the last question was how to add the boxes without breaking production.
The target design only works if the move is safe
The end shape mattered. The path mattered as much.
A practical rollout:
- Phase 0: logs, runtime state, service behavior
- Phase 1: add
judge_queue, keep the old path - Phase 2: add
action_queue, move side effects off direct judgement - Phase 3: switch defaults only after watching real runs
That kept the work honest. We did not “upgrade everything”. We moved complexity only when something real pushed.
What actually changed
The win was not “more advanced”.
The win was: we could explain it again.
Easier to tell apart:
- what the runtime does now vs what we wish it did
- current state vs event history
- discovery time vs action time
- local transactions vs outside idempotency
- a staged move vs a big-bang rewrite
The line I keep:
Architecture is not about making a system look smarter. It is about putting complexity where it belongs.