Making a GroupMe bot that can mention everyone

GroupMe had no @everyone, so I built one — three times over five years. It shipped, it worked, and it died when Heroku killed free dynos. A postmortem.

A few years back, a big part of my church’s community chose GroupMe to be our defacto messaging app for all things church related. In hindsight that was a horrible idea given the lack of functionality in GroupMe, but we got to a point where we stuck with it.

There was one feature that was missing that especially bugged me: @Everyone. It was very frustrating, having to mention all the users of a group manually?? Why!!

So I decided to go down the path of GroupMe Bots.

Requirements

As a user of a GroupMe (let’s assume an admin), I should be able to @all in the chat. Super simple, right? Nope.

GroupMe’s ecosystem for Bot creation isn’t exactly the most straight forward. A bot in GroupMe is not a user. It’s a thing you register against a single group, and it gets back a bot_id that lets you post messages into that one group. That’s the whole surface area:

POST https://api.groupme.com/v3/bots/post
{ "bot_id": "...", "text": "hello" }

You also give the bot a callback_url at registration time. From then on, GroupMe POSTs every message in that group to your endpoint. So the shape of the thing is fixed early: you need a server that’s always listening, and it hears everything.

It started in Java

The first commit, in April 2017, is Bot.java, Parser.java and Server.java — with the compiled .class files committed next to them, because of course they are. That version never became anything.

Two years later I started over in Node. That second pass was a single Express file with one route, /post, and I used it for two completely different jobs — the OAuth redirect that comes back with a user’s access token, and the message callback that GroupMe fires on every message. Those got tangled together immediately.

I left myself a TODO at the bottom of that file that turned out to be the actual design of the version that shipped:

Separate the whole Web part from the callback url (Callback URL is only for messages nothing else)

That’s the lesson. The callback isn’t an endpoint you own the semantics of, it’s a firehose GroupMe points at you. Anything stateful — logging a user in, registering a group — belongs somewhere else.

The part I didn’t expect

Here’s the thing that turns this from a weekend hack into an actual application.

To mention everyone, you need to know who everyone is. And a bot_id can’t tell you that. It only posts. Reading the member roster is a different call, and it needs a user access token:

GET https://api.groupme.com/v3/groups/:group_id?token=:access_token

So the bot can’t be self-contained. Somebody has to log in with GroupMe OAuth, and I have to hold onto that token to look up members later. That one requirement drags in everything else: an OAuth flow, a database to store { groupId, botId, accessToken } per group, and — because I’m now storing credentials that act on someone’s behalf — a terms page and a privacy policy.

I did not set out to write a privacy policy for an @everyone button.

Mentions

The mention itself is an attachment on the outgoing message:

{
  bot_id: bot.botId,
  text: 'Mentioning Everyone',
  attachments: [{ type: 'mentions', user_ids: members }]
}

members is just every user_id from the group lookup. The text says “Mentioning Everyone” and the attachment does the work of actually pinging them.

That text choice is doing something load-bearing that I want to point out, because it was more luck than design. My trigger is an exact match:

isMentioningEveryone (msg) {
  const cleanStr = msg.trim().toLowerCase()
  return cleanStr === '@all'
}

The bot’s own reply is “Mentioning Everyone”, which is not @all — so when GroupMe echoes that message back to my callback, it doesn’t match, and the bot doesn’t trigger itself. Loop avoided. But it’s avoided by accident, as a consequence of the reply text. If I’d made the bot reply with @all for symmetry, it would have recursed until GroupMe rate-limited me. The right fix is to check sender_type on the incoming payload and ignore anything from a bot. Mine doesn’t. It works, but it works for the wrong reason.

The real feature was authorization

Once this worked, the interesting problem stopped being “can I ping everyone” and became “who is allowed to.”

A 200-person church group where anyone can ping all 200 people is not a feature, it’s a weapon. So the check happens server-side, against the roster fetched on that request, on every message:

const messagingUser = groupDetails.response.members
  .find(member => member.user_id === userId)

if (messagingUser &&
    (messagingUser.roles.includes('admin') ||
     messagingUser.roles.includes('owner'))) {
  // ...send the mention
}

Two things I’d still defend. It’s enforced on the callback, not just in the UI — the onboarding page only lists groups where you’re an admin or owner, but that’s a convenience, not a control. Anyone can type @all. Only the server decides what happens next.

And it re-reads the roster on every message rather than caching it, which I assumed meant a demoted admin would immediately lose the ability to fire it.

That assumption was wrong, and I know it was wrong because the last thing I ever did to this project was chase it. The second-to-last commit is called “logging bug where previous admins can still @everyone”, and all it does is add a console.log above that if. The commit after it removes the log. There is no commit after that.

I never found out whether roles lags behind a demotion on GroupMe’s side, or whether I was reading the wrong field, or whether it was something dumber. I stopped looking.

What it ended up being

Not a bot. A small multi-tenant web app that happens to install bots:

  • Onboarding — GroupMe OAuth, then a page listing the groups you own or admin, with a terms checkbox
  • InstallPOST /v3/bots with your group_id and my callback_url, then persist the resulting bot_id
  • Runtime — one callback endpoint, member lookup, role check, mention
  • UninstallPOST /v3/bots/destroy, then drop the record

Express, MongoDB via Mongoose, deployed to Heroku. The frontend is Bootstrap 3 and jQuery served as static files, which I’d do differently now, but it was three pages and it worked.

It’s dead

Heroku killed free dynos in November 2022, five months after that last commit, so the callback URL has been pointing at nothing ever since. As far as I can tell nobody outside my own church community was using it by then anyway.

Which is the honest ending. It solved a real problem for a group of people for a few years, on infrastructure I didn’t pay for, until the infrastructure went away and I didn’t care enough to move it. That’s most side projects.

The annoying part is that reviving it would be cheap now. The whole thing is one webhook, one roster lookup and one outbound POST — there is no reason it needs a dyno sitting there warm. Hono on Workers, or Hono on Bun, and the running cost rounds to nothing.

That’s not a hypothetical stack, either. It’s what I reach for now: coptic.io is a Hono API on Cloudflare Workers, and Nofri is Hono on Bun. The version of me that wrote this in 2020 stood up Express, Mongo and a paid-tier-shaped architecture for something that is, in the end, a function that fires when someone types @all.

It was impressive in 2017. The bar has moved.

← All posts