Add Discord bot, reactivate team-assign

This commit is contained in:
pikiou
2023-03-18 04:08:48 +01:00
parent a906dfff07
commit 71fd770a27
22 changed files with 674 additions and 23 deletions

View File

@@ -10,7 +10,7 @@ import { AskDayWishes, fetchFor as fetchForDayWishes } from "./AskDayWishes"
// import { AskHosting, fetchFor as fetchForHosting } from "./AskHosting"
// import { AskMeals, fetchFor as fetchForMeals } from "./AskMeals"
// import { AskPersonalInfo, fetchFor as fetchForPersonalInfo } from "./AskPersonalInfo"
// import { AskTeamWishes, fetchFor as fetchForTeamWishes } from "./AskTeamWishes"
import { AskTeamWishes, fetchFor as fetchForTeamWishes } from "./AskTeamWishes"
// import {
// AskParticipationDetails,
// fetchFor as fetchForParticipationDetails,
@@ -27,7 +27,7 @@ const Asks = (): JSX.Element | null => {
AskDiscord(asks, 5)
AskDayWishes(asks, 10)
// AskTeamWishes(asks, 11)
AskTeamWishes(asks, 11)
// AskParticipationDetails(asks, 12)
// AskPersonalInfo(asks, 15)
// AskHosting(asks, 20)
@@ -72,7 +72,7 @@ export const fetchFor = [
...fetchForDayWishes,
// ...fetchForHosting,
// ...fetchForMeals,
// ...fetchForTeamWishes,
...fetchForTeamWishes,
// ...fetchForParticipationDetails,
// ...fetchForPersonalInfo,
]

View File

@@ -29,7 +29,7 @@ const LoginForm = (): JSX.Element => {
return (
<form>
<div className={styles.loginIntro} key="login-intro">
Si tu es bénévole, connecte-toi pour accéder à ton espace.
Si tu es bénévole ou que tu l'as déjà é, connecte-toi pour accéder à ton espace.
</div>
<div className={styles.formLine} key="line-email">
<label htmlFor="email">Email</label>

View File

@@ -2,10 +2,10 @@ import React, { memo } from "react"
import { useSelector } from "react-redux"
import styles from "./styles.module.scss"
import TeamItem from "./TeamItem"
import { selectSortedActiveTeams } from "../../store/teamList"
import { selectSortedTeams } from "../../store/teamList"
const TeamList: React.FC = (): JSX.Element | null => {
const teams = useSelector(selectSortedActiveTeams)
const teams = useSelector(selectSortedTeams)
if (!teams || teams.length === 0) return null
return (

View File

@@ -7,8 +7,8 @@ import DayWishesFormModal from "./DayWishesForm/DayWishesFormModal"
// import MealsFormModal from "./MealsForm/MealsFormModal"
// import ParticipationDetails from "./ParticipationDetails/ParticipationDetails"
// import ParticipationDetailsFormModal from "./ParticipationDetailsForm/ParticipationDetailsFormModal"
// import TeamWishes from "./TeamWishes/TeamWishes"
// import TeamWishesFormModal from "./TeamWishesForm/TeamWishesFormModal"
import TeamWishes from "./TeamWishes/TeamWishes"
import TeamWishesFormModal from "./TeamWishesForm/TeamWishesFormModal"
// import VolunteerTeam from "./VolunteerTeam/VolunteerTeam"
import withUserConnected from "../../utils/withUserConnected"
import ContentTitle from "../ui/Content/ContentTitle"
@@ -16,7 +16,7 @@ import { fetchFor as fetchForDayWishesForm } from "./DayWishesForm/DayWishesForm
// import { fetchFor as fetchForHostingForm } from "./HostingForm/HostingForm"
// import { fetchFor as fetchForMealsForm } from "./MealsForm/MealsForm"
// import { fetchFor as fetchForParticipationDetailsForm } from "./ParticipationDetailsForm/ParticipationDetailsForm"
// import { fetchFor as fetchForTeamWishesForm } from "./TeamWishesForm/TeamWishesForm"
import { fetchFor as fetchForTeamWishesForm } from "./TeamWishesForm/TeamWishesForm"
import { fetchFor as fetchForPersonalInfoForm } from "./PersonalInfoForm/PersonalInfoForm"
import PersonalInfo from "./PersonalInfo/PersonalInfo"
import PersonalInfoFormModal from "./PersonalInfoForm/PersonalInfoFormModal"
@@ -43,10 +43,10 @@ const Board: FC = (): JSX.Element => (
<DayWishes />
<DayWishesFormModal />
{/* <ParticipationDetails />
<ParticipationDetailsFormModal />
<ParticipationDetailsFormModal /> */}
<TeamWishes />
<TeamWishesFormModal />
<VolunteerTeam />
{/* <VolunteerTeam />
<Hosting />
<HostingFormModal />
<Meals />
@@ -66,5 +66,5 @@ export const fetchFor = [
// ...fetchForHostingForm,
// ...fetchForMealsForm,
// ...fetchForParticipationDetailsForm,
// ...fetchForTeamWishesForm,
...fetchForTeamWishesForm,
]

View File

@@ -38,6 +38,12 @@ const DayWishes: FC = (): JSX.Element | null => {
Je <b>ne sais pas encore</b> si je participerai à PeL 2023
</div>
)}
{participation === "à distance" && (
<div className={styles.participationLabel}>
Je <b className={styles.yesParticipation}>participerai</b> à PeL 2023 ! Sans y
être pendant le weekend.
</div>
)}
{participation === "inconnu" && (
<div className={styles.lineEmpty}>
Participation à PeL 2023{" "}

View File

@@ -16,4 +16,5 @@ export default {
],
},
DEV_JWT_SECRET: "fakeqA6uF#msq2312bebf2FLFn4XzWQ6dttXSJwBX#?gL2JWf!",
DEV_DISCORD_TOKEN: "fakeqA6uF#msq2312bebf2FLFn4XzWQ6dttXSJwBX#?gL2JWf!",
}

View File

@@ -11,7 +11,7 @@ import AsyncTeamAssignment, { loadData as loadTeamAssignmentData } from "../page
import AsyncRegisterPage, { loadData as loadRegisterPage } from "../pages/Register"
import AsyncKnowledge, { loadData as loadKnowledgeData } from "../pages/Knowledge"
// import AsyncLoans, { loadData as loadLoansData } from "../pages/Loans"
// import AsyncLoaning, { loadData as loadLoaningData } from "../pages/Loaning"
import AsyncLoaning, { loadData as loadLoaningData } from "../pages/Loaning"
import AsyncKnowledgeCards, { loadData as loadCardKnowledgeData } from "../pages/KnowledgeCards"
import AsyncTeams, { loadData as loadTeamsData } from "../pages/Teams"
import AsyncBoard, { loadData as loadBoardData } from "../pages/Board"
@@ -51,11 +51,11 @@ export default [
// component: AsyncLoans,
// loadData: loadLoansData,
// },
// {
// path: "/emprunter",
// component: AsyncLoaning,
// loadData: loadLoaningData,
// },
{
path: "/emprunter",
component: AsyncLoaning,
loadData: loadLoaningData,
},
{
path: "/fiches",
component: AsyncKnowledgeCards,

405
src/server/discordBot.ts Normal file
View File

@@ -0,0 +1,405 @@
import _ from "lodash"
import {
Client,
GatewayIntentBits,
Collection,
Events,
SlashCommandBuilder,
CommandInteraction,
/* REST, Routes, */ Partials,
MessageReaction,
PartialMessageReaction,
Guild,
User,
PartialUser,
} from "discord.js"
import { promises as fs, constants } from "fs"
import path from "path"
import { translationVolunteer, Volunteer, VolunteerWithoutId } from "../services/volunteers"
import {
translationDiscordRoles,
DiscordRole,
DiscordRoleWithoutId,
} from "../services/discordRoles"
import { getSheet } from "./gsheets/accessors"
import config from "../config"
let cachedToken: string
// let cachedClientId: string
let cachedGuildId: string
const CREDS_PATH = path.resolve(process.cwd(), "access/discordToken.json")
getCreds() // Necessary until we can make async express middleware
type Command = {
data: SlashCommandBuilder
execute: (interaction: CommandInteraction) => Promise<void>
}
const commands: Collection<string, Command> = new Collection()
// const userCommand: Command = {
// data: new SlashCommandBuilder()
// .setName('user')
// .setDescription('Provides information about the user.'),
// async execute(interaction: CommandInteraction) {
// const { commandName } = interaction
// if (commandName === 'user') {
// const message = await interaction.reply({ content: 'You can react with Unicode emojis!', fetchReply: true })
// message.react('😄')
// }
// },
// }
let hasDiscordAccessReturn: boolean | undefined
export async function hasDiscordAccess(): Promise<boolean> {
if (hasDiscordAccessReturn !== undefined) {
return hasDiscordAccessReturn
}
try {
// eslint-disable-next-line no-bitwise
await fs.access(CREDS_PATH, constants.R_OK | constants.W_OK)
hasDiscordAccessReturn = true
} catch {
hasDiscordAccessReturn = false
}
return hasDiscordAccessReturn
}
// export async function discordRegisterCommands(): Promise<void> {
// if (!__REGISTER_DISCORD_COMMANDS__) {
// return
// }
// if (!(await hasDiscordAccess())) {
// console.error(`Discord bot: no creds found, not running bot`)
// return
// }
// await getCreds()
// const commandsToRegister = []
// commandsToRegister.push(userCommand.data.toJSON())
// const rest = new REST({ version: '10' }).setToken(cachedToken)
// try {
// await rest.put(
// Routes.applicationGuildCommands(cachedClientId, cachedGuildId),
// { body: commandsToRegister },
// )
// } catch (error) {
// console.error(error)
// }
// process.exit()
// }
export async function discordBot(): Promise<void> {
try {
if (__REGISTER_DISCORD_COMMANDS__) {
return
}
if (!(await hasDiscordAccess())) {
console.error(`Discord bot: no creds found, not running bot`)
return
}
await getCreds()
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.MessageContent,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
})
// commands.set(userCommand.data.name, userCommand)
client.once(Events.ClientReady, (localClient) => {
setInterval(() => setBotReactions(localClient), 5 * 60 * 1000)
setTimeout(() => setBotReactions(localClient), 20 * 1000)
setInterval(() => setAllRoles(localClient), 5 * 60 * 1000)
setTimeout(() => setAllRoles(localClient), 5 * 1000)
})
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`)
return
}
try {
await command.execute(interaction)
} catch (error) {
console.error(error)
}
})
client.on(Events.MessageReactionAdd, async (reaction, user) => {
await fetchPartial(reaction)
await setRolesFromEmoji(client, user, reaction, "add")
})
client.on(Events.MessageReactionRemove, async (reaction, user) => {
await fetchPartial(reaction)
await setRolesFromEmoji(client, user, reaction, "remove")
})
client.login(cachedToken)
} catch (error) {
console.error("Discord error", error)
}
}
async function setBotReactions(client: Client) {
try {
const discordRolesSheet = await getSheet<DiscordRoleWithoutId, DiscordRole>(
"DiscordRoles",
new DiscordRole(),
translationDiscordRoles
)
const discordRolesList = await discordRolesSheet.getList()
if (!discordRolesList) {
return
}
client.channels.cache.each(async (channel) => {
if (!channel.isTextBased()) {
return
}
discordRolesList.forEach(async (discordRole: DiscordRole) => {
let message
try {
message = await channel.messages.fetch(discordRole.messageId)
} catch (error) {
return
}
const reaction = message.reactions.cache.find(
(r) => r.emoji.name === discordRole.emoji
)
if (reaction) {
return
}
await message.react(discordRole.emoji)
})
})
} catch (error) {
console.error("Error in setBotReactions", error)
}
}
async function setAllRoles(client: Client) {
try {
const volunteerSheet = await getSheet<VolunteerWithoutId, Volunteer>(
"Volunteers",
new Volunteer(),
translationVolunteer
)
const volunteerList = await volunteerSheet.getList()
if (!volunteerList) {
return
}
const volunteerByDiscordId = _.mapKeys(volunteerList, (v) => v.discordId.toString())
const guild = await client.guilds.fetch(cachedGuildId)
if (!guild || !guild.members.cache) {
return
}
// Set (maybe) volunteer roles
const volunteerRoleIds: { [key: string]: string } = _.mapValues(
{
oui: "Bénévole",
"peut-etre": "Bénévole incertain",
"à distance": "Bénévole à distance",
non: "",
},
(v) => (_.isEmpty(v) ? "" : guild.roles.cache.find((role) => role.name === v)?.id || "")
)
await setVolunteersRoles(
guild,
volunteerByDiscordId,
volunteerRoleIds,
(volunteer: Volunteer) => volunteer.active
)
// Set Team- and Référent- roles
const teamIds = {
"1": "accueil",
"5": "paillante",
"21": "photo",
"4": "tournois",
// "19": "exposants-asso", ignored because it's mixed with volunteers of last edition
"2": "jav",
"18": "jeux-xl",
"27": "ring",
"8": "coindespetitsjoueurs",
"23": "jeuxdelires",
"3": "jdd",
"9": "figurines",
"10": "jdr",
"28": "jeuxhistoire",
"6": "évènements",
"29": "presse",
"30": "initiation-18xx",
"0": "",
}
const teamRoleIds: { [key: string]: string } = _.mapValues(teamIds, (v) =>
_.isEmpty(v)
? ""
: guild.roles.cache.find((role) => role.name === `Team-${v}`)?.id || ""
)
await setVolunteersRoles(guild, volunteerByDiscordId, teamRoleIds, (volunteer: Volunteer) =>
_.includes(["oui", "peut-etre", "à distance"], volunteer.active)
? `${volunteer.team}`
: ""
)
const referentRoleIds: { [key: string]: string } = _.mapValues(teamIds, (v) =>
_.isEmpty(v)
? ""
: guild.roles.cache.find((role) => role.name === `Référent-${v}`)?.id || ""
)
await setVolunteersRoles(
guild,
volunteerByDiscordId,
referentRoleIds,
(volunteer: Volunteer) =>
_.includes(["oui", "peut-etre", "à distance"], volunteer.active) &&
volunteer.roles.includes("référent")
? `${volunteer.team}`
: "0"
)
} catch (error) {
console.error("Error in setAllRoles", error)
}
}
async function setVolunteersRoles(
guild: Guild,
volunteerByDiscordId: { [key: string]: Volunteer },
volunteerRoleIds: { [key: string]: string },
funcKey: (volunteer: Volunteer) => string
) {
const members = await guild.members.fetch()
members.each(async (member) => {
const volunteer = volunteerByDiscordId[member.id]
if (!volunteer) {
return
}
_.forOwn(volunteerRoleIds, (roleId, active) => {
if (!roleId) {
return
}
const shouldHaveRole = active === funcKey(volunteer)
const hasRole = member.roles.cache.has(roleId)
if (hasRole && !shouldHaveRole) {
member.roles.remove(roleId)
} else if (!hasRole && shouldHaveRole) {
member.roles.add(roleId)
}
})
})
}
async function setRolesFromEmoji(
client: Client,
user: User | PartialUser,
reaction: MessageReaction | PartialMessageReaction,
action: "add" | "remove"
) {
const discordRolesSheet = await getSheet<DiscordRoleWithoutId, DiscordRole>(
"DiscordRoles",
new DiscordRole(),
translationDiscordRoles
)
const discordRolesList = await discordRolesSheet.getList()
if (!discordRolesList) {
return
}
await client.guilds.fetch()
const guild = client.guilds.resolve(cachedGuildId)
if (!guild || !guild.members.cache) {
return
}
discordRolesList.forEach(async (discordRole: DiscordRole) => {
if (
reaction.message.id === discordRole.messageId &&
reaction.emoji.name === discordRole.emoji
) {
const roleId = guild.roles.cache.find((role) => role.name === discordRole.role)
if (!roleId) {
return
}
const member = guild.members.cache.find((m) => m.id === user.id)
if (!member) {
return
}
await member.fetch()
if (action === "add") {
member.roles.add(roleId)
} else if (action === "remove") {
member.roles.remove(roleId)
}
}
})
}
async function fetchPartial(reaction: MessageReaction | PartialMessageReaction): Promise<boolean> {
if (reaction.partial) {
try {
await reaction.fetch()
} catch (error) {
console.error("Something went wrong when fetching the message", error)
return false
}
}
return true
}
async function getCreds(): Promise<void> {
if (!cachedToken) {
try {
const credsContent = await fs.readFile(CREDS_PATH)
const parsedCreds = credsContent && JSON.parse(credsContent.toString())
if (!parsedCreds) {
return
}
cachedToken = parsedCreds.token
// cachedClientId = parsedCreds.clientId
cachedGuildId = parsedCreds.guildId
} catch (e: any) {
cachedToken = config.DEV_DISCORD_TOKEN
}
}
}

View File

@@ -11,8 +11,8 @@ export { SheetNames } from "./localDb"
const CRED_PATH = path.resolve(process.cwd(), "access/gsheets.json")
const REMOTE_UPDATE_DELAY = 40000
const DELAY_BETWEEN_ATTEMPTS = 10000
const REMOTE_UPDATE_DELAY = 120000
const DELAY_BETWEEN_ATTEMPTS = 30000
const DELAY_BETWEEN_FIRST_LOAD = 1500
let creds: string | undefined | null
@@ -59,7 +59,7 @@ export async function getSheet<
sheet = new Sheet<ElementNoId, Element>(sheetName, specimen, translation)
await sheet.waitForFirstLoad()
sheetList[sheetName] = sheet
setInterval(() => sheet.dbUpdate(), REMOTE_UPDATE_DELAY)
setInterval(() => sheet.dbUpdate(), REMOTE_UPDATE_DELAY * (1 + Math.random() / 10))
} else {
sheet = sheetList[sheetName] as Sheet<ElementNoId, Element>
}
@@ -265,6 +265,7 @@ export class Sheet<
}
await tryNTimesVoidReturn(async () => {
console.log(`dbSaveAsync on ${this.name} at ${new Date()}`)
// Load sheet into an array of objects
const rows = await sheet.getRows()
if (!rows[0]) {
@@ -317,6 +318,7 @@ export class Sheet<
await rows[rowToDelete].delete()
}
}
console.log(`dbSaveAsync successful on ${this.name} at ${new Date()}`)
})
}
@@ -330,6 +332,7 @@ export class Sheet<
await tryNTimesVoidReturn(async () => {
// Load sheet into an array of objects
console.log(`dbLoadAsync on ${this.name} at ${new Date()}`)
const rows = (await sheet.getRows()) as StringifiedElement[]
const elements: Element[] = []
if (!rows[0]) {
@@ -357,6 +360,7 @@ export class Sheet<
})
this._state = elements
console.log(`dbLoadAsync successful on ${this.name} at ${new Date()}`)
})
}

View File

@@ -0,0 +1,14 @@
import ExpressAccessors from "./expressAccessors"
import {
DiscordRole,
DiscordRoleWithoutId,
translationDiscordRoles,
} from "../../services/discordRoles"
const expressAccessor = new ExpressAccessors<DiscordRoleWithoutId, DiscordRole>(
"DiscordRoles",
new DiscordRole(),
translationDiscordRoles
)
export const discordRolesListGet = expressAccessor.listGet()

View File

@@ -16,6 +16,8 @@ export class SheetNames {
Boxes = "Boîtes"
DiscordRoles = "Rôles Discord"
Games = "Jeux"
Miscs = "Divers"

View File

@@ -50,6 +50,7 @@ import {
import { wishListGet, wishAdd } from "./gsheets/wishes"
import config from "../config"
import { notificationsSubscribe, notificationMain } from "./notifications"
import { /* discordRegisterCommands, */ discordBot, hasDiscordAccess } from "./discordBot"
import checkAccess from "./checkAccess"
import { hasGSheetsAccess } from "./gsheets/accessors"
import { addStatus, showStatusAt } from "./status"
@@ -64,6 +65,9 @@ checkAccess()
notificationMain()
// discordRegisterCommands()
discordBot()
const app = express()
// Allow receiving big images
@@ -243,6 +247,14 @@ if (hasPushNotifAccess) {
addStatus("Push notif:", chalk.blue(`🚧 offline, simulated`))
}
hasDiscordAccess().then((hasApiAccess: boolean) => {
if (hasApiAccess) {
addStatus("Discord bot:", chalk.green(`✅ online through discord.js`))
} else {
addStatus("Discord bot:", chalk.blue(`🚧 no creds, disabled`))
}
})
hasSecret().then((has: boolean) => {
if (has) {
addStatus("JWT secret:", chalk.green(`✅ prod private one from file`))

View File

@@ -108,7 +108,9 @@ async function notifyAboutAnnouncement(): Promise<void> {
}
const audience = volunteerList.filter(
(v) => v.acceptsNotifs === "oui" && (v.active === "oui" || v.active === "peut-etre")
(v) =>
v.acceptsNotifs === "oui" &&
(v.active === "oui" || v.active === "peut-etre" || v.active === "à distance")
)
console.error(

View File

@@ -0,0 +1,28 @@
/* eslint-disable max-classes-per-file */
export class DiscordRole {
id = 0
messageId = ""
emoji = ""
role = ""
}
export const translationDiscordRoles: { [k in keyof DiscordRole]: string } = {
id: "id",
messageId: "messageId",
emoji: "emoji",
role: "rôle",
}
export const elementName = "DiscordRoles"
export type DiscordRoleWithoutId = Omit<DiscordRole, "id">
export interface DiscordRole {
id: DiscordRole["id"]
messageId: DiscordRole["messageId"]
emoji: DiscordRole["emoji"]
role: DiscordRole["role"]
}

View File

@@ -59,6 +59,10 @@ export const selectTeamList = createSelector(
}
)
export const selectSortedTeams = createSelector(selectTeamList, (teams) =>
[...teams].sort((a, b) => get(a, "order", 0) - get(b, "order", 0))
)
export const selectSortedActiveTeams = createSelector(selectTeamList, (teams) =>
[...teams.filter((team) => get(team, "status") === "active")].sort(
(a, b) => get(a, "order", 0) - get(b, "order", 0)

View File

@@ -2,6 +2,7 @@ declare const __CLIENT__: boolean
declare const __SERVER__: boolean
declare const __DEV__: boolean
declare const __LOCAL__: boolean
declare const __REGISTER_DISCORD_COMMANDS__: boolean
declare const __TEST__: boolean
declare module "*.svg"
@@ -19,6 +20,7 @@ declare namespace NodeJS {
__SERVER__: boolean
__DEV__: boolean
__LOCAL__: boolean
__REGISTER_DISCORD_COMMANDS__: boolean
__TEST__: boolean
$RefreshReg$: () => void
$RefreshSig$$: () => void