diff --git a/features/bot_whitelist/api.ts b/features/bot_whitelist/api.ts new file mode 100644 index 0000000..c15ee45 --- /dev/null +++ b/features/bot_whitelist/api.ts @@ -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), + }, + }); +} diff --git a/features/bot_whitelist/command.ts b/features/bot_whitelist/command.ts new file mode 100644 index 0000000..004c214 --- /dev/null +++ b/features/bot_whitelist/command.ts @@ -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; diff --git a/features/bot_whitelist/index.ts b/features/bot_whitelist/index.ts new file mode 100644 index 0000000..9812e1b --- /dev/null +++ b/features/bot_whitelist/index.ts @@ -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 }; diff --git a/features/bot_whitelist/listener.ts b/features/bot_whitelist/listener.ts new file mode 100644 index 0000000..9a5897c --- /dev/null +++ b/features/bot_whitelist/listener.ts @@ -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 }; diff --git a/features/index.ts b/features/index.ts index e38c118..a720d1d 100644 --- a/features/index.ts +++ b/features/index.ts @@ -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, @@ -16,6 +17,7 @@ export const features = [ threadLock, threadDestroy, messageMatch, + botWhitelist, ]; export { slowmode, readonly, channelBan, shush, purge, threadLock, threadDestroy }; diff --git a/prisma/migrations/20260609015913_add_bot_whitelist/migration.sql b/prisma/migrations/20260609015913_add_bot_whitelist/migration.sql new file mode 100644 index 0000000..fca3ffc --- /dev/null +++ b/prisma/migrations/20260609015913_add_bot_whitelist/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1922fa1..7bf5be5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,3 +91,9 @@ model FlaggedMessage { threadTs String? createdAt DateTime @default(now()) } + +model BotWhitelist { + channelId String @id + enabled Boolean @default(false) + botIds String[] @default([]) +}