forked from FreesideHull/FreesideBot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathbot.js
367 lines (328 loc) · 13.1 KB
/
bot.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
const auth = require("basic-auth");
const axios = require("axios");
const cheerio = require("cheerio");
const discord = require("discord.js");
const express = require("express");
const timingSafeCompare = require("tsscmp");
require("console-stamp")(console); // add console timestamp and log level info
// bot options (see README)
if (!process.env.DISCORD_TOKEN) {
console.error("Set required DISCORD_TOKEN environment variable.");
process.exit(1);
}
const DISCORD_TOKEN = process.env.DISCORD_TOKEN.trim();
const PUBLIC_REPLY_DECAY_TIME = process.env.PUBLIC_REPLY_DECAY_TIME || 120000;
const MAX_THREAD_TITLE_LENGTH = 100;
const API_SERVER_ENABLED = (process.env.API_SERVER_ENABLED ||
"true").trim().match("^(true|on|y|1)") != null;
const API_SERVER_HOST = (process.env.API_SERVER_HOST ||
"0.0.0.0").trim();
const API_SERVER_PORT = Number(process.env.API_SERVER_PORT) || 8080;
const API_SERVER_USERNAME = process.env.API_SERVER_USERNAME;
const API_SERVER_PASSWORD = process.env.API_SERVER_PASSWORD;
const STATS_ENABLED = (process.env.STATS_ENABLED || "true").trim()
.match("^(true|on|y|1)") != null;
const STATS_CATEGORY_NAME = (process.env.STATS_CATEGORY_NAME || "stats").trim();
const STATS_UPDATE_INTERVAL = Number(process.env.STATS_UPDATE_INTERVAL) ||
600000;
const STATS_COUNT_ROLES = (process.env.STATS_COUNT_ROLES || "").split(",")
.map(name => name.trim().toLowerCase())
.filter(name => name.length != 0);
// setup bot
botSetup(DISCORD_TOKEN).catch((e) => {
console.error(e);
process.exit(1);
});
// BOT CODE...
/*
Connect bot and setup event handlers.
*/
async function botSetup (token) {
const bot = new discord.Client({
intents: ["GUILDS", "GUILD_MESSAGES", "GUILD_MEMBERS",
"DIRECT_MESSAGES"]
});
bot.on("messageCreate", m => {
// ignore own and other bots
if (m.author.bot) return;
// deal with server text channels only
if (m.channel.type != "GUILD_TEXT") return;
// #news channel
if (m.channel.name == "news") return handleNewsMessage(m);
});
bot.login(token);
await new Promise((resolve, reject) => {
bot.once("ready", resolve);
bot.once("error", reject);
});
console.log("Bot is now online.");
let guild = bot.guilds.cache.first();
if (!guild) {
console.warn("Waiting for bot to be added to a guild...");
guild = await new Promise(resolve => {
bot.once("guildCreate", resolve);
});
console.log(`Joined guild '${guild.name}' (${guild.id}).`);
}
await guildStatsSetup(guild);
await apiServerSetup(bot, guild);
console.log("Ready.");
}
function apiServerSetup (bot, guild) {
if (!API_SERVER_ENABLED) return;
const server = express();
const listener = server.listen(API_SERVER_PORT, API_SERVER_HOST);
// basic logging of requests
server.use((req, res, next) => {
let requestedWith = req.get("User-Agent") || "";
if (requestedWith) requestedWith = " with " + requestedWith;
console.log("%s - Requested %s %s%s.", req.ip, req.method,
req.url, requestedWith);
next();
});
// setup GET request handlers
server.get("/messages", (req, res) => apiGetMessages(bot, guild, req, res));
server.get("/events", (req, res) => apiGetEvents(bot, guild, req, res));
server.get("/member", (req, res) => apiGetMember(bot, guild, req, res));
server.get(/\/.+/, (req, res) => res.redirect("/"));
// setup server listener
return new Promise((resolve, reject) => {
listener.once("listening", () => {
console.log("API server is listening on %s:%i.",
API_SERVER_HOST, listener.address().port);
resolve(server);
});
listener.once("error", e => reject(e));
});
}
function apiIsUnauthenticated(req, res) {
if (API_SERVER_USERNAME && API_SERVER_PASSWORD) {
const creds = auth(req);
if (!creds || !timingSafeCompare(creds.name, API_SERVER_USERNAME) ||
!timingSafeCompare(creds.pass, API_SERVER_PASSWORD)) {
res.setHeader("WWW-Authenticate", "Basic");
res.status(401).send({
error: "Authentication failed. Check username and password."
});
return true;
}
}
return false;
}
async function apiGetMessages (bot, guild, req, res) {
if (apiIsUnauthenticated(req, res)) return;
const channel = (await guild.channels.fetch()).find(
c => c instanceof discord.BaseGuildTextChannel &&
// check everyone can view channel (don't expose private channels)
c.permissionsFor(guild.roles.everyone).has("VIEW_CHANNEL") &&
(!req.query.channel || req.query.channel == c.name)
);
if (!channel) {
res.status(404).send({error: "Text channel not found."});
return;
}
res.send(await channel.messages.fetch({
limit: Math.min(Number(req.query.limit) || 25, 500),
cache: false
}));
}
async function apiGetEvents (bot, guild, req, res) {
if (apiIsUnauthenticated(req, res)) return;
res.send(await guild.scheduledEvents.fetch());
}
async function apiGetMember (bot, guild, req, res) {
if (apiIsUnauthenticated(req, res)) return;
const member = (await guild.members.fetch()).find(
m => (req.query.id && req.query.id == m.id) ||
(req.query.tag && m.user.tag.startsWith(req.query.tag))
);
if (!member) {
res.status(404).send({error: "Member not found."});
return;
}
res.send({
id: member.id,
tag: member.user.tag,
displayName: member.displayName,
// displayHexColor returns "#000000" if color not set, use null instead.
displayColor: member.displayColor == 0 ? null : member.displayHexColor,
// two avatar functions, what's the difference?
avatarUrl: member.displayAvatarURL() || member.avatarURL()
});
}
/*
Setup guild statistics display. Stats such as total member count, how many
members there are in certain roles, etc. are displayed as locked voice
channels.
*/
async function guildStatsSetup (guild) {
if (!STATS_ENABLED) return;
console.log("Setting up guild statistics (server stats shown as locked " +
`voice channels) on '${guild.name}' (${guild.id}).`);
// get roles specified in by name in STATS_COUNT_ROLES
const roles = guild.roles.cache
.filter(role => STATS_COUNT_ROLES.includes(role.name.toLowerCase()));
// array of stats to show (functions returning stat string)
const statFuncs = [
// total member count function
() => "Discord Members: " + guild.members.cache
.filter(m => !m.user.bot).size.toLocaleString(),
// role member count functions
...roles.map(role =>
() => `${role.name}: ${role.members.size.toLocaleString()}`)
];
await guildStatsUpdate(guild, statFuncs);
setInterval(() => guildStatsUpdate(guild, statFuncs).catch(console.error),
STATS_UPDATE_INTERVAL);
}
/*
Function ran on an interval (STATS_UPDATE_INTERVAL) ms to update stats.
*/
async function guildStatsUpdate (guild, statFuncs) {
// ensure updated channel and member lists
await Promise.all([guild.members.fetch(), guild.channels.fetch()]);
// get category with name specified by STATS_CATEGORY_NAME
const category = guild.channels.cache.find(c => c.type == "GUILD_CATEGORY"
&& c.name.toLowerCase() == STATS_CATEGORY_NAME.toLowerCase()) ||
// or create if one doesn't exist
await guild.channels.create(STATS_CATEGORY_NAME, {
name: STATS_CATEGORY_NAME,
type: "GUILD_CATEGORY",
permissionOverwrites: [{
id: guild.id,
deny: ["VIEW_CHANNEL"]
}]
});
// get channels under the stats category
const channels = guild.channels.cache
.filter(c => c.parent == category && c.type == "GUILD_VOICE")
.sort((a, b) => a.position - b.position);
// update stat readings (set voice channel names)
await Promise.all(
[...statFuncs.keys()].map(indexNum => {
const func = statFuncs[indexNum]; // func returns stat to display
if (indexNum >= channels.size) // if no channel for this stat func
return createLockedVoiceChannel(guild, func(), category);
return channels.at(indexNum).setName(func());
})
);
}
/*
Create a locked voice channel with set name and in specified category.
(used for creating channels showing stats in name)
*/
function createLockedVoiceChannel (guild, name, category) {
return guild.channels.create(name, {
type: "GUILD_VOICE",
parent: category,
permissionOverwrites: [{
id: guild.id,
deny: ["VIEW_CHANNEL"]
}]
});
}
/*
Called every time a message is posted into news channel.
*/
async function handleNewsMessage (message) {
/*
The news channel can only contain links to news stories.
A message posted which does not contain a working link will be rejected.
*/
const match = message.content.match(/https?:\/\/[^ "]{2,}/);
const title = match && await fetchNewsTitle(match[0], message.author);
if (title) {
// message determined to be a news story
console.log("Creating news discussion thread for %s named '%s'.",
message.author.tag, title);
await message.startThread({
// keep under thread limit
name: title.substr(0, MAX_THREAD_TITLE_LENGTH)
});
} else {
// not a news story, remove message
await removeMessage(message, "Please only post news articles. " +
"Discussion on news stories should take place inside their " +
"designated thread which will be created automatically.");
}
}
/*
Fetch title of a news article given its URL. This title will be used for
creating a thread.
*/
async function fetchNewsTitle (url) {
console.log("Attempting to fetch page %s for news title.", url);
const response = await axios.get(url, {
headers: {
/*
Some sites omit OpenGraph tags if bot user agent not used.
*/
"User-Agent": "Mozilla/5.0 (compatible; Discordbot/2.0; " +
"+https://discordapp.com)"
}
}).catch(e => console.log("Request for %s failed. %s", url, e.message));
if (!response) return null; // request failed
const $ = cheerio.load(response.data); // parse response HTML
let title = url;
const metaOgTitleElement = $("meta[property=og:title],meta[name=og:title]");
if (metaOgTitleElement.length != 0) {
title = metaOgTitleElement.attr("content")
} else {
console.log("Page %s contains no OpenGraph title tag.", url);
const titleElement = $("title");
if (titleElement.length != 0 && titleElement.text().length != 0) {
title = titleElement.text();
} else {
console.log("Page %s also contains no title tag. Title not found.",
url);
}
}
// short or removal of unnecessary prefixes...
return title.replace(/^From the (.+) community on Reddit:/, "r/$1:");
}
/**
Inform user with a text message. First tries to send message to the user's
DM channel. If this fails (user has it closed) then the message will be
sent in publicChannel. Exception is thrown forward if this fails still.
Message sent in publicChannel will expire and be deleted after a set period
of time for cleanliness. See PUBLIC_REPLY_DECAY_TIME.
*/
async function inform (user, message, publicChannel = null,
tryingPublic = false) {
// channel to send in
const channel = (tryingPublic && publicChannel) || user.dmChannel ||
await user.createDM();
try {
return await channel.send(user.toString() + "\n" + message);
} catch (e) {
// message already tried through dms, public channel now also failed
if (tryingPublic || !publicChannel) throw e; // pass on exception
}
// to get here: sending dm to user failed, try posting in public channel
const reply = await inform(user, message, publicChannel, true);
// clean up public messages after time
await new Promise(resolve => setTimeout(resolve, PUBLIC_REPLY_DECAY_TIME));
try {
await reply.delete();
} catch {
console.warn(`Failed to automatically delete ${message.url}. ` +
"Message may have already been deleted manually.");
}
}
/*
Remove a user's message with a reason.
*/
async function removeMessage (message, reason) {
console.log("Removing message '%s' by %s with explaination '%s'.",
message.content, message.author.tag, reason);
await message.delete();
/*
Send message to author with their quoted message and why it was removed.
> message content quoted (replace "\n" with "\n>"to continue quote over
multiple lines)
** This message was removed ** (reason)
*/
await inform(message.author, "> " +
discord.Util.escapeMarkdown(message.content).replace(/\n/g, "\n> ") +
`\n**This message was removed.** ${reason}`, message.channel);
}