Taming Your .NET Twitter Bots With Rules

Khalid Abuhakmeh built two Twitter bots in C#: GuidGenie, which generates a unique GUID whenever someone mentions it, and TiredManhattan, which replies with a custom meme image based on the Watchmen comic panel of Dr. Manhattan saying he is tired of Earth. Both bots do something similar under the hood, but TiredManhattan behaved noticeably better in production. The difference came down to a small set of rules that decided when the bot should actually respond.

This article walks through those rules and the C# code behind them, using the TweetinviAPI wrapper around the Twitter Filtered Stream v2 endpoint. The pattern itself is worth studying even if you never build a Twitter bot again, because it is really a small rules engine, and rules engines show up everywhere from fraud checks to feature flag evaluation.

Why a bot needs explicit rules

A bot that responds to every mention it receives quickly turns into a nuisance. It replies inside long threads where nobody wants another notification, it reacts to profanity or spam accounts, and it sometimes answers the same tweet more than once because Twitter delivered a duplicate event. None of this is obvious until the bot is live and users start complaining.

GuidGenie ran into exactly this problem. It kept generating new GUIDs for as long as it stayed mentioned in a thread, which made it noisy and, honestly, a bit annoying. TiredManhattan avoided this by checking a short list of conditions before ever sending a reply.

The three rules

  • Do not respond to profanity or vulgar language, at least as far as an automated filter can catch it.
  • Only respond when the bot is directly mentioned, not just referenced somewhere in a thread.
  • Only respond once, to the original tweet that started the conversation, not to every reply that follows.

These sound obvious once you write them down, but Twitter’s API does not enforce any of them for you. The Filtered Stream will happily push every matching tweet to your application, including replies, quote tweets, and tweets with attached media. Your code has to decide what counts as a fresh, valid trigger.

Telling Twitter what to listen for

Before any of the filtering logic runs, the bot has to register a rule with Twitter’s Filtered Stream so it only receives tweets that mention its own handle. This is done once, typically at startup.

await twitterClient.StreamsV2.AddRulesToFilteredStreamAsync(
    new FilteredStreamRuleConfig($"@{_user.ScreenName}", "mention"));

This rule tells Twitter’s servers to push any tweet containing the bot’s handle. It does not do any additional filtering beyond matching the mention, so the stream will still include replies buried deep in a thread, tweets with images attached, and anything else that happens to mention the bot. All the real decision-making still has to happen on the application side.

Requesting the full tweet payload

By default, the Twitter API returns a fairly bare-bones tweet object. To apply the rules described above, the bot needs extra data: whether the tweet has attachments, whether it references other tweets, and its conversation identifier. That means asking for the full set of tweet fields, user fields, and expansions when starting the stream.

await _stream.StartAsync(new StartFilteredStreamV2Parameters
{
    TweetFields = new TweetFields().ALL,
    UserFields = new UserFields().ALL,
    Expansions = TweetResponseFields.Expansions.ALL
});

Requesting everything with .ALL is fine for a small hobby bot, but it is worth being deliberate about this in anything closer to production. Pulling every field and expansion increases payload size and, on paid API tiers, can affect your usage costs. A more disciplined version of this code would request only TweetFields.ConversationId, Attachments, and ReferencedTweets, since those are the only three fields the rules actually check.

Guarding against empty events

The stream occasionally fires events that are not tied to an actual tweet, for example heartbeat or system messages. The very first check in the event handler filters these out before any rule logic runs.

private async void Received(TweetV2EventArgs args)
{
    if (args.Tweet is null)
    {
        _logger.LogInformation("Not a tweet: {Information}", args.Json);
        return;
    }
   // more code later...
}

This is a defensive null check, nothing more, but it matters. Skipping it means the rest of the method has to null-check tweet properties everywhere, and a single unhandled null reference exception here can silently kill the event handler and stop the bot from processing anything further.

Checking that this is the original tweet, not a reply

Twitter groups a tweet and all its replies under a single conversation identifier. The conversation ID equals the tweet ID only for the tweet that started the conversation. Comparing the two tells the bot whether it is looking at the original mention or a reply somewhere further down the thread.

tweet.ConversationId.Equals(tweet.Id) == false

If this evaluates to true, the tweet is a reply, not the tweet that kicked off the conversation, and the bot should skip it. Without this check, a bot would keep responding every time someone continues a conversation it already replied to, which is exactly the behavior that made GuidGenie annoying.

Skipping tweets with attached media

The next check looks at whether the tweet already has media attached.

tweet.Attachments?.MediaKeys?.Any() == true

This one is a safety net rather than a strict rule. If a tweet already carries an image, there is a reasonable chance the bot (or something else) has already reacted to it, so it is safer to skip. It also avoids the awkward case of the bot replying with its own generated image underneath a tweet that already has one.

Skipping tweets that reference other tweets

The last structural check looks at referenced tweets, which covers quote tweets and retweets in addition to plain replies.

tweet.ReferencedTweets?.Any() == true

A tweet that references another tweet is not a standalone mention, it is commentary on something else. Responding to these tends to produce confusing replies that make sense out of context to the bot but look strange to anyone reading the thread.

Combining the rules into one guard clause

Once each condition is defined, they combine into a single early-exit check at the top of the handler.

if (
    tweet.ConversationId.Equals(tweet.Id) == false ||
    tweet.Attachments?.MediaKeys?.Any() == true ||
    tweet.ReferencedTweets?.Any() == true
)
{
    _logger.LogInformation("Ignore conversations, as they can get noisy");
    return;
}

This is a guard clause pattern: check every disqualifying condition up front, log why the tweet was skipped, and return early. It keeps the rest of the method focused on the happy path, where the bot has already decided this tweet deserves a reply. Logging the reason for skipping is a small habit worth keeping, it makes debugging a misbehaving bot far easier later.

Filtering out profanity

The final rule handles language. The bot strips its own handle out of the tweet text and runs the remainder through a profanity filter before deciding to respond.

var text = tweet.Text.Replace($"@{_user.ScreenName}", "").Trim();
// I'm not dealing with this s#@$!
if (_profanityFilter.ContainsProfanity(text))
{
    _logger.LogInformation("Filtered out {Text} from {@From}", text, mentions);
    return;
}

This uses the ProfanityDetector library from NuGet, which works reasonably well out of the box. It does produce occasional false positives, flagging perfectly innocent text as profane, so treat it as a first line of defense rather than a guarantee. For anything customer-facing, pair a library like this with manual review of flagged content, or use a hosted moderation service such as Azure AI Content Safety, which tends to handle context better than a word-list-based filter.

Where this pattern goes beyond Twitter bots

Strip away the Twitter-specific pieces and what remains is a small rules engine: a set of independent boolean conditions evaluated against an incoming event, with an early exit as soon as one of them fails. That structure applies well beyond bots. Fraud detection triage, content moderation queues, and feature flag evaluation all use some version of the same pattern.

The version in this bot is intentionally simple, a chain of OR conditions in one if statement. For a project this size that is the right amount of engineering, adding an abstraction here would be over-engineering a two-bot hobby project. But if you find yourself copying this pattern into a third or fourth bot, or into an actual production system, it is worth extracting it into a proper rules engine: a list of named IRule objects, each returning a pass or fail with a reason, evaluated in sequence. Wrapping that in a small fluent configuration API and publishing it as a NuGet package would make the pattern reusable without forcing every consumer to rewrite the same guard clause by hand.

A note on the Twitter API since this was written

This bot was built against Twitter’s v2 Filtered Stream endpoint back in 2022. Twitter’s API access tiers changed substantially afterward, and Filtered Stream access now sits behind a paid tier rather than the free tier this article originally assumed. If you are adapting this pattern today, check your current API access level before assuming the filtered stream endpoint is reachable on a free plan, and budget for the subscription cost if you are building something more than a personal side project. The rules logic itself, the guard clause pattern, the conversation ID check, the profanity filter, still applies regardless of which tier or which platform you end up building against.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading