this repo has no description

feat: add browser tokens for channel manager checks

dunkirk.sh 7c725e6c 0fb23519

verified
+8 -1
.env.example
···
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
+
# Slack workspace URL (for admin API calls)
+
SLACK_API_URL=https://hackclub.enterprise.slack.com
+
+
# Optional: For channel manager permission checks
+
SLACK_USER_COOKIE=your-slack-cookie-here
+
SLACK_USER_TOKEN=your-user-token-here
+
# IRC Configuration
IRC_NICK=slackbridge
# Admin users (comma-separated Slack user IDs)
-
ADMINS=U1234567890
+
ADMINS=U1234567890,U0987654321
# Hack Club CDN Token (for file uploads)
CDN_TOKEN=your-cdn-token-here
+11 -1
README.md
···
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
+
# Slack workspace URL (for admin API calls)
+
SLACK_API_URL=https://hackclub.enterprise.slack.com
+
+
# Optional: For channel manager permission checks
+
SLACK_USER_COOKIE=your-slack-cookie-here
+
SLACK_USER_TOKEN=your-user-token-here
+
# IRC Configuration
IRC_NICK=slackbridge
# Admin users (comma-separated Slack user IDs)
-
ADMINS=U1234567890
+
ADMINS=U1234567890,U0987654321
# Hack Club CDN Token (for file uploads)
CDN_TOKEN=your-cdn-token-here
# Server Configuration (optional)
PORT=3000
+
+
# Note: Channel and user mappings are now stored in the SQLite database (bridge.db)
+
# Use the API or database tools to manage mappings
```
See `.env.example` for a template.
+23 -1
src/commands.ts
···
import type { AnyMessageBlock, Block, BlockElement } from "slack-edge";
import { channelMappings, userMappings } from "./db";
import { slackApp, ircClient } from "./index";
+
import { canManageChannel } from "./permissions";
export function registerCommands() {
// Link Slack channel to IRC channel
···
const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value;
// @ts-expect-error
const slackChannelId = payload.actions?.[0]?.value;
+
const userId = payload.user?.id;
+
if (!context.respond) {
return;
}
···
return;
}
+
// Check if user has permission to manage this channel
+
if (!userId || !(await canManageChannel(userId, slackChannelId))) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
+
replace_original: true,
+
});
+
return;
+
}
+
try {
channelMappings.create(slackChannelId, ircChannel);
ircClient.join(ircChannel);
···
// Unlink Slack channel from IRC
slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => {
const slackChannelId = payload.channel_id;
+
const userId = payload.user_id;
const mapping = channelMappings.getBySlackChannel(slackChannelId);
if (!mapping) {
···
return;
}
+
// Check if user has permission to manage this channel
+
if (!(await canManageChannel(userId, slackChannelId))) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
+
});
+
return;
+
}
+
context.respond({
response_type: "ephemeral",
text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?",
···
],
},
],
-
replace_original: true,
});
});
+21 -10
src/index.ts
···
function getAvatarForNick(nick: string): string {
let hash = 0;
for (let i = 0; i < nick.length; i++) {
-
hash = ((hash << 5) - hash) + nick.charCodeAt(i);
+
hash = (hash << 5) - hash + nick.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length];
···
// Parse IRC mentions and convert to Slack mentions
let messageText = parseIRCFormatting(text);
-
+
// Extract image URLs from the message
-
const imagePattern = /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
+
const imagePattern =
+
/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
const imageUrls = Array.from(messageText.matchAll(imagePattern));
-
+
// Find all @mentions and nick: mentions in the IRC message
const atMentionPattern = /@(\w+)/g;
const nickMentionPattern = /(\w+):/g;
-
+
const atMentions = Array.from(messageText.matchAll(atMentionPattern));
const nickMentions = Array.from(messageText.matchAll(nickMentionPattern));
-
+
for (const match of atMentions) {
const mentionedNick = match[1] as string;
const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
if (mentionedUserMapping) {
-
messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>`);
+
messageText = messageText.replace(
+
match[0],
+
`<@${mentionedUserMapping.slack_user_id}>`,
+
);
}
}
-
+
for (const match of nickMentions) {
const mentionedNick = match[1] as string;
const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
if (mentionedUserMapping) {
-
messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>:`);
+
messageText = messageText.replace(
+
match[0],
+
`<@${mentionedUserMapping.slack_user_id}>:`,
+
);
}
}
···
try {
const response = await fetch(
`https://cachet.dunkirk.sh/users/${userId}`,
+
{
+
// @ts-ignore - Bun specific option
+
tls: { rejectUnauthorized: false },
+
},
);
if (response.ok) {
const data = (await response.json()) as CachetUser;
···
if (response.ok) {
const data = await response.json();
-
+
// Send each uploaded file URL to IRC
for (const file of data.files) {
const fileMessage = `<${username}> ${file.deployedUrl}`;
+71
src/permissions.ts
···
+
/**
+
* Check if a user has permission to manage a Slack channel
+
* Returns true if the user is:
+
* - A global admin (in ADMINS env var)
+
* - The channel creator
+
* - A channel manager
+
*/
+
export async function canManageChannel(
+
userId: string,
+
channelId: string,
+
): Promise<boolean> {
+
// Check if user is a global admin
+
const admins = process.env.ADMINS?.split(",").map((id) => id.trim()) || [];
+
if (admins.includes(userId)) {
+
return true;
+
}
+
+
try {
+
// Check if user is channel creator
+
const channelInfo = await fetch(
+
"https://slack.com/api/conversations.info",
+
{
+
method: "POST",
+
headers: {
+
"Content-Type": "application/json",
+
Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+
},
+
body: JSON.stringify({ channel: channelId }),
+
},
+
).then((res) => res.json());
+
+
if (channelInfo.ok && channelInfo.channel?.creator === userId) {
+
return true;
+
}
+
+
// Check if user is a channel manager
+
if (
+
process.env.SLACK_USER_COOKIE &&
+
process.env.SLACK_USER_TOKEN &&
+
process.env.SLACK_API_URL
+
) {
+
const formdata = new FormData();
+
formdata.append("token", process.env.SLACK_USER_TOKEN);
+
formdata.append("entity_id", channelId);
+
+
const response = await fetch(
+
`${process.env.SLACK_API_URL}/api/admin.roles.entity.listAssignments`,
+
{
+
method: "POST",
+
headers: {
+
Cookie: process.env.SLACK_USER_COOKIE,
+
},
+
body: formdata,
+
},
+
);
+
+
const json = await response.json();
+
+
if (json.ok) {
+
const managers = json.role_assignments?.[0]?.users || [];
+
if (managers.includes(userId)) {
+
return true;
+
}
+
}
+
}
+
} catch (error) {
+
console.error("Error checking channel permissions:", error);
+
}
+
+
return false;
+
}