A WeChat Group Chat AI Built on GUI Automation: 5 Real Failures and 7 Anti-Robot Design Rules
Using GUI Automation to Write a Group Chat AI: 5 Real Incidents, and 7 Designs That Make AI Not Act Like a Robot
Author: 假菌
Why it started
The initial goal was simple: wanted to make the group chat livelier.
That's a high school alumni group, from graduation to now, about ten people scattered in different cities for college. Before it was active daily, but later everyone got busy, the group gradually quieted down—occasionally someone says something, and only half an hour later someone replies.
So I thought, why not put something in there that at least responds.
Thus came "Ajun", a fish.
It doesn't do real work—doesn't answer questions, search info, summarize meetings. It only does one thing: catching jokes in the group.
But it's not the kind of "bot that connects an API". I gave it a real WeChat account, a computer, and a pair of "hands" that can move the mouse—it can see images sent by group members, hear voice messages, send emoji, and the messages it sends appear to group members as ordinary chat history, with zero mechanical trace.
Sounds simple: throw a joke line, ten lines of code to call an LLM API.
But getting it running took me three days; after that, most of the time was spent teaching it when to shut up.
This post covers the three days of development pitfalls, plus the problems that only appeared after it ran. If you want to build something similar, hope you can avoid some detours.
About the AI in this piece, I'm not being vague. The skeleton came from me organizing a few days of logs and spoken notes; AI helped structure it; sentences were shortened by it, numbers and logs were cross-checked line by line, and it helped look up API and library usage. (Models used:
deepseek-v4.1-flashplus a few helper tools.)But the core isn't from it: "Limiting the AI's ability to speak is ten times more important than making it good at speaking" is a conclusion I hit by running on real hardware. 229 group messages vs. 9 private, 51 deliberate silences, 445 tests all green—those raw numbers came from my own machine. I really stepped on all five incidents, and I really changed all seven designs.
I checked all facts and quotes one by one; anything the AI got wrong or made up I deleted; any miswording is on me.
Desensitization note: All group members in this post are referred to as "group member X" (group member C, group member E...). Real nicknames and chat text do not appear in this post—this matches the approach of the project itself (real nicknames stay only local, and the model is always sent aliases).
Part 1: Why "being able to reply" is not the hard part
Why it had to be WeChat
If you've done Slack bots or Discord bots, they all share a common path: official open API, webhook callbacks, standard event formats.
WeChat has none of that.
But it's not just "WeChat has no API"—more precisely: the official openings for bots are not where I needed to go.
I checked all the WeChat-ecosystem channels that touch "bot". Their boundaries are:
| Channel | Can enter a regular WeChat group? | Can be @-triggered? |
|---|---|---|
| Official AI channel for personal accounts (ClawBot / 龙虾) | ❌ Officially states "prohibited from being added to groups" | ❌ |
| Work WeChat "group bot" (Webhook) | ❌ Can only one-way push to the group | ❌ Cannot receive group messages |
| Work WeChat "smart bot" | ⚠️ Can enter groups, but only internal enterprise groups | ✅ |
| Work WeChat customer service | ❌ Not in group (separate 1-on-1 window) | ❌ |
These aren't my guesses; the officials said so:
- WeChat open community reply: "Currently does not support access to regular WeChat and group chats"
- Work WeChat tech support: "Smart bot currently only supports adding to internal groups; interconnection groups, upstream/downstream groups, external groups are not supported"
- For the only official AI channel on personal accounts (the ClawBot plugin launched in March 2026, which connects to "龙虾"), WeChat's response to media: "Only supports one-to-one private chat, prohibited from being added to groups, other users cannot add or view"
Note the last one's wording: not "not yet supported", but "prohibited". This is a design choice, not a missing feature.
One more: WeChat's own built-in AI ("Xiao Wei") is the same—test reports say it can only passively respond to commands, cannot proactively send group messages.
Even Tencent's own AI has no say in group chats.
Now you see the problem: it's not lack of capability—officially, there is no place for a bot in a WeChat group chat. The personal-account channel is positioned as a "personal private AI assistant"; the Work WeChat route requires enterprise verification and enters internal groups—it serves enterprise employees, not my high school alumni group.
Work messages are on WeChat, family group on WeChat, bill notices on WeChat, and of course the high school alumni group is on WeChat. A group pet living in Discord, no matter how fully featured, won't make us move there.
So this isn't a question of "which route is more capable"—those API routes are indeed more capable (sending images, emoji, quote replies, thread topics, emoji reactions—all first-class citizens, and they don't occupy a computer or fear screen lock). But they can't go where I need to go.
And there's a more fundamental difference, which I only realized after finishing:
| Official bot API | My approach | |
|---|---|---|
| What it is | A service attached to the group | It is an account |
| How it enters a group | Needs to be "added" (and regular groups don't allow adding) | That account is already in the group |
| Is it a group member | No, it's a plugin | Yes, it's a group member |
I didn't "pull a bot into the group"; I made an existing account in the group able to talk by itself. That also explains why it can see group history, catch jokes, and be "present"—for it, that isn't a "connected session", it's its everyday life.
So the first hurdle isn't the model, it's the channel: first someone has to be there, then you can talk about having it live there.
I gave it a "manual operation seat"
Here's a key choice I want to be clear about: I didn't connect to WeChat's API. I gave the model a real WeChat account + a screen + a pair of hands.
Every one of its messages goes out like this:
① Find the WeChat window → switch to foreground
② Locate the input box → click it
③ Write the reply text to the system clipboard
④ Ctrl+V paste
⑤ Ctrl+A → Delete to clear (in case of leftovers)
⑥ Press Enter
⑦ Read back the input box, confirm it's actually empty, then consider sending successful
This is CV engineering—copy, paste. No API, no protocol, just a program clicking and typing for you.
Full capability list:
| Capability | Implementation |
|---|---|
| See | Local vision model (Ollama) reads images, understands screenshots and emoji from group members |
| Hear | Local speech-to-text (faster-whisper) turns voice messages into text |
| Speak | Clipboard + Ctrl+V + Enter |
| Send images | Local images go through clipboard paste, WeChat inserts them as image messages |
| Read quotes | Parses the quoted original text in group members' quote messages (can only read, can't quote-reply yet) |
In plain terms: it's not a "bot that connected an API"; it's the person sitting at the computer using the mouse to click WeChat.
Why this step specifically
Let me state upfront: these API routes could also do these things. The four below aren't "API can't do it"; they're "in this no-API place, I forced them out":
- Can see images (not just text)—API route uploads an image in one line; mine uses a local vision model to recognize the image and stitch it into the prompt
- Can hear voice (many people in the group are too lazy to type)—API route receives a file; I have to dig the SILK out of the local library, decode it, then transcribe
- Can send emoji (the common language of group chat)—Telegram has
sendSticker; I have to build an image pool first, then teach the model when to pair which one - The message it sends appears to group members with no discernible mechanical feature—API routes hang a label (Discord puts "APP" next to the name, Telegram shows
xxx_botwith a bot mark), and this last point is the only thing I actually bought with this approach
⚠️ Saying "exactly like a real human" would be bragging. A real human can retract a message, scroll chat history, and reply even when the screen is locked—it can't do any of these. The only claim that truly holds: at the moment that message is seen, no one can tell it was machine-generated.
A fair word on the side: that label isn't a flaw; it's a deliberate information disclosure. I bypass it not because my tech is stronger, but because WeChat never left an opening for this scenario. I know this doesn't sound good—bypassing the label is the thing I'm most unsure whether I should be doing here. The only accounting I can give is two things: it only runs on a secondary account and in a small group of people I know; and the moment I get a platform warning, I will shut it down.
The cost is also real—it occupies a computer's desktop, and there's a bunch of "physical" constraints (details below).
This is also why getting it running took three days: It isn't a pure software problem; it's "how to make a program impersonate a person sitting at a computer".
And this route is fragile. A minimized window, being covered by another window, a moved input box—any of these can fail. That's why the flow has so many "read-back validations": confirm after sending, retry on failure, otherwise replies silently vanish.
Several pitfalls only this route hits
① The WeChat window can't be maximized.
Because it computes relative coordinates—locates the input box by ratio to window size. When maximized to 2590px wide, the ratio-calculated position is off, it can't click the input box, and then it hangs for a full 180-second timeout.
Set the window to around 1100×759, and the same operation takes 3 seconds.
② It steals your clipboard.
Because it's "copy → paste", the instant it sends a message, your clipboard content is replaced.
③ The window must be in the foreground.
If another window covers it, it can't click. So while it's working, that machine basically can't do anything else.
④ Screen lock kills everything.
Once the screen locks, GUI operations have no target; nothing works.
These four together mean one thing: this is not a "service running on a server"; it's something that needs a person to be present. Eventually I accepted that—it's fundamentally a "group member", not a "service".
Why "group", not "private chat"
This is something I only understood after doing it: a group chat is far easier to sustain than a private chat.
At first I thought private chat was simpler—one on one, no need to compete for a turn, no quota worries, no pressure of an audience. Actually the opposite.
The biggest problem with private chat: finding a topic.
A group doesn't need topic-finding. Topics are already there—someone's complaining about the timetable, someone's posting a lunch photo, someone's shouting for a game. It just has to keep up.
Private chat? Two people staring at each other. What do you say first? "What are you up to"? That's the most customer-service-like opener.
| Dimension | Group chat | Private chat |
|---|---|---|
| Where topics come from | Already there, someone else throws them | Have to make them up |
| Is silence awkward? | No, the group is already noisy | Yes, staring at the screen in silence is weird |
| Wrong reply | Someone follows up with a joke, atmosphere stays | One-on-one cold, it's over |
| Rhythm | Short lines in bursts, can interject | Back and forth, like an interview |
| Cost of wrong | People treat it as a meme | The other person may think "what is this" |
Real data confirms this too—ran for five days:
| Number of messages it said | |
|---|---|
| Group chat | 229 |
| Private chat | 9 (7 of which were on the day friend request was accepted) |
That's a 25x difference.
And of those 9, most were replies after the other person spoke first. Its proactive topic initiation in private chat—basically zero.
So the conclusion: this thing naturally belongs in a group. The group gives it a position to "be present but not speak", and private chat has no such position— in one-on-one, not speaking is a kind of rudeness.
That's also why the opening line said "group members don't want an assistant, they want a living thing that can catch jokes"— catching jokes requires a joke to exist. And jokes grow in a group.
Four designs forced by the environment
Because we took this route, all subsequent designs had to change thinking:
| World with open APIs | The world it lives in |
|---|---|
| Process events when they arrive | Must actively poll, and also deduplicate |
| Messages carry full context | Only bare text, context must be assembled |
| Sending a message is idempotent | Once sent, can't take it back, a wrong send is a real mistake |
| There's a users API | Who is who must be recognized by itself |
| There's a rate limit header | No feedback at all, only rely on delay to pace itself |
"Who is who must be recognized by itself" directly determined half of my subsequent design.
And it needs to see and hear
Half of group chat isn't text. An AI that only reads text is half-blind in a group.
So it connects to two local models:
| Capability | What's used | Cost |
|---|---|---|
| See images | Local Ollama running a vision model | ~5-15 seconds per image, uses 4GB VRAM |
| Hear voice | Local faster-whisper transcription | ~0.5 seconds per message, uses 1GB |
Why local: anything a group member might send—household photos, ID cards, screenshots, random snaps. Those shouldn't leave your computer.
Also the vision model eats VRAM. Since it stays resident just to "occasionally look at an image", I added an idle auto-unload: automatically unload after not being used for a while, freeing the VRAM.
The biggest takeaway from this part: multimodality isn't adding a feature; it's changing how you listen to people. A group member posts a photo of "cafeteria roast duck rice" with "so good"—reading only text you know it's good; being able to see the image, you know they're bragging about their school.
Back to catching jokes
Technically it's three steps:
- Listen for messages
- Assemble a prompt and send to the model
- Send the reply
That can run in half a day. Then you'll find—the moment it opens its mouth, it sounds like a robot.
Specifically:
| Phenomenon | Why it's not human |
|---|---|
| Instant reply | A real person needs to see the message, think, type—fastest is a few seconds delay |
| Says a huge block at once | Group chat is pacing of short bursts |
| Responds to whoever speaks | Real people don't; they have closeness |
| Answers everything | Real people don't know everything |
| Emoji in every sentence | Real people use them occasionally |
The real difficulty of "not behaving like a robot" isn't whether it can talk; it's whether it knows when not to talk.
Part 2: Five real incidents
Lead with the conclusion: of the pitfalls I hit, 80% were not in "how to say something", but in "why the message never got sent".
List first so you can compare with your own case:
| # | Incident | One-line summary |
|---|---|---|
| 1 | The same sentence was queued twice | Looks like a concurrency bug, but actually ran two instances |
| 2 | Replied to a message from 86 minutes ago | Replying to an old message is worse than not replying |
| 3 | Took 186 seconds to send | A good reply went stale while waiting in line |
| 4 | A good reply was replaced by a new message | Most hidden—you don't even know what was lost |
| 5 | It didn't know it hadn't been sent | Most severe—it will say back "didn't I already say that?" |
Each section below is real, with logs and numbers.
Incident 1 · The same sentence was queued twice
Symptom: two identical messages appear in the group.
First reaction: check dedup logic. Checked, it's fine—after decideInbound() is immediately marked as handled, a second entry in the same process will definitely hit "already handled", and tests pin that order.
Real explanation: two instances ran at the same time.
Both processes read the same database, each enqueued, each sent at its turn. Evidence: both queue lengths went from 0 to 1—so each side from then on went through "burst protection" and didn't repeat, and the end result was exactly two messages. That shape can't be produced by a single instance.
Fix: single-instance lock.
// Criterion is "is the holder process alive", not "does the file exist"
process.kill(pid, 0)
A few details worth noting:
- Lock file stores
pid/startedAt/channel, so troubleshooting shows at a glance who owns it - The second instance doesn't preempt, doesn't crash, stops quietly and says who holds the lock
- Stale lock auto-takeover: if the holder process is dead, overwrite. Can't let a crash keep the plugin from ever starting again—that's harder to debug than a duplicate send
The counter-intuitive point of this one: it reads like a concurrency bug, but it's actually a deployment problem. Looking in the wrong direction.
Incident 2 · Replied to a message from 86 minutes ago
Symptom: the group @ at 18:18, it replied at 19:44.
In those 86 minutes, the group had already talked about "so it's AI" and "what is the big fat fish". Then it suddenly said "here. what's up"—the most typical robot stupidity.
Key insight: replying to an old message is worse than not replying.
And this is a high-frequency scenario—laptop lid closed for a few hours, wakes up, dozens of messages piled up, all stale.
Fix: stale message gate.
staleMessageMs: 600000 # 10 minutes, 0 = off
Details:
| Detail | Why |
|---|---|
| Placed after "late-night silence" and before "quota" | It's a different dimension from "how many replied today", but same family as "what time is it now" |
ts must be truly numeric for the check |
Number(null) === 0 is finite, so direct conversion treats it as 1970 → every message judged stale |
| Return code + plain text | Logs must say "stale by X duration", not just a code |
| Threshold 10 minutes, not 2 | In a normal group, replying after a dozen minutes is completely fine, don't kill that |
This is complementary to "when the queue is full, evict the oldest" as two layers: that layer manages whether it gets in line (when capacity is full, old ones are squeezed out); this layer manages whether this one is worth replying to (too old, stay quiet).
Incident 3 · Took 186 seconds to send
Symptom: logs show "queued: sent at 186s".
Cause: several messages each occupied "minimum interval" slots, stacking into a three-minute queue.
And this led to a more painful problem—
Incident 4 · A good reply was replaced by a new message
This is the most invisible kind of loss.
Queuing itself is fine—at least you know it's waiting.
What's truly terrible: a really good reply gets squeezed out by a later message, and you have no idea.
Here's how it goes: the queue has a capacity limit. When full, what happens to new messages?
The old approach was "drop the new one"—reasoning was "there are already two replies queued, skip this one".
Sounds reasonable, but the result is absurd:
[Queue] Two replies generated 1-3 minutes ago, still waiting for a slot
[New] Someone @'s it, it thinks of a particularly good reply
→ queue full → this good reply gets dropped
[Group] It answers the previous round, doesn't acknowledge this one at all
You can't see how good the dropped reply was. The log only says "queue full, skipped".
I only later realized: this is the exact opposite of real human behavior. A busy human doesn't do this. They'll swallow the stale thought and answer the newest message. Because "answering the wrong question" is worse than "not answering"—the former is foolish, the latter is just busy.
Fix: change from "drop the new" to "evict the oldest".
① Queue full → evict the oldest (it's already stale)
② Put the newest one in
③ Exception: if the new one is "proactive interjection" and the queue is full of "someone called me" → let the new one yield
④ Replies to a named person: never evict, count toward a separate higher ceiling
③ and ④ are two faces of the same principle:
- Someone called me > I want to talk
- Called several times, should answer several times (real people do this)
So the queue's shape now:
[Queue]
├─ Named replies ×N ← protected first, higher ceiling
└─ Proactive interjections ×M ← squeezed first when full
Incident 5 · It thought it had said it
This is the most severe.
After all the above, there's a deeper pit—I hadn't thought of it at all.
The model's reply is "generated", and once generated, it's written into the chat history. It will think:
"I already said that."
But sending is a separate later step. That message might:
- Be evicted when queue is full
- Expire while queuing
- Fail to send
- Even be "on the way to waiting for a slot"
And in the chat history, it has already "said" it.
The result: the next time the topic comes up in the group, it says "didn't I already say that?"—but you never saw it.
This is worse than "not replied":
| Situation | Group sees | Severity |
|---|---|---|
| No reply | It said nothing | Normal, real people miss things too |
| Thinks it replied | It turns around and questions you for not seeing | Social damage |
An AI firmly believing it said something nobody ever saw—this isn't a technical problem; it's a "character collapse" problem.
My current observation: this requires separating "what to say" and "what was actually sent" into two ledgers—only messages truly sent count as "things it said".
I saw evictOneQueued at the place where the queue evicts—it releases the slot, writes a log, removes the message from the waiting set, but no step tells the session layer "this one wasn't sent".
Strictly speaking, I haven't really solved this one; I've only located it.
I'm writing it out because it's more hidden than the previous three: For the first three, you can at least see them in logs; this one can only be inferred from "it talks nonsense".
One thing I'm certain of now:
The model's world must match the group's world. Where they don't match, it starts talking nonsense—and with great confidence.
By the way: the eviction logs a message (new message matters more).
Because "what was lost" must leave a trace—otherwise you'll never discover you've lost something.
Part 3: The designs that actually make it not behave like a robot
Once incidents are fixed, that's where this thing gets interesting. Core principle in one line:
Limiting its ability to speak is ten times more important than making it good at speaking.
1. Delay is not a bug, it's a feature
Real humans take time to type. So replies aren't sent immediately; they have a random delay (30 seconds to 2 minutes).
But the lower bound must be held—instant reply is the strongest robot feature.
This changed once: originally 45 seconds to 5 minutes, but actual logs showed "queued: sent after 218s", plus message-reading/decision time, the group felt like "we asked and it ignored us for ages". The long tail is a experience killer. So I cut the upstream long tail, but kept the 30-second floor.
The distribution shape also matters—not uniform, but truncated log-normal. Because real human reply intervals are long-tailed: most are fast, occasional ones are long.
2. Bursts should be like a human
What's common in a group isn't "one complete sentence", but short bursts:
我打算翘了
全勤是什么东西
So let it use line breaks to mean "pause here", and the code turns line breaks into actually sending multiple messages, with a few seconds in between.
But the cap is three. Beyond that it's spam; and splitting every message is another kind of un-human.
3. Judging "has the information been fully given"
This is the design I find most interesting.
Problem: real people rarely say a whole thing in one sentence when talking in a group. It's often:
甲:那个比赛
甲:就是上次说的那个
甲:你报不报
If the AI receives the first message and eagerly replies, it will answer "which competition?"—and the answer arrives two seconds later. That kind of preemptive response is the most typical robot feature.
But you can't just wait forever—if you wait too long, the other person thinks you've gone unresponsive.
So it needs to judge: is this message "finished" or "has a second half".
Three layers of handling
Layer 1: wait for them to finish typing (debounce)
Messages from the same person sent consecutively are merged into one batch before processing:
| Situation | Wait |
|---|---|
| Was @'ed | 1.5 seconds (he's waiting for you, don't drag) |
| Normal chat | 6 seconds (let him finish) |
If someone sends another message, the timer resets—until they stop.
Layer 2: what gets merged must still be remembered
Here I hit a pitfall worth telling separately:
甲:那个比赛 ← merged away
甲:就是上次说的那个 ← merged away
甲:你报不报 ← this one triggers a reply
On the surface, "merging" means dropping the first two and only processing the last. But then it only sees "you in or not", with no idea which competition.
So merged messages must be added to the background board. The original comment says it directly:
"Wait until he finishes" becoming "only see the last sentence" is worse than per-message processing.
Layer 3: seize the timing, but don't miss it
Another case: the message itself is fine, but now isn't the right time to speak (e.g., it just said something and the group hasn't digested it yet).
Then it doesn't drop the message; it schedules a time to reconsider:
节奏未到,92s 后重新考虑
It can defer at most twice. And there's a natural upper bound—if it drags too long, the message expires by itself (the stale gate mentioned earlier), so it won't hang forever.
By the way: it only carries 20 messages of context
When sending, its "background board" isn't the whole chat log; it's the most recent 20 messages.
Why not 100? Two reasons:
- Cost: each one enters the prompt, over a month it's astronomical
- Key point: 20 messages is enough to understand "what's being discussed now" (about a dozen minutes in the group); anything older is stale
This is different from its "memory": 20 messages are "current context", memory is "long-term people and events".
(This changed from 8 to 20: 8 messages in an active group is only one or two minutes, the model can't see where the topic came from, and can only answer the current sentence isolated—that's the direct cause of "answering without understanding context".)
4. Emoji need a quota
Emoji are the stuff of group hype. So:
- Max 8 per day (initially only 2, but in practice only 1 came out a day—the quota was cut off before being used up by other limits, so I opened it up)
- Minimum 15 minutes between two (originally 1 hour, which meant hardly any got sent in a day)
- Only in group chat; never in private chat
The last rule was added from a pitfall: it once sent a cartoon emoji in a one-on-one private chat, and the other person asked "did you message the wrong person?"
5. "Who is who" must be recognized by itself
This is what I mentioned earlier—WeChat groups don't give a users API, so who each person is has to be read from the chat itself.
So it builds a profile for each group member:
{
"alias": "群友C",
"displayName": "(real nickname only local, not in prompt)",
"traits": ["loves to tease AI", "sharp tongue", "loves to break"],
"bond": "often jokes with the fish setting",
"callsMe": "big fat fish",
"facts": [...]
}
Note alias and displayName are two fields—real nicknames stay only in local files; the model is always sent aliases like "group member C".
(This is not something I added later; it was set from the start: group members' privacy shouldn't be sent into the model just because it's technically convenient. And it incidentally solves an engineering problem—the model doesn't need real names, only knows "this is the same person".)
After running for a few days, what it concluded on its own was more accurate than I expected:
| Member | Traits it wrote itself |
|---|---|
| C | Loves to tease AI, sharp tongue, loves to break |
| E | Loves to hype, loves memes, loves emoji |
| G | Sharp tongue, loves to argue, loves to manage the big fat fish |
| H | Foodie, asks what to eat, loves to play King of Glory |
| K | Homework-cramming student, loves to complain, loves memes |
Note the third row's "loves to manage the big fat fish" (big fat fish is its group nickname)—it read that person's behavior pattern. That's not my writing; it summarized it itself.
Pitfall hit: a ghost member named "?" once got into the profile, whose only record was "so-and-so patted so-and-so". Another time, it recorded itself as a group member.
The fix: filter before data enters the model—system events (pat, recall, join) are not collected, its own sends are not collected, and names like ?, 我, 未知 are not allowed to be stored as nicknames.
This kind of "who is real, who is noise" judgement is necessary in any group; WeChat just forces you to face it earlier.
6. Memory must split "traits" and "facts"
Build a profile for each member, but split fields into two types:
| Type | Example | Might change |
|---|---|---|
| Traits | stays up late, sharp tongue, studies CS | Rarely changes |
| Facts | wants to test into Shenzhen University, family in Guangzhou | Might change; new statement overrides old |
Key design: every fact must carry the original quote as evidence + a timestamp. When verifying, the quote is checked word-for-word against the chat history; if it doesn't match, it's discarded. Rather missing than wrong.
This is the most effective way to prevent hallucination—not by telling the prompt "don't make things up", but by verbatim post-hoc checking.
7. Minefields: better left unwritten
The profile also has a "minefield" field—topics not to touch.
But I added a hard rule:
Only accept explicit signals, forbid making vague guesses. If written wrong, it will make it avoid people when it should care. Better not write at all.
In practice, this field is empty for most people—as expected. If nobody in the group has revealed a minefield, it should be empty.
Part 4: For people who want to build something similar
A few pitfalls, written for convenience
① Output quota and timeout must be tuned together.
Using a reasoning model, it "thinks" then "answers", and these two share the same output quota. I initially gave the summarization task 4096, but it spent all the quota on thinking and returned an empty string for the body—the error looked like a broken model, but it was just truncation.
So I raised the quota to 16K. Then it immediately timed out—because the timeout was still the 60 seconds set for casual chat, while 16K-quota thinking takes minutes.
Changing one parameter always drags out another. These two must be set together.
② In logs, "silence" needs more trace than "speaking".
This pitfall stuck with me the most.
One day someone said in the group "is the AI stuck?", and I went to check—took half an hour, only to deduce by elimination. Because the most frequent result (it choosing to stay silent) had no record at all. The console had long scrolled away, and the disk files had not a word.
After adding it, I could see: it wasn't stuck—it decided not to speak.
③ Tests written thicker than source code isn't shameful.
7000 lines of source code with 6500 lines of tests. Sounds overkill, but when I changed gate rules, I genuinely dared to change—because I knew hundreds of test cases were watching.
Oh, and one more
The real world is dirtier than any test suite. I encountered:
- A ghost member whose nickname was "?" (nickname parsing failed)
- WeChat system messages being treated as "something someone said"
- A message timestamp of
null, treated as 1970 → every message judged stale
None of these could I have thought of while writing code. All of them came from being beaten by real group data.
After running, the data looks like this
Let it sit a few days, flipped through logs, counted.
One premise to state clearly: the "it said" column is counted from logs, precise to the message; the "group messages" column is estimated by order of magnitude—flashes fast, didn't count each. So the precision differs between columns; look at the magnitude relationship:
| Date | Group messages (est.) | It said (exact) |
|---|---|---|
| Day 1 | ~100+ | 7 |
| Day 2 | 200+ | 65 |
| Day 3 | ~210 | 95 |
| Day 4 | 100+ | 25 |
| Day 5 | 100+ | 46 |
It speaks only about one-tenth to one-third of the group's messages—the exact ratio depends on how active the group is that day. The quieter the group, the higher its share; once the group blows up, it steps back.
And this part is what it considered "worth saying". The rest it held back—the top reason in logs for "didn't speak" is it deliberately chose silence (51 times): after reading the message and judging "I can't run with this or shouldn't", it proactively stayed out. Then come the mechanism-level blocks: dedup (27 times "already processed this") and anti-self-trigger (10 times "this is my own message").
What I'm most satisfied with isn't how much it said; it's that it explicitly decided not to say anything 51 times.
Back to costs and boundaries
Everything above was about making it human-like; this section covers the cost of this route itself—because the first question every reader has is "won't this get banned?", so I'll say it myself.
Does this actually have risk?
Yes. I won't sugarcoat.
WeChat's personal account rules don't welcome automation. I can't guarantee "absolutely safe", but I can state my actual boundaries:
- Only use a secondary account. Not a main account I'm regularly using—if something happens, the loss is just an account, not my social relationships
- Only run it in a small group of people I know. A ten-person high school alumni group, no spreading outward, no adding strangers, no group-control
- Stop immediately on any official reminder. Any security notice means shutdown first, cool down for a few days, no "try another tool" moves—that only strengthens the fingerprint
- Not chasing scale or duration. This thing's positioning is "a pet in our few people's group", not a service for anyone
Ran for a few days, no notices. But this is "hasn't happened yet", not "won't happen". I know the difference.
Technically, what it is and isn't
I care about this because it directly decides whether this counts as a "cheat tool":
| It is not | It is |
|---|---|
| Doesn't parse or forge WeChat's network protocol | A desktop program that reads/writes the screen, clicks mouse, presses keys |
| Doesn't crack the client, doesn't inject processes | Uses the most ordinary GUI automation |
| Doesn't modify database, doesn't fake login state (read-only) | Operates the client I'm logged into |
| Doesn't call any "WeChat bot API"—WeChat doesn't have one | Screenshots, copy, paste, Enter—same as you using the mouse |
Plainly: it's a program that sits at the computer and clicks for you. What you can do with your hands, it can do; what you can't do with your hands, it can't either. It gained no "beyond-human" permissions—which is also why it's so fragile (covered window = blind, screen lock = dead).
A contradiction I must admit
Earlier I said the API route carries an "APP/bot" label and my route doesn't—I said that's the only thing I actually bought.
But that label isn't a flaw; it's a deliberate information disclosure. I can bypass it not because my tech is stronger, but because WeChat never left an opening for this scenario.
So bypassing it leaves me slightly guilty. The only accounting I can give is the four boundaries above, plus one sentence: if this approach ever makes anyone uncomfortable in the group, I will stop it directly; I won't optimize it to keep going.
Technically interesting things are many; it's not worth making the people around me uncomfortable.
Conclusion
Back to the original purpose: I just wanted the group to be livelier.
After writing this whole thing, looking back at what I did most—it wasn't making it talk; it was making it know when not to talk.
Group @'s it, it can not reply; Message older than 10 minutes, it shouldn't reply; Someone sends three in a row, it must wait; Nobody's paying attention, it doesn't need to force itself in.
These are all "no" actions.
But it's these "no" actions that turned it from "a robot that can reply" into "that slightly interesting thing in the group".
What makes it human isn't what it says; it's what it restrains itself from saying.
Another truth I learned only by doing:
It needs "presence", not "service".
The group gives it a position—can listen, can occasionally chip in, or stay silent all day. That position is unique to group chat. In one-on-one there's no such thing as "presence"; there's only "serving" or "offline".
So when it messages me privately, it always seems a bit dry—because that's not the scenario where I need it.
It doesn't need a "user"; it needs a lively place.
This post contains AI-assisted content.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Incident 5, that 'it thought it had said it' — I stepped in the same pit on the other side: I thought it hadn't replied, but it actually had. On the web-end streaming response there's no reliable message ID, so the only way is to take a baseline snapshot of the reply list before sending and count the extra containers. But the outer container gets wholesale replaced before and after rendering, and the reference is already detached from the document, so what's read is still the previous round — in the group it looks like 'why is it repeating what it just said'. Switching to deduplication by text hash, when both rounds reply '好的' (okay) it gets falsely killed, and it keeps waiting until timeout. The judgement finally comes down to one criterion: the reply text is sampled twice in a row without changing, plus the send button returns to clickable. One different view: these two accounts shouldn't be charged to the conversation layer. Letting the model itself remember 'I said' is exactly the cause of incident 5. On my side, delivery success only writes an external marker, and I check it before retrying — keep it inside, and it will cover up lies for you.
A hug to a fellow professional.