├── .gitignore ├── .prettierignore ├── .env_sample ├── src ├── util │ ├── asyncio.ts │ ├── emoji.ts │ ├── error.ts │ ├── color.ts │ ├── react.ts │ ├── permission.ts │ └── panel.ts ├── clear.ts ├── init.ts ├── events │ ├── ready.ts │ ├── guildJoinLeave.ts │ ├── interactionCreate.ts │ ├── commandError.ts │ └── reactionAdd.ts ├── const │ └── index.ts ├── index.ts ├── bot.ts ├── commands │ ├── deletePanel.ts │ ├── selectWithId.ts │ ├── selected.ts │ ├── copy.ts │ ├── refresh.ts │ ├── edit.ts │ ├── stop.ts │ ├── autoremove.ts │ ├── debug.ts │ ├── remove.ts │ ├── createPanel.ts │ ├── transfer.ts │ ├── add.ts │ ├── init.ts │ ├── select.ts │ └── selectMenu.ts └── types │ ├── command.ts │ ├── error.ts │ └── client.ts ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20221115100744_init │ │ └── migration.sql └── schema.prisma ├── .githooks └── pre-commit ├── Dockerfile ├── package.json ├── LICENSE ├── .dockerignore └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .idea 3 | node_modules 4 | out -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | tsconfig.json 3 | -------------------------------------------------------------------------------- /.env_sample: -------------------------------------------------------------------------------- 1 | BOT_TOKEN=YOURBOTTOKEN 2 | GUILD_ID=000000000 3 | CHANNEL_LOG=0000000 4 | CHANNEL_TRACEBACK=000000000 -------------------------------------------------------------------------------- /src/util/asyncio.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number) { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, ms) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/util/emoji.ts: -------------------------------------------------------------------------------- 1 | export const ABCEmojiIndex = 0x1f1e6 2 | export function getABCEmoji(i: number) { 3 | return String.fromCodePoint(ABCEmojiIndex + i) 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/clear.ts: -------------------------------------------------------------------------------- 1 | import { clearCommand } from './commands/init' 2 | 3 | async function main() { 4 | await clearCommand(process.env.BOT_TOKEN as string, process.env.GUILD_ID) 5 | } 6 | 7 | main() 8 | -------------------------------------------------------------------------------- /src/util/error.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPIError } from 'discord.js' 2 | 3 | export function is403Error(e: unknown): e is DiscordAPIError { 4 | return e instanceof DiscordAPIError && e.httpStatus === 403 5 | } 6 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') 3 | [ -z "$FILES" ] && exit 0 4 | 5 | echo "$FILES" | xargs ./node_modules/.bin/prettier --ignore-unknown --write \ 6 | && echo "$FILES" | xargs git add 7 | -------------------------------------------------------------------------------- /prisma/migrations/20221115100744_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "selected_message" ( 3 | "key" TEXT NOT NULL, 4 | "channel_id" TEXT NOT NULL, 5 | "message_id" TEXT NOT NULL, 6 | 7 | CONSTRAINT "selected_message_pkey" PRIMARY KEY ("key") 8 | ); 9 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import { initCommand } from './commands/init' 2 | import { BOT_TOKEN, GUILD_ID } from './const' 3 | 4 | async function main() { 5 | const guildIds = (>[undefined]).concat(GUILD_ID) 6 | await initCommand(BOT_TOKEN, ...guildIds) 7 | } 8 | 9 | main() 10 | -------------------------------------------------------------------------------- /src/util/color.ts: -------------------------------------------------------------------------------- 1 | import { ColorResolvable, Util } from 'discord.js' 2 | import { ColorParseError } from '../types/error' 3 | 4 | export function parseColor(color: string) { 5 | try { 6 | return Util.resolveColor(color.toUpperCase() as ColorResolvable) 7 | } catch (e) { 8 | throw new ColorParseError(e) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model selected_message{ 14 | key String @id 15 | channel_id String 16 | message_id String 17 | } -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from 'discord.js' 2 | // import { initStopCommand } from '../commands/stop' 3 | import { RolePanelClient } from '../types/client' 4 | 5 | export async function onReady( 6 | client: RolePanelClient, 7 | []: ClientEvents['ready'] 8 | ) { 9 | // await initStopCommand(client) 10 | client.log(`[BOOT] name:\`${client.user!.username}(ID:${client.user!.id})\``) 11 | } 12 | -------------------------------------------------------------------------------- /src/const/index.ts: -------------------------------------------------------------------------------- 1 | export const BOT_TOKEN: string = process.env.BOT_TOKEN ?? '' 2 | export const GUILD_ID: string[] = process.env.GUILD_ID?.split(',') ?? [] 3 | export const CHANNEL_LOG = process.env.CHANNEL_LOG ?? '' 4 | export const CHANNEL_TRACEBACK = process.env.CHANNEL_TRACEBACK ?? '' 5 | export const JOIN_MESSAGE = 6 | process.env.IS_PTB === '0' ?? false 7 | ? 'PTBバージョンではないときの参加時メッセージ' 8 | : 'PTBバージョンのときの参加時メッセージ' 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager, Util } from 'discord.js' 2 | import { BOT_TOKEN } from './const' 3 | 4 | process.on('unhandledRejection', (reason) => { 5 | console.error(reason) 6 | }) 7 | 8 | async function main() { 9 | const amount = await Util.fetchRecommendedShards(BOT_TOKEN) 10 | const manager = new ShardingManager(`${__dirname}/bot.js`, { 11 | token: BOT_TOKEN, 12 | mode: 'worker', 13 | respawn: true, 14 | }) 15 | await manager.spawn({ timeout: 35_000 * amount }) 16 | } 17 | 18 | main() 19 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { RolePanelClient } from './types/client' 2 | import { Intents } from 'discord.js' 3 | import { BOT_TOKEN } from './const' 4 | 5 | process.on('unhandledRejection', (reason) => { 6 | console.error(reason) 7 | }) 8 | 9 | const client = new RolePanelClient({ 10 | intents: 11 | 2 ** 16 - 12 | 1 - 13 | Intents.FLAGS.GUILD_PRESENCES - 14 | Intents.FLAGS.GUILD_MEMBERS - 15 | (1 << 15), // MESSAGE_CONTENT 16 | partials: ['REACTION', 'MESSAGE', 'GUILD_MEMBER'], 17 | }) 18 | 19 | ;(async () => { 20 | await client.login(BOT_TOKEN) 21 | })() 22 | -------------------------------------------------------------------------------- /src/util/react.ts: -------------------------------------------------------------------------------- 1 | import { REST } from '@discordjs/rest' 2 | import { Routes } from 'discord-api-types/v9' 3 | import { EmojiIdentifierResolvable, Message } from 'discord.js' 4 | import { BOT_TOKEN } from '../const' 5 | 6 | const rest = new REST({ offset: 0, version: '9' }).setToken(BOT_TOKEN) 7 | 8 | export async function fastReact( 9 | message: Message, 10 | emoji: EmojiIdentifierResolvable 11 | ) { 12 | return await rest.put( 13 | Routes.channelMessageOwnReaction( 14 | message.channelId, 15 | message.id, 16 | encodeURIComponent(emoji.toString().replace(/<|>/g, '')) 17 | ) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/deletePanel.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { selectedMessage } from './select' 4 | 5 | export const command = new SlashCommandSubcommandBuilder() 6 | .setName('delete') 7 | .setDescription('選択したパネルを削除します') 8 | 9 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 10 | const message = await selectedMessage.getFromInteraction(client, interaction) 11 | await message.delete() 12 | await interaction.deleteReply() 13 | await selectedMessage.deleteFromInteraction(interaction) 14 | } 15 | -------------------------------------------------------------------------------- /src/util/permission.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember, Permissions, Role } from 'discord.js' 2 | import { UseHigherRole, YouDontHaveRoleManagement } from '../types/error' 3 | 4 | export function hasRoleManagement(member: GuildMember) { 5 | const permissions = [Permissions.FLAGS.MANAGE_ROLES] 6 | if (!member.permissions.has(permissions)) { 7 | throw new YouDontHaveRoleManagement() 8 | } 9 | } 10 | 11 | export function canUseRoleArgument(member: GuildMember, role: Role) { 12 | if (member.guild.ownerId === member.id) { 13 | return 14 | } 15 | hasRoleManagement(member) 16 | if (member.roles.highest.position <= role.position) { 17 | throw new UseHigherRole({ role }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM amd64/node:16.20.2-buster as bulider 2 | WORKDIR /opt 3 | 4 | COPY package.json package-lock.json tsconfig.json ./ 5 | COPY ./prisma ./prisma 6 | COPY ./src ./src 7 | 8 | RUN npm set-script postinstall "" && npm i && npm run build:tsc 9 | 10 | FROM node:16.20.2-buster-slim 11 | 12 | RUN apt update && apt install -y --no-install-recommends \ 13 | openssl \ 14 | && apt-get clean \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | WORKDIR /opt 18 | COPY package.json package-lock.json ./ 19 | COPY ./prisma ./prisma 20 | RUN npm set-script postinstall "" && npm i --production && npm run build:prisma 21 | COPY --from=bulider /opt/out /opt/out 22 | CMD ["node", "out/index.js"] 23 | 24 | -------------------------------------------------------------------------------- /src/events/guildJoinLeave.ts: -------------------------------------------------------------------------------- 1 | import { RolePanelClient } from '../types/client' 2 | import { ClientEvents } from 'discord.js' 3 | import { JOIN_MESSAGE } from '../const' 4 | 5 | export async function onGuildJoin( 6 | client: RolePanelClient, 7 | [guild]: ClientEvents['guildCreate'] 8 | ) { 9 | try { 10 | const owner = await guild.members.fetch(guild.ownerId) 11 | await owner.send(JOIN_MESSAGE) 12 | } catch (e) {} 13 | client.log(`[GUILD-ENTER] guild:\`${guild.name} (ID:${guild.id})\``) 14 | } 15 | 16 | export async function onGuildLeave( 17 | client: RolePanelClient, 18 | [guild]: ClientEvents['guildDelete'] 19 | ) { 20 | client.log(`[GUILD-LEAVE] guild:\`${guild.name} (ID:${guild.id})\``) 21 | } 22 | -------------------------------------------------------------------------------- /src/commands/selectWithId.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { selectPanel } from './select' 4 | 5 | export const command = new SlashCommandSubcommandBuilder() 6 | .setName('selectwithid') 7 | .setDescription('メッセージIDによってパネルを選択します(スマホ版向けコマンドです)') 8 | .addNumberOption((option) => 9 | option 10 | .setName('メッセージID') 11 | .setDescription('パネルのメッセージIDです') 12 | .setRequired(true) 13 | ); 14 | 15 | export const handler: CommandHandlerWithGuild = async (client, interaction) =>{ 16 | const channel = await client.fetchTextChannel(interaction.channelId) 17 | const messageId = interaction.options.getNumber('メッセージID', true) 18 | const message = await channel.messages.fetch(messageId.toString()) 19 | await selectPanel(client, interaction, message) 20 | } -------------------------------------------------------------------------------- /src/commands/selected.ts: -------------------------------------------------------------------------------- 1 | import { NoSelectedPanel } from '../types/error' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 4 | import { selectedMessage } from './select' 5 | 6 | export const command = new SlashCommandSubcommandBuilder() 7 | .setName('selected') 8 | .setDescription('現在選択されているパネルのリンクを返します') 9 | 10 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 11 | let content 12 | try { 13 | const message = await selectedMessage.getFromInteraction( 14 | client, 15 | interaction 16 | ) 17 | content = 'あなたは以下のパネルを選択しています。\n' + message.url 18 | } catch (e) { 19 | if (e instanceof NoSelectedPanel) { 20 | content = 'あなたはパネルを選択していません。' 21 | } else { 22 | throw e 23 | } 24 | } 25 | await interaction.editReply({ content: content }) 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "out/index.js", 3 | "scripts": { 4 | "build": "npm run build:tsc && npm run build:prisma", 5 | "build:tsc": "tsc", 6 | "build:prisma": "prisma generate", 7 | "init": "node out/init.js", 8 | "main": "node out/index.js", 9 | "clear": "node out/clear.js", 10 | "lint": "npx prettier --write src", 11 | "postinstall": "git config --local core.hooksPath .githooks && chmod -R +x .githooks/" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^16.18.61", 15 | "prettier": "^2.5.1", 16 | "typescript": "^4.6.2", 17 | "prisma": "^4.6.1" 18 | }, 19 | "dependencies": { 20 | "@discordjs/builders": "^0.12.0", 21 | "@discordjs/rest": "^0.3.0", 22 | "@prisma/client": "^4.6.1", 23 | "discord-api-types": "^0.33.5", 24 | "discord.js": "^13.16.0" 25 | }, 26 | "prettier": { 27 | "semi": false, 28 | "singleQuote": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/types/command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseCommandInteraction, 3 | CommandInteraction, 4 | ContextMenuInteraction, 5 | Snowflake, 6 | } from 'discord.js' 7 | import { RolePanelClient } from './client' 8 | 9 | export type WithGuild = T & { guildId: Snowflake } 10 | 11 | export type CommandHandler = ( 12 | client: RolePanelClient, 13 | interaction: CommandInteraction 14 | ) => Promise 15 | export type CommandHandlerWithGuild = ( 16 | client: RolePanelClient, 17 | interaction: CommandInteraction<'cached'> 18 | ) => Promise 19 | export type ContextHandlerWithGuild = ( 20 | client: RolePanelClient, 21 | interaction: ContextMenuInteraction<'cached'> 22 | ) => Promise 23 | type BaseCommandHandlerWithGuild = ( 24 | client: RolePanelClient, 25 | interaction: BaseCommandInteraction<'cached'> 26 | ) => Promise 27 | 28 | export type HandlerWithGuild = 29 | T extends ContextMenuInteraction 30 | ? ContextHandlerWithGuild 31 | : T extends CommandInteraction 32 | ? CommandHandlerWithGuild 33 | : BaseCommandHandlerWithGuild 34 | -------------------------------------------------------------------------------- /src/commands/copy.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { hasRoleManagement } from '../util/permission' 4 | import { fastReact } from '../util/react' 5 | import { selectedMessage } from './select' 6 | 7 | export const command = new SlashCommandSubcommandBuilder() 8 | .setName('copy') 9 | .setDescription('選択したパネルをコピーします。') 10 | 11 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 12 | const guild = await client.fetchGuild(interaction.guildId) 13 | const member = await client.fetchMember(guild, interaction.user.id) 14 | hasRoleManagement(member) 15 | const channel = await client.fetchTextChannel(interaction.channelId) 16 | const message = await selectedMessage.getFromInteraction(client, interaction) 17 | const newMessage = await channel.send({ 18 | embeds: message.embeds, 19 | }) 20 | for (const reaction of message.reactions.cache.values()) { 21 | await fastReact(newMessage, reaction.emoji) 22 | } 23 | await interaction.deleteReply() 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/refresh.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { hasRoleManagement } from '../util/permission' 4 | import { fastReact } from '../util/react' 5 | import { selectedMessage } from './select' 6 | 7 | export const command = new SlashCommandSubcommandBuilder() 8 | .setName('refresh') 9 | .setDescription('選択したパネルのリアクションをつけ直します。') 10 | 11 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 12 | const guild = await client.fetchGuild(interaction.guildId) 13 | const member = await client.fetchMember(guild, interaction.user.id) 14 | hasRoleManagement(member) 15 | const message = await selectedMessage.getFromInteraction(client, interaction) 16 | await message.reactions.removeAll() 17 | const embed = message.embeds[0] 18 | const lines = embed.description?.split('\n') 19 | if (!lines) { 20 | await interaction.deleteReply() 21 | return 22 | } 23 | for (const line of lines) { 24 | const emoji = line.replace(/:<@&\d+>/, '') 25 | await fastReact(message, emoji) 26 | } 27 | await interaction.deleteReply() 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/edit.ts: -------------------------------------------------------------------------------- 1 | import { selectedMessage } from './select' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 4 | import { hasRoleManagement } from '../util/permission' 5 | import { parseColor } from '../util/color' 6 | 7 | export const command = new SlashCommandSubcommandBuilder() 8 | .setName('edit') 9 | .setDescription('選択したパネルのタイトルを変更します。') 10 | .addStringOption((option) => 11 | option.setName('title').setDescription('タイトル') 12 | ) 13 | .addStringOption((option) => option.setName('color').setDescription('カラー')) 14 | 15 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 16 | // Argument parse 17 | const message = await selectedMessage.getFromInteraction(client, interaction) 18 | const title = interaction.options.getString('title') 19 | const color = interaction.options.getString('color') 20 | 21 | const guild = await client.fetchGuild(message.guildId!) 22 | const member = await client.fetchMember(guild, interaction.user.id) 23 | hasRoleManagement(member) 24 | const embed = message.embeds[0] 25 | if (title) { 26 | embed.setTitle(title) 27 | } 28 | if (color) { 29 | embed.setColor(parseColor(color)) 30 | } 31 | await message.edit({ embeds: [embed] }) 32 | await interaction.deleteReply() 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/stop.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '../types/command' 2 | import { ApplicationCommandPermissionData, Client } from 'discord.js' 3 | export const StopCommandName = 'rpstop' 4 | 5 | export const StopCommandHandler: CommandHandler = async ( 6 | client, 7 | interaction 8 | ) => { 9 | if (interaction.user.id === client.application?.owner?.id) { 10 | await interaction.deleteReply() 11 | await client.destroy() 12 | return 13 | } 14 | await interaction.editReply({ content: 'あなたにこのコマンドは使えません' }) 15 | } 16 | 17 | export const initStopCommand = async (client: Client) => { 18 | if (!client.application?.owner) { 19 | await client.application?.fetch() 20 | } 21 | const ownerId = client.application!.owner!.id 22 | const commandData = { 23 | name: StopCommandName, 24 | description: 'BOT停止', 25 | defaultPermission: false, 26 | } 27 | const permissions: Array = [ 28 | { 29 | id: ownerId, 30 | type: 'USER', 31 | permission: true, 32 | }, 33 | ] 34 | for (const [_, guild] of client.guilds.cache) { 35 | try { 36 | await guild.members.fetch(ownerId) 37 | } catch (e) { 38 | continue 39 | } 40 | const command = await guild.commands.create(commandData) 41 | await command!.permissions.add({ permissions: permissions }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/autoremove.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { selectedMessage } from './select' 4 | import { hasRoleManagement } from '../util/permission' 5 | 6 | export const command = new SlashCommandSubcommandBuilder() 7 | .setName('autoremove') 8 | .setDescription('選択したパネル内にある`@deleted-role`を削除します') 9 | 10 | const pattern = /<@&(\d*)>/ 11 | 12 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 13 | const message = await selectedMessage.getFromInteraction(client, interaction) 14 | const guild = await client.fetchGuild(interaction.guildId) 15 | const member = await client.fetchMember(guild, interaction.user.id) 16 | hasRoleManagement(member) 17 | const embed = message.embeds[0] 18 | const description = embed.description 19 | if (!description) { 20 | return 21 | } 22 | const lines = description.split('\n') 23 | let index = 0 24 | for (const line of lines.slice()) { 25 | const match = pattern.exec(line) 26 | if (!match) { 27 | continue 28 | } 29 | const role = await guild.roles.fetch(match[1]) 30 | // 役職が無いならその行は消す! 31 | if (!role) { 32 | lines.splice(index, 1) 33 | } else { 34 | index += 1 35 | } 36 | } 37 | if (lines.length > 0) { 38 | embed.setDescription(lines.join('\n')) 39 | await message.edit({ embeds: [embed] }) 40 | } else { 41 | await message.delete() 42 | } 43 | await interaction.deleteReply() 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/debug.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandlerWithGuild } from '../types/command' 2 | import { Embed, SlashCommandSubcommandBuilder } from '@discordjs/builders' 3 | import { PermissionString } from 'discord.js' 4 | 5 | export const command = new SlashCommandSubcommandBuilder() 6 | .setName('debug') 7 | .setDescription('デバッグのための情報を出力します') 8 | 9 | const PERM_KEYS: Array = [ 10 | 'VIEW_CHANNEL', 11 | 'SEND_MESSAGES', 12 | 'EMBED_LINKS', 13 | 'USE_EXTERNAL_EMOJIS', 14 | 'MANAGE_MESSAGES', 15 | 'READ_MESSAGE_HISTORY', 16 | 'ADD_REACTIONS', 17 | ] 18 | const emoji = (x: any): string => { 19 | if (x) { 20 | return ':white_check_mark:' 21 | } else { 22 | return ':x:' 23 | } 24 | } 25 | 26 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 27 | const guild = await client.fetchGuild(interaction.guildId) 28 | const me = await guild.members.fetch(client.user!.id) 29 | const embed = new Embed({ 30 | title: 'デバッグ情報', 31 | description: ` 32 | ギルドID: ${interaction.guildId} 33 | チャンネルID: ${interaction.channelId} 34 | ユーザーID: ${interaction.user.id} 35 | 役職の管理があるか?: ${emoji(me.permissions.has('MANAGE_ROLES'))} 36 | `, 37 | }) 38 | const channel = interaction.channel 39 | if (channel) { 40 | const permission = channel.permissionsFor(me, true) 41 | const value = PERM_KEYS.map( 42 | (key) => `${key}: ${emoji(permission.has(key))}` 43 | ).join('\n') 44 | embed.addField({ 45 | name: 'チャンネル権限情報', 46 | value: value, 47 | }) 48 | } 49 | await interaction.editReply({ embeds: [embed] }) 50 | } 51 | -------------------------------------------------------------------------------- /src/util/panel.ts: -------------------------------------------------------------------------------- 1 | import { Client, Message, MessageEmbed } from 'discord.js' 2 | import { APIMessage } from 'discord-api-types/v9' 3 | 4 | const pattern = /役職パネル\((.+?)\)\((\d+?)ページ目\)/ 5 | const panelFotterName = '役職パネル' 6 | 7 | export type Panel = { 8 | tag: string 9 | page: number 10 | } 11 | 12 | export function setPanel(embed: MessageEmbed) { 13 | embed.setFooter({ text: panelFotterName }) 14 | } 15 | 16 | export function isPanel(client: Client, message: Message): boolean { 17 | return ( 18 | message.author.id === client.user?.id && 19 | message.embeds.at(0)?.footer?.text === panelFotterName 20 | ) 21 | } 22 | 23 | export function isV2Panel(message: Message | APIMessage): boolean { 24 | return !!( 25 | message.author.id === '682774762837377045' && 26 | message.embeds.at(0)?.title?.startsWith('役職パネル') 27 | ) 28 | } 29 | 30 | export function isOldv3Panel(message: Message | APIMessage): boolean { 31 | return ( 32 | (message.author.id === '895912135039803402' || 33 | message.author.id === '971523089550671953' || 34 | message.author.id === '1137367652482957313') && 35 | message.embeds.at(0)?.footer?.text === '役職パネル' 36 | ) 37 | } 38 | 39 | export function getPanel(client: Client, message: Message): Panel | null { 40 | if (message === null || message.author.id !== client.user?.id) { 41 | return null 42 | } 43 | const title = message.embeds[0]?.title 44 | if (!title) { 45 | return null 46 | } 47 | const match = pattern.exec(title) 48 | if (match === null) { 49 | return null 50 | } 51 | return { 52 | tag: match[1], 53 | page: Number(match[2]), 54 | } 55 | } 56 | 57 | export function createPanelTitle(panel: Panel): string { 58 | return `役職パネル(${panel.tag})(${panel.page}ページ目)` 59 | } 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | 19 | "Commons Clause" License Condition v1.0 20 | 21 | The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. 22 | 23 | Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software. 24 | 25 | For purposes of the foregoing, "Sell" means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. 26 | 27 | Software: rolepanel-v3 28 | License: MIT 29 | Licensor: Kesigomon -------------------------------------------------------------------------------- /src/commands/remove.ts: -------------------------------------------------------------------------------- 1 | import { roleMention, SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { canUseRoleArgument } from '../util/permission' 4 | import { selectedMessage } from './select' 5 | 6 | export const command = new SlashCommandSubcommandBuilder() 7 | .setName('remove') 8 | .setDescription('選択されたパネルから役職を削除します') 9 | ;[...Array(20).keys()].forEach((i) => { 10 | command.addRoleOption((option) => 11 | option 12 | .setName(`role${i + 1}`) 13 | .setDescription('パネルから削除する役職です。') 14 | .setRequired(i === 0) 15 | ) 16 | }) 17 | 18 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 19 | const message = await selectedMessage.getFromInteraction(client, interaction) 20 | const guild = await client.fetchGuild(interaction.guildId) 21 | const member = await client.fetchMember(guild, interaction.user.id) 22 | 23 | // Argument parse 24 | const options = interaction.options 25 | for (let i = 0; i < 20; i++) { 26 | const partialRole = options.getRole(`role${i + 1}`) 27 | if (!partialRole) { 28 | continue 29 | } 30 | const role = await client.fetchRole(guild, partialRole.id) 31 | // Todo: 使えない役職はスキップする 32 | canUseRoleArgument(member, role) 33 | const mention = roleMention(role.id) 34 | const embed = message.embeds[0] 35 | const description = embed.description || '' 36 | const pattern = new RegExp('(?:|(.+)):' + mention + '\\n?') 37 | const match = pattern.exec(description) 38 | if (!match) { 39 | continue 40 | } 41 | let emoji: string | undefined 42 | for (let j = 1; j <= 2 && !emoji; j++) { 43 | // j = 1: Custom Emoji 44 | // j = 2: Unicode Emoji 45 | emoji = match.at(j) 46 | } 47 | embed.description = description.replace(match.at(0)!, '') 48 | if (!embed.description) { 49 | await message.delete() 50 | } else { 51 | await message.edit({ embeds: [embed] }) 52 | if (!!emoji) { 53 | await message.reactions.resolve(emoji)?.remove() 54 | } 55 | } 56 | } 57 | await interaction.deleteReply() 58 | } 59 | -------------------------------------------------------------------------------- /src/commands/createPanel.ts: -------------------------------------------------------------------------------- 1 | import { roleMention, SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { canUseRoleArgument } from '../util/permission' 4 | import { MessageEmbed } from 'discord.js' 5 | import { setPanel } from '../util/panel' 6 | import { getABCEmoji } from '../util/emoji' 7 | import { selectedMessage } from './select' 8 | import { parseColor } from '../util/color' 9 | 10 | export const command = new SlashCommandSubcommandBuilder() 11 | .setName('create') 12 | .setDescription('パネルを新しく作成します') 13 | .addRoleOption((option) => 14 | option 15 | .setName('role') 16 | .setDescription('パネルに最初に追加する役職です。') 17 | .setRequired(true) 18 | ) 19 | .addStringOption((option) => 20 | option 21 | .setName('title') 22 | .setDescription( 23 | 'パネルのタイトルです。指定しなければ「役職パネル」になります。' 24 | ) 25 | ) 26 | .addStringOption((option) => 27 | option 28 | .setName('color') 29 | .setDescription('パネルのカラーです。指定しなければ黒になります。') 30 | ) 31 | .addStringOption((option) => 32 | option 33 | .setName('emoji') 34 | .setDescription( 35 | '最初に追加する役職の絵文字です。指定しなければABC絵文字が使用されます。' 36 | ) 37 | ) 38 | 39 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 40 | // Todo: Emoji引数 41 | const options = interaction.options 42 | const guild = await client.fetchGuild(interaction.guildId) 43 | const member = await client.fetchMember(guild, interaction.user.id) 44 | const role = await client.fetchRole(guild, options.getRole('role', true).id) 45 | const channel = await client.fetchTextChannel(interaction.channelId) 46 | const emoji = options.getString('emoji') ?? getABCEmoji(0) 47 | canUseRoleArgument(member, role) 48 | const title = options.getString('title') ?? '役職パネル' 49 | const color = parseColor(options.getString('color') ?? 'DEFAULT') 50 | const embed = new MessageEmbed({ 51 | title: title, 52 | description: `${emoji}:${roleMention(role.id)}`, 53 | color: color, 54 | }) 55 | setPanel(embed) 56 | const message = await channel.send({ embeds: [embed] }) 57 | try { 58 | await message.react(emoji) 59 | } catch (error) { 60 | await message.delete() 61 | throw error 62 | } 63 | await selectedMessage.setFromInteraction(interaction, message) 64 | await interaction.deleteReply() 65 | } 66 | -------------------------------------------------------------------------------- /src/types/error.ts: -------------------------------------------------------------------------------- 1 | import { Role, Snowflake } from 'discord.js' 2 | import { channelMention } from '@discordjs/builders' 3 | 4 | // Todo: Messageの定義 5 | 6 | export abstract class RolePanelError extends Error { 7 | // Todo: メッセージ全部定義できたらabstractにする 8 | abstract get response(): string 9 | // abstract get response(): string 10 | } 11 | 12 | export class NoPrivateMessage extends Error {} 13 | 14 | abstract class FetchError extends RolePanelError { 15 | public id: Snowflake 16 | constructor(props: { id: Snowflake }) { 17 | super() 18 | this.id = props.id 19 | } 20 | } 21 | 22 | export class GuildNotFound extends FetchError { 23 | get response(): string { 24 | return `サーバー${this.id}が見つかりませんでした。` 25 | } 26 | } 27 | export class RoleNotFound extends FetchError { 28 | get response(): string { 29 | return `ロール${this.id}が見つかりませんでした。` 30 | } 31 | } 32 | export class MemberNotFound extends FetchError { 33 | get response(): string { 34 | return `メンバー${this.id}が見つかりませんでした。` 35 | } 36 | } 37 | export class ChannelNotFound extends FetchError { 38 | get response(): string { 39 | return `チャンネル${channelMention(this.id)}が見つかりませんでした。` 40 | } 41 | } 42 | export class ChannelIsNotTextChannel extends FetchError { 43 | get response(): string { 44 | return `チャンネル${channelMention( 45 | this.id 46 | )}はテキストチャンネルではありません。` 47 | } 48 | } 49 | 50 | export class UseHigherRole extends RolePanelError { 51 | public role: Role 52 | constructor(props: { role: Role }) { 53 | super() 54 | this.role = props.role 55 | } 56 | 57 | get response(): string { 58 | return ( 59 | `${this.role.name}は、あなたの一番上の役職以上の役職であるため、追加/削除できません。\n` + 60 | `この役職以上の役職を持つメンバーまたはサーバーの所有者にコマンドを頼んでください。` 61 | ) 62 | } 63 | } 64 | 65 | export class YouDontHaveRoleManagement extends RolePanelError { 66 | get response(): string { 67 | return `あなたに「役職の付与」の権限がないので、このコマンドを実行できません。` 68 | } 69 | } 70 | 71 | export class NoSelectedPanel extends RolePanelError { 72 | get response(): string { 73 | return 'パネルを選択していません。まずはコンテキストメニューからパネルを選択してください。' 74 | } 75 | } 76 | 77 | export class EmbedIsDeleted extends RolePanelError { 78 | get response(): string { 79 | return 'パネルのEmbedが削除されています。パネルを作成し直すか、既存のパネルを選択し直して下さい。' 80 | } 81 | } 82 | 83 | export class ColorParseError extends RolePanelError { 84 | reason: unknown 85 | constructor(reason: unknown) { 86 | super() 87 | this.reason = reason 88 | } 89 | get response(): string { 90 | return '色引数のパースに失敗しました' 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import { BaseCommandInteraction, ClientEvents, DMChannel } from 'discord.js' 2 | import { 3 | allContextMenus, 4 | allSubCommands, 5 | CommandPrefix, 6 | } from '../commands/init' 7 | import { RolePanelClient } from '../types/client' 8 | import { HandlerWithGuild } from '../types/command' 9 | import { onCommandError } from './commandError' 10 | import { NoPrivateMessage } from '../types/error' 11 | import { StopCommandHandler, StopCommandName } from '../commands/stop' 12 | 13 | async function handleCommand( 14 | client: RolePanelClient, 15 | interaction: T, 16 | handler: HandlerWithGuild | undefined 17 | ) { 18 | if (!handler) { 19 | return 20 | } 21 | await interaction.deferReply() 22 | if (interaction.inCachedGuild()) { 23 | try { 24 | await handler(client, interaction) 25 | } catch (e) { 26 | await onCommandError(client, interaction, e) 27 | } 28 | } else { 29 | await onCommandError(client, interaction, new NoPrivateMessage()) 30 | } 31 | } 32 | 33 | function logCommand( 34 | client: RolePanelClient, 35 | interaction: BaseCommandInteraction, 36 | commandName: string 37 | ) { 38 | const guild = interaction.guild 39 | const channel = interaction.channel 40 | const channelName = (() => { 41 | if (!channel || channel instanceof DMChannel || channel?.partial) { 42 | return 'DM' 43 | } else { 44 | return channel.name 45 | } 46 | })() 47 | client.log( 48 | `[COMMAND-USED]` + 49 | `guild:\`${guild?.name} (ID:${guild?.id})\`` + 50 | `ch:\`${channelName}(ID:${interaction.channel?.id})\`` + 51 | `cmdname:\`${commandName}\`` 52 | ) 53 | } 54 | 55 | export async function onInteractionCreate( 56 | client: RolePanelClient, 57 | [interaction]: ClientEvents['interactionCreate'] 58 | ) { 59 | if (interaction.isCommand()) { 60 | let handler 61 | const commandName = interaction.commandName 62 | if (commandName === CommandPrefix) { 63 | const target = interaction.options.getSubcommand(true) 64 | const command = allSubCommands.find( 65 | ({ command }) => command.name === target 66 | ) 67 | handler = command?.handler 68 | logCommand(client, interaction, target) 69 | } else if (commandName === StopCommandName) { 70 | handler = StopCommandHandler 71 | logCommand(client, interaction, 'stop') 72 | } 73 | await handleCommand(client, interaction, handler) 74 | } else if (interaction.isContextMenu()) { 75 | const command = allContextMenus.find( 76 | ({ command }) => command.name === interaction.commandName 77 | ) 78 | logCommand(client, interaction, interaction.commandName) 79 | await handleCommand(client, interaction, command?.handler) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/transfer.ts: -------------------------------------------------------------------------------- 1 | import { APIMessage } from 'discord-api-types/v9' 2 | import { 3 | ApplicationCommandData, 4 | Message, 5 | MessageEmbed, 6 | PartialTextBasedChannelFields, 7 | } from 'discord.js' 8 | import { ContextHandlerWithGuild } from '../types/command' 9 | import { isOldv3Panel, isV2Panel, setPanel } from '../util/panel' 10 | import { fastReact } from '../util/react' 11 | 12 | export const commandName = 'パネル引き継ぎ' 13 | export const command: ApplicationCommandData = { 14 | name: commandName, 15 | type: 3, 16 | defaultPermission: true, 17 | } 18 | 19 | // キャプチャについて、カスタムリアクションの場合は `emoji_name:emoji_id` 形式で、 20 | // Unicode絵文字の場合は `絵文字名` 形式でキャプチャされる。 21 | // このあとのmatch.findでは、キャプチャされた文字列のうち、最初のものを返す。 22 | const pattern = /(?:|(.+)):<@&\d+>\n?/g 23 | 24 | export const handler: ContextHandlerWithGuild = async (client, interaction) => { 25 | const message = interaction.options.getMessage('message', true) 26 | const channel = await client.fetchTextChannel(interaction.channelId) 27 | if (isV2Panel(message)) { 28 | await v2Transfer(message, channel) 29 | await interaction.deleteReply() 30 | } else if (isOldv3Panel(message)) { 31 | await v3Transfer(message, channel) 32 | await interaction.deleteReply() 33 | } else { 34 | await interaction.editReply({ 35 | content: 'このメッセージはパネルではありません。', 36 | }) 37 | } 38 | } 39 | 40 | export const v2Transfer = async ( 41 | message: Message | APIMessage, 42 | channel: PartialTextBasedChannelFields 43 | ) => { 44 | const embed = new MessageEmbed(message.embeds.at(0)!) 45 | setPanel(embed) 46 | const newMessage = await channel.send({ 47 | embeds: [embed], 48 | }) 49 | const description = embed.description 50 | if (!description) { 51 | return 52 | } 53 | const allMatch = description.matchAll(pattern) 54 | for (const match of allMatch) { 55 | // ()によってキャプチャされた文字列(index != 0)のうち、最初のものを返す。 56 | // キャプチャされなかった方は空文字になるのでそこで判定 57 | const emoji = match.find((v, i) => i !== 0 && v) 58 | if (emoji) { 59 | await fastReact(newMessage, emoji) 60 | } 61 | } 62 | } 63 | 64 | export const v3Transfer = async ( 65 | message: Message | APIMessage, 66 | channel: PartialTextBasedChannelFields 67 | ) => { 68 | const embed = new MessageEmbed(message.embeds.at(0)!) 69 | const newMessage = await channel.send({ 70 | embeds: [embed], 71 | }) 72 | const description = embed.description 73 | if (!description) { 74 | return 75 | } 76 | const allMatch = description.matchAll(pattern) 77 | for (const match of allMatch) { 78 | // ()によってキャプチャされた文字列(index != 0)のうち、最初のものを返す。 79 | // キャプチャされなかった方は空文字になるのでそこで判定 80 | const emoji = match.find((v, i) => i !== 0 && v) 81 | if (emoji) { 82 | await fastReact(newMessage, emoji) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/events/commandError.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseCommandInteraction, 3 | DiscordAPIError, 4 | FileOptions, 5 | GuildChannel, 6 | TextChannel, 7 | } from 'discord.js' 8 | import { RolePanelError } from '../types/error' 9 | import { RolePanelClient } from '../types/client' 10 | import { CHANNEL_TRACEBACK } from '../const' 11 | import { channel } from 'diagnostics_channel' 12 | 13 | export async function onCommandError( 14 | client: RolePanelClient, 15 | interaction: BaseCommandInteraction, 16 | error: unknown 17 | ) { 18 | let content = '' 19 | if (error instanceof RolePanelError) { 20 | content = error.response 21 | } else if (error instanceof DiscordAPIError) { 22 | switch (error.code) { 23 | case 10008: 24 | content = 25 | 'メッセージが見つかりませんでした。もう一度パネルを選択し直してください。' 26 | break 27 | case 10014: 28 | content = 29 | 'この絵文字はリアクションとして付与できません。\n別な絵文字を指定してください。' 30 | break 31 | case 50001: 32 | case 50013: 33 | content = 34 | '権限不足です。以下の権限があるかもう一度確認してください。\n' + 35 | '・メッセージを送信\n・埋め込みリンク\n' + 36 | '・メッセージ履歴を読む\n・リアクションを追加' 37 | break 38 | } 39 | // Todo: Error Handler 40 | } 41 | 42 | // エラーメッセージ未定義 or 未想定エラー 43 | if (!content) { 44 | let ok = false 45 | if (error instanceof Error && error.stack) { 46 | const errorDetails = 47 | `サーバー: ${interaction!.guild!.name}(ID:${interaction!.guildId!})\n` + 48 | `チャンネル: ${ 49 | (interaction!.channel as GuildChannel).name ?? `不明` 50 | }(ID:${interaction!.channelId!})\n` + 51 | `ユーザー: ${interaction!.user.tag}(ID:${interaction!.user.id})\n` + 52 | `コマンド: ${interaction!.commandName}\n` + 53 | // `コマンドオプション: ${interaction!.options.data.map((optionData) => {return `${optionData.name}: ${optionData.value}`;}).toString()}\n` + //書き方が正しいか不明なため保留 54 | `エラー内容: ${error.stack}` 55 | const buf = Buffer.from(errorDetails) 56 | const file: FileOptions = { 57 | attachment: buf, 58 | name: `${interaction.id}.txt`, 59 | } 60 | try { 61 | const channel = await client.channels.fetch(CHANNEL_TRACEBACK, { 62 | allowUnknownGuild: true, 63 | }) 64 | if (channel instanceof TextChannel) { 65 | await channel.send({ 66 | files: [file], 67 | }) 68 | ok = true 69 | } 70 | } catch (e) { 71 | console.error(e) 72 | } 73 | if (!ok) { 74 | console.error(errorDetails) 75 | } 76 | } 77 | content = '何らかの想定されていないエラーが発生しました。' 78 | content += ok 79 | ? 'エラーはトレースバックされました。サポートサーバーにお問い合わせください。' 80 | : 'エラーがトレースバックされませんでした。' 81 | } 82 | try { 83 | await interaction.editReply({ content: content }) 84 | } catch {} 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/add.ts: -------------------------------------------------------------------------------- 1 | import * as discord from 'discord.js' 2 | import { Formatters, Snowflake } from 'discord.js' 3 | import { SlashCommandSubcommandBuilder } from '@discordjs/builders' 4 | import { CommandHandlerWithGuild } from '../types/command' 5 | import { canUseRoleArgument } from '../util/permission' 6 | import { selectedMessage } from './select' 7 | 8 | export const command = new SlashCommandSubcommandBuilder() 9 | .setName('add') 10 | .setDescription('パネルに役職を追加します') 11 | ;[...Array(10).keys()].forEach((i) => { 12 | command 13 | .addRoleOption((option) => 14 | option 15 | .setName(`role${i + 1}`) 16 | .setDescription('パネルに追加する役職です。') 17 | .setRequired(i === 0) 18 | ) 19 | .addStringOption((option) => 20 | option 21 | .setName(`emoji${i + 1}`) 22 | .setDescription('追加する役職に使用する絵文字です。') 23 | ) 24 | }) 25 | 26 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 27 | const message = await selectedMessage.getFromInteraction(client, interaction) 28 | const guild = await client.fetchGuild(interaction.guildId) 29 | const member = await client.fetchMember(guild, interaction.user.id) 30 | // Option Parsing 31 | const options = interaction.options 32 | for (let i = 0; i < 10; i++) { 33 | const partialRole = options.getRole(`role${i + 1}`) 34 | const emoji = options.getString(`emoji${i + 1}`) 35 | if (!partialRole) { 36 | continue 37 | } 38 | const role = await client.fetchRole(guild, partialRole.id) 39 | // Todo: 使えない役職はスキップする 40 | canUseRoleArgument(member, role) 41 | if (!(await addToRoleMessage(message, role.id, emoji))) { 42 | break 43 | } 44 | } 45 | await interaction.deleteReply() 46 | } 47 | 48 | async function addToRoleMessage( 49 | message: discord.Message, 50 | roleId: Snowflake, 51 | emoji: null | string 52 | ): Promise { 53 | if (message.reactions.cache.size >= 20) { 54 | return false 55 | } 56 | const embed = message.embeds[0] 57 | const description = embed.description || '' 58 | const lines = description.split('\n') 59 | let character, i 60 | if (emoji !== null) { 61 | character = emoji 62 | i = lines.length 63 | } else { 64 | for (i = 0; i < 20; i++) { 65 | character = String.fromCodePoint(0x1f1e6 + i) 66 | if (!description.includes(character)) { 67 | break 68 | } 69 | } 70 | // ここでundefinedを弾かないと次のif文でエラーが出る 71 | if (i === 20 || character === undefined) { 72 | return false 73 | } 74 | } 75 | if (!description.includes(character)) { 76 | // 最初にリアクションを確認(つけられないならここでエラーになるので全止め可能) 77 | await message.react(character) 78 | const newLines = lines 79 | .slice(0, i) 80 | .concat(`${character}:${Formatters.roleMention(roleId)}`) 81 | .concat(lines.slice(i, lines.length + 1)) 82 | .join('\n') 83 | embed.setDescription(newLines) 84 | await message.edit({ embeds: [embed] }) 85 | return true 86 | } 87 | return false 88 | } 89 | -------------------------------------------------------------------------------- /src/types/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyChannel, 3 | Client, 4 | ClientOptions, 5 | Guild, 6 | GuildMember, 7 | Message, 8 | Role, 9 | Snowflake, 10 | TextChannel, 11 | } from 'discord.js' 12 | import { 13 | ChannelIsNotTextChannel, 14 | ChannelNotFound, 15 | GuildNotFound, 16 | MemberNotFound, 17 | RoleNotFound, 18 | } from './error' 19 | import { onReactionAdd } from '../events/reactionAdd' 20 | import { onInteractionCreate } from '../events/interactionCreate' 21 | import { getPanel } from '../util/panel' 22 | import { onReady } from '../events/ready' 23 | import { CHANNEL_LOG } from '../const' 24 | import { onGuildJoin, onGuildLeave } from '../events/guildJoinLeave' 25 | 26 | function isTextChannel(channel: AnyChannel): channel is TextChannel { 27 | return channel.type == 'GUILD_TEXT' 28 | } 29 | 30 | export class RolePanelClient extends Client { 31 | constructor(option: ClientOptions) { 32 | super(option) 33 | this.on('messageReactionAdd', async (...args) => { 34 | try { 35 | await onReactionAdd(this, args) 36 | } catch (e) { 37 | console.error(e) 38 | } 39 | }) 40 | this.on('interactionCreate', async (...args) => { 41 | try { 42 | await onInteractionCreate(this, args) 43 | } catch (e) { 44 | console.error(e) 45 | } 46 | }) 47 | this.on('ready', async (...args) => { 48 | try { 49 | await onReady(this, args) 50 | } catch (e) { 51 | console.error(e) 52 | } 53 | }) 54 | this.on('guildCreate', async (...args) => { 55 | try { 56 | await onGuildJoin(this, args) 57 | } catch (e) { 58 | console.error(e) 59 | } 60 | }) 61 | this.on('guildDelete', async (...args) => { 62 | try { 63 | await onGuildLeave(this, args) 64 | } catch (e) { 65 | console.error(e) 66 | } 67 | }) 68 | } 69 | 70 | public log(content: string) { 71 | try { 72 | this.channels 73 | .fetch(CHANNEL_LOG, { allowUnknownGuild: true }) 74 | .then((channel) => { 75 | if (channel instanceof TextChannel) { 76 | channel.send(content).catch(console.error) 77 | } 78 | }) 79 | } catch {} 80 | } 81 | 82 | public async fetchTextChannel(id: Snowflake): Promise { 83 | const ret = await this.channels.fetch(id, { force: false }) 84 | if (ret === null) { 85 | throw new ChannelNotFound({ id }) 86 | } else if (!isTextChannel(ret)) { 87 | throw new ChannelIsNotTextChannel({ id }) 88 | } 89 | return ret 90 | } 91 | public async fetchGuild(id: Snowflake): Promise { 92 | const ret = await this.guilds.fetch({ guild: id, force: false }) 93 | if (ret === null) { 94 | throw new GuildNotFound({ id }) 95 | } 96 | return ret 97 | } 98 | 99 | public async fetchRole(guild: Guild, roleId: Snowflake): Promise { 100 | const entity = await guild.roles.fetch(roleId, { force: false }) 101 | if (entity === null) { 102 | throw new RoleNotFound({ id: roleId }) 103 | } 104 | return entity 105 | } 106 | 107 | public async fetchMember( 108 | guild: Guild, 109 | memberId: Snowflake 110 | ): Promise { 111 | const entity = await guild.members.fetch({ user: memberId, force: false }) 112 | if (entity === null) { 113 | throw new MemberNotFound({ id: memberId }) 114 | } 115 | return entity 116 | } 117 | 118 | public async fetchMessage( 119 | channelId: Snowflake, 120 | messageId: Snowflake 121 | ): Promise { 122 | const channel = await this.fetchTextChannel(channelId) 123 | return channel.messages.fetch(messageId) 124 | } 125 | 126 | public checkIsPanel(tag: string | null) { 127 | return (m: Message) => { 128 | const panel = getPanel(this, m) 129 | return !!(panel && (!tag || panel.tag == tag)) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import * as add from './add' 2 | import * as remove from './remove' 3 | import * as select from './select' 4 | import * as createPanel from './createPanel' 5 | import * as selected from './selected' 6 | import * as deletePanel from './deletePanel' 7 | import * as transfer from './transfer' 8 | import * as copy from './copy' 9 | import * as edit from './edit' 10 | import * as refresh from './refresh' 11 | import * as autoremove from './autoremove' 12 | import * as debug from './debug' 13 | import * as selectMenu from './selectMenu' 14 | import { 15 | APIApplication, 16 | APIApplicationCommand, 17 | Routes, 18 | } from 'discord-api-types/v9' 19 | import { REST } from '@discordjs/rest' 20 | import { SlashCommandBuilder } from '@discordjs/builders' 21 | 22 | export const CommandPrefix = 'rp' 23 | const baseCommand = new SlashCommandBuilder() 24 | .setName(CommandPrefix) 25 | .setDescription('役職パネルのコマンド') 26 | 27 | export const allSubCommands = [ 28 | add, 29 | remove, 30 | createPanel, 31 | selected, 32 | deletePanel, 33 | copy, 34 | edit, 35 | refresh, 36 | autoremove, 37 | debug, 38 | selectMenu 39 | ] 40 | export const allContextMenus = [select, transfer] 41 | 42 | allSubCommands.forEach((c) => baseCommand.addSubcommand(c.command)) 43 | interface command { 44 | name: string 45 | } 46 | const allCommands = new Map( 47 | [baseCommand as command] 48 | .concat(allContextMenus.map((c) => c.command)) 49 | .map((c) => [c.name, c]) 50 | ) 51 | 52 | function fetchApplication(rest: REST) { 53 | return rest.get(Routes.user('@me')) as Promise 54 | } 55 | 56 | export async function initCommand( 57 | token: string, 58 | ...guildIds: Array 59 | ) { 60 | const rest = new REST({ version: '9' }).setToken(token) 61 | const user = await fetchApplication(rest) 62 | const clientId = user.id 63 | const task = async (guildId?: string) => { 64 | try { 65 | const route = guildId 66 | ? Routes.applicationGuildCommands(clientId, guildId) 67 | : Routes.applicationCommands(clientId) 68 | // まずすべてのコマンドを取得する 69 | const commands = (await rest.get(route)) as Array 70 | const toRegister = new Map(allCommands) 71 | // すでに登録済みなものを更新する 72 | for (const apiCommand of commands) { 73 | const command = toRegister.get(apiCommand.name) 74 | if (!command) { 75 | continue 76 | } 77 | const route = guildId 78 | ? Routes.applicationGuildCommand(clientId, guildId, apiCommand.id) 79 | : Routes.applicationCommand(clientId, apiCommand.id) 80 | await rest.patch(route, { body: command }) 81 | toRegister.delete(apiCommand.name) 82 | } 83 | // 登録してないないものは登録する 84 | 85 | for (const command of toRegister.values()) { 86 | await rest.post(route, { body: command }) 87 | } 88 | } catch (e) { 89 | // グローバルコマンドであれば無視しない 90 | if (!guildId) { 91 | throw e 92 | } 93 | // ギルドコマンドならトレースだけ 94 | console.trace(e) 95 | } 96 | } 97 | await Promise.all(guildIds.map(task)) 98 | } 99 | 100 | export async function clearCommand(token: string, guildId?: string) { 101 | const rest = new REST({ version: '9' }).setToken(token) 102 | const user = await fetchApplication(rest) 103 | const clientId = user.id 104 | let route: `/${string}` 105 | if (!guildId) { 106 | route = Routes.applicationCommands(clientId) 107 | } else { 108 | route = Routes.applicationGuildCommands(clientId, guildId) 109 | } 110 | const commands = (await rest.get(route)) as Array 111 | 112 | const promises = commands.map((command) => () => { 113 | let route 114 | if (!guildId) { 115 | route = Routes.applicationCommand(clientId, command.id) 116 | } else { 117 | route = Routes.applicationGuildCommand(clientId, guildId, command.id) 118 | } 119 | return rest.delete(route) 120 | }) 121 | for (const promise of promises) { 122 | await promise() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/artifacts 33 | # .idea/compiler.xml 34 | # .idea/jarRepositories.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### VisualStudioCode template 75 | .vscode/* 76 | !.vscode/settings.json 77 | !.vscode/tasks.json 78 | !.vscode/launch.json 79 | !.vscode/extensions.json 80 | *.code-workspace 81 | 82 | # Local History for Visual Studio Code 83 | .history/ 84 | 85 | ### Node template 86 | # Logs 87 | logs 88 | *.log 89 | npm-debug.log* 90 | yarn-debug.log* 91 | yarn-error.log* 92 | lerna-debug.log* 93 | 94 | # Diagnostic reports (https://nodejs.org/api/report.html) 95 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 96 | 97 | # Runtime data 98 | pids 99 | *.pid 100 | *.seed 101 | *.pid.lock 102 | 103 | # Directory for instrumented libs generated by jscoverage/JSCover 104 | lib-cov 105 | 106 | # Coverage directory used by tools like istanbul 107 | coverage 108 | *.lcov 109 | 110 | # nyc test coverage 111 | .nyc_output 112 | 113 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 114 | .grunt 115 | 116 | # Bower dependency directory (https://bower.io/) 117 | bower_components 118 | 119 | # node-waf configuration 120 | .lock-wscript 121 | 122 | # Compiled binary addons (https://nodejs.org/api/addons.html) 123 | build/Release 124 | 125 | # Dependency directories 126 | node_modules/ 127 | jspm_packages/ 128 | 129 | # Snowpack dependency directory (https://snowpack.dev/) 130 | web_modules/ 131 | 132 | # TypeScript cache 133 | *.tsbuildinfo 134 | 135 | # Optional npm cache directory 136 | .npm 137 | 138 | # Optional eslint cache 139 | .eslintcache 140 | 141 | # Microbundle cache 142 | .rpt2_cache/ 143 | .rts2_cache_cjs/ 144 | .rts2_cache_es/ 145 | .rts2_cache_umd/ 146 | 147 | # Optional REPL history 148 | .node_repl_history 149 | 150 | # Output of 'npm pack' 151 | *.tgz 152 | 153 | # Yarn Integrity file 154 | .yarn-integrity 155 | 156 | # dotenv environment variables file 157 | .env 158 | .env.test 159 | 160 | # parcel-bundler cache (https://parceljs.org/) 161 | .cache 162 | .parcel-cache 163 | 164 | # Next.js build output 165 | .next 166 | out 167 | 168 | # Nuxt.js build / generate output 169 | .nuxt 170 | dist 171 | 172 | # Gatsby files 173 | .cache/ 174 | # Comment in the public line in if your project uses Gatsby and not Next.js 175 | # https://nextjs.org/blog/next-9-1#public-directory-support 176 | # public 177 | 178 | # vuepress build output 179 | .vuepress/dist 180 | 181 | # Serverless directories 182 | .serverless/ 183 | 184 | # FuseBox cache 185 | .fusebox/ 186 | 187 | # DynamoDB Local files 188 | .dynamodb/ 189 | 190 | # TernJS port file 191 | .tern-port 192 | 193 | # Stores VSCode versions used for testing VSCode extensions 194 | .vscode-test 195 | 196 | # yarn v2 197 | .yarn/cache 198 | .yarn/unplugged 199 | .yarn/build-state.yml 200 | .yarn/install-state.gz 201 | .pnp.* 202 | 203 | -------------------------------------------------------------------------------- /src/events/reactionAdd.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents, DiscordAPIError, MessageEmbed } from 'discord.js' 2 | import { isPanel } from '../util/panel' 3 | import { is403Error } from '../util/error' 4 | import { roleMention, userMention } from '@discordjs/builders' 5 | import { sleep } from '../util/asyncio' 6 | import { RolePanelClient } from '../types/client' 7 | 8 | export async function onReactionAdd( 9 | client: RolePanelClient, 10 | [reaction, user]: ClientEvents['messageReactionAdd'] 11 | ) { 12 | // Ready前 or 自分自身のリアクションの場合は無視 13 | if (!client.user || user.id === client.user.id) { 14 | return 15 | } 16 | let message = reaction.message 17 | const guild = message.guild 18 | const me = guild?.members.me 19 | const channel = message.channel 20 | // ギルドのメッセージ以外のリアクションは無視 21 | if (!guild || !me || channel.type === 'DM') { 22 | return 23 | } 24 | const permission = channel.permissionsFor(me) 25 | // メッセージの履歴を読む権限が無い場合は無視 26 | if ( 27 | !permission.has('READ_MESSAGE_HISTORY') || 28 | !permission.has('VIEW_CHANNEL') 29 | ) { 30 | return 31 | } 32 | try { 33 | message = await reaction.message.fetch(false) 34 | } catch (e) { 35 | // Missing Access 36 | if (e instanceof DiscordAPIError && e.code === 50001) { 37 | return 38 | } 39 | throw e 40 | } 41 | const member = guild.members.resolve(user.id)! 42 | if (isPanel(client, message)) { 43 | try { 44 | await reaction.users.remove(user.id) 45 | } catch (e) { 46 | // Ignore Forbidden Error 47 | if (!is403Error(e)) { 48 | throw e 49 | } 50 | } 51 | const panelDescription = message.embeds[0].description 52 | if (!panelDescription) { 53 | return 54 | } 55 | const pattern = RegExp(reaction.emoji.toString() + ':<@&(\\d*)>') 56 | const match = pattern.exec(panelDescription) 57 | if (!match) { 58 | return 59 | } 60 | const role = await guild.roles.fetch(match[1], { force: false }) 61 | let description, 62 | actionName, 63 | logContent = '', 64 | reason: string | null = null 65 | if (!role) { 66 | actionName = 'NOTFOUND' 67 | description = '役職が存在しないか、見つかりませんでした。' 68 | } else { 69 | const mention = roleMention(role.id) 70 | let action 71 | if (!member.roles.cache.has(role.id)) { 72 | action = () => member.roles.add(role) 73 | actionName = 'ADD' 74 | description = `${mention}の役職を付与しました。` 75 | } else { 76 | action = () => member.roles.remove(role) 77 | actionName = 'REMOVE' 78 | description = `${mention}の役職を解除しました。` 79 | } 80 | try { 81 | await action() 82 | actionName += ':SUCCESS' 83 | } catch (e) { 84 | if (!is403Error(e)) { 85 | throw e 86 | } 87 | actionName += ':FAILED' 88 | description = '役職の設定に失敗しました。\n' 89 | const me = guild.me! 90 | if (!me.permissions.has('MANAGE_ROLES')) { 91 | description += 'BOTに「役職の管理」の権限が無いかも?' 92 | reason = 'MISSING_PERMISSION' 93 | } else if (me.roles.highest.position <= role.position) { 94 | description += 95 | 'BOTの一番上の役職よりも高い役職をつけようとしてるかも?' 96 | reason = 'ROLE_POSITION' 97 | } else if (!!role.tags?.botId) { 98 | description += '特定のBOTにしか付与できない役職であるからかも?' 99 | reason = 'BOT_ROLE' 100 | } else if (!!role.tags?.premiumSubscriberRole) { 101 | description += 'サーバーブースター用の役職であるからかも?' 102 | reason = 'SUBSCRIBER_ROLE' 103 | } else if (role.id === role.guild.id) { 104 | description += 'everyone役職であるからかも?' 105 | reason = 'EVERYONE_ROLE' 106 | } else { 107 | description += 'エラーの原因がぜんぜんわからん!' 108 | reason = 'UNKNOWN' 109 | } 110 | } 111 | } 112 | logContent += 113 | `[ROLE-${actionName}]` + 114 | ` guild:\`${guild.name} (ID:${guild.id})\`` + 115 | ` ch:\`${channel.name} (ID:${channel.id})\`` + 116 | ` user:\`${user.username} (ID:${user.id})\`` 117 | if (role) { 118 | logContent += ` role:\`${role.name} (ID:${role.id})\`` 119 | } else { 120 | logContent += ` role:\`(ID:${match[1]})\`` 121 | } 122 | if (reason) { 123 | logContent += ` reason:\`${reason}\`` 124 | } 125 | client.log(logContent) 126 | if (!channel.permissionsFor(client.user.id)?.has('SEND_MESSAGES')) { 127 | return 128 | } 129 | try { 130 | const newMessage = await channel.send({ 131 | content: userMention(user.id), 132 | embeds: [ 133 | new MessageEmbed({ 134 | description: description, 135 | }), 136 | ], 137 | }) 138 | await sleep(10 * 1000) 139 | await newMessage.delete() 140 | } catch {} 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/commands/select.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandData, 3 | BaseCommandInteraction, 4 | InteractionReplyOptions, 5 | Message, 6 | SelectMenuInteraction, 7 | Snowflake, 8 | WebhookMessageOptions, 9 | } from 'discord.js' 10 | import { ContextHandlerWithGuild } from '../types/command' 11 | import { isPanel } from '../util/panel' 12 | import { EmbedIsDeleted, NoSelectedPanel } from '../types/error' 13 | import { RolePanelClient } from '../types/client' 14 | import { APIMessage } from 'discord-api-types/v9' 15 | import { PrismaClient } from '@prisma/client' 16 | 17 | type interactionType = { 18 | guildId: Snowflake 19 | user: { id: Snowflake } 20 | } 21 | 22 | type MessageManagerKeyType = `${Snowflake}:${Snowflake}` 23 | type MessageManagerValueType = [Snowflake, Snowflake] 24 | 25 | type SelectedStore = { 26 | get: ( 27 | key: MessageManagerKeyType 28 | ) => Promise 29 | set: ( 30 | key: MessageManagerKeyType, 31 | value: MessageManagerValueType 32 | ) => Promise 33 | delete: (key: MessageManagerKeyType) => Promise 34 | } 35 | 36 | class MessageManager { 37 | private data: SelectedStore 38 | constructor(data: SelectedStore) { 39 | this.data = data 40 | } 41 | private async get(client: RolePanelClient, key: MessageManagerKeyType) { 42 | const entity = await this.data.get(key) 43 | if (!entity) { 44 | throw new NoSelectedPanel() 45 | } 46 | const message = await client.fetchMessage(entity[0], entity[1]) 47 | if (message.embeds.at(0) === undefined) { 48 | throw new EmbedIsDeleted() 49 | } 50 | return message 51 | } 52 | public getFromInteraction( 53 | client: RolePanelClient, 54 | interaction: interactionType 55 | ) { 56 | return this.get(client, `${interaction.guildId}:${interaction.user.id}`) 57 | } 58 | 59 | public async setFromInteraction( 60 | interaction: interactionType, 61 | value: Message 62 | ) { 63 | return await this.data.set( 64 | `${interaction.guildId}:${interaction.user.id}`, 65 | [value.channelId, value.id] 66 | ) 67 | } 68 | 69 | public async deleteFromInteraction(interaction: interactionType) { 70 | return await this.data.delete( 71 | `${interaction.guildId}:${interaction.user.id}` 72 | ) 73 | } 74 | } 75 | 76 | class PrismaSelectedStore implements SelectedStore { 77 | private client: PrismaClient 78 | constructor(client: PrismaClient) { 79 | this.client = client 80 | } 81 | 82 | async get( 83 | key: MessageManagerKeyType 84 | ): Promise { 85 | const selected = await this.client.selected_message.findUnique({ 86 | where: { 87 | key: key, 88 | }, 89 | }) 90 | if (!selected) { 91 | return undefined 92 | } 93 | return [selected.channel_id, selected.message_id] 94 | } 95 | 96 | async delete(key: MessageManagerKeyType): Promise { 97 | await this.client.selected_message.delete({ 98 | where: { 99 | key: key, 100 | }, 101 | }) 102 | } 103 | 104 | async set( 105 | key: MessageManagerKeyType, 106 | value: MessageManagerValueType 107 | ): Promise { 108 | const data = { 109 | channel_id: value[0], 110 | message_id: value[1], 111 | } 112 | await this.client.selected_message.upsert({ 113 | where: { key }, 114 | create: { key, ...data }, 115 | update: data, 116 | }) 117 | } 118 | } 119 | 120 | export const selectedMessage = new MessageManager( 121 | new PrismaSelectedStore(new PrismaClient()) 122 | ) 123 | export const commandName = 'パネル選択' 124 | export const command: ApplicationCommandData = { 125 | name: commandName, 126 | type: 3, 127 | defaultPermission: true, 128 | } 129 | 130 | export const selectPanel = async ( 131 | client: RolePanelClient, 132 | interaction: 133 | | BaseCommandInteraction<'cached'> 134 | | SelectMenuInteraction<'cached'>, 135 | message: Message | APIMessage, 136 | action?: (option: InteractionReplyOptions) => Promise 137 | ) => { 138 | if (!action) { 139 | action = (option: InteractionReplyOptions) => interaction.editReply(option) 140 | } 141 | if (!(message instanceof Message)) { 142 | const channel = await client.fetchTextChannel(message.channel_id) 143 | message = await channel.messages.fetch(message.id, { force: true }) 144 | } else { 145 | message = await message.fetch(true) 146 | } 147 | if (!isPanel(client, message)) { 148 | await action({ 149 | content: 'このメッセージはパネルではありません', 150 | }) 151 | return 152 | } 153 | await selectedMessage.setFromInteraction(interaction, message) 154 | await action({ 155 | content: `以下のパネルを選択しました。\n${message.url}`, 156 | }) 157 | } 158 | 159 | export const handler: ContextHandlerWithGuild = async (client, interaction) => { 160 | let message = interaction.options.getMessage('message', true) 161 | await selectPanel(client, interaction, message) 162 | } 163 | -------------------------------------------------------------------------------- /src/commands/selectMenu.ts: -------------------------------------------------------------------------------- 1 | import { Embed, SlashCommandSubcommandBuilder } from '@discordjs/builders' 2 | import { CommandHandlerWithGuild } from '../types/command' 3 | import { 4 | Interaction, 5 | Message, 6 | MessageActionRow, 7 | MessageSelectMenu, 8 | Snowflake, 9 | TextChannel, 10 | WebhookEditMessageOptions, 11 | } from 'discord.js' 12 | import { isPanel, isV2Panel } from '../util/panel' 13 | import { getABCEmoji } from '../util/emoji' 14 | import { APIMessage } from 'discord-api-types/v9' 15 | import * as assert from 'assert' 16 | import { selectPanel } from './select' 17 | import { RolePanelClient } from '../types/client' 18 | import { v2Transfer } from './transfer' 19 | 20 | export const command = new SlashCommandSubcommandBuilder() 21 | .setName('select') 22 | .setDescription('スマホ向けのパネル選択コマンドです。') 23 | .addBooleanOption((option) => 24 | option 25 | .setName('oldest_first') 26 | .setDescription( 27 | 'メッセージの検索を古い方から行います。指定しなければ最新のメッセージから検索します。' 28 | ) 29 | ) 30 | 31 | const searchPanel = async ( 32 | channel: TextChannel, 33 | before?: Snowflake, 34 | after?: Snowflake 35 | ): Promise> => { 36 | const client = channel.client 37 | const result = [] 38 | while (result.length === 0) { 39 | const messages = await channel.messages.fetch( 40 | { before, after, limit: 100 }, 41 | { cache: true } 42 | ) 43 | if (messages.size === 0) { 44 | return [] 45 | } 46 | const panels = messages.filter((message) => { 47 | return isPanel(client, message) || isV2Panel(message) 48 | }) 49 | for (const [, message] of panels) { 50 | result.push(message) 51 | } 52 | if (before) { 53 | before = messages.lastKey() 54 | } else { 55 | after = messages.firstKey() 56 | } 57 | } 58 | assert(parseInt(result[0].id) >= parseInt(result[result.length - 1].id)) 59 | return result.slice(0, 22) 60 | } 61 | 62 | const createMenu = (messages: Array): WebhookEditMessageOptions => { 63 | const description = messages 64 | .map((message, index) => `${getABCEmoji(index)}: ${message.url}`) 65 | .join('\n') 66 | const embed = new Embed({ 67 | title: 'どのパネルを選択しますか?', 68 | description: description, 69 | }) 70 | const menu = new MessageSelectMenu() 71 | .setCustomId('panelSelector') 72 | .setMinValues(1) 73 | .setMaxValues(1) 74 | .addOptions( 75 | messages.map((message, index) => ({ 76 | label: getABCEmoji(index), 77 | description: isV2Panel(message) ? 'v2パネル引き継ぎ' : 'パネル選択', 78 | value: `${index}`, 79 | })) 80 | ) 81 | .addOptions({ 82 | label: 'もっと新しく', 83 | value: 'newer', 84 | }) 85 | .addOptions({ 86 | label: 'もっと古く', 87 | value: 'older', 88 | }) 89 | .addOptions({ 90 | label: 'キャンセル', 91 | value: 'cancel', 92 | }) 93 | return { 94 | embeds: [embed], 95 | components: [new MessageActionRow().addComponents(menu)], 96 | } 97 | } 98 | 99 | type Common = { 100 | client: RolePanelClient 101 | channel: TextChannel 102 | author: Snowflake 103 | } 104 | 105 | export const replyMenu = async ( 106 | common: Common, 107 | sender: (options: WebhookEditMessageOptions) => Promise, 108 | before?: Snowflake, 109 | after?: Snowflake 110 | ) => { 111 | const { channel } = common 112 | const panels = await searchPanel(channel, before, after) 113 | if (panels.length === 0) { 114 | await sender({ 115 | content: 'パネルが見つかりませんでした', 116 | }) 117 | return 118 | } 119 | const content = createMenu(panels) 120 | let message = await sender(content) 121 | if (message instanceof Message) { 122 | assert.equal(message.channel.id, channel.id) 123 | } else { 124 | assert.equal(message.channel_id, channel.id) 125 | message = await channel.messages.fetch(message.id) 126 | } 127 | 128 | registerHandler(common, message, panels) 129 | } 130 | 131 | const registerHandler = ( 132 | common: Common, 133 | message: Message, 134 | panels: Message[] 135 | ) => { 136 | const { author, client } = common 137 | const newer = panels[0].id 138 | const older = panels[panels.length - 1].id 139 | const handler = async (interaction: Interaction) => { 140 | if ( 141 | !interaction.isSelectMenu() || 142 | !interaction.inCachedGuild() || 143 | interaction.message.id !== message.id || 144 | interaction.channelId !== message.channelId 145 | ) { 146 | return 147 | } 148 | if (interaction.user.id !== author) { 149 | await interaction.reply({ 150 | content: 'コマンドの使用者以外は使えません', 151 | ephemeral: true, 152 | }) 153 | return 154 | } 155 | client.off('interactionCreate', handler) 156 | const value = interaction.values[0] 157 | await message.delete() 158 | if (value === 'cancel') { 159 | return 160 | } else if (value === 'newer') { 161 | const m = interaction.reply({ fetchReply: true }) 162 | await replyMenu( 163 | common, 164 | (o) => 165 | interaction.reply({ 166 | ...o, 167 | fetchReply: true, 168 | ephemeral: false, 169 | }), 170 | undefined, 171 | newer 172 | ) 173 | } else if (value === 'older') { 174 | await replyMenu( 175 | common, 176 | (o) => 177 | interaction.reply({ 178 | ...o, 179 | fetchReply: true, 180 | ephemeral: false, 181 | }), 182 | older, 183 | undefined 184 | ) 185 | } else { 186 | const panel = panels[parseInt(value)] 187 | if (isV2Panel(panel)) { 188 | await v2Transfer(panel, interaction.channel!) 189 | } else { 190 | await selectPanel(client, interaction, panel, async (o) => 191 | interaction.reply(o) 192 | ) 193 | } 194 | } 195 | } 196 | client.on('interactionCreate', handler) 197 | } 198 | 199 | export const handler: CommandHandlerWithGuild = async (client, interaction) => { 200 | const channel = await client.fetchTextChannel(interaction.channelId) 201 | const oldest_first = interaction.options.getBoolean('oldest_first') ?? false 202 | const after = oldest_first ? '0' : undefined 203 | await replyMenu( 204 | { 205 | client, 206 | channel, 207 | author: interaction.user.id, 208 | }, 209 | (o) => interaction.editReply(o), 210 | undefined, 211 | after 212 | ) 213 | } 214 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "CommonJS", /* Specify what module code is generated. */ 28 | "rootDir": "./src", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./out", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | 68 | /* Interop Constraints */ 69 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 70 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 71 | // "esModuleInterop": false, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 72 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 73 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 74 | 75 | /* Type Checking */ 76 | "strict": true, /* Enable all strict type-checking options. */ 77 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 78 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 79 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 80 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 81 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 82 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 83 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 84 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 85 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 86 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 87 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 88 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 89 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 90 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 91 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 92 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 93 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 94 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 95 | 96 | /* Completeness */ 97 | // "skipDefaultLibCheck": false, /* Skip type checking .d.ts files that are included with TypeScript. */ 98 | // "skipLibCheck": false    /* Skip type checking all .d.ts files. */ 99 | }, 100 | "exclude": [ 101 | "node_modules/**", 102 | "out" 103 | ] 104 | } 105 | --------------------------------------------------------------------------------