Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions features/bot_whitelist/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { getPrisma } from '../../utils/index.js';

export async function getWhitelistConfig(channelId: string) {
const prisma = getPrisma();
return await prisma.botWhitelist.findUnique({
where: { channelId },
});
}

export async function enableWhitelist(channelId: string) {
const prisma = getPrisma();
return await prisma.botWhitelist.upsert({
where: { channelId },
update: { enabled: true },
create: { channelId, enabled: true },
});
}

export async function disableWhitelist(channelId: string) {
const prisma = getPrisma();
return await prisma.botWhitelist.update({
where: { channelId },
data: { enabled: false },
});
}

export async function addBotToWhitelist(channelId: string, botId: string) {
const prisma = getPrisma();
const config = await getWhitelistConfig(channelId);

if (config?.botIds.includes(botId)) return config;

return await prisma.botWhitelist.upsert({
where: { channelId },
update: { botIds: { push: botId } },
create: { channelId, enabled: false, botIds: [botId] },
});
}

export async function removeBotFromWhitelist(channelId: string, botId: string) {
const prisma = getPrisma();
const config = await getWhitelistConfig(channelId);
if (!config) return null;

return await prisma.botWhitelist.update({
where: { channelId },
data: {
botIds: config.botIds.filter((id) => id !== botId),
},
});
}
160 changes: 160 additions & 0 deletions features/bot_whitelist/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { SlackCommandMiddlewareArgs, AllMiddlewareArgs } from '@slack/bolt';
import {
getChannelManagers,
isUserAdmin,
postEphemeral,
logInternal,
userClient,
client,
} from '../../utils/index.js';
import * as api from './api.js';

/**
* Handles the /bot-whitelist command.
* Strictly operates on the current channel to keep moderation simple and scoped.
*/
async function botWhitelistCommand({
payload: { text, channel_id, user_id },
ack,
}: SlackCommandMiddlewareArgs & AllMiddlewareArgs) {
await ack();
const args = text.split(' ').filter(Boolean);

if (args.length < 1) {
return await postEphemeral(
channel_id,
user_id,
'*Bot Whitelist (Current Channel Only)*\n' +
'• `/bot-whitelist status` - Show current settings\n' +
'• `/bot-whitelist enable` - Start protecting this channel\n' +
'• `/bot-whitelist disable` - Stop protecting this channel\n' +
'• `/bot-whitelist add @bot` - Allow a bot here\n' +
'• `/bot-whitelist remove @bot` - Remove bot from safe list'
);
}


const action = args[0].toLowerCase();

const botMatch = text.match(/<@([A-Z0-9]+)\|?.*>/);
const botId = botMatch?.[1] || (['add', 'remove'].includes(action) ? args[1]?.replace(/^@/, '') : undefined);

if (['add', 'remove'].includes(action) && !botId) {
return await postEphemeral(channel_id, user_id, 'A bot mention or ID is required.');
}

const isAdmin = await isUserAdmin(user_id);
let isManager = false;
try {
const managers = await getChannelManagers(channel_id);
isManager = managers.includes(user_id);
} catch (e) {
console.error('Failed to fetch channel managers, falling back to admin check:', e);
}

if (!isAdmin && !isManager) {
return await postEphemeral(channel_id, user_id, 'Only admins or channel managers can run this command.');
}

const auth = await client.auth.test();
const selfId = auth.user_id;

if (botId && (botId === selfId || botId.toLowerCase() === 'firehose')) {
return await postEphemeral(channel_id, user_id, 'Firehose is already exempt from bot protection and cannot be added or removed from the whitelist.');
}

try {
switch (action) {
case 'status': {
const config = await api.getWhitelistConfig(channel_id);
if (!config) {
await postEphemeral(channel_id, user_id, `No configuration found for <#${channel_id}>.`);
} else {
const channelStatus = config.enabled ? 'Enabled' : 'Disabled';
const botList = config.botIds.length > 0
? config.botIds.map(id => `<@${id}>`).join(', ')
: '_None_';

await postEphemeral(
channel_id,
user_id,
`*Whitelist Status for <#${channel_id}>*\n` +
`• *Protection:* ${channelStatus}\n` +
`• *Whitelisted Bots:* ${botList}`
);
}
break;
}

case 'enable':
await api.enableWhitelist(channel_id);
try {
await client.conversations.join({ channel: channel_id });
} catch (e) {
console.error(`Failed to join channel ${channel_id} during enable:`, e);
}
await postEphemeral(channel_id, user_id, `Bot whitelisting *enabled* for <#${channel_id}>. Firehose has joined the channel to monitor for bots.`);
await logInternal(`<@${user_id}> enabled bot whitelisting for <#${channel_id}>.`);
break;

case 'disable':
await api.disableWhitelist(channel_id);
await postEphemeral(channel_id, user_id, `Bot whitelisting *disabled* for <#${channel_id}>.`);
await logInternal(`<@${user_id}> disabled bot whitelisting for <#${channel_id}>.`);
break;

case 'add':
if (botId) {
await api.addBotToWhitelist(channel_id, botId);
await postEphemeral(channel_id, user_id, `Safe list updated: *${botId}* is now allowed in <#${channel_id}>.`);
await logInternal(`<@${user_id}> added bot *${botId}* to whitelist for <#${channel_id}>.`);
}
break;

case 'remove':
if (botId) {
const config = await api.getWhitelistConfig(channel_id);
await api.removeBotFromWhitelist(channel_id, botId);

if (config?.enabled) {
try {
const members = await client.conversations.members({ channel: channel_id });
const lowerBotId = botId.toLowerCase();

for (const memberId of members.members || []) {
try {
const info = await client.users.info({ user: memberId });
if (info.user?.is_bot) {
if (memberId.toLowerCase() === lowerBotId ||
info.user.name?.toLowerCase() === lowerBotId ||
info.user.real_name?.toLowerCase() === lowerBotId) {

await userClient.conversations.kick({ channel: channel_id, user: memberId });
await logInternal(`*Bot Protection:* Kicked bot *${info.user.real_name || memberId}* from <#${channel_id}> after removal from whitelist.`);
break;
}
}
} catch (memberError) {
console.error(`Error processing member ${memberId}:`, memberError);
}
}
} catch (e) {
console.error(`Detailed kick failure for ${botId}:`, e);
}
}

await postEphemeral(channel_id, user_id, `Removed *${botId}* from the whitelist for <#${channel_id}>.`);
await logInternal(`<@${user_id}> removed bot *${botId}* from whitelist for <#${channel_id}>.`);
}
break;

default:
await postEphemeral(channel_id, user_id, `Unknown action: *${action}*. Use: status, enable, disable, add, remove.`);
}
} catch (e) {
console.error('Error in botWhitelistCommand:', e);
await postEphemeral(channel_id, user_id, 'An error occurred while processing your request.');
}
}

export default botWhitelistCommand;
10 changes: 10 additions & 0 deletions features/bot_whitelist/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { App } from '@slack/bolt';
import botWhitelistCommand from './command.js';
import { onMemberJoined, messageListener } from './listener.js';

function register(app: App) {
app.command(/\/(.*dev-)?bot-whitelist$/, botWhitelistCommand);
app.event('member_joined_channel', onMemberJoined);
}

export { register, messageListener };
115 changes: 115 additions & 0 deletions features/bot_whitelist/listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { SlackEventMiddlewareArgs, AllMiddlewareArgs } from '@slack/bolt';
import { client, userClient, logInternal, deleteMessage, getMessageLink } from '../../utils/index.js';
import * as api from './api.js';

/**
* Listener for the member_joined_channel event.
* Automatically kicks bots that are not on the whitelist if protection is enabled for that channel.
*/
async function onMemberJoined({
event,
}: SlackEventMiddlewareArgs<'member_joined_channel'> & AllMiddlewareArgs) {
const { user, channel } = event;

try {
const config = await api.getWhitelistConfig(channel);
if (!config || !config.enabled) return;

const [userInfo, authInfo] = await Promise.all([
client.users.info({ user }),
client.auth.test(),
]);

const isBot = userInfo.user?.is_bot || false;
const isSelf = user === authInfo.user_id;

if (isSelf) return;

const username = userInfo.user?.name?.toLowerCase();
const realName = userInfo.user?.real_name?.toLowerCase();

const isWhitelisted = config.botIds.some(id => {
const lowerId = id.toLowerCase();
return lowerId === user.toLowerCase() ||
lowerId === username ||
lowerId === realName;
});

if (isBot && !isWhitelisted) {
await userClient.conversations.kick({
channel,
user,
});

await logInternal(`*Bot Protection:* Kicked unauthorized bot *${userInfo.user?.real_name || user}* from <#${channel}>.`);
}
} catch (e) {
console.error(`Error in onMemberJoined bot whitelist enforcement:`, e);
}
}

/**
* Message listener to catch unauthorized bot messages (including chat:write.public).
* Deletes top-level messages from bots not on the whitelist in protected channels.
*/
async function messageListener({
payload: message,
}: SlackEventMiddlewareArgs<'message'> & AllMiddlewareArgs) {
if (!message || !message.channel) return;
const { channel, ts, subtype } = message;

const isBot = subtype === 'bot_message' || 'bot_id' in message;
if (!isBot) return;

const isThread = 'thread_ts' in message && message.thread_ts !== ts;
if (isThread) return;

try {
const config = await api.getWhitelistConfig(channel);
if (!config || !config.enabled) return;

const botId = 'bot_id' in message ? (message.bot_id as string) : undefined;
const userId = 'user' in message ? (message.user as string) : undefined;
const identifier = userId || botId;

if (!identifier) return;

const [userInfo, authInfo] = await Promise.all([
client.users.info({ user: identifier }).catch(() => null),
client.auth.test(),
]);

if (identifier === authInfo.user_id || botId === authInfo.bot_id) return;

const username = userInfo?.user?.name?.toLowerCase();
const realName = userInfo?.user?.real_name?.toLowerCase();

const isWhitelisted = config.botIds.some((id) => {
const lowerId = id.toLowerCase();
return (
lowerId === identifier.toLowerCase() ||
lowerId === username ||
lowerId === realName
);
});

if (!isWhitelisted) {
const messageLink = getMessageLink(channel, ts);
const appId = userInfo?.user?.profile?.api_app_id || 'unknown';
const marketplaceLink = appId !== 'unknown'
? `\n*Manage this bot:* https://hackclub.slack.com/marketplace/${appId}`
: '';

await logInternal(
`*Bot Protection:* Deleted top-level message from unauthorized bot *${userInfo?.user?.real_name || identifier}* in <#${channel}>.\n` +
`*Original Message:* ${messageLink}${marketplaceLink}`
);

await deleteMessage(channel, ts);
}
} catch (e) {
console.error(`Error in messageListener bot whitelist enforcement:`, e);
}
}

export { onMemberJoined, messageListener };
2 changes: 2 additions & 0 deletions features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as purge from './purge/index.js';
import * as threadLock from './thread_lock/index.js';
import * as threadDestroy from './thread_destroy/index.js';
import * as messageMatch from './automod/index.js';
import * as botWhitelist from './bot_whitelist/index.js';

export const features = [
slowmode,
Expand All @@ -16,6 +17,7 @@ export const features = [
threadLock,
threadDestroy,
messageMatch,
botWhitelist,
];

export { slowmode, readonly, channelBan, shush, purge, threadLock, threadDestroy };
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "BotWhitelist" (
"channelId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"botIds" TEXT[] DEFAULT ARRAY[]::TEXT[],

CONSTRAINT "BotWhitelist_pkey" PRIMARY KEY ("channelId")
);
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,9 @@ model FlaggedMessage {
threadTs String?
createdAt DateTime @default(now())
}

model BotWhitelist {
channelId String @id
enabled Boolean @default(false)
botIds String[] @default([])
}