feat(bots/discord): add source

This commit is contained in:
PalmDevs
2024-03-28 21:52:23 +07:00
parent b3b7723b4f
commit f9d50a0a6b
30 changed files with 1482 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import type { ClientWebSocketEvents } from '@revanced/bot-api'
import { api } from '../../context'
const { client } = api
export function on<Event extends EventName>(event: Event, listener: ListenerOf<Event>) {
client.on(event, listener)
}
export function once<Event extends EventName>(event: Event, listener: ListenerOf<Event>) {
client.once(event, listener)
}
export type EventName = keyof ClientWebSocketEvents
export type ListenerOf<Event extends EventName> = ClientWebSocketEvents[Event]

View File

@@ -0,0 +1,19 @@
import type { Command } from '$commands'
import { listAllFilesRecursive } from '$utils/fs'
export const loadCommands = async () => {
const commandsMap: Record<string, Command> = {}
const files = await listAllFilesRecursive('src/commands')
const commands = await Promise.all(
files.map(async file => {
const command = await import(file)
return command.default
}),
)
for (const command of commands) {
if (command) commandsMap[command.data.name] = command
}
return commandsMap
}

View File

@@ -0,0 +1,48 @@
import { DefaultEmbedColor, ReVancedLogoURL } from '$/constants'
import { EmbedBuilder } from 'discord.js'
import type { ConfigMessageScanResponseMessage } from '../../../config.example'
export const createErrorEmbed = (title: string, description?: string) =>
applyCommonStyles(
new EmbedBuilder()
.setTitle(title)
.setDescription(description ?? null)
.setAuthor({ name: 'Error' })
.setColor('Red'),
false,
)
export const createStackTraceEmbed = (stack: unknown) =>
// biome-ignore lint/style/useTemplate: shut
createErrorEmbed('An exception was thrown', '```js' + stack + '```')
export const createSuccessEmbed = (title: string, description?: string) =>
applyCommonStyles(
new EmbedBuilder()
.setTitle(title)
.setDescription(description ?? null)
.setAuthor({ name: 'Success' })
.setColor('Green'),
false,
)
export const createMessageScanResponseEmbed = (response: ConfigMessageScanResponseMessage) => {
const embed = new EmbedBuilder().setTitle(response.title)
if (response.description) embed.setDescription(response.description)
if (response.fields) embed.addFields(response.fields)
return applyCommonStyles(embed)
}
const applyCommonStyles = (embed: EmbedBuilder, setColor = true, setThumbnail = true) => {
embed.setFooter({
text: 'ReVanced',
iconURL: ReVancedLogoURL,
})
if (setColor) embed.setColor(DefaultEmbedColor)
if (setThumbnail) embed.setThumbnail(ReVancedLogoURL)
return embed
}

View File

@@ -0,0 +1,19 @@
import * as context from '$/context'
import type { ClientEvents } from 'discord.js'
const { client } = context.discord
export const on = <Event extends EventName>(event: Event, listener: ListenerOf<Event>) =>
client.on(event, (...args) => listener(context, ...args))
export const once = <Event extends EventName>(event: Event, listener: ListenerOf<Event>) =>
client.once(event, (...args) => listener(context, ...args))
export type EventName = keyof ClientEvents
export type EventMap = {
[K in EventName]: ListenerOf<K>
}
type ListenerOf<Event extends EventName> = (
...args: [typeof import('$/context'), ...ClientEvents[Event]]
) => void | Promise<void>

View File

@@ -0,0 +1,140 @@
import type { LabeledResponse } from '$/classes/Database'
import type { Config, ConfigMessageScanResponseLabelConfig, ConfigMessageScanResponseMessage } from 'config.example'
import type { Message, PartialUser, User } from 'discord.js'
import { createMessageScanResponseEmbed } from './embeds'
export const getResponseFromContent = async (
content: string,
{ api, logger, config: { messageScan: config } }: typeof import('src/context'),
) => {
if (!config || !config.responses) {
logger.warn('No message scan config found')
return {
response: null,
label: undefined,
}
}
let label: string | undefined
let response: ConfigMessageScanResponseMessage | undefined | null
const firstLabelIndexes: number[] = []
// Test if all regexes before a label trigger is matched
for (const trigger of config.responses) {
const { triggers, response: resp } = trigger
if (response) break
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
logger.debug(`Message matched regex (before mode): ${trigger.source}`)
response = resp
break
}
} else {
firstLabelIndexes.push(i)
break
}
}
}
// If none of the regexes match, we can search for labels immediately
if (!response) {
const scan = await api.client.parseText(content)
if (scan.labels.length) {
const matchedLabel = scan.labels[0]!
logger.debug(`Message matched label with confidence: ${matchedLabel.name}, ${matchedLabel.confidence}`)
let triggerConfig: ConfigMessageScanResponseLabelConfig | undefined
const labelConfig = config.responses.find(x => {
const config = x.triggers.find(
(x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name,
)
if (config) triggerConfig = config
return config
})
if (!labelConfig) {
logger.warn(`No label config found for label ${matchedLabel.name}`)
return { response: null, label: undefined }
}
if (matchedLabel.confidence >= triggerConfig!.threshold) {
logger.debug('Label confidence is enough')
label = matchedLabel.name
response = labelConfig.response
}
}
}
// If we still don't have a label, we can match all regexes after the initial label trigger
if (!response)
for (let i = 0; i < config.responses.length; i++) {
const { triggers, response: resp } = config.responses[i]!
const firstLabelIndex = firstLabelIndexes[i] ?? -1
for (let i = firstLabelIndex + 1; i < triggers.length; i++) {
const trigger = triggers[i]!
if (trigger instanceof RegExp) {
if (trigger.test(content)) {
logger.debug(`Message matched regex (after mode): ${trigger.source}`)
response = resp
break
}
}
}
}
return {
response,
label,
}
}
export const shouldScanMessage = (
message: Message,
config: NonNullable<Config['messageScan']>,
): message is Message<true> => {
if (message.author.bot) return false
if (!message.guild) return false
const filters = [
config.users?.includes(message.author.id),
message.member?.roles.cache.some(x => config.roles?.includes(x.id)),
config.channels?.includes(message.channel.id),
]
if (config.whitelist && filters.every(x => !x)) return false
if (!config.whitelist && filters.some(x => x)) return false
return true
}
export const handleUserResponseCorrection = async (
{ api, database: db, config: { messageScan: msConfig }, logger }: typeof import('$/context'),
response: LabeledResponse,
reply: Message,
label: string,
user: User | PartialUser,
) => {
const correctLabelResponse = msConfig!.responses!.find(r => r.triggers.some(t => 'label' in t && t.label === label))
if (!correctLabelResponse) throw new Error('Cannot find label config for the selected label')
if (!correctLabelResponse.response) return void (await reply.delete())
if (response.label !== label) {
db.labeledResponses.edit(response.reply, { label, correctedBy: user.id })
await reply.edit({
embeds: [createMessageScanResponseEmbed(correctLabelResponse.response)],
})
}
await api.client.trainMessage(response.text, label)
logger.debug(`User ${user.id} trained message ${response.reply} as ${label} (positive)`)
await reply.reactions.removeAll()
}

View File

@@ -0,0 +1,14 @@
import type { Guild, GuildManager } from 'discord.js'
import { config, logger } from '../../context'
export function leaveDisallowedGuild(guild: Guild) {
logger.warn(`Server ${guild.name} (${guild.id}) is not allowed to use this bot.`)
return guild.leave().then(() => logger.debug(`Left guild ${guild.name} (${guild.id})`))
}
export async function leaveDisallowedGuilds(guildManager: GuildManager) {
const guilds = await guildManager.fetch()
for (const [id, guild] of guilds) {
if (!config.allowedGuilds.includes(id)) await leaveDisallowedGuild(await guild.fetch())
}
}

View File

@@ -0,0 +1,17 @@
import { join } from 'path'
import { readdir, stat } from 'fs/promises'
export async function listAllFilesRecursive(dir: string): Promise<string[]> {
const files = await readdir(dir)
const result: string[] = []
for (const file of files) {
const filePath = join(dir, file)
const fileStat = await stat(filePath)
if (fileStat.isDirectory()) {
result.push(...(await listAllFilesRecursive(filePath)))
} else {
result.push(filePath)
}
}
return result
}