From b63efc98c7e4222ff594ac464c385fa5d8bbfb3f Mon Sep 17 00:00:00 2001 From: Murilo Alves Date: Tue, 28 Mar 2023 02:29:14 -0300 Subject: [PATCH] feat(discord): add slash commands to ban/timeout users of twitch from discord * fix(CustomAuthProvider); use correct clientIds * feat: update typeorm to latest package * chore(locales): crowdin update (#5614) * fix(panel): health check return version * fix(CustomAuthProvider): fix incorrent tokenService name * fix(refresh): add ECONNRESET catch * feat(validate): add async-mutex to acquire locks * fix(refresh): use node-fetch with higher timeout * fix(google): fix multiplication of intervals FIxes #5618 * fix(obswebsocket): fix typeorm find Fixes #5617 * fix: update websocket entity * fix(google): attempt to fix incorrect time marks * fix(google): fix incorrect game check * fix(sendMessage): fix incorrect setting of messageId on filters Fixes #5619 * chore: declare for kids * chore: add google debug * chore: ui-admin version update * chore: fix socket.d.ts * fix(main): show versions on one line * chore: deps update * feat(sendMessage): add support for /announce and color variations * chore: fix incorrect findOneBy for permission command * feat: use official chatters endpoint * feat: use eventsub-ws * chore: update ui-admin * fix: fix several menu links * chore: remove unused table * chore: (revert)remove unused table This reverts commit af1c22b5a668650aee1247cdcc42e832ff9ad35d. * chore: update custom variable entity (#5624) * chore: import only defaultPermissions * chore: remove index.ts loading all folder * chore: fix path * chore: add customvariable validator * fix: add custom variables validation on save * chore(locales): crowdin update (#5625) Co-authored-by: Crowdin Bot * chore: remove custom variable unused variables (#5627) * chore(locales): crowdin update (#5626) Co-authored-by: Crowdin Bot * chore: force runEvery 0 if type isUsed * chore(locales): crowdin update (#5628) Co-authored-by: Crowdin Bot * fix(discord): add error logging * chore: update ui-admin * build: 16.9.0 * build: 16.10.0-SNAPSHOT * feat(runScript): add axios to runScript node * fix(refresh): revert removal of token string check * build: 16.10.0 * build: 16.11.0-SNAPSHOT * fix(twitch): check null recipient and correctly populate by Id * build: 16.10.1 * build: 16.11.0-SNAPSHOT * fix(refresh): mutex refreshing of token with timeout fallback * build: 16.10.2 * build: 16.11.0-SNAPSHOT * fix(changelog): update changelog and fix workflow * fix(twitch): change initiation on token validity * chore: fix chat join on startup * fix(spotify): log proper error if spotify API is temporarily unavailable * fix(getChannelChatters): fix empty parted even * feat: add tags to title cache and change to updateChannelInfo (#5630) * fix(discord): fix empty tags * build: 16.10.3 * build: 16.11.0-SNAPSHOT * fix(general): fix !_debug command using old typeorm command * fix(changelog): fix generator not setting correct build * chore: randomizer to entity * fix(panel): add missing plugins endpoint * build: 16.10.4 * build: 16.11.0-SNAPSHOT * fix(sendMessage): fix sending /announce message twice * build: 16.10.5 * build: 16.11.0-SNAPSHOT * fix(eventsub): show proper error during init * fix(eventsub): disable eventsub on dev mode * chore(locales): crowdin update (#5631) Co-authored-by: Crowdin Bot * fix(users): remove all anonymous users and set inactive duplicates * chore: deps update * fix(eventlist): eventlist should properly show events even with banned users * build: 16.10.6 * build: 16.11.0-SNAPSHOT * fix(twitch): fix setting up own app generator * build: 16.10.7 * build: 16.11.0-SNAPSHOT * fix(songs): fix incorrectly sending rows from db FIxes #5634 * build: 16.10.8 * build: 16.11.0-SNAPSHOT * fix(getChannelInformation): update tags cache when changed outside of a bot * fix(twitch): init chat only if tokens are valid * chore(locales): crowdin update (#5635) Co-authored-by: Crowdin Bot * refactor: overlays to groups only (#5636) * chore: fix migration * fix(eventlist): show properly overlay if user is banned * chore: add missing entity isVisible property * feat: remove Twitter integration as API to be paid * docs: update pg version * chore: fix events emitter * chore(locales): crowdin update (#5637) Co-authored-by: Crowdin Bot * fix(countdown): add missing default value for showMilliseconds * fix(overlays): return data on save * chore: remove cache clean * fix(eventlist): hide event if username is unknown * chore: use npm install instead of ci * chore: remove incorrect default values keys * chore: add spacebetween attr * chore: package update * chore: downgrade to npm8 https://github.com/npm/cli/issues/5889 * fix(validate): update broadcaster type on forced validation * chore: add eventlist overlay font support * chore: add more attributes to eventlist * chore: add space between * chore: add eventlist fadeout * feat: add ui-admin and ui-overlay to packages dir * chore: update ui-admin * fix: command of docker.sh to run with debug mode * fix: migration of hltbtimestamps * feat: update packages and add patchs * feat: add discord ban user integration * fix: get user to check is moderator * feat: improve embed message of banned user * fix: add help of ban command to reply user * fix: remove missed spaced on command ban * feat: add discord slash commands * fix: add moderator discord announce channel * fix: add catch error of linked account on user slash commands * feat: add discord attachment to ban slash command * feat: add discord timeout slash command * ci: customize docker image on gh actions * fix: add command timeout trigger fn * feat: add choices do timeout duration * discord: add ban user modal and confirm * chore: update dependencies * build: 15.5.0-SNAPSHOT * fix(entity): expand varchar to 30 chars to handle more precise dates (#5390) * chore(locales): crowdin update (#5391) Co-authored-by: Crowdin Bot * chore: remove retyping in migration as it is not needed * chore: remove not needed sqlite migration * fix(entity): expand varchar to 30 chars to handle more precise dates (#5390) * feat: update OBS Websocket to version 5 (#5396) BREAKING CHANGE: This will work only with OBS +28, also we needed to remove simple tasks and only coding with OBS websockets are allowed * feat: add Price validation and rest access * feat: remove followers permission (#5400) BREAKING CHANGE: followers permissions are not supported anymore. Instead consider use of viewers permission or subscriber permission. We are not tracking follower status anymore, every follower based permissions, etc. were deleted. * chore(locales): crowdin update (#5401) Co-authored-by: Crowdin Bot * chore: fix incorrect obs call * chore: set obswebsocket to wss by default * chore: remove protocol * chore: update dependency * chore: add missing wss * fix(updater): don't crash bot if ui is being updated * resolve migration values structure * chore: some updates from master branch * fix: add packages to makefile * fix: add builder stage to dockerfile * fix: timerAttrChange migration * fix: remove npm cache clean * fix: add compatibility to postgres 14 date * fix: remove always compact * fix: remove ui's patch packages * fix: generate default uuid with node uuid package * ci: add luacomtio branch to trigger pipelines * ci: improve build-and-push speed by remove unused platforms * fix: remove copying of .env files on pack * feat: add to docker-compose nginx and certbot * fix: add depends on to nginx docker-compose service step * fix: update custom variables migration order of operations * fix: use generated id instead default * fix: remove default v4 uuid --------- Co-authored-by: Michal Orlik Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Crowdin Bot fix(discord): remove commands and use only one to ban and timeout fix: modify tmiEmitter.emit timeout ismod --- .github/workflows/codeql.yml | 2 +- .github/workflows/dockerimage-release.yml | 34 +- .github/workflows/dockerimage.yml | 11 +- .github/workflows/tests-postgres.yml | 1 + Makefile | 8 +- docker-compose.yml | 77 ++++ src/expects.ts | 37 +- src/helpers/commons/announce.ts | 4 +- src/integrations/discord.ts | 474 +++++++++++++++++++--- 9 files changed, 581 insertions(+), 67 deletions(-) create mode 100644 docker-compose.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a5b5f2134a9..fe92b7066f0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ name: "CodeQL" on: push: - branches: [ "master" ] + branches: [ "master", "luacomtio" ] pull_request: branches: [ "master" ] schedule: diff --git a/.github/workflows/dockerimage-release.yml b/.github/workflows/dockerimage-release.yml index 8b44e75ec91..5e9e4472bf4 100644 --- a/.github/workflows/dockerimage-release.yml +++ b/.github/workflows/dockerimage-release.yml @@ -41,13 +41,13 @@ jobs: - uses: actions/upload-artifact@v3 with: - name: sogeBot + name: luacomtio-sogeBot path: ${{ github.workspace }}/*.zip - name: Create Release uses: ncipollo/release-action@v1 with: - name: SOGEBOT ${{ github.ref_name }} + name: LUACOMTIO ${{ github.ref_name }} artifacts: "*.zip" bodyFile: "body.md" makeLatest: true @@ -79,18 +79,38 @@ jobs: - uses: actions/download-artifact@master with: - name: sogeBot + name: luacomtio-sogeBot path: ${{ github.workspace }}/*.zip - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v4 with: context: . - platforms: linux/amd64,linux/arm/v7,linux/arm64 + # platforms: linux/amd64 #,linux/arm/v7,linux/arm64 push: true tags: | - sogebot/release:latest - sogebot/release:${{ steps.get_version.outputs.VERSION }} + omurilo/luacomtio-bot:latest + omurilo/luacomtio-bot:${{ steps.get_version.outputs.VERSION }} cache-from: type=gha cache-to: type=gha,mode=max + + deployment: + needs: build + runs-on: ubuntu-latest + steps: + - name: executing remote ssh commands using password + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{ secrets.DIGITALOCEAN_HOST }} + username: ${{ secrets.DIGITALOCEAN_USERNAME }} + key: ${{ secrets.DIGITALOCEAN_PRIV_KEY }} + port: ${{ secrets.DIGITALOCEAN_PORT }} + passphrase: ${{ secrets.PASSPHRASE }} + script: | + cd sogebot + docker pull omurilo/luacomtio-bot:latest + docker-compose down + docker-compose up -d + sleep 150 + docker-compose logs sogebot diff --git a/.github/workflows/dockerimage.yml b/.github/workflows/dockerimage.yml index 1661d9bb8d2..bb18a1cd3c1 100644 --- a/.github/workflows/dockerimage.yml +++ b/.github/workflows/dockerimage.yml @@ -8,6 +8,7 @@ on: push: branches: - master + - luacomtio jobs: artifact: @@ -43,7 +44,7 @@ jobs: - uses: actions/upload-artifact@v3 with: - name: sogeBot-${{ steps.slug.outputs.SHA }} + name: luacomtio-bot-${{ steps.slug.outputs.SHA }} path: ${{ github.workspace }}/*.zip build: @@ -72,7 +73,7 @@ jobs: - uses: actions/download-artifact@master with: - name: sogeBot-${{ steps.slug.outputs.SHA }} + name: luacomtio-bot-${{ steps.slug.outputs.SHA }} path: ${{ github.workspace }}/*.zip - @@ -83,7 +84,7 @@ jobs: platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true tags: | - sogebot/nightly:latest - sogebot/nightly:${{ github.sha }} - cache-from: type=gha + omurilo/luacomtio-bot-nightly:latest + omurilo/luacomtio-bot-nightly:${{ github.sha }} + cache-from: type=gha cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/tests-postgres.yml b/.github/workflows/tests-postgres.yml index f418a5f3ef1..b60a80ab433 100644 --- a/.github/workflows/tests-postgres.yml +++ b/.github/workflows/tests-postgres.yml @@ -8,6 +8,7 @@ on: push: branches: - 'master' + - 'luacomtio' pull_request: jobs: diff --git a/Makefile b/Makefile index 72f7a111dd9..d6039274e27 100644 --- a/Makefile +++ b/Makefile @@ -44,10 +44,8 @@ endif @npx tsc-alias pack: - @echo -ne "\n\t ----- Packing into sogeBot-$(VERSION).zip\n" - @cp ./src/data/.env* ./ - @cp ./src/data/.env.sqlite ./.env - @npx --yes bestzip sogeBot-$(VERSION).zip .commit .npmrc .env* package-lock.json patches/ dest/ locales/ LICENSE package.json docs/ AUTHORS tools/ bin/ bat/ fonts.json assets/ favicon.ico + @echo -ne "\n\t ----- Packing into luacomtio-bot-$(VERSION).zip\n" + @npx --yes bestzip luacomtio-bot-$(VERSION).zip .npmrc package-lock.json patches/ dest/ locales/ LICENSE package.json docs/ AUTHORS tools/ bin/ bat/ fonts.json assets/ favicon.ico prepare: @echo -ne "\n\t ----- Cleaning up node_modules\n" @@ -56,4 +54,4 @@ prepare: clean: @echo -ne "\n\t ----- Cleaning up compiled files\n" @rm -rf public/dist/bootstrap* public/dist/carousel/* public/dist/gallery/* public/dist/jquery public/dist/lodash public/dist/velocity-animate public/dist/popper.js public/dist/flv.js - @rm -rf dest \ No newline at end of file + @rm -rf dest diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..1596ae36b80 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +version: "3.2" + +services: + nginx: + image: nginx:1.15-alpine + ports: + - "80:80" + - "443:443" + volumes: + - ../proxy/nginx:/etc/nginx/conf.d + - ../proxy/certbot/conf:/etc/letsencrypt + - ../proxy/certbot/www:/var/www/certbot + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + depends_on: + - sogebot + - certbot + - pgadmin + + certbot: + image: certbot/certbot + volumes: + - ../proxy/certbot/conf:/etc/letsencrypt + - ../proxy/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + + sogebot: + image: omurilo/luacomtio-bot:latest + restart: always + volumes: + - $PWD/shared:/app/shared/ + - $PWD/logs:/app/logs/ + ports: + - 20000:20000 # change your output port + # - 443:20443 + - 9229:9229 # uncomment to use --inspect port + env_file: + - ./.env + environment: + # ensure locale exists in container, you may need to install it + LANG: pt_BR.UTF-8 + NODE_OPTIONS: --max_old_space_size=1024 # uncomment to set max 4GB RAM usage (default 2GB) + #PROFILER: y # uncomment to enable --inspect + depends_on: + - db + + db: + image: postgres + restart: always + ports: + - 5432:5432 + env_file: + - ./.env + environment: + POSTGRES_PASSWORD: ${TYPEORM_PASSWORD} + POSTGRES_USER: ${TYPEORM_USERNAME} + POSTGRES_DB: ${TYPEORM_DATABASE} + volumes: + - pgdata:/var/lib/postgresql/data + + pgadmin: + image: dpage/pgadmin4 + restart: always + ports: + - 8080:80 + env_file: + - ./.env + environment: + PGADMIN_DEFAULT_EMAIL: ${PG_ADMIN_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PG_ADMIN_PSSWD} + PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: 'True' + PGADMIN_CONFIG_LOGIN_BANNER: '"Authorized users only!"' + PGADMIN_CONFIG_CONSOLE_LOG_LEVEL: 10 + depends_on: + - db + +volumes: + pgdata: diff --git a/src/expects.ts b/src/expects.ts index 92b9f96157d..64fea3dec59 100644 --- a/src/expects.ts +++ b/src/expects.ts @@ -113,6 +113,13 @@ class Expects { + (param.opts.optional ? ']' : ''), ); break; + case 'reason': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `` + + (param.opts.optional ? ']' : ''), + ); + break; case 'argument': expectedParameters.push( (param.opts.optional ? '[' : '') @@ -440,7 +447,7 @@ class Expects { this.checkText(); } - const regexp = XRegExp(`@?(?[A-Za-z0-9_]+)`, 'ix'); + const regexp = XRegExp(`${opts.prefix ?? ''}@?(?[A-Za-z0-9_]+)`, 'ix'); const match = XRegExp.exec(`${this.text}`, regexp); if (match && match.groups) { this.match.push(match.groups.username.toLowerCase()); @@ -612,6 +619,34 @@ class Expects { } return this; } + + reason (opts?: any) { + opts = opts || {}; + defaults(opts, { + exec: false, optional: false, default: null, + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'reason', opts }); + return this; + } + if (!opts.optional) { + this.checkText(); + } + + const regexp = XRegExp(`${opts.prefix ?? ''}(?.+)`, 'ix'); + const match = XRegExp.exec(`${this.text}`, regexp); + if (match && match.groups) { + this.match.push(match.groups.reason.toLowerCase()); + this.text = this.text.replace(match.groups.reason, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('Reason not found'); + } else { + this.match.push(opts.default); + } + } + return this; + } } module.exports = Expects; diff --git a/src/helpers/commons/announce.ts b/src/helpers/commons/announce.ts index 045d8d62fa5..95a6e61d160 100644 --- a/src/helpers/commons/announce.ts +++ b/src/helpers/commons/announce.ts @@ -12,7 +12,7 @@ import { variables } from '~/watchers'; * * announce('Lorem Ipsum Dolor', 'timers); */ -export const announceTypes = ['bets', 'duel', 'heist', 'timers', 'songs', 'scrim', 'raffles', 'polls', 'general'] as const; +export const announceTypes = ['bets', 'duel', 'heist', 'timers', 'songs', 'scrim', 'raffles', 'polls', 'general', 'moderator'] as const; export async function announce(messageToAnnounce: string, type: typeof announceTypes[number], replaceCustomVariables = true) { const botUsername = variables.get('services.twitch.botUsername') as string; const botId = variables.get('services.twitch.botId') as string; @@ -39,4 +39,4 @@ export async function announce(messageToAnnounce: string, type: typeof announceT } } } -} \ No newline at end of file +} diff --git a/src/integrations/discord.ts b/src/integrations/discord.ts index 36db6353107..f2e96013668 100644 --- a/src/integrations/discord.ts +++ b/src/integrations/discord.ts @@ -1,52 +1,45 @@ +import { SlashCommandBuilder } from '@discordjs/builders'; import { DiscordLink } from '@entity/discord'; import { Events } from '@entity/event'; import { Permissions as PermissionsEntity } from '@entity/permissions'; -import { User } from '@entity/user'; - +import { User, UserInterface } from '@entity/user'; import { HOUR, MINUTE } from '@sogebot/ui-helpers/constants'; - import { dayjs, timezone } from '@sogebot/ui-helpers/dayjsHelper'; - -import * as changelog from '~/helpers/user/changelog.js'; - import chalk from 'chalk'; - -import { getIdFromTwitch } from '~/services/twitch/calls/getIdFromTwitch'; -import { variables } from '~/watchers'; - import * as DiscordJs from 'discord.js'; -import { ChannelType, GatewayIntentBits } from 'discord.js'; import { get } from 'lodash'; import { IsNull, LessThan, Not } from 'typeorm'; import { v5 as uuidv5 } from 'uuid'; import Integration from './_interface'; + +import { AppDataSource } from '~/database'; import { command, persistent, settings, -} from '../decorators'; +} from '~/decorators'; import { onChange, onStartup, onStreamEnd, onStreamStart, -} from '../decorators/on'; -import events from '../events'; -import Expects from '../expects'; -import { Message } from '../message'; -import Parser from '../parser'; -import users from '../users'; -import { AppDataSource } from '~/database'; +} from '~/decorators/on'; +import events from '~/events'; +import Expects from '~/expects'; import { isStreamOnline, stats } from '~/helpers/api'; import { attributesReplace } from '~/helpers/attributesReplace'; -import { - announceTypes, getOwner, getUserSender, isUUID, prepare, -} from '~/helpers/commons'; +import { announceTypes, getOwner, getUserSender, isUUID, prepare } from '~/helpers/commons'; import { isBotStarted, isDbConnected } from '~/helpers/database'; import { debounce } from '~/helpers/debounce'; import { eventEmitter } from '~/helpers/events'; -import { - chatIn, chatOut, debug, error, info, warning, whisperOut, -} from '~/helpers/log'; +import { chatIn, chatOut, debug, error, info, warning, whisperOut } from '~/helpers/log'; import { check } from '~/helpers/permissions/check'; import { get as getPermission } from '~/helpers/permissions/get'; import { adminEndpoint } from '~/helpers/socket'; +import { tmiEmitter } from '~/helpers/tmi'; +import { isModerator } from '~/helpers/user'; +import * as changelog from '~/helpers/user/changelog.js'; +import { Message } from '~/message'; +import Parser from '~/parser'; +import { getIdFromTwitch } from '~/services/twitch/calls/getIdFromTwitch'; +import users from '~/users'; +import { variables } from '~/watchers'; class Discord extends Integration { client: DiscordJs.Client | null = null; @@ -76,15 +69,16 @@ class Discord extends Integration { @settings('bot') sendAnnouncesToChannel: { [key in typeof announceTypes[number]]: string } = { - bets: '', - duel: '', - general: '', - heist: '', - polls: '', - raffles: '', - scrim: '', - songs: '', - timers: '', + bets: '', + duel: '', + general: '', + heist: '', + polls: '', + raffles: '', + scrim: '', + songs: '', + timers: '', + moderator: '', }; @settings('bot') @@ -239,6 +233,7 @@ class Discord extends Integration { discordUser.roles.add(role).catch(roleError => { warning(`Cannot add role '${role.name}' to user ${user.userId}, check permission for bot (bot cannot set role above his own)`); warning(roleError); + debug('discord.roles', JSON.stringify(discordUser.roles.cache.values())); }).then(member => { debug('discord.roles', `User ${user.userId} have new role ${role.name}`); }); @@ -251,6 +246,7 @@ class Discord extends Integration { discordUser.roles.remove(role).catch(roleError => { warning('Cannot remove role to user, check permission for bot (bot cannot set role above his own)'); warning(roleError); + debug('discord.roles', JSON.stringify(discordUser.roles.cache.values())); }).then(member => { debug('discord.roles', `User ${user.userId} have removed role ${role.name}`); }); @@ -345,6 +341,98 @@ class Discord extends Integration { this.embedMessageId = ''; } + extractUsernameAndReasonFromMsg(opts: any) { + const [username] = new Expects(opts.parameters).username({ prefix: 'username:\\s*', optional: false }).toArray(); + const [reason] = new Expects(opts.parameters).reason({ prefix: 'reason:\\s*', optional: true }).toArray(); + + return { username, reason }; + } + + async banUser(username: string, author: DiscordJs.User, user: Readonly>, reason?: string, attachment?: string | DiscordJs.Attachment) { + try { + tmiEmitter.emit('ban', username); + eventEmitter.emit('ban', { + userName: username, + reason: reason || '', + }); + + this.announceBan(author, username, user, reason, attachment); + } catch (e) { + chatOut(`#DISCORD: Error on ban [${username}]`); + } + } + + async announceBan(author: DiscordJs.User, username: string, user: Readonly>, reason?: string, attachment?: string | DiscordJs.Attachment) { + const embedBody: DiscordJs.EmbedData = { + color: author.accentColor || DiscordJs.Colors.DarkRed, + description: `${user.userName} baniu um usuário na live`, + title: 'Usuário banido na live', + fields: [{ name: 'Nome de usuário', value: username }, { name: 'Motivo', value: reason || '' }], + footer: { + text: 'Esse já era ou alguém é contra?', + }, + author: { + name: author.tag, + iconURL: author.avatarURL() || '', + }, + timestamp: new Date(), + }; + + if (typeof attachment === 'string') { + embedBody.thumbnail = { url: attachment }; + } else if (typeof attachment !== 'string' && attachment) { + embedBody.thumbnail = { url: attachment.url, proxyURL: attachment.proxyURL, height: attachment.height ?? 0, width: attachment.width ?? 0 }; + } + + const embed = new DiscordJs.EmbedBuilder(embedBody); + + const channel = this.client?.guilds.cache.get(this.guild)?.channels.cache.get(this.sendAnnouncesToChannel.moderator); + + await (channel as DiscordJs.TextChannel).send({ embeds: [embed] }); + chatOut(`#${(channel as DiscordJs.TextChannel).name}: [[user banned on live]] [${username}]`); + } + + async timeoutUser(username: string, seconds: number, author: DiscordJs.User, user: Readonly>, reason?: string, attachment?: string | DiscordJs.Attachment) { + try { + tmiEmitter.emit('timeout', username, seconds, { mod: isModerator(user) }); + eventEmitter.emit('timeout', { userName: username, duration: seconds }); + + await this.announceTimeout(author, username, user, seconds, reason, attachment); + } catch (e) { + chatOut(`#DISCORD: Error on apply timeout to [${username}]`); + } + } + + async announceTimeout(author: DiscordJs.User, username: string, user: Readonly>, duration: number, reason?: string, attachment?: string | DiscordJs.Attachment) { + const embedBody: DiscordJs.EmbedData = { + color: author.accentColor || DiscordJs.Colors.DarkOrange, + description: `${user.userName} deu um timeout de ${duration} segundos em um usuário na live`, + title: 'Usuário "timeoutado" na live', + fields: [{ name: 'Nome de usuário', value: username }, { name: 'Tempo', value: String(duration) }, { name: 'Motivo', value: reason || '' }], + footer: { + text: 'Daqui a pouco ele volta, né?!', + }, + author: { + name: author.tag, + iconURL: author.avatarURL() || '', + }, + timestamp: new Date(), + }; + + if (typeof attachment === 'string') { + embedBody.thumbnail = { url: attachment }; + } else if (typeof attachment !== 'string' && attachment) { + embedBody.thumbnail = { url: attachment.url, proxyURL: attachment.proxyURL, height: attachment.height ?? 0, width: attachment.width ?? 0 }; + } + + const embed = new DiscordJs.EmbedBuilder(embedBody); + + const channel = this.client?.guilds.cache.get(this.guild)?.channels.cache.get(this.sendAnnouncesToChannel.moderator); + + await (channel as DiscordJs.TextChannel).send({ embeds: [embed] }); + chatOut(`#${(channel as DiscordJs.TextChannel).name}: [[user timed out on live]] [${username}]`); + } + filterFields(o: string, isOnline: boolean) { const broadcasterType = variables.get('services.twitch.broadcasterType') as string; @@ -498,7 +586,7 @@ class Discord extends Integration { const message = attributesReplace(attributes, String(operation.messageToSend)); const messageContent = await self.replaceLinkedUsernameInMessage(await new Message(message).parse()); - const channel = await self.client.guilds.cache.get(self.guild)?.channels.cache.get(dMchannel); + const channel = self.client.guilds.cache.get(self.guild)?.channels.cache.get(dMchannel); await (channel as DiscordJs.TextChannel).send(messageContent); chatOut(`#${(channel as DiscordJs.TextChannel).name}: ${messageContent} [${self.client.user?.tag}]`); } catch (e: any) { @@ -527,10 +615,10 @@ class Discord extends Integration { if (!this.client) { this.client = new DiscordJs.Client({ intents: [ - GatewayIntentBits.GuildMessages, - GatewayIntentBits.Guilds, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, + DiscordJs.GatewayIntentBits.GuildMessages, + DiscordJs.GatewayIntentBits.Guilds, + DiscordJs.GatewayIntentBits.MessageContent, + DiscordJs.GatewayIntentBits.DirectMessages, ], partials: [ DiscordJs.Partials.Reaction, @@ -540,20 +628,314 @@ class Discord extends Integration { }); this.client.on('ready', () => { if (this.client) { - info(chalk.yellow('DISCORD: ') + `Logged in as ${get(this.client, 'user.tag', 'unknown')}!`); + info( + chalk.yellow('DISCORD: ') + + `Logged in as ${get(this.client, 'user.tag', 'unknown')}!` + ); this.changeClientOnlinePresence(); this.updateRolesOfLinkedUsers(); + const guild = this.client.guilds.cache.get(this.guild); + + let commands; + + if (guild) { + commands = guild.commands; + } else { + commands = this.client.application?.commands; + } + + const modalCommand = new SlashCommandBuilder().setName('ban-or-timeout').setDescription('Banir ou Suspender usuário com prova do crime (print do motivo)') + .addAttachmentOption(option => option.setName('proof').setDescription('Prova do crime (print do motivo) (opcional)').setRequired(false)) + .setDefaultMemberPermissions(DiscordJs.PermissionsBitField.Flags.BanMembers | DiscordJs.PermissionsBitField.Flags.KickMembers | DiscordJs.PermissionsBitField.Flags.MuteMembers); + + commands?.create({ name: 'ping', description: 'Respond with pong' }); + commands?.create(modalCommand.toJSON()).then(() => info(chalk.yellow('DISCORD: ') + 'modal ban-or-timeout slash command created')); } }); this.client.on('error', (err) => { error(`DISCORD: ${err.stack || err.message}`); }); + this.client.on('interactionCreate', async (interaction) => { + if (interaction.isButton()) { + if (['ban-confirm', 'timeout-confirm'].includes(interaction.customId)) { + const username = new DiscordJs.TextInputBuilder() + .setCustomId('username-input') + .setLabel('Nome do usuário na twitch') + .setStyle(DiscordJs.TextInputStyle.Short) + .setPlaceholder('Nome do usuário sem "@"') + .setRequired(true); + + const reason = new DiscordJs.TextInputBuilder() + .setCustomId('reason-input') + .setStyle(DiscordJs.TextInputStyle.Paragraph) + .setRequired(true); + + const inputFileUrl = new DiscordJs.TextInputBuilder() + .setCustomId('proof-image-input') + .setLabel('Url da imagem de prova do malandro') + .setStyle(DiscordJs.TextInputStyle.Short) + .setValue(interaction.message.embeds[0].image?.url ?? '') + .setPlaceholder('Não precisa se não quiser'); + + const modal = new DiscordJs.ModalBuilder(); + + if (interaction.customId === 'ban-confirm') { + reason + .setLabel('Motivo do banimento') + .setPlaceholder('Eu quero banir o malandro porque ele estava fazendo tal coisa'); + modal + .setCustomId('ban-modal') + .setTitle('Informe os dados para o banimento'); + } + + if (interaction.customId === 'timeout-confirm') { + reason + .setLabel('Motivo da suspensão') + .setPlaceholder('Ex: Eu quero suspender o malandro porque ele estava fazendo tal coisa'); + modal + .setCustomId('timeout-modal') + .setTitle('Informe os dados para a suspensão'); + } + + const rows = [username, reason, inputFileUrl].map( + (component) => new DiscordJs.ActionRowBuilder() + .addComponents(component) + ); + + modal.addComponents(...rows); + + await interaction.showModal(modal); + } else if (interaction.customId === 'ban-cancel') { + info('[[ cancellation of ban action ]]'); + await interaction.deferReply({ ephemeral: true }); + await interaction.editReply({ content: 'É cabaço mesmo né?!' }); + } + } + + if (interaction.isModalSubmit()) { + const username = interaction.fields.getTextInputValue('username-input'); + const reason = interaction.fields.getTextInputValue('reason-input'); + const proofUrl = interaction.fields.getTextInputValue('proof-image-input'); + + if (!username) { + return; + } + + if (['ban-modal', 'timeout-modal'].includes(interaction.customId)) { + try { + await interaction.deferReply({ + ephemeral: true, + }); + + const link = await AppDataSource.getRepository(DiscordLink).findOneByOrFail({ + discordId: interaction.user.id, + userId: Not(IsNull()), + }); + + if (!link.userId) { + return; + } + + const user = await changelog.getOrFail(link.userId); + + if (!isModerator(user)) { + interaction.editReply({ + content: prepare('permissions.without-permission', { + command: '/ban-or-timeout', + }), + }); + } + + if (interaction.customId === 'ban-modal') { + await this.banUser( + username, + interaction.user, + user, + reason || undefined, + proofUrl || undefined + ); + + interaction.editReply({ + content: 'Recebemos o seu pedido e ele já foi processado, se deu certo poderás vê-lo no canal de banimentos e timeouts!', + }); + } else if (interaction.customId === 'timeout-modal'){ + const time = new DiscordJs.StringSelectMenuBuilder() + .setCustomId('time-input') + .setPlaceholder('Duração da suspensão (segundos)') + .setMaxValues(1) + .addOptions({ + label: '15s', + description: 'Suspender usuário por 15s', + value: String(15), + default: true, + }, { + label: '30s', + description: 'Suspender usuário por 30s', + value: String(30), + }, { + label: '1 min', + description: 'Suspender usuário por 1 min', + value: String(60), + }, { + label: '5 min', + description: 'Suspender usuário por 5 min', + value: String(60 * 5), + }, { + label: '10 min', + description: 'Suspender usuário por 10 min', + value: String(60 * 10), + }, { + label: '15 min', + description: 'Suspender usuário por 15 min', + value: String(60 * 15), + }, { + label: '30 min', + description: 'Suspender usuário por 30 min', + value: String(60 * 30), + }, { + label: '1 hour', + description: 'Suspender usuário por 1 hour', + value: String(60 * 60), + }, { + label: '12 hours', + description: 'Suspender usuário por 12 hours', + value: String(60 * 60 * 12), + }, { + label: '1 Day', + description: 'Suspender usuário por 1 Day', + value: String(60 * 60 * 24), + }); + + const confirmTimeoutButton = new DiscordJs.ButtonBuilder() + .setCustomId('timeout-modal-confirm') + .setStyle(DiscordJs.ButtonStyle.Success); + + const rowTime = new DiscordJs.ActionRowBuilder().addComponents(time); + const rowTimeoutButton = new DiscordJs.ActionRowBuilder().addComponents(confirmTimeoutButton); + + let timeoutDuration = '15'; + const message = await interaction.fetchReply(); + const selectCollector = message.createMessageComponentCollector({ componentType: DiscordJs.ComponentType.StringSelect }); + const buttonCollector = message.createMessageComponentCollector({ componentType: DiscordJs.ComponentType.Button, max: 1 }); + + interaction.editReply({ + content: 'Selecione por quanto tempo deseja suspender o usuário.', + components: [rowTime], + }); + + selectCollector.on('collect', async collected => { + await collected.deferUpdate(); + timeoutDuration = collected.values[0]; + confirmTimeoutButton.setLabel(`Confirmar suspensão de ${timeoutDuration}s?`); + rowTimeoutButton.setComponents(confirmTimeoutButton); + + const options = time.options.map(option => { + option.setDefault(option.data.value === timeoutDuration); + return option; + }); + time.setOptions(options); + rowTime.setComponents(time); + + await collected.editReply({ components: [rowTime, rowTimeoutButton] }); + }); + + buttonCollector.on('end', async collected => { + if ('timeout-modal-confirm' === collected.first()?.customId) { + await this.timeoutUser( + username, + Number(timeoutDuration), + interaction.user, + user, + reason || undefined, + proofUrl || undefined + ); + + interaction.editReply({ + content: 'Recebemos o seu pedido e ele já foi processado, se deu certo poderás vê-lo no canal de banimentos e timeouts!', + components: [], + }); + } + }); + } + } catch (e: unknown) { + if ( + (e as DiscordJs.ErrorEvent).message.includes( + 'Could not find any entity of type "discord_link" matching' + ) + ) { + interaction.reply({ + content: prepare( + 'integrations.discord.your-account-is-not-linked', + { command: this.getCommand('!link') } + ), + }); + } + } + } + } + + if (!interaction.isChatInputCommand()) { + return; + } + + const { commandName, options } = interaction; + + if (commandName === 'ban-or-timeout') { + await interaction.deferReply({ ephemeral: true }); + const attachment = options.getAttachment('proof'); + const row = new DiscordJs.ActionRowBuilder(); + + const confirmBanButton = new DiscordJs.ButtonBuilder() + .setCustomId('ban-confirm') + .setStyle(DiscordJs.ButtonStyle.Success) + .setLabel('Quero banir um malandro'); + + const confirmTimeoutButton = new DiscordJs.ButtonBuilder() + .setCustomId('timeout-confirm') + .setStyle(DiscordJs.ButtonStyle.Primary) + .setLabel('Quero suspender um malandro'); + + const denyButton = new DiscordJs.ButtonBuilder() + .setCustomId('ban-cancel') + .setStyle(DiscordJs.ButtonStyle.Danger) + .setLabel('Eu fiz sem querer'); + + row.addComponents(confirmBanButton, confirmTimeoutButton, denyButton); + + const embed = new DiscordJs.EmbedBuilder() + .setTitle('Banimento/Suspensão de usuário') + .setDescription('Você vai banir/suspender um usuário? Então escolhe e clica em um dos botões ai embaixo e informa o nome do malandro pra nois!'); + + if (attachment?.url) { + embed.setImage(attachment.url); + } + + interaction.editReply({ + components: [row], + embeds: [embed], + }); + + const collector = interaction.channel?.createMessageComponentCollector({ componentType: DiscordJs.ComponentType.Button, max: 1 }); + + collector?.on('end', async collected => { + if (['ban-confirm', 'timeout-confirm', 'ban-cancel'].includes(collected.first()?.customId || '')) { + await interaction.deleteReply(); + } + }); + } else if (commandName === 'ping') { + interaction.reply({ + content: 'Pong mermão', + ephemeral: true, + }); + } + }); + this.client.on('messageCreate', async (msg) => { if (this.client && this.guild) { const isSelf = msg.author.tag === get(this.client, 'user.tag', null); - const isDM = msg.channel.type === ChannelType.DM; + const isDM = msg.channel.type === DiscordJs.ChannelType.DM; const isDifferentGuild = msg.guild?.id !== this.guild; const isInIgnoreList = this.ignorelist.includes(msg.author.tag) @@ -563,7 +945,7 @@ class Discord extends Integration { return; } - if (msg.channel.type === ChannelType.GuildText) { + if (msg.channel.type === DiscordJs.ChannelType.GuildText) { const listenAtChannels = [ ...Array.isArray(this.listenAtChannels) ? this.listenAtChannels : [this.listenAtChannels], ].filter(o => o !== ''); @@ -576,7 +958,7 @@ class Discord extends Integration { } } - async message(content: string, channel: DiscordJsTextChannel, author: DiscordJsUser, msg?: DiscordJs.Message) { + async message(content: string, channel: DiscordJs.TextChannel, author: DiscordJs.User, msg?: DiscordJs.Message) { chatIn(`#${channel.name}: ${content} [${author.tag}]`); if (msg) { const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; @@ -662,7 +1044,7 @@ class Discord extends Integration { if (responses) { for (let i = 0; i < responses.length; i++) { setTimeout(async () => { - if (channel.type === ChannelType.GuildText) { + if (channel.type === DiscordJs.ChannelType.GuildText) { const messageToSend = await new Message(await responses[i].response).parse({ ...responses[i].attr, forceWithoutAt: true, // we dont need @ @@ -691,7 +1073,7 @@ class Discord extends Integration { } } catch (e: any) { const message = prepare('integrations.discord.your-account-is-not-linked', { command: this.getCommand('!link') }); - if (msg) { + if (msg && !msg.content.startsWith('!')) { const reply = await msg.reply(message); chatOut(`#${channel.name}: @${author.tag}, ${message} [${author.tag}]`); if (this.deleteMessagesAfterWhile) { @@ -759,7 +1141,7 @@ class Discord extends Integration { try { if (this.client && this.guild) { cb(null, this.client.guilds.cache.get(this.guild)?.channels.cache - .filter(o => o.type === ChannelType.GuildText) + .filter(o => o.type === DiscordJs.ChannelType.GuildText) .sort((a, b) => { const nameA = (a as DiscordJs.TextChannel).name.toUpperCase(); // ignore upper and lowercase const nameB = (b as DiscordJs.TextChannel).name.toUpperCase(); // ignore upper and lowercase