A Telegram Spam Bot on a Local Decision Model
Hand your Telegram group to a cloud-moderated bot and read the fine print of the arrangement. Every message your members type, in every language, including the ones about their health, their politics and their arguments at 3 a.m., travels to a vendor’s server, gets classified by a model you cannot inspect, and comes back as a verdict. The contract is that someone else’s computer reads your community’s conversations before your community does. I kept the moderation and threw away the rest: a Telegram moderation bot that runs a Laya decision model entirely on my own machine, where no message ever leaves, and where the whole pipeline is a hundred lines of Python you can read in an afternoon.
A moderation pipeline without a cloud
The architecture is deliberately boring. Messages enter a pipeline. The pipeline runs modules in priority order. Any module can return KEEP, DELETE or FLAG, and the first DELETE wins and short-circuits the rest. Admins, the bot itself and anonymous-admin posts bypass everything. That is the entire state machine, and because each module is a class registered by decorator, adding a fourth rule to the group means adding a file, not editing the core.
The interesting engineering problem is that three different rules want to consult the same model about the same message. Spam wants to know what kind of message this is. The greeting filter wants to know whether it is only a pleasantry. Running the model twice per message would double the latency and the energy for no insight, so the verdict is computed lazily, once, and shared:
async def classify(self) -> LayaVerdict:
"""Return the shared Laya verdict, computing it on first call."""
if self._verdict is None:
async with self._verdict_lock:
if self._verdict is None: # re-check: another caller may have won
self._verdict = await self.classifier.classify(self.text)
return self._verdict
That is a double-checked locking pattern from the 1990s guarding a 2026 decision model, and it means the group pays for exactly one forward pass per message no matter how many modules ask. The classifier itself loads the weights once at startup, warms up with a throwaway prediction so schema drift fails at boot rather than on the first real message, and serializes calls through a lock, because one forward pass at a time is how this class of model wants to be served.[1]
Three guards, one model
The spam filter works in two stages. A tiny set of near-certain regexes deletes the obvious before the model is ever consulted, because spending 130 milliseconds of inference on a message that is literally a crypto giveaway link is a waste of a good model. Everything else goes to the decision model, whose answer is a probability, and the probability meets a ladder:
verdict = await ctx.classify()
if verdict.spam_probability >= self.config.spam_delete_threshold: # 0.85
return ModuleResult.delete("laya: spam", confidence=verdict.spam_probability)
if verdict.spam_probability >= self.config.spam_flag_threshold: # 0.60
return ModuleResult.flag("laya: borderline spam", confidence=verdict.spam_probability)
return ModuleResult.keep()
The space between the thresholds is where the design lives. Deleting a legitimate message is the expensive mistake in moderation, far more expensive than leaving spam up for a minute, so the band between 0.60 and 0.85 does not delete anything: it logs a FLAG with the confidence attached, and those logs are the calibration data for tightening the ladder later. The model proposes; the threshold disposes.
The greeting filter is the one that shows why a decision model earns its keep. A greeting-only message, “hi”, “gm”, “buongiorno”, “hola”, is noise in a busy group, but catching it with regex means maintaining a list of greetings in every language your members speak, and greetings are the most polymorphic noise there is. Instead, the module applies guardrails that need no intelligence at all, media, links and anything longer than 200 characters are kept without a model call, and hands everything else to the multilingual checkpoint with one question: is this message only a greeting? One model, every language, no word lists.[1]
The third module is the least glamorous and the most used: it deletes join and leave notices and other bots’ helper messages, ten seconds after posting so that humans can see them, because the commonwealth of a group also includes not having to watch the machinery groom itself.
Why a decision model and not an LLM
The honest answer is that every axis points the same way for this job. Privacy: the messages are the community’s private speech, and a local decision model means the data goes nowhere; there is no API key in the repository because there is nothing to authenticate to. Cost: a hosted LLM reading your group all day bills per token, forever; this costs the electricity to keep a small model resident, about 700 megabytes of weights and a few watts. Latency: a forward pass on a CPU costs around 130 milliseconds; a network round trip to a hosted model costs that in overhead before the first token. Determinacy: the model returns probabilities over a fixed schema, so the code that acts on it is a threshold comparison, not a parser that can be surprised. And multilinguality: one checkpoint covers the whole group, which is precisely what regex lists can never do.[2]
There is a political footnote too. A moderator that runs open weights on your own hardware cannot be turned off by anyone else’s export regime, cannot be dark-perioded by a vendor’s policy change, and cannot leak your community to a breach of somebody else’s API. I have made the general case that open weights are irreversible; here it is at the scale of one group chat.[3]
The case for the other side
The cloud arrangement exists for reasons, and this design pays real costs to refuse them. A frontier model reads context better than a 322-million-parameter encoder: sarcasm, in-group jokes, a greeting that carries a knife inside it. Regex and heuristics alone would suffice for many groups, which is why the fast path exists; the model is there for the residue that regex cannot name. A decision model is also one more thing to calibrate, and calibration is not optional: the multilingual checkpoint ships deliberately uncalibrated, prone to reporting 100 percent and 0 percent, and its thresholds are guesses until you have measured them on your own group’s messages.
So the bot ships the way any honest moderator should: it starts in dry-run mode, where it logs every would-be decision and deletes nothing, and it comes with an evaluation script that takes a JSONL file of your group’s real messages and reports what the thresholds would have done. Run it quiet for a week. Look at the flags. Move the numbers. Only then let it touch anything.[4]
Moderation is not understanding; it is deciding, at volume, in public, with someone’s patience on the other side of every mistake. For that job, the model that never phones home is not a compromise. The cheapest place to keep a secret is a machine that never speaks to anyone.
- erubboli/telegram_spam_bot, the bot this article describes: pipeline with priority-ordered modules and short-circuit on first DELETE, one shared Laya verdict per message via double-checked locking, regex fast path, FLAG band between thresholds, dry-run default, media/link/length guardrails before the model, 36 tests passing offline with a fake classifier. Model:
convaiinnovations/laya, subfoldermultilingual. - NandhaKishorM/laya, Apache-2.0: the decision model, its three question types and its multilingual checkpoint. The category and the closed alternative it undercuts are covered in From LLMs to System One Models: Jev and Laya.
- The AI Act Regulates Invoices. Open Weights Don’t Send Any., on enforcement living where the invoices are, and why released weights cannot be withdrawn.
- Threshold choice and the evaluation script are covered in detail in Calibrating Laya: Question Phrasing, Direction and Thresholds, the next article in this series.