Give each person who texts you a Slack channel
Build a small Slack app that opens a private channel for each person who texts your number, posts their texts there, and sends whatever your team writes in the channel back to them as a text from your number.
Maya manages the office at Coastline Property Management. Tenants text the office number about leaks, lockouts and late rent, and three people answer them: Maya, the leasing agent and the maintenance lead. Until now that meant passing one phone around. With this guide, each tenant who texts gets a private Slack channel of their own, such as #text-15555550123. Their texts appear there as they come in. Whatever the team writes in the channel goes back to the tenant as a text from the office number, and a thread under any message is a place to talk it over without the tenant seeing a word.
What you’ll have
- A private Slack channel for each person who texts you. It’s made the first time they text, and your team is in it.
- Their texts posted in their channel, and the texts you send them from anywhere else (the Mac, your iPhone, BlueBot’s inbox) posted there too, so the channel holds the whole conversation.
- Every top-level message in the channel sent as a text from your number. A tick on the message once it’s sent, and a reply in its thread when it’s waiting or wasn’t sent.
- Threads that stay in Slack, so the team can talk about a text before answering it.
If you only want to hear about new texts in Slack and answer them elsewhere, you need no code: add a Slack incoming webhook under Settings in the BlueBot dashboard and turn on alerts for new texts.
What you need
- A BlueBot account with your Mac connected. A number used only for the business is best, because of the next point.
- Which chats to copy set to Everything under Settings → Sending rules, so people who text you first get a channel. With the default, BlueBot copies only chats with people you’ve texted through BlueBot.
- A Slack workspace where you can install an app.
- Somewhere to run a small server with an
httpsaddress: Node.js 18 or later, or Python 3.10 or later. While you build, a tunnel to your laptop is enough.
How it fits together
The server talks to both sides. BlueBot posts a webhook to it for each text, and it posts the text to the person’s channel through the Slack API. Slack posts an event to it for each message in those channels, and it sends the message through the BlueBot API.
Each channel’s topic holds the conversation it belongs to, such as bluebot:+15555550123. That’s the only record the server keeps: when it starts, it reads the topics of the channels it’s in and knows every conversation again. A group chat gets a channel too, keyed by its chat_id, and messages in it go to the whole group.
Each text sent from Slack carries the Slack channel, the message and who wrote it in its . BlueBot echoes metadata on every webhook about the text, which is how the server finds the Slack message to tick or to answer in a thread.
Make the Slack app
- Open , press Create New App, choose From scratch, name it “Tenant texts” and pick your workspace.
- Open OAuth & Permissions. Under Bot Token Scopes, add
groups:write(make private channels, set their topic and invite people),groups:read(list them when the server starts),groups:history(read messages in them),chat:write(post) andreactions:write(tick a message once it’s sent). - Press Install to Workspace and allow it. Copy the Bot User OAuth Token, which starts
xoxb-. - Open Basic Information and copy the Signing Secret under App Credentials.
- Find the member ID of each person who answers texts: in Slack, open their profile, press the ⋮ button and choose Copy member ID. It starts with
U.
Leave Event Subscriptions for later. Slack checks the address when you save it, so the server has to be running first.
Make a BlueBot key
In the BlueBot dashboard, open Developers → API keys and make a key named “Slack” with the scope messages:send. Put everything in the server’s environment:
export BLUEBOT_API_KEY="bb_live_…"
export SLACK_BOT_TOKEN="xoxb-…"
export SLACK_SIGNING_SECRET="…"
export SLACK_TEAM_USER_IDS="U0123ABCD,U0456EFGH,U0789IJKL" # who joins each new channelWrite the server
One small server takes both Slack’s events at /slack/events and BlueBot’s webhooks at /bluebot/webhooks. It uses Bolt, Slack’s own framework, which checks each Slack request’s signature for you. The BlueBot route checks BlueBot’s signature the same way.
// npm install @slack/bolt @usebluebot/sdk express
import express from 'express';
import { App, ExpressReceiver, type GenericMessageEvent } from '@slack/bolt';
import BlueBot, { APIError, WebhookVerificationError } from '@usebluebot/sdk';
const bluebot = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
const receiver = new ExpressReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET!, endpoints: '/slack/events' });
const slack = new App({ token: process.env.SLACK_BOT_TOKEN, receiver });
const TEAM = (process.env.SLACK_TEAM_USER_IDS ?? '').split(',').map((id) => id.trim()).filter(Boolean);
// A conversation's key is the person's number or address, or chat:<id> for a group chat.
const channelByKey = new Map<string, string>();
const keyByChannel = new Map<string, string>();
const handled = new Set<string>(); // BlueBot event ids; use a table in production
const queues = new Map<string, Promise<unknown>>();
function inOrder<T>(key: string, run: () => Promise<T>): Promise<T> {
const next = (queues.get(key) ?? Promise.resolve()).then(run, run); // two first texts at once make one channel
queues.set(key, next.catch(() => undefined));
return next;
}
function remember(key: string, channel: string) {
channelByKey.set(key, channel);
keyByChannel.set(channel, key);
}
// Slack sends &, < and > escaped, and links and email addresses wrapped in < >.
function plain(text: string) {
return text
.replace(/<((?:https?|mailto):[^|>]+)\|([^>]*)>/g, (_, target: string, label: string) => (target.startsWith('mailto:') ? label : target))
.replace(/<((?:https?|mailto):[^>]+)>/g, '$1')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&');
}
const slackSafe = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
function channelName(key: string) {
const id = key.startsWith('chat:') ? `group-${key.slice(-8)}` : key.replace(/^\+/, '');
return `text-${id.toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}`.slice(0, 72);
}
async function loadChannels() {
let cursor: string | undefined;
do {
const page = await slack.client.conversations.list({ types: 'private_channel', exclude_archived: true, limit: 200, cursor });
for (const channel of page.channels ?? []) {
const topic = plain(channel.topic?.value ?? '');
if (channel.id && topic.startsWith('bluebot:')) remember(topic.slice('bluebot:'.length), channel.id);
}
cursor = page.response_metadata?.next_cursor || undefined;
} while (cursor);
}
async function channelFor(key: string) {
const known = channelByKey.get(key);
if (known) return known;
let created;
try {
created = await slack.client.conversations.create({ name: channelName(key), is_private: true });
} catch (error: any) {
if (error?.data?.error !== 'name_taken') throw error; // an archived channel keeps its name
created = await slack.client.conversations.create({ name: `${channelName(key)}-${Date.now().toString(36).slice(-6)}`, is_private: true });
}
const channel = created.channel!.id!;
await slack.client.conversations.setTopic({ channel, topic: `bluebot:${key}` });
if (TEAM.length) await slack.client.conversations.invite({ channel, users: TEAM.join(',') });
remember(key, channel);
return channel;
}
const SENT_FROM: Record<string, string> = { mac: 'the Mac or an iPhone', inbox: 'BlueBot’s inbox' };
async function handle(event: ReturnType<typeof bluebot.webhooks.verify>) {
if (event.type === 'message.received') {
const message = event.data;
const key = message.chatId ? `chat:${message.chatId}` : message.from;
if (!key) return;
await inOrder(key, async () => {
const channel = await channelFor(key);
const lines = [];
if (message.text) lines.push(message.chatId ? `*${slackSafe(message.from ?? 'Someone')}:* ${slackSafe(message.text)}` : slackSafe(message.text));
if (message.files.length) lines.push(`_Sent ${message.files.length === 1 ? 'a picture or file' : `${message.files.length} pictures or files`}. Open the conversation in BlueBot to see them._`);
await slack.client.chat.postMessage({ channel, text: lines.join('\n'), unfurl_links: false, unfurl_media: false });
});
}
if (event.type === 'message.sent') {
const message = event.data;
const { slack_channel: channel, slack_ts: ts } = message.metadata;
if (channel && ts) {
// Sent from Slack: tick the Slack message.
await slack.client.reactions.add({ channel, timestamp: ts, name: 'white_check_mark' }).catch((error: any) => {
if (error?.data?.error !== 'already_reacted') throw error;
});
return;
}
// Sent some other way: post it, but only into a conversation that already has a channel.
const key = message.chatId ? `chat:${message.chatId}` : message.to;
const known = key && channelByKey.get(key);
if (!known || !message.text) return;
const from = SENT_FROM[message.source] ?? `BlueBot (${message.source})`;
await slack.client.chat.postMessage({ channel: known, text: `_Sent from ${from}:_ ${slackSafe(message.text)}`, unfurl_links: false });
}
if (event.type === 'message.failed' || event.type === 'message.canceled') {
const message = event.data;
const { slack_channel: channel, slack_ts: ts } = message.metadata;
if (!channel || !ts) return;
const why =
event.type === 'message.failed'
? `Not sent: ${message.failure?.detail ?? message.failure?.reason ?? 'the Mac couldn’t send it'}.`
: message.cancelReason === 'opted_out'
? 'Not sent: they asked not to get texts from you (they texted a stop word).'
: 'Not sent: it was canceled.';
await slack.client.chat.postMessage({ channel, thread_ts: ts, text: why });
}
}
// BlueBot's webhooks
receiver.router.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = bluebot.webhooks.verify(req.body, req.headers, process.env.BLUEBOT_WEBHOOK_SECRET!);
} catch (error) {
return res.status(error instanceof WebhookVerificationError ? 400 : 500).end();
}
if (handled.has(event.id)) return res.status(204).end();
try {
await handle(event);
} catch (error: any) {
console.error('Slack refused', event.id, error?.data?.error ?? 'unknown'); // Slack's error code, never the text's words
return res.status(500).end(); // BlueBot sends it again
}
handled.add(event.id);
res.status(204).end();
});
// Messages in the texting channels
slack.message(async ({ message, client }) => {
const posted = message as GenericMessageEvent;
if (posted.subtype || posted.bot_id || !posted.text) return; // edits, joins, topics and bots
if (posted.thread_ts && posted.thread_ts !== posted.ts) return; // threads stay in Slack
const key = keyByChannel.get(posted.channel);
if (!key) return; // not a texting channel
const reply = (text: string) => client.chat.postMessage({ channel: posted.channel, thread_ts: posted.ts, text });
if (/<[@#!]/.test(posted.text)) {
await reply('Not sent. A message that mentions someone stays in Slack. Mention people in a thread instead.');
return;
}
const text = plain(posted.text).trim();
if (text.length > 2000) {
await reply('Not sent. A text is at most 2,000 characters.');
return;
}
try {
const sent = await bluebot.messages.send(
{
...(key.startsWith('chat:') ? { chatId: key.slice('chat:'.length) } : { to: key }),
text,
metadata: { slack_channel: posted.channel, slack_ts: posted.ts, slack_user: posted.user },
},
{ idempotencyKey: `slack-${posted.channel}-${posted.ts}` }, // Slack's retries never send twice
);
if (sent.hold) await reply(`Waiting: ${sent.hold.description}`);
} catch (error) {
if (!(error instanceof APIError)) throw error;
await reply(`Not sent. ${error.message}`);
}
});
async function start() {
await loadChannels();
await slack.start(Number(process.env.PORT ?? 3000));
console.log(`Listening on ${process.env.PORT ?? 3000}, with ${channelByKey.size} texting channels`);
}
start();A few choices in that code are worth knowing:
- A message that mentions someone is never sent.
@Dana can you take this?is meant for the team, so it stays in Slack with a reply saying so. The habit to teach the team is simple: write to the tenant in the channel, talk about it in a thread. - The idempotency key is the Slack message. Slack sends an event again when it doesn’t hear back fast enough, and the key
slack-<channel>-<ts>makes BlueBot answer a second send with the first text rather than a second one. - Pictures stay in BlueBot. A file’s address from BlueBot works for an hour, so the channel says a picture came rather than posting a link that stops working. To copy them into Slack, add the
files:writescope and upload each file withfiles.uploadV2within the hour. - Nothing is logged but ids and error codes. Texts from tenants are private; keep them out of logs.
Run it and connect both sides
Start the server where it has an https address, or on your laptop with a tunnel to port 3000.
npx tsx server.ts # or: flask --app server run --port 3000In Slack: open your app’s Event Subscriptions, turn on Enable Events and set the Request URL to your address with /slack/events at the end. Slack checks it at once and shows Verified. Under Subscribe to bot events, add message.groups and press Save Changes. If Slack asks you to reinstall the app, do.
In BlueBot: make a webhook to your address with /bluebot/webhooks at the end, in Developers → Webhooks or with the API:
/v1/webhookscurl -X POST "https://usebluebot.com/api/v1/webhooks" \
-H "Authorization: Bearer $BLUEBOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://texts.coastlinepm.example/bluebot/webhooks",
"events": [
"message.received",
"message.sent",
"message.failed",
"message.canceled"
],
"description": "Slack channels"
}'Copy the secret from the answer into BLUEBOT_WEBHOOK_SECRET and restart the server. The secret is shown once.
Test it with your own number
From your own phone, text your business number: “Testing the Slack channels.”
| You got | It means | Do next |
|---|---|---|
A new private channel, #text- and your number, with your text in it and your team invited | BlueBot to Slack works. | Write “Got it” in the channel, not in a thread. |
| “Got it” on your phone, and a tick on the Slack message | Slack to BlueBot works. | Reply from your phone and watch it appear in the channel. |
| No channel | BlueBot didn’t copy your text, or the webhook didn’t reach the server. | Check Which chats to copy is Everything, then open the webhook in Developers → Webhooks and read its deliveries. |
| A channel, but your message in it never went | Slack isn’t sending the app events. | Check Event Subscriptions shows Verified and lists message.groups, and that the app is in the channel (it made it, so it should be). |
| A thread reply: “Waiting: …” | The text is accepted and waiting, often for quiet hours. | Nothing; it goes when the hold ends. |
| A thread reply: “Not sent. …” | BlueBot refused the text, and the reply says why. | Fix what it names. A refusal needs a new message, since its key is spent. |
When it goes wrong
- Somebody texts STOP. BlueBot puts them on your stop list and cancels anything sent to them after that, so a message in their channel gets the thread reply “Not sent: they asked not to get texts from you.” Leave the channel be, or archive it.
- The channel list gets long. Archive a channel when the conversation is over. If that person texts again, the server makes a fresh channel with a short suffix on its name, since Slack keeps the archived channel’s name.
- Your Mac is asleep. Texts from Slack get the reply “Waiting: …” and go when the Mac wakes. Nothing comes in from tenants while it’s asleep either.
- The server was down for a while. BlueBot keeps trying each webhook for about three days, so the channels catch up when it’s back. Messages written in Slack while it was down weren’t sent; Slack gives up on events after a few tries.
- Two people answer the same tenant at once. Both texts go. A thread under the tenant’s message is a good place to say “I’ve got this one.”