├── src-discord-starboard-bot ├── intents.ts ├── handlers │ ├── messageReactionAdd.js │ └── ready.js ├── classes │ ├── MessageCache.js │ └── Starboard.js └── config.json ├── assets └── demo.gif ├── .github └── FUNDING.yml ├── LICENSE └── README.md /src-discord-starboard-bot/intents.ts: -------------------------------------------------------------------------------- 1 | module.exports = []; 2 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterthehan/discord-starboard-bot/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [peterthehan] 2 | patreon: peterthehan 3 | ko_fi: peterthehan 4 | custom: ["https://paypal.me/peterthehan", "https://venmo.com/peterthehan"] 5 | -------------------------------------------------------------------------------- /src-discord-starboard-bot/handlers/messageReactionAdd.js: -------------------------------------------------------------------------------- 1 | const MessageCache = require("../classes/MessageCache"); 2 | const Starboard = require("../classes/Starboard"); 3 | 4 | const messageCache = new MessageCache(); 5 | 6 | module.exports = async (messageReaction, user) => { 7 | const starboard = new Starboard(messageReaction, user); 8 | if (!starboard.validateInput()) { 9 | return; 10 | } 11 | 12 | starboard.handleVote(); 13 | 14 | if (!starboard.validateRules() || !starboard.validateCache(messageCache)) { 15 | return; 16 | } 17 | 18 | starboard.sendWebhook(); 19 | starboard.sendPinnedIndicatorMessage(); 20 | }; 21 | -------------------------------------------------------------------------------- /src-discord-starboard-bot/handlers/ready.js: -------------------------------------------------------------------------------- 1 | const rules = require("../config.json"); 2 | 3 | module.exports = async (client) => { 4 | console.log(__dirname.split("\\").slice(-2)[0]); 5 | 6 | client.starboardRules = {}; 7 | rules.forEach((rule) => { 8 | if (!(rule.guildId in client.starboardRules)) { 9 | client.starboardRules[rule.guildId] = []; 10 | } 11 | 12 | client.starboardRules[rule.guildId].push({ 13 | emojis: new Set([ 14 | ...rule.upvote.emojis, 15 | ...rule.upvote.overrideEmojis, 16 | ...rule.downvote.emojis, 17 | ...rule.downvote.overrideEmojis, 18 | ]), 19 | rule, 20 | }); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src-discord-starboard-bot/classes/MessageCache.js: -------------------------------------------------------------------------------- 1 | module.exports = class MessageCache { 2 | constructor() { 3 | this.cache = {}; 4 | } 5 | 6 | has(key) { 7 | return key in this.cache; 8 | } 9 | 10 | createMessage(key) { 11 | this.cache[key] = { 12 | starred: false, 13 | upvoteUsers: new Set(), 14 | downvoteUsers: new Set(), 15 | }; 16 | } 17 | 18 | isStarred(key) { 19 | return this.cache[key].starred; 20 | } 21 | 22 | addToUpvoteUsers(key, userId) { 23 | this.cache[key].upvoteUsers.add(userId); 24 | } 25 | 26 | addToDownvoteUsers(key, userId) { 27 | this.cache[key].downvoteUsers.add(userId); 28 | } 29 | 30 | getNetVotes(key) { 31 | return ( 32 | this.cache[key].upvoteUsers.size - this.cache[key].downvoteUsers.size 33 | ); 34 | } 35 | 36 | clearMessage(key) { 37 | this.cache[key].starred = true; 38 | delete this.cache[key].upvoteUsers; 39 | delete this.cache[key].downvoteUsers; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src-discord-starboard-bot/config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guildId": "258167954913361930", 4 | "channelId": "752760008483012639", 5 | "reactionThreshold": 2, 6 | "votePolicy": "hidden", 7 | "upvote": { 8 | "emojis": ["⭐", "⬆️"], 9 | "overrideEmojis": ["🌟"], 10 | "overrideUserIds": [], 11 | "overrideRoleIds": ["759669237789753355"] 12 | }, 13 | "downvote": { 14 | "emojis": ["⬇️"], 15 | "overrideEmojis": ["⛔"], 16 | "overrideUserIds": ["206161807491072000"], 17 | "overrideRoleIds": [] 18 | }, 19 | "pinnedIndicator": { 20 | "message": "{1}'s message was pinned to {2}.", 21 | "pingUser": true 22 | }, 23 | "embed": { 24 | "color": "ffac33", 25 | "footerText": "Starboard", 26 | "jumpText": "link", 27 | "renderJumpLink": true 28 | }, 29 | "ignore": { 30 | "rules": true, 31 | "nsfw": false, 32 | "self": true, 33 | "botMessage": false, 34 | "channelIds": ["649020657522180128"] 35 | } 36 | } 37 | ] 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Peter Han 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discord Starboard Bot 2 | 3 | [![Discord](https://discord.com/api/guilds/258167954913361930/embed.png)](https://discord.gg/WjEFnzC) [![Twitter Follow](https://img.shields.io/twitter/follow/peterthehan.svg?style=social)](https://twitter.com/peterthehan) 4 | 5 | A Discord bot that allows for the democratic pinning of messages. 6 | 7 |
8 | demo 12 |
13 | 14 | ## Setup 15 | 16 | 1. Follow the instructions in [create-discord-bot](https://github.com/peterthehan/create-discord-bot). 17 | 18 | > Don't forget to give your bot the `Manage Webhooks` permission! 19 | 20 | 2. Download this bot and move the `src-discord-starboard-bot` folder into the [/src/bots](https://github.com/peterthehan/create-discord-bot/tree/master/src/bots) folder from step 1. 21 | 22 | 3. Open [config.json](./src-discord-starboard-bot/config.json) to configure your own settings: 23 | 24 | ```json 25 | [ 26 | { 27 | "guildId": "258167954913361930", 28 | "channelId": "752760008483012639", 29 | "reactionThreshold": 2, 30 | "votePolicy": "hidden", 31 | "upvote": { 32 | "emojis": ["⭐", "⬆️"], 33 | "overrideEmojis": ["🌟"], 34 | "overrideUserIds": [], 35 | "overrideRoleIds": ["759669237789753355"] 36 | }, 37 | "downvote": { 38 | "emojis": ["⬇️"], 39 | "overrideEmojis": ["⛔"], 40 | "overrideUserIds": ["206161807491072000"], 41 | "overrideRoleIds": [] 42 | }, 43 | "pinnedIndicator": { 44 | "message": "{1}'s message was pinned to {2}.", 45 | "pingUser": true 46 | }, 47 | "embed": { 48 | "color": "ffac33", 49 | "footerText": "Starboard", 50 | "jumpText": "link", 51 | "renderJumpLink": true 52 | }, 53 | "ignore": { 54 | "rules": true, 55 | "nsfw": false, 56 | "self": true, 57 | "botMessage": false, 58 | "channelIds": ["649020657522180128"] 59 | } 60 | } 61 | ] 62 | ``` 63 | 64 | You can have multiple starboards in one server! Simply add more rule objects inside the `config.json` file. The only requirement is that **all** the `emojis` between each rule **must** be unique. 65 | 66 | - `guildId` is the server you wish to enable starboard for. 67 | - `channelId` is the text channel you wish to be the starboard. 68 | - `reactionThreshold` is the number of reactions needed before the message gets pinned. 69 | - `votePolicy` **must** be one of the following strings: 70 | 71 | - `public`: Reaction count is allowed to accumulate without bot interference. Anyone can see the total reaction counts. 72 | - `private`: Bot removes all user reactions and replaces it with its own. Vote count is not visible but the bot's reaction lets users know that _someone_ has reacted. 73 | - `hidden`: Bot removes all user reactions. Vote count is not visible at all. 74 | 75 | - `upvote`/`downvote` 76 | 77 | - `emojis` are the reaction emojis the bot tracks. The message gets pinned when the count difference between upvotes and downvotes is greater than or equal to the `reactionThreshold`. An emoji can be: 78 | 79 | - A unicode emoji. https://emojipedia.org is a good reference to copy and paste from. 80 | 81 | ``` 82 | "😳", "🥺", // etc 83 | ``` 84 | 85 | - An emoji ID for custom emojis. You can get a custom emoji's ID by sending `\:YourCustomEmoji:` in chat (prefix a backslash `\` character in front of your desired emoji). 86 | 87 | ``` 88 | "716344914706694165", "622635442013208589", // etc 89 | ``` 90 | 91 | - `overrideEmojis` are the reaction emojis that automatically pins the message (`upvote`) or prevents the pinning of the message (`downvote`) no matter what the count was at. 92 | - `overrideUserIds` are the users who can use `overrideEmojis`. 93 | - `overrideRoleIds` are the roles whose assigned users can use `overrideEmojis`. 94 | 95 | > Leave `downvote`'s array options empty `[]` if you wish to not use the downvote logic. 96 | 97 | - `pinnedIndicator` 98 | 99 | - `message` is the string the bot sends post-pin in the channel where the original message is. 100 | - Use `{1}` to mention the user whose message was pinned and use `{2}` to mention the starboard channel (it uses `channelId`). 101 | - If blank, a post-pin message will not be sent. 102 | - `pingUser` determines whether `message` pings the user (`true`) or not (`false`). 103 | - The bot makes a quick message edit to mention the user, thus avoiding the ping. 104 | 105 | - `embed` 106 | 107 | - `color` is the color of the embed. 108 | - `footerText` is the text rendered at the footer of the embed. 109 | - `jumpText` is the hyperlink text rendered to jump to the message. 110 | - `renderJumpLink` determines whether the hyperlink text renders (`true`) or not (`false`). 111 | 112 | - `ignore` 113 | 114 | - `rules` determines whether override users can ignore all the rules listed below (`true`) or not (`false`). 115 | - `nsfw` determines whether the bot ignores NSFW channels (`true`) or not (`false`). 116 | - `self` determines whether the bot ignores reactions made by the message's author (`true`) or not (`false`). 117 | - `botMessage` determines whether the bot ignores bot messages (`true`) or not (`false`). 118 | - `channelIds` are the text channels the bot ignores. 119 | 120 | 4. `npm start` to run the bot. 121 | 122 | Visit for more help or information! 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src-discord-starboard-bot/classes/Starboard.js: -------------------------------------------------------------------------------- 1 | module.exports = class Starboard { 2 | constructor(messageReaction, user) { 3 | this.messageReaction = messageReaction; 4 | this.user = user; 5 | this.emoji = messageReaction.emoji.id || messageReaction.emoji.name; 6 | this.message = messageReaction.message; 7 | this.client = user.client; 8 | this.rule = null; 9 | } 10 | 11 | validateInput() { 12 | if ( 13 | this.message.system || 14 | this.user.bot || 15 | this.user.system || 16 | this.message.channel.type === "DM" || 17 | !(this.message.guild.id in this.client.starboardRules) 18 | ) { 19 | return false; 20 | } 21 | 22 | const rules = this.client.starboardRules[this.message.guild.id]; 23 | const { rule } = rules.find((rule) => rule.emojis.has(this.emoji)) || { 24 | rule: false, 25 | }; 26 | this.rule = rule; 27 | 28 | return Boolean(this.rule); 29 | } 30 | 31 | handleVote() { 32 | if (this.rule.votePolicy === "public") { 33 | return; 34 | } 35 | 36 | if (this.rule.votePolicy === "private") { 37 | if (!this.messageReaction.users.cache.has(this.client.user.id)) { 38 | this.message.react(this.emoji); 39 | } 40 | 41 | this.messageReaction.users.remove(this.user); 42 | return; 43 | } 44 | 45 | if (this.rule.votePolicy === "hidden") { 46 | this.messageReaction.users.remove(this.user); 47 | return; 48 | } 49 | } 50 | 51 | validateRules() { 52 | return ( 53 | (this.rule.ignore.rules && 54 | (this.validateOverrides(this.rule.upvote) || 55 | this.validateOverrides(this.rule.downvote))) || 56 | !( 57 | (this.rule.ignore.nsfw && this.message.channel.nsfw) || 58 | (this.rule.ignore.self && this.message.author === this.user) || 59 | (this.rule.ignore.botMessage && this.message.author.bot) || 60 | this.rule.ignore.channelIds.includes(this.message.channel.id) 61 | ) 62 | ); 63 | } 64 | 65 | validateOverrides(voteType) { 66 | const memberRoles = 67 | this.message.guild.members.resolve(this.user.id).roles.cache || new Map(); 68 | 69 | return ( 70 | voteType.overrideEmojis.includes(this.emoji) && 71 | (voteType.overrideUserIds.includes(this.user.id) || 72 | voteType.overrideRoleIds.some((roleId) => memberRoles.has(roleId))) 73 | ); 74 | } 75 | 76 | validateEmoji(voteType) { 77 | return voteType.emojis.includes(this.emoji); 78 | } 79 | 80 | validateCache(messageCache) { 81 | const key = this.message.id; 82 | 83 | if (!messageCache.has(key)) { 84 | messageCache.createMessage(key); 85 | } 86 | 87 | if (messageCache.isStarred(key)) { 88 | return false; 89 | } 90 | 91 | if (this.validateOverrides(this.rule.upvote)) { 92 | messageCache.clearMessage(key); 93 | return true; 94 | } 95 | 96 | if (this.validateOverrides(this.rule.downvote)) { 97 | messageCache.clearMessage(key); 98 | return false; 99 | } 100 | 101 | if (this.validateEmoji(this.rule.upvote)) { 102 | messageCache.addToUpvoteUsers(key, this.user.id); 103 | } else if (this.validateEmoji(this.rule.downvote)) { 104 | messageCache.addToDownvoteUsers(key, this.user.id); 105 | } 106 | 107 | const isValid = 108 | messageCache.getNetVotes(key) >= this.rule.reactionThreshold; 109 | if (isValid) { 110 | messageCache.clearMessage(key); 111 | } 112 | 113 | return isValid; 114 | } 115 | 116 | async getWebhook() { 117 | const channel = await this.client.channels.fetch(this.rule.channelId); 118 | const webhooks = await channel.fetchWebhooks(); 119 | 120 | return !webhooks.size 121 | ? channel.createWebhook(this.client.user.username) 122 | : webhooks.first(); 123 | } 124 | 125 | getImages() { 126 | const embeds = [ 127 | ...this.message.attachments.map(({ url }) => ({ image: { url } })), 128 | ...this.message.embeds, 129 | ] 130 | .slice(0, 4) 131 | .map(({ image }) => ({ image, url: this.message.url })); 132 | 133 | return embeds.length ? embeds : [{}]; 134 | } 135 | 136 | createEmbeds() { 137 | const embeds = this.getImages(); 138 | const description = `${this.message.author} | ${this.message.channel}\n${ 139 | this.message.content 140 | }${ 141 | this.rule.embed.renderJumpLink 142 | ? ` [[${this.rule.embed.jumpText}]](${this.message.url})` 143 | : "" 144 | }`; 145 | 146 | embeds[0] = { 147 | ...embeds[0], 148 | description, 149 | url: this.message.url, 150 | timestamp: this.message.createdAt, 151 | color: this.rule.embed.color, 152 | footer: { 153 | text: this.rule.embed.footerText, 154 | icon_url: this.message.author.displayAvatarURL(), 155 | }, 156 | }; 157 | 158 | return embeds; 159 | } 160 | 161 | async sendWebhook() { 162 | const webhook = await this.getWebhook(); 163 | const embeds = this.createEmbeds(); 164 | const webhookOptions = { 165 | username: this.message.guild.members.resolve(this.client.user.id) 166 | .displayName, 167 | avatarURL: this.client.user.displayAvatarURL(), 168 | }; 169 | 170 | webhook.send({ embeds, ...webhookOptions }); 171 | } 172 | 173 | async sendPinnedIndicatorMessage() { 174 | if (!this.rule.pinnedIndicator.message) { 175 | return; 176 | } 177 | 178 | if (this.rule.pinnedIndicator.pingUser) { 179 | return this.message.channel.send( 180 | this.rule.pinnedIndicator.message 181 | .replace(/\{1\}/g, this.message.author) 182 | .replace(/\{2\}/g, `<#${this.rule.channelId}>`) 183 | ); 184 | } 185 | 186 | const nonMentionable = `\`@${this.message.author.tag}\``; 187 | 188 | const newMessage = await this.message.channel.send( 189 | this.rule.pinnedIndicator.message 190 | .replace(/\{1\}/g, nonMentionable) 191 | .replace(/\{2\}/g, `<#${this.rule.channelId}>`) 192 | ); 193 | newMessage.edit( 194 | newMessage.content.replace( 195 | new RegExp(nonMentionable, "gi"), 196 | this.message.author 197 | ) 198 | ); 199 | } 200 | }; 201 | --------------------------------------------------------------------------------