├── 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 | [](https://discord.gg/WjEFnzC) [](https://twitter.com/peterthehan) 4 | 5 | A Discord bot that allows for the democratic pinning of messages. 6 | 7 |
12 |
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 |
--------------------------------------------------------------------------------