slack-selfbot
Advanced tools
Sorry, the diff of this file is not supported yet
| { | ||
| "tabWidth": 4 | ||
| } |
+216
-70
| #!/usr/bin/env bun | ||
| import { SlackClient } from 'slack-undoc-client'; | ||
| import { SlackClient } from "slack-undoc-client"; | ||
| const SLACK_COOKIE = process.env.SLACK_COOKIE; | ||
| const SLACK_XOXP = process.env.SLACK_XOXP; | ||
| const SLACK_WORKSPACE = process.env.SLACK_WORKSPACE || "hackclub"; | ||
| if (!SLACK_COOKIE) { | ||
| throw new Error('SLACK_COOKIE environment variable is not set.'); | ||
| throw new Error("SLACK_COOKIE environment variable is not set."); | ||
| } else if (!SLACK_XOXP) { | ||
| throw new Error('SLACK_XOXP environment variable is not set.'); | ||
| throw new Error("SLACK_XOXP environment variable is not set."); | ||
| } | ||
@@ -16,11 +17,13 @@ | ||
| cookie: SLACK_COOKIE, | ||
| workspace: 'hackclub', | ||
| }) | ||
| workspace: SLACK_WORKSPACE, | ||
| }); | ||
| const userInfo = await client.authTest(); | ||
| const userChannelId: string = (await client.callUnknown("conversations.open", { | ||
| users: userInfo.user_id, | ||
| }) as any).channel.id; | ||
| const selfUserInfo = await client.authTest(); | ||
| const userChannelId: string = ( | ||
| (await client.callUnknown("conversations.open", { | ||
| users: selfUserInfo.user_id, | ||
| })) as any | ||
| ).channel.id; | ||
| console.log(`Logged in as ${userInfo.user} (${userInfo.user_id})`); | ||
| console.log(`Logged in as ${selfUserInfo.user} (${selfUserInfo.user_id})`); | ||
@@ -32,8 +35,14 @@ let websocketUrl: string = `wss://wss-primary.slack.com/?token=${client.token}&sync_desync=1&slack_client=desktop&start_args=?agent=client&org_wide_aware=true&eac_cache_ts=true&cache_ts=0&name_tagging=true&only_self_subteams=true&connect_only=true&ms_latest=true&no_query_on_subscribe=1&flannel=3&lazy_channels=1&gateway_server=T09V59WQY1E-1&enterprise_id=E09V59WQY1E&batch_presence_aware=1`; | ||
| function chatPostEphemeral(channel: string, text: string, thread_ts?: string) { | ||
| async function chatPostEphemeral( | ||
| channel: string, | ||
| text: string, | ||
| thread_ts?: string, | ||
| blocks?: any[], | ||
| user?: string, | ||
| ) { | ||
| fetch("https://slack.com/api/chat.postEphemeral", { | ||
| method: "POST", | ||
| headers: { | ||
| "Authorization": `Bearer ${SLACK_XOXP}`, | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${SLACK_XOXP}`, | ||
| "Content-Type": "application/json;charset=utf-8", | ||
| }, | ||
@@ -44,13 +53,51 @@ body: JSON.stringify({ | ||
| thread_ts, | ||
| user: userInfo.user_id, | ||
| blocks, | ||
| user: user ?? selfUserInfo.user_id, | ||
| }), | ||
| }); | ||
| }) | ||
| .then((res) => res.json()) | ||
| .then((data) => { | ||
| if (!(data as any).ok) { | ||
| console.error("Error posting ephemeral message:", data); | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| console.error("Error posting ephemeral message:", err); | ||
| }); | ||
| } | ||
| function getChannelName(channelId: string): Promise<string> { | ||
| type UserInfo = { | ||
| type: "user"; | ||
| id: string; | ||
| userId: string; | ||
| displayName: string; | ||
| pronouns: string; | ||
| imageUrl: string; | ||
| }; | ||
| async function getUserInfo(userId: string): Promise<UserInfo> { | ||
| return fetch(`https://cachet.dunkirk.sh/users/${userId}`) | ||
| .then((res) => res.json()) | ||
| .then((data) => data as UserInfo); | ||
| } | ||
| async function getChannelName(channelId: string): Promise<string> { | ||
| return fetch(`https://flaron.halceon.dev/cid/${channelId}`) | ||
| .then(res => res.json()) | ||
| .then(data => (data as any).name ?? "unknown :("); | ||
| .then((res) => res.json()) | ||
| .then((data) => (data as any).name ?? "unknown :("); | ||
| } | ||
| async function getChannelNames(channelIds: string[]): Promise<string[]> { | ||
| const dedupedIds = [...new Set(channelIds)]; | ||
| const namesById = new Map( | ||
| await Promise.all( | ||
| dedupedIds.map( | ||
| async (id) => [id, await getChannelName(id)] as const, | ||
| ), | ||
| ), | ||
| ); | ||
| return channelIds.map((id) => namesById.get(id) ?? "unknown :("); | ||
| } | ||
| type MessageMetadata = { | ||
@@ -63,3 +110,3 @@ text: string; | ||
| thread_ts?: string; | ||
| } | ||
| }; | ||
@@ -71,3 +118,3 @@ type ShallowMessageMetadata = { | ||
| thread_ts?: string; | ||
| } | ||
| }; | ||
@@ -78,3 +125,3 @@ type Command = { | ||
| handler: (msg: MessageMetadata, args: string[]) => Promise<void>; | ||
| } | ||
| }; | ||
@@ -86,3 +133,3 @@ type ReactionTrigger = { | ||
| any?: (msg: ShallowMessageMetadata, reaction: string) => Promise<void>; | ||
| } | ||
| }; | ||
@@ -92,3 +139,5 @@ function formatHelpText() { | ||
| .map(([name, command]) => { | ||
| const usage = command.args ? `*${name}* ${command.args}` : `*${name}*`; | ||
| const usage = command.args | ||
| ? `*${name}* ${command.args}` | ||
| : `*${name}*`; | ||
| return `- ${usage}: ${command.description}`; | ||
@@ -105,7 +154,7 @@ }) | ||
| // @ts-ignore | ||
| text: `Waiter, waiter! One more <https://hackclub.slack.com/archives/${msg.channel}/p${msg.ts.replace(".","")}|OOC> please!`, | ||
| text: `Waiter, waiter! One more <https://${SLACK_WORKSPACE}.slack.com/archives/${msg.channel}/p${msg.ts.replace(".", "")}|OOC> please!`, | ||
| }); | ||
| }, | ||
| }, | ||
| "private": { | ||
| private: { | ||
| me: async (msg: ShallowMessageMetadata) => { | ||
@@ -130,16 +179,29 @@ const message = await client.conversationsHistory({ | ||
| const channels = [...messageText.matchAll(/<#(\w+)(?:\|[^>]*)?>/g)]; | ||
| const channelIds = channels.flatMap((channel) => | ||
| channel[1] ? [channel[1]] : [], | ||
| ); | ||
| const channelNames = await Promise.all(channels.map(channel => getChannelName(channel[1] ?? ""))); | ||
| const channelNames = await getChannelNames(channelIds); | ||
| const constructedMessage = channels.length > 0 | ||
| ? channels.map((channel, index) => `\`${channel[1]}\`: ${channelNames[index]}`).join("\n") | ||
| : "No channels found in message"; | ||
| const constructedMessage = | ||
| channelIds.length > 0 | ||
| ? channelIds | ||
| .map( | ||
| (channelId, index) => | ||
| `\`${channelId}\`: ${channelNames[index]}`, | ||
| ) | ||
| .join("\n") | ||
| : "No channels found in message"; | ||
| chatPostEphemeral(msg.channel, constructedMessage, msg.thread_ts); | ||
| await chatPostEphemeral( | ||
| msg.channel, | ||
| constructedMessage, | ||
| msg.thread_ts, | ||
| ); | ||
| }, | ||
| }, | ||
| } | ||
| }; | ||
| const COMMANDS: Record<string, Command> = { | ||
| "help": { | ||
| help: { | ||
| description: "Show this help message.", | ||
@@ -151,17 +213,27 @@ args: "[command]", | ||
| const command = COMMANDS[commandName]; | ||
| const usage = command.args ? `${commandName} ${command.args}` : commandName; | ||
| chatPostEphemeral(msg.channel, `${usage}: ${command.description}`, msg.thread_ts); | ||
| const usage = command.args | ||
| ? `${commandName} ${command.args}` | ||
| : commandName; | ||
| await chatPostEphemeral( | ||
| msg.channel, | ||
| `${usage}: ${command.description}`, | ||
| msg.thread_ts, | ||
| ); | ||
| return; | ||
| } | ||
| chatPostEphemeral(msg.channel, `Available commands:\n${formatHelpText()}`, msg.thread_ts); | ||
| await chatPostEphemeral( | ||
| msg.channel, | ||
| `Available commands:\n${formatHelpText()}`, | ||
| msg.thread_ts, | ||
| ); | ||
| }, | ||
| }, | ||
| "ping": { | ||
| ping: { | ||
| description: "Check whether the selfbot is running.", | ||
| handler: async (msg: MessageMetadata, args: string[]) => { | ||
| chatPostEphemeral(msg.channel, `Pong!`, msg.thread_ts); | ||
| await chatPostEphemeral(msg.channel, `Pong!`, msg.thread_ts); | ||
| }, | ||
| }, | ||
| "echo": { | ||
| echo: { | ||
| description: "Send a message as yourself.", | ||
@@ -180,3 +252,3 @@ args: "<text>", | ||
| }, | ||
| "id": { | ||
| id: { | ||
| description: "Get the ID of a user, channel or usergroup.", | ||
@@ -187,3 +259,7 @@ args: "<@user|#channel|@usergroup>", | ||
| if (!target) { | ||
| chatPostEphemeral(msg.channel, "Please provide a user, channel or usergroup.", msg.thread_ts); | ||
| await chatPostEphemeral( | ||
| msg.channel, | ||
| "Please provide a user, channel or usergroup.", | ||
| msg.thread_ts, | ||
| ); | ||
| return; | ||
@@ -199,3 +275,6 @@ } | ||
| id = target.slice(2, -1); | ||
| } else if (target.startsWith("<!subteam^") && target.endsWith(">")) { | ||
| } else if ( | ||
| target.startsWith("<!subteam^") && | ||
| target.endsWith(">") | ||
| ) { | ||
| // Usergroup | ||
@@ -206,21 +285,28 @@ id = target.slice(10, -1); | ||
| if (!id) { | ||
| chatPostEphemeral(msg.channel, "Invalid user, channel or usergroup.", msg.thread_ts); | ||
| await chatPostEphemeral( | ||
| msg.channel, | ||
| "Invalid user, channel or usergroup.", | ||
| msg.thread_ts, | ||
| ); | ||
| return; | ||
| } | ||
| chatPostEphemeral(msg.channel, id, msg.thread_ts); | ||
| await chatPostEphemeral(msg.channel, id, msg.thread_ts); | ||
| }, | ||
| }, | ||
| } | ||
| }; | ||
| function connect() { | ||
| if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) { | ||
| if ( | ||
| ws && | ||
| (ws.readyState === WebSocket.CONNECTING || | ||
| ws.readyState === WebSocket.OPEN) | ||
| ) { | ||
| return; | ||
| } | ||
| ws = new WebSocket( | ||
| websocketUrl, { | ||
| ws = new WebSocket(websocketUrl, { | ||
| headers: { | ||
| Cookie: `d=${SLACK_COOKIE}` | ||
| } | ||
| Cookie: `d=${SLACK_COOKIE}`, | ||
| }, | ||
| }); | ||
@@ -248,30 +334,90 @@ | ||
| await trigger.any(msg, data.reaction); | ||
| } else if (data.user === userInfo.user_id && trigger.me) { | ||
| } else if ( | ||
| data.user === selfUserInfo.user_id && | ||
| trigger.me | ||
| ) { | ||
| await trigger.me(msg, data.reaction); | ||
| } | ||
| } | ||
| break; | ||
| case "message": | ||
| if (data.subtype !== "me_message" || data.user !== userInfo.user_id) return; | ||
| client.chatDelete({ | ||
| channel: data.channel, | ||
| ts: data.ts, | ||
| }); | ||
| const messageText: string = data.text.trim(); | ||
| const [command, ...args] = messageText.split(/\s+/); | ||
| if (!command) return; | ||
| if ( | ||
| data.subtype === "me_message" && | ||
| data.user === selfUserInfo.user_id | ||
| ) { | ||
| client.chatDelete({ | ||
| channel: data.channel, | ||
| ts: data.ts, | ||
| }); | ||
| const messageText: string = data.text.trim(); | ||
| const [command, ...args] = messageText.split(/\s+/); | ||
| if (!command) return; | ||
| const msg: MessageMetadata = { | ||
| text: messageText, | ||
| blocks: data.blocks, | ||
| ts: data.ts, | ||
| channel: data.channel, | ||
| user: data.user, | ||
| thread_ts: data.thread_ts, | ||
| }; | ||
| const msg: MessageMetadata = { | ||
| text: messageText, | ||
| blocks: data.blocks, | ||
| ts: data.ts, | ||
| channel: data.channel, | ||
| user: data.user, | ||
| thread_ts: data.thread_ts, | ||
| }; | ||
| const commandDef = COMMANDS[command]; | ||
| if (commandDef) { | ||
| await commandDef.handler(msg, args); | ||
| const commandDef = COMMANDS[command]; | ||
| if (commandDef) { | ||
| await commandDef.handler(msg, args); | ||
| } | ||
| } else if ( | ||
| data.subtype === "message_changed" || | ||
| data.subtype === "message_deleted" | ||
| ) { | ||
| const oldMessage = data.previous_message; | ||
| const subtype = data.subtype; | ||
| if (selfUserInfo.user_id === oldMessage.user) return; // skip self | ||
| if (oldMessage.metadata?.event_type === "anchor") return; // skip anchors | ||
| if (oldMessage.app_id) return; // skip bots | ||
| if (data.subtype === "message_changed" && (oldMessage.attachments?.length !== data.message?.attachments?.length)) return; // skip url unfurls | ||
| if ( | ||
| data.ts === "1780106006.044689" && | ||
| data.channel === "C0266FRGT" | ||
| ) | ||
| return; // thanks manitej! | ||
| const userInfo = await getUserInfo(oldMessage.user); | ||
| const action = | ||
| subtype === "message_changed" ? "edited" : "deleted"; | ||
| //console.log(`User ${userInfo.displayName} (${userInfo.userId}) ${action} a message in channel ${data.channel}: ${oldMessage.text}`); | ||
| let blocks: any[] = oldMessage.blocks; | ||
| const editedTs = | ||
| subtype === "message_changed" | ||
| ? data.message.ts.split(".")[0] | ||
| : Math.round(new Date().getTime() / 1000); | ||
| const fallbackTsText = new Date( | ||
| editedTs * 1000, | ||
| ).toLocaleString(); | ||
| const messageHardLink = `https://${SLACK_WORKSPACE}.slack.com/archives/${data.channel}/p${oldMessage.ts.replace(".", "")}`; | ||
| if (blocks[0] && blocks[0].type === "rich_text") { | ||
| blocks[0].elements[0].elements.unshift({ | ||
| type: "text", | ||
| text: ": ", | ||
| }); | ||
| blocks[0].elements[0].elements.unshift({ | ||
| type: "user", | ||
| user_id: oldMessage.user, | ||
| }); | ||
| } | ||
| blocks.push({ | ||
| type: "context", | ||
| elements: [ | ||
| { | ||
| type: "mrkdwn", | ||
| text: `<${messageHardLink}|${action}> at <!date^${editedTs}^{time_secs} {date_short_pretty}|${fallbackTsText}>`, | ||
| }, | ||
| ], | ||
| }); | ||
| await chatPostEphemeral( | ||
| data.channel, | ||
| `${userInfo.displayName}: ${oldMessage.text} (${action})`, | ||
| oldMessage.thread_ts, | ||
| blocks, | ||
| undefined, | ||
| ); | ||
| } | ||
@@ -278,0 +424,0 @@ break; |
+9
-2
@@ -6,7 +6,14 @@ { | ||
| "readme": "README.md", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "bin": "./index.ts", | ||
| "scripts": { | ||
| "format": "prettier -w ." | ||
| }, | ||
| "engines": { | ||
| "node": ">=20" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/bun": "^1.3.14", | ||
| "@types/node": "^26.1.0" | ||
| "@types/node": "^26.1.0", | ||
| "prettier": "^3.9.4" | ||
| }, | ||
@@ -13,0 +20,0 @@ "peerDependencies": { |
+12
-0
@@ -5,2 +5,14 @@ # slack-selfbot | ||
| ## What does it do? | ||
| - Lets you run selfbot commands by using `/me` messages | ||
| - Supports these commands: | ||
| - `help [command]` - show available commands or details for one command | ||
| - `ping` - check whether the selfbot is alive | ||
| - `echo <text>` - send a normal message as yourself with mrkdwn support | ||
| - `id <@user|#channel|@usergroup>` - gets the SLack ID from the provided parameter | ||
| - Watches for a couple reaction shortcuts: | ||
| - `:i-would-ooc-this-but-i-cant:` - DMs you every time someone reacts to a message with it | ||
| - `:private:` - gives you the name of the private channel | ||
| ## Setup | ||
@@ -7,0 +19,0 @@ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
18674
46.99%9
28.57%442
47.83%54
28.57%3
50%5
25%3
50%