From a8f2f3e527b9e7926b34ee923d9cba3b0a97813b Mon Sep 17 00:00:00 2001 From: pernucia <82934114+pernucia@users.noreply.github.com> Date: Thu, 2 Nov 2023 19:16:54 +0900 Subject: [PATCH] JAS_demo --- JAS/UI.py | 385 +++++++++++++ JAS/__init__.py | 0 JAS/cogs/__init__.py | 1 + JAS/cogs/community.py | 691 ++++++++++++++++++++++ JAS/cogs/fishing.py | 378 ++++++++++++ JAS/cogs/inventory.py | 174 ++++++ JAS/cogs/manage.py | 610 ++++++++++++++++++++ JAS/cogs/stat.py | 207 +++++++ JAS/cogs/store.py | 75 +++ JAS/mailing.py | 63 ++ JAS/resources/Addons.py | 137 +++++ JAS/resources/Base.py | 421 ++++++++++++++ JAS/resources/Buttons.py | 49 ++ JAS/resources/Connector.py | 1083 +++++++++++++++++++++++++++++++++++ JAS/resources/Embeds.py | 267 +++++++++ JAS/resources/Exceptions.py | 181 ++++++ JAS/resources/Selects.py | 301 ++++++++++ JAS/resources/Views.py | 1040 +++++++++++++++++++++++++++++++++ JAS/resources/__init__.py | 0 JAS/setup.py | 461 +++++++++++++++ app.py | 90 +++ discord_app.py | 40 ++ img/UI/disk.png | Bin 0 -> 14182 bytes img/UI/exit.png | Bin 0 -> 8495 bytes img/UI/folder.png | Bin 0 -> 11265 bytes img/UI/icon.ico | Bin 0 -> 41662 bytes img/UI/icon.png | Bin 0 -> 5559 bytes img/UI/play.png | Bin 0 -> 11616 bytes img/UI/settings.png | Bin 0 -> 18833 bytes img/UI/stop.png | Bin 0 -> 7393 bytes img/sample.png | Bin 0 -> 918 bytes requirements.txt | 6 + 32 files changed, 6660 insertions(+) create mode 100644 JAS/UI.py create mode 100644 JAS/__init__.py create mode 100644 JAS/cogs/__init__.py create mode 100644 JAS/cogs/community.py create mode 100644 JAS/cogs/fishing.py create mode 100644 JAS/cogs/inventory.py create mode 100644 JAS/cogs/manage.py create mode 100644 JAS/cogs/stat.py create mode 100644 JAS/cogs/store.py create mode 100644 JAS/mailing.py create mode 100644 JAS/resources/Addons.py create mode 100644 JAS/resources/Base.py create mode 100644 JAS/resources/Buttons.py create mode 100644 JAS/resources/Connector.py create mode 100644 JAS/resources/Embeds.py create mode 100644 JAS/resources/Exceptions.py create mode 100644 JAS/resources/Selects.py create mode 100644 JAS/resources/Views.py create mode 100644 JAS/resources/__init__.py create mode 100644 JAS/setup.py create mode 100644 app.py create mode 100644 discord_app.py create mode 100644 img/UI/disk.png create mode 100644 img/UI/exit.png create mode 100644 img/UI/folder.png create mode 100644 img/UI/icon.ico create mode 100644 img/UI/icon.png create mode 100644 img/UI/play.png create mode 100644 img/UI/settings.png create mode 100644 img/UI/stop.png create mode 100644 img/sample.png create mode 100644 requirements.txt diff --git a/JAS/UI.py b/JAS/UI.py new file mode 100644 index 0000000..c2a04f7 --- /dev/null +++ b/JAS/UI.py @@ -0,0 +1,385 @@ +import sys, logging, os, multiprocessing +import logging, logging.handlers +from time import sleep +import uuid, clipboard + +#Pyside6 +from PySide6.QtCore import QRunnable, Slot, QThreadPool, QObject, Signal, QThread +from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QGridLayout +from PySide6.QtWidgets import QPlainTextEdit, QLabel, QLineEdit, QComboBox, QStatusBar +from PySide6.QtGui import QIcon, QScreen, QAction, QFont + +#get libs +from JAS.resources.Addons import read_json, write_json, MAIN_PATH, IMG_FOLDER_PATH, resource_path +from JAS.resources.Exceptions import * + +class QueueMsg(): + msg_dict = { + "가동 실패":'봇 가동에 실패하였습니다.', + "봇 운영중":"봇 가동이 성공적으로 진행되었습니다.", + "봇 정지":"봇이 중지되었습니다.", + "경고":"봇 가동 중 경고 사항이 발견되었습니다.", + "오류 발생":'봇 가동 중 오류가 발생하였습니다.', + } + + @property + def run_failed(self) -> str: return "가동 실패" + @property + def started(self) -> str: return "봇 운영중" + @property + def closed(self) -> str: return "봇 정지" + @property + def warn(self) -> str: return "경고" + @property + def error(self) -> str: return "오류 발생" + + +class PySideHdr(logging.handlers.RotatingFileHandler): + def __init__(self, logbox, filepath) -> None: + super().__init__(filename=filepath, encoding='utf-8', maxBytes=32 * 1024 * 1024, backupCount=5) + self.logbox:QPlainTextEdit = logbox + + def emit(self, record): + self.logbox.appendPlainText(self.format(record)) + + +class WorkerSignals(QObject): + started = Signal(str) + closed = Signal(str) + run_fail = Signal(str) + error = Signal(str) + warning = Signal(str) + terminate = Signal() + + queue_check_in = Signal(object) + queue_check_out = Signal() + + +class Worker(QRunnable): + signals = WorkerSignals() + + def __init__(self) -> None: + super().__init__() + self.is_stop = False + self.queue_empty = True + self.queue_msg = '' + self.signals.terminate.connect(self.stop) + self.signals.queue_check_in.connect(self.check_in) + + @Slot() + def run(self): + wait_count = 0 + connected = False + while True: + if self.is_stop: + return + + if not connected: + if wait_count == 100: + print('===launch failed===') + self.signals.run_fail.emit(QueueMsg().run_failed) + break + else: + wait_count += 1 + sleep(1) + + self.check_out() + if self.queue_empty: + wait_time = 5 if connected else 1 + sleep(wait_time) + else: + if 'started' == self.queue_msg: # 봇 가동 확인 + print('===bot connected===') + connected = True + self.signals.started.emit(QueueMsg().started) + elif 'warn' == self.queue_msg: + print('===warning===') + self.signals.warning.emit(QueueMsg().warn) + sleep(1) + elif 'closed' == self.queue_msg: + print('===closed===') + self.signals.closed.emit(QueueMsg().closed) + self.is_stop = True + break + elif 'error' == self.queue_msg: + print('===error===') + self.signals.error.emit(QueueMsg().error) + break + self.queue_empty = True + self.queue_msg = '' + pass + + @Slot() + def stop(self): + print('stop msg send') + self.is_stop = True + + @Slot() + def check_out(self) -> None: + # print('checkout') + self.signals.queue_check_out.emit() + sleep(1) + return + + @Slot() + def check_in(self, result): + if type(result) == bool: + self.queue_empty = result + else: + self.queue_msg = result + +# Main Window +class ComuBotAPP(QMainWindow): + rec_queue:multiprocessing.Queue + send_queue:multiprocessing.Queue + data = { + "TOKEN":"", + "PROFILE":"", + "TEST_SERVER_ID":"", + "AUTH_JSON_PATH":"", + "AUTH_KEY":"", + } + is_stoped = True + + def __init__(self) -> None: + super().__init__() + self.get_vars() + self.define_btns() + self.set_logger() + self.threadpool:QThreadPool = QThreadPool() + self.initUI() + + def initUI(self): + self.cwidget = self.center_widget_init() + self.setCentralWidget(self.cwidget) + + self.setWindowTitle(f'JAS [DEMO]') + self.setWindowIcon(QIcon(resource_path('img/UI/icon.png'))) + self.setGeometry(300,300,600,500) + + self.setStatusBar(QStatusBar()) + + self.toolbar = self.toolbar_init() + self.logger.info('===안녕하세요 여러분의 도우미 J.A.S.입니다===') + self.center() + + # cWidget + def center_widget_init(self): + cwidget = QWidget() + + grid = QGridLayout() + grid.addWidget(self.tokenLabel, 0, 0) + grid.addWidget(self.serverLabel, 1, 0) + grid.addWidget(self.statusLabel, 2, 0) + + grid.addWidget(self.tokenLine, 0, 1) + grid.addWidget(self.serverLine, 1, 1) + grid.addWidget(self.statusLine, 2, 1) + grid.addWidget(self.logText, 3, 0, 1, 2) + + cwidget.setLayout(grid) + return cwidget + + # toolbar + def toolbar_init(self): + toolbar = self.addToolBar('main toolbar') + toolbar.setMovable(False) + toolbar.addAction(self.runbtn) + toolbar.addAction(self.stopbtn) + toolbar.addSeparator() + toolbar.addAction(self.savebtn) + toolbar.addAction(self.openbtn) + toolbar.addSeparator() + toolbar.addAction(self.exitbtn) + return toolbar + + def center(self): + center = QScreen.availableGeometry(QApplication.primaryScreen()).center() + geo = self.frameGeometry() + geo.moveCenter(center) + self.move(geo.topLeft()) + + def define_btns(self): + runAction = QAction(QIcon(resource_path('img/UI/play.png')), '실행', self) + runAction.setShortcut('Ctrl+R') + runAction.setStatusTip('봇 구동') + runAction.triggered.connect(self.run) + self.runbtn = runAction + + stopAction = QAction(QIcon(resource_path('img/UI/stop.png')), '정지', self) + stopAction.setShortcut('Ctrl+E') + stopAction.setStatusTip('봇 정지') + stopAction.triggered.connect(self.stop) + stopAction.setEnabled(False) + self.stopbtn = stopAction + + saveAction = QAction(QIcon(resource_path('img/UI/disk.png')), '설정 저장', self) + saveAction.setShortcut('Ctrl+S') + saveAction.setStatusTip('설정 저장') + saveAction.triggered.connect(self.save_setting) + self.savebtn = saveAction + + openAction = QAction(QIcon(resource_path('img/UI/folder.png')), '데이터 위치 열기', self) + openAction.setShortcut('Ctrl+O') + openAction.setStatusTip('데이터 위치 열기') + openAction.triggered.connect(self.open_btn) + self.openbtn = openAction + + exitAction = QAction(QIcon(resource_path('img/UI/exit.png')), '종료', self) + exitAction.setShortcut('Ctrl+Q') + exitAction.setStatusTip('종료') + exitAction.triggered.connect(self.close_btn) + self.exitbtn = exitAction + + self.tokenLabel = QLabel('봇 Token') + self.serverLabel = QLabel('테스트 서버 ID') + self.statusLabel = QLabel('상태') + + self.tokenLine = QLineEdit(self.data["TOKEN"]) + self.tokenLine.setEchoMode(QLineEdit.Password) + self.serverLine = QLineEdit(str(self.data["TEST_SERVER_ID"])) + self.statusLine = QLabel() + self.logText = QPlainTextEdit() + self.logText.setReadOnly(True) + self.logText.setFont(QFont("Courier New", 10)) + + def set_logger(self): + logger = logging.getLogger('PySide6') + logger.setLevel(logging.INFO) + + filepath = os.path.join(MAIN_PATH, 'UI.log') + pyside_handler = PySideHdr(self.logText, filepath) + dt_fmt = '%m-%d %H:%M:%S' + formatter = logging.Formatter('[{asctime}][{levelname:<8}] {message}', dt_fmt, style='{') + pyside_handler.setFormatter(formatter) + logger.addHandler(pyside_handler) + + self.logger = logger + + def get_vars(self): + self.data = read_json() + + def set_vars(self): + self.data['TOKEN'] = self.tokenLine.text() + self.data['PROFILE'] = '테스트' + self.data['TEST_SERVER_ID'] = int(self.serverLine.text()) + + write_json(self.data) + + # signals + def check_queue(self): + is_empty = self.rec_queue.empty() + if is_empty: + self.worker.signals.queue_check_in.emit(True) + else: + self.worker.signals.queue_check_in.emit(False) + self.worker.signals.queue_check_in.emit(self.rec_queue.get()) + + def started(self, string): + print('started', string) + self.statusLine.setText(string) + self.logger.info(QueueMsg.msg_dict[string]) + + def closed(self, string): + print('closed', string) + self.logger.info(QueueMsg.msg_dict[string]) + self.statusLine.setText('봇 정지') + self.is_stoped = True + self.available_on_stop() + + def run_failed(self, string): + print('run fail', string) + self.is_stoped = True + self.statusLine.setText(string) + self.logger.error(QueueMsg.msg_dict[string]) + + def warning(self, string): + print('warning', string) + self.logger.warn(QueueMsg.msg_dict[string]) + self.logger.warn(self.rec_queue.get(timeout=10)) + + def error_occured(self, string): + print('error_occured', string) + self.is_stoped = True + self.statusLine.setText(string) + self.logger.error(QueueMsg.msg_dict[string]) + self.logger.warn(self.rec_queue.get(timeout=10)) + + def disable_on_start(self): + self.runbtn.setEnabled(False) + self.savebtn.setEnabled(False) + self.openbtn.setEnabled(False) + self.exitbtn.setEnabled(False) + + self.stopbtn.setEnabled(True) + + self.tokenLine.setEnabled(False) + self.serverLine.setEnabled(False) + + def available_on_stop(self): + self.runbtn.setEnabled(True) + self.savebtn.setEnabled(True) + self.openbtn.setEnabled(True) + self.exitbtn.setEnabled(True) + + self.stopbtn.setEnabled(False) + + self.tokenLine.setEnabled(True) + self.serverLine.setEnabled(True) + + + @Slot() + def run(self): + print('===run bot===') + try: + self.send_queue.put('start bot') + self.statusLine.setText('봇 가동') + self.logger.info('봇을 가동합니다.') + + worker = Worker() + worker.signals.queue_check_out.connect(self.check_queue) + worker.signals.started.connect(self.started) + worker.signals.run_fail.connect(self.run_failed) + worker.signals.closed.connect(self.closed) + worker.signals.warning.connect(self.warning) + worker.signals.error.connect(self.error_occured) + self.threadpool.start(worker) + self.worker = worker + self.is_stoped = False + self.disable_on_start() + except Exception as e: + print(traceback.format_exc()) + self.statusLine.setText('봇 실행 중 오류 발생') + self.logger.error(str(e)) + self.available_on_stop() + + @Slot() + def stop(self): + print('===stop bot===') + self.send_queue.put('stop bot') + self.logger.info('봇을 정지합니다.') + # self.worker.signals.terminate.emit() + + @Slot() + def save_setting(self): + print('===save setting===') + self.set_vars() + self.statusLine.setText('설정 저장') + self.logger.info('설정이 저장되었습니다.') + + @Slot() + def open_btn(self): + print('=====open folder=====') + os.startfile(MAIN_PATH) + + @Slot() + def close_btn(self): + print('===exit app===') + if self.is_stoped: + self.send_queue.put_nowait('stop process') + self.close() + else: + self.logger.warn('봇이 가동중입니다.') + self.logger.info('봇을 정지 시킨 후 앱을 종료 해 주시기 바랍니다.') + + diff --git a/JAS/__init__.py b/JAS/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/JAS/cogs/__init__.py b/JAS/cogs/__init__.py new file mode 100644 index 0000000..792ea3c --- /dev/null +++ b/JAS/cogs/__init__.py @@ -0,0 +1 @@ +__all__ = ['fishing', 'gather', 'inventory', 'manage','store'] diff --git a/JAS/cogs/community.py b/JAS/cogs/community.py new file mode 100644 index 0000000..a141782 --- /dev/null +++ b/JAS/cogs/community.py @@ -0,0 +1,691 @@ +import discord, shutil, os +from discord import Color, File +from JAS.resources.Base import commands, app_commands, CommandBase, AppCommandBase, Views, Embeds +from JAS.resources.Addons import resize_img, key_gen, filename, IMG_FOLDER_PATH, resource_path +from JAS.resources.Exceptions import * +from asyncio import sleep + +class Community(CommandBase): + async def anon_message(self, message:discord.Message): + print(f'{message.author.display_name} | {message.author.id} | {message.content}') + content = message.content + + channel_id = list(filter(lambda x: x.name == self.channel.anon, message.guild.text_channels))[0].id + channel = message.guild.get_channel(channel_id) + await channel.send(embed=Embeds.general(content)) + + async def chara_talk(self, message:discord.Message): + id = message.author.id + code = self.user[id].charas # self.connector.get_user_chara(id)[0] + chara = self.charas[code] + content = message.content + color = chara.color or Color.brand_green() + path = os.path.join(IMG_FOLDER_PATH, str(message.guild.id), f'{filename(chara.name)}.png') + embedmsg, icon = Embeds.chara_talk(self.charas[code], path, content, color) + print(icon.filename) + files = [icon] + if message.attachments: + for attachment in message.attachments: + file = await attachment.to_file(use_cached=True) + files.append(file) + channel_name = message.channel.name + webhook = list(filter(lambda x:x.name == f"{channel_name} Chat", await message.guild.webhooks())) + print(webhook) + if not webhook: + webhook = await message.channel.create_webhook(name=f"{channel_name} Chat") + else: + webhook = webhook[0] + # if message.reference: + # print(message.reference) + # # msg = await message.channel.fetch_message(message.reference.message_id) + # # await msg.reply(embed=embedmsg, files=files) + # print(await webhook.fetch()) + # msg = await webhook.fetch_message(message.reference.message_id) + # await msg.reply(embed=embedmsg, files=files) + # else: + # await webhook.send(embed=embedmsg, files=files, username=chara.name, avatar_url=message.author.display_avatar.url, wait=True) + await webhook.send(content=message.reference.jump_url if message.reference else None, + embed=embedmsg, files=files, + username=chara.name, + avatar_url=message.author.display_avatar.url, + wait=True) + # await webhook.delete() + + @app_commands.command(name="스레드명", description="현재 속해있는 스레드의 이름을 변경합니다") + @app_commands.rename(title="이름") + @app_commands.describe(title="변경하고자 하는 이름") + async def change_thread_name(self, inter:discord.Interaction, title:str): + await self.on_ready(inter) + try: + if inter.channel.type==discord.ChannelType.text: + return await inter.response.send_message(embed=Embeds.general('해당 채널은 스레드가 아닙니다','스레드에 접속된 상태에서 다시 시도해 보세요.'), ephemeral=True) + else: + before_title = inter.channel.name + await inter.channel.edit(name=title) + await inter.response.send_message(embed=Embeds.general('스레드 명이 변경되었습니다',f'{before_title} > {title}'), ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="투표", description="투표를 생성합니다. 항목은 최대 5개까지 설정 가능합니다. 기한을 설정하지 않을 시 10분으로 설정됩니다.") + @app_commands.rename(timeout="기한", + op1="항목1", + op2="항목2", + op3="항목3", + op4="항목4", + op5="항목5") + @app_commands.describe(timeout="투표 마감 기한을 설정해 주세요. 기준은 분입니다. 예) 10", + op1="투표 항목. /로 설명을 기입할 수 있습니다. 예) 투표한다/당신은 투표를 해야한다.", + op2="투표 항목. /로 설명을 기입할 수 있습니다. 예) 투표한다/당신은 투표를 해야한다.", + op3="투표 항목. /로 설명을 기입할 수 있습니다. 예) 투표한다/당신은 투표를 해야한다.", + op4="투표 항목. /로 설명을 기입할 수 있습니다. 예) 투표한다/당신은 투표를 해야한다.", + op5="투표 항목. /로 설명을 기입할 수 있습니다. 예) 투표한다/당신은 투표를 해야한다.") + async def vote(self, inter:discord.Interaction, + op1:str, op2:str, op3:str="", op4:str="", op5:str="", + timeout:int=10): + await self.on_ready(inter, check_user=False, check_chara=False) + embedmsg = Embeds.vote(timeout, op1, op2, op3, op4, op5) + await inter.response.send_message(embed=embedmsg, allowed_mentions=discord.AllowedMentions.none()) + msg = await inter.edit_original_response() + await msg.add_reaction('1️⃣') + await msg.add_reaction('2️⃣') + if op3: await msg.add_reaction(discord.Reaction('3️⃣')) + if op4: await msg.add_reaction(discord.Reaction('4️⃣')) + if op5: await msg.add_reaction(discord.Reaction('5️⃣')) + # print(msg) + + await sleep(timeout*60) + + msg = await inter.channel.fetch_message(msg.id) + # print(msg) + result = {} + total = sum([reaction.count-1 for reaction in msg.reactions]) + # print(msg.reactions) + for reaction in msg.reactions: + # print(reaction) + if reaction.emoji == '1️⃣': + key = op1.split('/')[0] + if reaction.emoji == '2️⃣': + key = op2.split('/')[0] + if reaction.emoji == '3️⃣': + key = op3.split('/')[0] + if reaction.emoji == '4️⃣': + key = op4.split('/')[0] + if reaction.emoji == '5️⃣': + key = op5.split('/')[0] + count = reaction.count-1 + percent = int(count/total*100) + result[key] = [percent, count] + resultmsg = Embeds.vote_result(total, result) + + await inter.channel.send(content=msg.jump_url, embed=resultmsg) + + @commands.command(name="캐릭터등록", hidden=True) + @commands.has_any_role('관리자') + async def add_chara(self, ctx:commands.Context, + user:discord.Member=commands.parameter(displayed_name="추가 대상 사용자",description="캐릭터 추가를 진행할 사용자",displayed_default="@사용자")): + """ + 대상자의 캐릭터를 등록합니다. + 이름, 키워드(옵션), 설명을 등록합니다. + 이미지를 첨부할 시 해당 이미지를 아이콘으로 사용합니다. 첨부되지 않은 경우 기본 아이콘이 사용됩니다. + 이미지 사이즈 100x100 + """ + try: + chara_codes = list(self.charas.keys()) + id = user.id + code = self.user[id].charas + if code in chara_codes: + return await ctx.send(embed=Embeds.setting('이미 캐릭터가 등록되어 있습니다.','정보 변경을 원하신다면 `!캐릭터변경`을 사용해 주시기 바랍니다.')) + infoview=Views.AddChara() + await ctx.send(embed=Embeds.setting("캐릭터를 등록하시겠습니까?"), view=infoview) + await infoview.info.wait() + name = infoview.info.name.value + keyword = infoview.info.keyword.value + desc = infoview.info.desc.value + link = infoview.info.link.value + if 'https://' not in link: + link = 'https://'+ link + self.connector.set_charactor_data(id, code, name, keyword, desc, link) + path = os.path.join(IMG_FOLDER_PATH, str(ctx.guild.id), f'{filename(name)}.png') + if ctx.message.attachments: + icon=ctx.message.attachments[0] + if icon.height > 300 or icon.width > 300: + await resize_img(icon, path) + else: + await icon.save(path) + chara_embed, icon = Embeds.show_chara(self.charas[code], path) + await ctx.send(embeds=[Embeds.setting(f"{name}을/를 등록하였습니다."),chara_embed], file=icon) + else: + shutil.copy(resource_path(IMG_FOLDER_PATH,'sample.png'), path) + chara_embed, icon = Embeds.show_chara(self.charas[code], path) + await ctx.send(embeds=[Embeds.setting(f"{name}을/를 등록하였습니다.","기본 이미지가 등록 되었습니다.\n`!캐릭터변경`으로 인장을 등록해 주시기 바랍니다."),chara_embed], file=icon) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(name="캐릭터변경", hidden=True) + @commands.has_any_role('관리자') + async def change_chara(self, ctx:commands.Context, + user:discord.Member=commands.parameter(displayed_name="추가 대상 사용자",description="캐릭터 변경을 진행할 사용자",displayed_default="@사용자")): + """ + 대상자의 캐릭터를 변경합니다. + 이름, 키워드(옵션), 설명을 등록합니다. + 이미지를 첨부할 시 해당 이미지를 아이콘으로 사용합니다. 첨부되지 않은 경우 기본 아이콘이 사용됩니다. + 이미지 사이즈 100x100 + """ + try: + id = user.id + code = self.user[id].charas + before_name = self.charas[code].name + infoview=Views.ChangeChara(before_name, self.charas[code]) + await ctx.send(embed=Embeds.setting(f'{before_name}을/를 수정하시겠습니까?'), view=infoview) + await infoview.info.wait() + if infoview.cancel: return + name = infoview.info.name.value + keyword = infoview.info.keyword.value + desc = infoview.info.desc.value + link = infoview.info.link.value + if 'https://' not in link: + link = 'https://'+ link + path = os.path.join(IMG_FOLDER_PATH, str(ctx.guild.id), f'{filename(name)}.png') + if ctx.message.attachments: + icon=ctx.message.attachments[0] + if icon.height > 300 or icon.width > 300: + await resize_img(icon, path) + else: + await icon.save(path) + self.connector.update_charactor_data(code, name, keyword, desc, link) + chara_embed, icon = Embeds.show_chara(self.charas[code], path) + await ctx.send(embeds=[Embeds.setting(f"{name}의 정보가 변경되었습니다."),chara_embed], file=icon) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(name="캐릭터삭제", hidden=True) + @commands.has_any_role('관리자') + async def delete_chara(self, ctx:commands.Context, + user:discord.Member=commands.parameter(displayed_name="추가 대상 사용자",description="캐릭터 삭제를 진행할 사용자",displayed_default="@사용자")): + """ + 대상의 캐릭터를 삭제합니다. + """ + try: + id = user.id + code = self.user[id].charas + name = self.charas[code].name + remove_view = Views.RemoveChara(name) + await ctx.send(embed=Embeds.warning(f"캐릭터 {name}을/를 삭제하시겠습니까?"),view=remove_view) + await remove_view.wait() + if remove_view.cancel: return + self.connector.delete_charactor_data(code, id) + path = os.path.join(IMG_FOLDER_PATH, str(ctx.guild.id), f'{filename(name)}.png') + os.remove(path) + except Exception as e: + self.bot.logger.error(e) + +class CharaGroup(AppCommandBase): + def __init__(self, bot, name, description): + super().__init__(bot, name, description) + self.bot.tree.add_command(app_commands.ContextMenu( + name="캐릭터 정보", + callback=self.view_chara, + )) + + @app_commands.command(name="등록", description="커뮤에서 사용할 캐릭터를 등록합니다.") + @app_commands.rename(icon="아이콘") + @app_commands.describe(icon="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.") + async def add_chara_slash(self, interaction:discord.Interaction, icon:discord.Attachment=None): + """ + 자신의 캐릭터를 등록합니다. + 이름, 키워드(옵션), 설명을 등록합니다. + 이미지를 첨부할 시 해당 이미지를 아이콘으로 사용합니다. 첨부되지 않은 경우 기본 아이콘이 사용됩니다. + 이미지 사이즈 300x300 + """ + await self.on_ready(interaction, check_chara=False) + try: + chara_codes = list(self.charas.keys()) + id = interaction.user.id + code = key_gen() + if code in chara_codes: + return await interaction.response.send_message(embed=Embeds.setting('이미 캐릭터가 등록되어 있습니다.','정보 변경을 원하신다면 `!캐릭터변경`을 사용해 주시기 바랍니다.'), ephemeral=True, delete_after=10) + modal=Views.CharaInfo("캐릭터정보") + await interaction.response.send_modal(modal) + await modal.wait() + if not modal.finish: return + name = modal.name.value + keyword = modal.keyword.value + desc = modal.desc.value + link = modal.link.value + if 'https://' not in link: + link = 'https://'+ link + self.connector.set_charactor_data(id, code, name, keyword, desc, link) + path = os.path.join(IMG_FOLDER_PATH, str(interaction.guild_id), f'{filename(name)}.png') + if icon: + if icon.height > 300 or icon.width > 300: + await resize_img(icon, path) + else: + await icon.save(path) + chara_embed, icon = Embeds.show_chara(self.charas[code], path) + await interaction.followup.send(embeds=[Embeds.setting(f"{name}을/를 등록하였습니다."),chara_embed], file=icon, ephemeral=True) + else: + shutil.copy(resource_path(IMG_FOLDER_PATH,'sample.png'), path) + chara_embed, icon = Embeds.show_chara(self.charas[code], path) + await interaction.followup.send(embeds=[Embeds.setting(f"{name}을/를 등록하였습니다.","기본 이미지가 등록 되었습니다.\n`!캐릭터변경`으로 인장을 등록해 주시기 바랍니다."),chara_embed], file=icon, ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="변경", description="커뮤에서 사용할 캐릭터 정보를 변경합니다.") + @app_commands.rename(icon="아이콘") + @app_commands.describe(icon="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.") + async def update_chara_slash(self, interaction:discord.Interaction, icon:discord.Attachment=None): + """ + 자신의 캐릭터를 변경합니다. + 이름, 키워드(옵션), 설명을 등록합니다. + 이미지를 첨부할 시 해당 이미지를 아이콘으로 사용합니다. 첨부되지 않은 경우 기본 아이콘이 사용됩니다. + 이미지 사이즈 300x300 + """ + await self.on_ready(interaction) + try: + id = interaction.user.id + code = self.user[id].charas + before_name = self.charas[code].name + modal=Views.CharaInfo(f"{before_name} 정보", self.charas[code]) + await interaction.response.send_modal(modal) + await modal.wait() + if not modal.finish: return + name = modal.name.value + keyword = modal.keyword.value + desc = modal.desc.value + link = modal.link.value + if 'https://' not in link: + link = 'https://'+ link + path = os.path.join(IMG_FOLDER_PATH, str(interaction.guild_id), f'{filename(name)}.png') + if icon: + if icon.height > 300 or icon.width > 300: + await resize_img(icon, path) + else: + await icon.save(path) + self.connector.update_charactor_data(code, name, keyword, desc, link) + chara_embed, icon = Embeds.show_chara(self.charas[code], path) + await interaction.followup.send(embeds=[Embeds.setting(f"{name}의 정보가 변경되었습니다."),chara_embed], file=icon, ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="삭제", description="자신의 캐릭터를 삭제합니다.") + async def delete_chara_slash(self, interaction:discord.Interaction): + """ + 자신의 캐릭터를 삭제합니다. + """ + await self.on_ready(interaction) + try: + id = interaction.user.id + code = self.user[id].charas + name = self.charas[code].name + remove_view = Views.RemoveChara(name) + await interaction.response.send_message(embed=Embeds.warning(f"캐릭터 {name}을/를 삭제하시겠습니까?"),view=remove_view, ephemeral=True) + await remove_view.wait() + if remove_view.cancel: return + self.connector.delete_charactor_data(code, id) + path = os.path.join(IMG_FOLDER_PATH, str(interaction.guild_id), f'{filename(name)}.png') + os.remove(path) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="확인", description="자신 혹은 타인의 캐릭터를 확인합니다.") + @app_commands.rename(user="사용자명") + @app_commands.describe(user="확인하고 싶은 캐릭터의 대상자명. 입력하지 않으면 자신의 캐릭터를 확인합니다.") + async def show_user_chara(self, interaction:discord.Interaction, + user:discord.Member=None): + """ + 타인의 캐릭터를 확인합니다. + 유저명은 필수도 입력해 주시기 바랍니다. + """ + await self.on_ready(interaction, check_chara=False) + try: + if user: + code = self.user[user.id].charas + else: + code = self.user[interaction.user.id].charas + chara = self.charas[code] + path = os.path.join(IMG_FOLDER_PATH, str(interaction.guild_id), f'{filename(chara.name)}.png') + msg, icon = Embeds.show_chara(chara, path) + await interaction.response.send_message(embed=msg, file=icon, ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + async def view_chara(self, interaction:discord.Interaction, user:discord.User): + await self.on_ready(interaction) + try: + code = self.user[user.id].charas + chara = self.charas[code] + path = os.path.join(IMG_FOLDER_PATH, str(interaction.guild_id), f'{filename(chara.name)}.png') + msg, icon = Embeds.show_chara(chara, path) + await interaction.response.send_message(embed=msg, file=icon, ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="색상", description="자신의 캐릭터 대화창의 색상을 변경합니다.") + @app_commands.rename(input_color='색상코드') + @app_commands.describe(input_color="원하는 색상코드를 입력해 주세요. 예) #1E45D2") + async def change_chara_color(self, interaction:discord.Interaction, input_color:str=''): + """ + 캐릭터 대화창의 색상을 변경합니다. + """ + await self.on_ready(interaction) + try: + code = self.user[interaction.user.id].charas + if input_color: + try: + color = int(input_color, 16) + Color(color) + print('direct',color) + except: + color_code = input_color.replace('#','0x') if input_color.startswith('#') else f'0x{input_color}' + color = int(color_code, 16) + print('convert',color) + else: + color_view = Views.CharaColor() + await interaction.response.send_message(embed=Embeds.general('원하시는 색상을 선택해 주세요'),view=color_view, ephemeral=True) + await color_view.wait() + if not color_view.finish: + return await interaction.delete_original_response() + color = int(color_view.select.values[0]) + self.connector.update_charactor_color(code, color) + msg = Embeds.general(title="캐릭터 색상이 변경되었습니다",color=Color(color)) + if input_color: + await interaction.response.send_message(embed=msg, ephemeral=True) + else: + await interaction.edit_original_response(embed=msg, view=None) + except ValueError as e: + print('input_color error') + await interaction.response.send_message(embed=Embeds.warning('올바른 색상코드가 아닙니다', f'입력된 값: {input_color}'), ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + +@app_commands.default_permissions(manage_guild=True) +@app_commands.checks.has_role('관리자') +class NPCGroup(AppCommandBase): + @app_commands.command(name="등록", description="커뮤에서 사용할 NPC를 등록합니다.") + @app_commands.rename(color="캐릭터색상", icon1="아이콘1",icon2="아이콘2",icon3="아이콘3",icon4="아이콘4") + @app_commands.describe( + color="캐릭터의 채팅창 색상 예) #4F231E", + icon1="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.", + icon2="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.", + icon3="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.", + icon4="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.") + async def add_NPC_slash(self, interaction:discord.Interaction, + color:str='', + icon1:discord.Attachment=None, + icon2:discord.Attachment=None, + icon3:discord.Attachment=None, + icon4:discord.Attachment=None,): + """ + 새로운 NPC 정보를 등록합니다. + 필요사항 + 이름 / 상황 + """ + await self.on_ready(interaction, check_user=False, check_chara=False) + try: + guild_id = interaction.guild_id + code = 'NPC_'+ key_gen() + modal=Views.NPCInfo() + await interaction.response.send_modal(modal) + await modal.wait() + if not modal.finish: return + name = modal.name.value + vers1 = modal.vers1.value + vers2 = modal.vers2.value + vers3 = modal.vers3.value + vers4 = modal.vers4.value + vers_list = [ + [vers1.split('/')[0], '/'.join(vers1.split('/')[1:])], + [vers2.split('/')[0], '/'.join(vers2.split('/')[1:])], + [vers3.split('/')[0], '/'.join(vers3.split('/')[1:])], + [vers4.split('/')[0], '/'.join(vers4.split('/')[1:])], + ] + title = f"NPC {name}을/를 등록하였습니다." + + # NPC 색상 + if color: + try: + color = int(color, 16) + Color(color) + print('direct',color) + except: + color_code = color.replace('#','0x') if color.startswith('#') else f'0x{color}' + color = int(color_code, 16) + print('convert',color) + + self.connector.set_NPC_info(code, name, color) + for vers_data in vers_list: + case, vers = vers_data + if vers: + path = os.path.join(IMG_FOLDER_PATH, str(interaction.guild_id), filename(f'{name}_{case}.png')) + + if vers_list.index(vers_data) == 0: + icon = icon1 + elif vers_list.index(vers_data) == 1: + icon = icon2 + elif vers_list.index(vers_data) == 2: + icon = icon3 + elif vers_list.index(vers_data) == 3: + icon = icon4 + + if icon: + if icon.height > 300 or icon.width > 300: + await resize_img(icon, path) + else: + await icon.save(path) + else: + shutil.copy(resource_path(IMG_FOLDER_PATH,'sample.png'), path) + + self.connector.set_NPC_vers(code, case, vers) + chara_embed = Embeds.show_npc(self.npc[code]) + files = [] + for case in self.npc[code].case: + file = File(os.path.join(IMG_FOLDER_PATH, str(guild_id), filename(f'{name}_{case}.png'))) + files.append(file) + await interaction.followup.send(embeds=[Embeds.setting(title),chara_embed], files=files) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="변경", description="커뮤에서 사용할 NPC 정보를 변경합니다.") + @app_commands.rename(color="캐릭터색상", icon1="아이콘1",icon2="아이콘2",icon3="아이콘3",icon4="아이콘4") + @app_commands.describe( + color="캐릭터의 채팅창 색상 예) #4F231E", + icon1="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.", + icon2="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.", + icon3="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.", + icon4="캐릭터를 표시하는 아이콘. 정사각형 형태의 그림을 넣어주세요.") + async def update_NPC_slash(self, interaction:discord.Interaction, + color:str='', + icon1:discord.Attachment=None, + icon2:discord.Attachment=None, + icon3:discord.Attachment=None, + icon4:discord.Attachment=None,): + """ + NPC를 변경합니다. + 이름, 키워드(옵션), 설명을 등록합니다. + 이미지를 첨부할 시 해당 이미지를 아이콘으로 사용합니다. 첨부되지 않은 경우 기본 아이콘이 사용됩니다. + 이미지 사이즈 300x300 + """ + await self.on_ready(interaction, check_user=False, check_chara=False) + try: + guild_id = interaction.guild_id + + data = [[self.npc[code].name, code] for code in self.npc.keys()] + select_view = Views.UpdateNPC(data, self.npc) + await interaction.response.send_message(embed=Embeds.setting('변경할 NPC를 선택해 주세요'), view=select_view) + await select_view.wait() + if select_view.cancel: return + code = select_view.select.values[0] + + # NPC 색상 + if color: + try: + color = int(color, 16) + Color(color) + print('direct',color) + except: + color_code = color.replace('#','0x') if color.startswith('#') else f'0x{color}' + color = int(color_code, 16) + print('convert',color) + + name = select_view.modal.name.value + vers1 = select_view.modal.vers1.value + vers2 = select_view.modal.vers2.value + vers3 = select_view.modal.vers3.value + vers4 = select_view.modal.vers4.value + vers_list = [ + [vers1.split('/')[0], '/'.join(vers1.split('/')[1:])], + [vers2.split('/')[0], '/'.join(vers2.split('/')[1:])], + [vers3.split('/')[0], '/'.join(vers3.split('/')[1:])], + [vers4.split('/')[0], '/'.join(vers4.split('/')[1:])], + ] + title = f"NPC {name}을/를 등록하였습니다." + self.connector.update_NPC_info(code, name, Color) + + self.npc[code].number = 0 + self.npc[code].case = [] + self.npc[code].vers = [] + + for vers_data in vers_list: + case, vers = vers_data + if vers: + path = os.path.join(IMG_FOLDER_PATH, str(guild_id), filename(f'{name}_{case}.png')) + + if vers_list.index(vers_data) == 0: + icon = icon1 + elif vers_list.index(vers_data) == 1: + icon = icon2 + elif vers_list.index(vers_data) == 2: + icon = icon3 + elif vers_list.index(vers_data) == 3: + icon = icon4 + + if icon: + if icon.height > 300 or icon.width > 300: + await resize_img(icon, path) + else: + await icon.save(path) + else: + shutil.copy(resource_path(IMG_FOLDER_PATH,'sample.png'), path) + + self.connector.set_NPC_vers(code, case, vers) + chara_embed = Embeds.show_npc(self.npc[code]) + files = [] + for case in self.npc[code].case: + file = File(os.path.join(IMG_FOLDER_PATH, str(guild_id), filename(f'{name}_{case}.png'))) + files.append(file) + await interaction.followup.send(embeds=[Embeds.setting(title),chara_embed], files=files, ephemeral=True) + # id = interaction.user.id + # code = self.user[id].charas + # before_name = self.charas[code].name + # modal=Views.NPCInfo(f"{before_name} 정보", self.npc[code]) + # await interaction.response.send_modal(modal) + # await modal.wait() + # if not modal.finish: return + # name = modal.name.value + # if icon: + # if icon.height > 300 or icon.width > 300: + # await resize_img(icon, path) + # else: + # await icon.save(path) + # self.connector.update_charactor_data(code, name, keyword, desc, link) + # chara_embed, icon = Embeds.show_chara(self.charas[code], path) + # await interaction.followup.send(embeds=[Embeds.setting(f"NPC {name}의 정보가 변경되었습니다."),chara_embed], file=icon, ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="삭제", description="NPC를 삭제합니다.") + async def delete_NPC_slash(self, interaction:discord.Interaction): + """ + 자신의 캐릭터를 삭제합니다. + """ + await self.on_ready(interaction, check_user=False, check_chara=False) + try: + guild_id = interaction.guild_id + data = [[self.npc[code].name, code] for code in self.npc.keys()] + select_view = Views.RemoveNPC(data) + await interaction.response.send_message(embed=Embeds.setting('삭제할 NPC를 선택해 주세요'), view=select_view) + await select_view.wait() + if select_view.cancel: return + code = select_view.select.values[0] + name = self.npc[code].name + cases = self.npc[code].case + + self.connector.delete_NPC_data(code) + + for case in cases: + path = os.path.join(IMG_FOLDER_PATH, str(guild_id), filename(f'{name}_{case}.png')) + os.remove(path) + except Exception as e: + self.bot.logger.error(e) + + class NPC_Vers_Num(discord.enums.Enum): + 대사1 = 0 + 대사2 = 1 + 대사3 = 2 + 대사4 = 3 + + async def NPC_talk(self, inter:discord.Interaction, code, vers): + npc = self.npc[code] + content = vers + color = npc.color or Color.brand_green() + target_case = npc.case[npc.vers.index(vers)] + path = os.path.join(IMG_FOLDER_PATH, str(inter.guild_id), filename(f'{npc.name}_{target_case}.png')) + embedmsg, icon = Embeds.chara_talk(self.npc[code], path, content, color) + # print(icon.filename) + files = [icon] + channel_name = inter.channel.name + webhook = list(filter(lambda x:x.name == f"{channel_name} Chat", await inter.guild.webhooks())) + # print(webhook) + if not webhook: + webhook = await inter.channel.create_webhook(name=f"{channel_name} Chat") + else: + webhook = webhook[0] + # if message.reference: + # print(message.reference) + # # msg = await message.channel.fetch_message(message.reference.message_id) + # # await msg.reply(embed=embedmsg, files=files) + # print(await webhook.fetch()) + # msg = await webhook.fetch_message(message.reference.message_id) + # await msg.reply(embed=embedmsg, files=files) + # else: + # await webhook.send(embed=embedmsg, files=files, username=chara.name, avatar_url=message.author.display_avatar.url, wait=True) + await webhook.send(embed=embedmsg, files=files, + username=npc.name, + avatar_url=inter.user.display_avatar, + wait=True) + # await webhook.delete() + + @app_commands.command(name="대사", description="NPC의 대사를 출력합니다.") + @app_commands.rename(number="출력대사") + @app_commands.describe(number="출력할 대사를 선택합니다. 대사의 번호를 선택해 주세요") + async def NPC_chat_slash(self, interaction:discord.Interaction, + number:NPC_Vers_Num): + """ + NPC의 대사를 사용합니다. + + """ + await self.on_ready(interaction, check_user=False, check_chara=False) + try: + data = [[self.npc[code].name, code] for code in self.npc.keys()] + select_view = Views.SelectNPC(data) + + await interaction.response.send_message(embed=Embeds.setting('변경할 NPC를 선택해 주세요'), view=select_view) + await select_view.wait() + if select_view.cancel: return + code = select_view.select.values[0] + + npc = self.npc[code] + if number.value >= npc.number: + total_vers = [npc.case[npc.vers.index(vers)]+' / '+vers for vers in npc.vers] + return await interaction.channel.send(embed=Embeds.general(f'현재 {npc.name}의 대사는 {npc.number}개 등록되어 있습니다', f'사용 가능한 대사:\n{total_vers}')) + target_vers = npc.vers[number.value] + await self.NPC_talk(interaction, code, target_vers) + except Exception as e: + self.bot.logger.error(e) + +async def setup(bot:commands.Bot): + await bot.add_cog(Community(bot)) + bot.tree.add_command(CharaGroup(bot=bot, name="캐릭터", description="커뮤 캐릭터를 등록하거나 관리합니다.")) + bot.tree.add_command(NPCGroup(bot=bot, name="npc", description="커뮤 NPC를 등록하거나 관리합니다.")) + + diff --git a/JAS/cogs/fishing.py b/JAS/cogs/fishing.py new file mode 100644 index 0000000..eb3d582 --- /dev/null +++ b/JAS/cogs/fishing.py @@ -0,0 +1,378 @@ +import discord +from discord import Color, Member +from JAS.resources.Exceptions import * +from JAS.resources.Base import commands, app_commands, CommandBase, AppCommandBase, Embeds, Views +from JAS.resources.Addons import key_gen +from math import floor +from random import choice, randrange, random +from time import strftime,localtime +from asyncio import sleep +from copy import deepcopy + +class Fishing(CommandBase): + def check_fishing_limit(self, user_id, channel): + max_count = self.setting.max_fishing + date = strftime('%Y-%m-%d',localtime()) + fishing_count = self.connector.get_fishing_history(user_id, channel, date) + if fishing_count >= max_count: + raise FishingLimit('낚시 횟수가 초과되었습니다') + + def fishing(self, fish_list, loc, user_id): + grades = { + "초라함": 0.5, + "일반": 1, + "희귀": 1.8, + "전설": 3, + "경이": 5 + } + + fishcode = choice(fish_list) + fish = self.fishes[fishcode] + print(fish) + + name = fish.name + dice = randrange(1,101) + print(dice) + grade = "경이" if dice < 2 else "전설" if dice < 11 else "희귀" if dice < 31 else "일반" if dice < 91 else "초라함" + num = 1 + length = round(randrange(fish.min, fish.max)+random(),2) + price = floor(length/(fish.min+fish.max)*2*fish.baseprice*grades[grade]) + great = bool(length>(fish.max*0.8)) + itemcode = f'{fishcode}-{num}-{str(random())[2:]}' + + now = strftime('%Y-%m-%d %H:%M:%S',localtime()) + self.connector.set_fishing_history(now, user_id, str(loc), name, length) + print('history logging success') + + desc = self.items[fishcode].desc + code = self.user[user_id].charas + chara_name = self.charas[code].name + if len(self.invs[code].items) <= self.invs[code].size: + self.connector.reg_item(itemcode, name, desc, num, price) + self.connector.store_item(code, itemcode, num) + title = "🎉월척이오!!!!!" if great else "🎣낚시 성공!" + desc = f'{chara_name}님이 {name}을 낚았습니다.' + color = Color.gold() if great or grade == "경이" else Color.green() if grade != "초라함" else Color.dark_grey() + data = (grade, f'{length} cm', f'G {price}') + else: + self.connector.add_gold(code, price) + print('set gold success') + desc=f'가방이 꽉 차 담을 수 없었습니다...\n{chara_name}은/는 G{price}를 얻었습니다.' + color = Color.dark_grey() + data = (grade, f'{length} cm', f'G {price}') + return (title, desc, color, data) + + @app_commands.command(name="낚시", description="낚시터에서 물고기를 낚습니다.") + async def fishing_slash(self, interaction:discord.Interaction): + """ + 낚시터에서 낚시를 합니다. + """ + loc = interaction.channel + user_id = interaction.user.id + await self.on_ready(interaction) + + try: + await interaction.response.send_message(embed=Embeds.general("낚시를 시작합니다.",None,Color.green()), ephemeral=True) + print("check fishing limit") + self.check_fishing_limit(user_id, loc) + + try: + fish_list = self.fish_data[str(loc)] + except: + raise IncorrectPlace('올바른 낚시터가 아닙니다.') + wait_time = randrange(4,10) + print(f'대기시간: {wait_time}') + await interaction.edit_original_response(embed=Embeds.fishing(0)) + await sleep(1) + for i in range(wait_time+1): + fishingview = Views.FishingView(False) + if i == wait_time: + fishingview = Views.FishingView(True) + type = 9 + elif i < 2: + type = i + else: + type = randrange(2,5) + await interaction.edit_original_response(embed=Embeds.fishing(type), view=fishingview) + await fishingview.wait() + if fishingview.result: + break + + # 낚시 버튼 클릭한 경우 + if fishingview.result: + if fishingview.hooked: + # 낚시에 성공한 경우 + title, desc, color, data = self.fishing(fish_list, loc, user_id) + else: + # 낚시에 실패한 경우 + title = "낚시 실패" + desc = "너무 일찍 당겨버렸다..." + color=Color.dark_grey() + data = None + else: + title = "낚시 실패..." + desc = "물고기가 미끼를 먹고 유유히 사라졌다..." + color = Color.dark_grey() + data = None + embeded = Embeds.fishing_result(title, desc, color, data) + await interaction.delete_original_response() + await interaction.channel.send(embed=embeded, view=None) + except FishingLimit as e: + print(e) + print('>> Error: exceed fishing limit') + print(traceback.format_exc()) + title = '더이상 고기를 낚을 수 없을것 같습니다…' + desc = None + color = Color.brand_red() + data = None + embeded = Embeds.fishing_result(title, desc, color, data) + await interaction.edit_original_response(embed=embeded, view=None) + except SellingError as e: + print(e) + print('>> Error: inventory error accured') + print(traceback.format_exc()) + title = "물고기 판매 도중 오류가 발생하였습니다." + desc = "다시 시도해 주시기 바랍니다" + color = Color.red() + data = None + embeded = Embeds.fishing_result(title, desc, color, data) + await interaction.edit_original_response(embed=embeded, view=None) + except FishingError as e: + print(e) + print('>> Error: fishing error accured') + print(traceback.format_exc()) + title = "낚시 도중 오류가 발생하였습니다." + desc = "다시 시도해 주시기 바랍니다" + color = Color.red() + data = None + embeded = Embeds.fishing_result(title, desc, color, data) + await interaction.edit_original_response(embed=embeded, view=None) + except IncorrectPlace as e: + print(e) + print('>> Error: incorrect place') + print(traceback.format_exc()) + title = "올바른 낚시터가 아닙니다" + desc = "다른 곳을 찾아가 보세요" + color = Color.yellow() + data = None + embeded = Embeds.fishing_result(title, desc, color, data) + await interaction.edit_original_response(embed=embeded, view=None) + except Exception as e: + print(e) + print('>> Error: Unkown Error') + print(traceback.format_exc()) + title = '예상치 못한 오류가 발생하였습니다.' + desc = '지속적으로 발생할 경우 관리자에게 문의 바랍니다.' + color = Color.red() + data = None + embeded = Embeds.fishing_result(title, desc, color, data) + await interaction.edit_original_response(embed=embeded, view=None) + + @app_commands.command(name="물고기목록", description="낚시터의 물고기 목록을 확인한다.") + @app_commands.rename(loc="낚시터") + @app_commands.describe(loc="지정된 낚시터. 입력할 경우 해당 낚시터의 물고기만 확인가능하다.") + async def 물고기목록(self, interaction:discord.Interaction, + loc:discord.TextChannel=None): + """ + 현재 낚시 가능한 물고기 목록을 확인합니다. + 낚시터 명을 입력할 경우 해당 낚시터의 목록만 확인 가능합니다. + """ + await self.on_ready(interaction) + try: + if loc: + try: + data = {} + data[loc.name] = self.fish_data[loc.name] + title = f"{loc.name}에서 발견 가능한 물고기 입니다." + await interaction.response.send_message(embed=Embeds.fish_data(title, data, self.fishes, True)) + except Exception as e: + print(e) + await interaction.response.send_message(embed=Embeds.general('해당 낚시터에는 물고기가 없습니다.',""), ephemeral=True) + else: + await interaction.response.send_message(embed=Embeds.fish_data("낚시터에서 발견 가능한 물고기 입니다.", self.fish_data, self.fishes, False)) + except Exception as e: + self.bot.logger.error(e)(e) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def 낚시설정(self, message:commands.Context, + value:int=commands.parameter(displayed_name="최대 횟수",description="- 낚시가 가능한 최대 횟수")): + """ + 최대 낚시 횟수를 변경합니다. + 횟수는 숫자로 입력해주시기 바랍니다. + """ + if value: + try: + self.connector.set_max_fishing(value) + await message.send(embed=Embeds.setting(f'최대 낚시 횟수가 {value}회로 변경되었습니다.')) + except Exception as e: + print(e) + print(traceback.format_exc()) + await message.send(embed=Embeds.error()) + else: + await message.send(embed=Embeds.warning('변경하고자 하는 수치를 입력해 주시기 바랍니다'), delete_after=5) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def 낚시이력삭제(self, message:commands.Context, + user:Member=commands.parameter(displayed_name="대상 유저",description="- 이력을 삭제하고자 하는 유저명. @를 통해 입력해 주세요")): + """ + 사용자의 낚시 이력을 삭제합니다. + @태그를 통해 입력해 주시기 바랍니다. + """ + if user: + if type(user) == Member: + self.connector.delete_fishing_history(user.id) + name = user.nick or user.name + await message.send(embed=Embeds.setting(f'{name}의 이력을 삭제하였습니다.')) + else: + await message.send(embed=Embeds.warning('@태그를 이용해 사용자를 입력 바랍니다.'), delete_after=5) + else: + await message.send(embed=Embeds.warning('대상 사용자를 입력해 주시기 바랍니다.'), delete_after=5) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def 물고기추가(self, message:commands.Context, + name:str=commands.parameter(displayed_name="물고기 이름-",default=None, displayed_default="text",description="추가하고자 하는 물고기 이름입니다.")): + """ + 새로운 물고기를 추가합니다. + 필요한 정보 + 이름 / 설명 / 최소길이 / 최고길이 / 기본 가격 / 등장위치(채널명) + """ + if not name: + await message.send(embed=Embeds.warning('물고기 이름을 입력해 주시기 바랍니다.'), delete_after=5) + return + infoview = Views.AddFishView() + await message.send(embed=Embeds.setting(f"{name}을/를 추가하시겠습니까?",None),view=infoview) + await infoview.wait() + if infoview.cancel:return + desc = infoview.modal.desc.value + min = int(infoview.modal.min.value) + max = int(infoview.modal.max.value) + baseprice = int(infoview.modal.baseprice.value) + loc = infoview.modal.loc.value + + try: + self.connector.add_fish_data(name, desc, min, max, baseprice, loc) + await message.send(embed=Embeds.add_fish(f'{name}을/를 추가했습니다.',name,desc,min,max,baseprice,loc)) + except CannotAddFish as e: + print(e) + print('>> Error: cannot add fish') + print(traceback.format_exc()) + await message.send(embed=Embeds.warning('물고기를 추가하지 못하였습니다. 다시 시도해 주시기 바랍니다.'), delete_after=5) + except Exception as e: + print(e) + print('>> Error: unkown error') + print(traceback.format_exc()) + raise e + + @commands.command(name="물고기변경", hidden=True) + @commands.has_any_role('관리자') + async def change_fish_data(self, message:commands.Context, + name:str=commands.parameter(displayed_name="물고기 이름-", default=None, description="수정하고자 하는 물고기 이름")): + """ + 낚시터의 물고기 정보를 변경합니다. + """ + msg = None + try: + if name: + code_list = list(filter(lambda x: self.fishes[x].name==name,list(self.fishes.keys()))) + if not code_list: + raise FishNotFOund('해당이름 물고기 없음') + code = code_list[0] + else: + title = "변경하실 물고기를 선택해 주세요." + selectview = Views.SelectFishView(self.fishes) + msg = await message.send(embed=Embeds.setting(title,None), view=selectview) + await selectview.wait() + if selectview.cancel: return + code = selectview.fish.values[0] + name = self.items[code].name + + print(f'{name} | {code}') + data = deepcopy(self.fishes[code]) + data.desc = self.items[code].desc + print(data) + infoview = Views.ChangeFishView(data) + if msg: + await msg.edit(embed=Embeds.setting(f"{name}을/를 변경하시겠습니까?",None),view=infoview) + else: + await message.send(embed=Embeds.setting(f"{name}을/를 변경하시겠습니까?",None),view=infoview) + await infoview.wait() + if infoview.cancel: return + desc = infoview.modal.desc.value + min = int(infoview.modal.min.value) + max = int(infoview.modal.max.value) + baseprice = int(infoview.modal.baseprice.value) + loc = infoview.modal.loc.value + + self.connector.change_fish_data(code, desc, min, max, baseprice, loc) + await message.send(embed=Embeds.add_fish(f'{name}을/를 변경했습니다.',name,desc,min,max,baseprice,loc)) + except CannotAddFish as e: + print(e) + print('>> Error: cannot add fish') + print(traceback.format_exc()) + await message.send(embed=Embeds.warning('물고기를 변경하지 못하였습니다. 다시 시도해 주시기 바랍니다.'), delete_after=5) + except FishNotFOund as e: + print(e) + print('>> Error: fish not found') + print(traceback.format_exc()) + await message.send(embed=Embeds.warning('해당 이름의 물고기가 존재하지 않습니다. 물고기 이름을 다시 확인해 주시기 바랍니다.'), delete_after=5) + except Exception as e: + print(e) + print('>> Error: unkown error') + print(traceback.format_exc()) + raise e + + @commands.command(name="물고기삭제", hidden=True) + @commands.has_any_role('관리자') + async def delete_fish_data(self, message:commands.Context, + name:str=commands.parameter(displayed_name="물고기 이름-", default=None, description="삭제하고자 하는 물고기 이름")): + """ + 낚시터의 물고기 정보를 삭제합니다. + """ + try: + msg = None + if name: + code_list = list(filter(lambda x: self.fishes[x].name==name,list(self.fishes.keys()))) + if not code_list: + raise FishNotFOund('해당이름 물고기 없음') + code = code_list[0] + else: + title = "삭제하실 물고기를 선택해 주세요." + selectview = Views.SelectFishView(self.fishes) + msg = await message.send(embed=Embeds.setting(title,None), view=selectview) + await selectview.wait() + if selectview.cancel: return + code = selectview.fish.values[0] + name = self.items[code].name + + print(f'{name} | {code}') + delview = Views.ConfirmFishDelete() + if msg: + await msg.edit(embed=Embeds.warning(f"{name}을/를 삭제하시겠습니까?"),view=delview) + else: + await message.send(embed=Embeds.warning(f"{name}을/를 삭제하시겠습니까?"),view=delview) + await delview.wait() + if delview.cancel: return + self.connector.delete_fish(code) + await message.send(embed=Embeds.setting(f"{name}을/를 삭제하였습니다.",None)) + except CannotAddFish as e: + print(e) + print('>> Error: cannot add fish') + print(traceback.format_exc()) + await message.send(embed=Embeds.warning('물고기를 삭제하지 못하였습니다. 다시 시도해 주시기 바랍니다.'), delete_after=5) + except FishNotFOund as e: + print(e) + print('>> Error: fish not found') + print(traceback.format_exc()) + await message.send(embed=Embeds.warning('해당 이름의 물고기가 존재하지 않습니다. 물고기 이름을 다시 확인해 주시기 바랍니다.'), delete_after=5) + except Exception as e: + print(e) + print('>> Error: unkown error') + print(traceback.format_exc()) + raise e + +async def setup(bot:commands.Bot): + await bot.add_cog(Fishing(bot)) + diff --git a/JAS/cogs/inventory.py b/JAS/cogs/inventory.py new file mode 100644 index 0000000..bab1a77 --- /dev/null +++ b/JAS/cogs/inventory.py @@ -0,0 +1,174 @@ +import discord +from JAS.resources.Exceptions import * +from JAS.resources.Base import JAS, commands, app_commands, CommandBase, AppCommandBase, Views, Embeds +from random import randrange, random +from asyncio import sleep +from copy import deepcopy + +class Inventory(CommandBase): + def __init__(self, bot: JAS) -> None: + super().__init__(bot) + bot.tree.add_command(app_commands.ContextMenu( + name="송금", + callback=self.transfer_gold_menu, + )) + + @app_commands.command(name="소지품") + @app_commands.rename(check_gold="골드확인") + @app_commands.describe(check_gold="소지중인 골드만 확인합니다.") + @app_commands.choices(check_gold=[ + app_commands.Choice(name="골드만 확인", value='True'), + app_commands.Choice(name="소지품 확인", value='False'), + ]) + async def inventory(self, interaction:discord.Interaction, + check_gold:app_commands.Choice[str]=None): + """ + 현재 자신이 가지고 있는 소지품을 확인합니다. + 남은 가방공간, 아이템, 골드를 확인가능합니다. + """ + await self.on_ready(interaction) + try: + id = interaction.user.id + code = self.user[id].charas + if check_gold: + if check_gold.value == 'True': + gold = self.invs[code].gold # self.connector.get_gold(id) + return await interaction.response.send_message(embed=Embeds.store(f'현재 G{gold}를 소지하고 있습니다.'), ephemeral=True) + name = self.charas[code].name + title = f'{name}님의 소지품입니다.' + inventory = self.invs[code] + desc = f'남은 공간: {len(inventory.items)} / {inventory.size}' + await interaction.response.send_message(embed=Embeds.inventory(title=title, desc=desc, inventory=inventory, items=self.items), ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + def send_gold(self, id, user, gold): + fromcode = self.user[id].charas + tocode = self.user[user].charas + current_gold = self.invs[fromcode].gold + if current_gold < gold: + raise NotEnoughGold('골드가 충분하지 않습니다.') + self.connector.add_gold(fromcode, -gold) + self.connector.add_gold(tocode, gold) + + @app_commands.command(name="송금",description="상대방에게 골드를 전달합니다.") + @app_commands.rename(user="대상", gold="금액") + @app_commands.describe(user="송금하고자 하는 대상", gold="송금하고자 하는 금액. 입력하지 않으면 10골드를 송금합니다.") + async def transfer_gold(self, interaction:discord.Interaction, + user:discord.Member, + gold:int=10): + """ + 골드를 다른 사람에게 전달합니다. + 골드를 입력하지 않은 경우 10골드가 전달됩니다. + """ + await self.on_ready(interaction) + try: + id = interaction.user.id + name = interaction.user.display_name + + user_name = user.display_name + user_id = user.id + code = self.user[user_id].charas + if not code: + return await interaction.response.send_message(embed=Embeds.general('캐릭터가 존재하지 않는 사용자에게 송금할 수 없습니다.'), ephemeral=True) + + transfer_view = Views.TransferView() + await interaction.response.send_message(embed=Embeds.store(f'{user_name}에게 송금하시겠습니까?',f'G{gold}를 전달합니다.'), view=transfer_view, ephemeral=True) + await transfer_view.wait() + if transfer_view.cancel: + return await interaction.edit_original_response(embed=Embeds.general('작업을 취소합니다.')) + await interaction.delete_original_response() + self.send_gold(id, user_id, gold) + await interaction.channel.send(embed=Embeds.store(f'{name}이/가 {user_name}에게 G{gold}를 전달하였습니다.')) + except NotEnoughGold as e: + await interaction.edit_original_response(embed=Embeds.warning('소지한 골드가 부족합니다.'), view=None) + except Exception as e: + self.bot.logger.error(e) + + async def transfer_gold_menu(self, interaction:discord.Interaction, user:discord.User): + await self.on_ready(interaction) + try: + id = interaction.user.id + name = interaction.user.display_name + gold = 10 + + user_name = user.display_name + user_id = user.id + code = self.user[user_id].charas + if not code: + return await interaction.response.send_message(embed=Embeds.general('캐릭터가 존재하지 않는 사용자에게 송금할 수 없습니다.'), ephemeral=True) + + transfer_view = Views.TransferView() + await interaction.response.send_message(embed=Embeds.store(f'{user_name}에게 송금하시겠습니까?',f'G{gold}를 전달합니다.'), view=transfer_view, ephemeral=True) + await transfer_view.wait() + if transfer_view.cancel: + return await interaction.edit_original_response(embed=Embeds.general('작업을 취소합니다.')) + self.send_gold(id, user_id, gold) + await interaction.delete_original_response() + await interaction.channel.send(embed=Embeds.store(f'{name}이/가 {user_name}에게 G{gold}를 전달하였습니다.')) + except NotEnoughGold as e: + print(e) + print('>> Error: not enough gold') + print(traceback.format_exc()) + await interaction.edit_original_response(embed=Embeds.warning('소지한 골드가 부족합니다.'), view=None) + except Exception as e: + self.bot.logger.error(e) + + @app_commands.command(name="아이템판매", description="갖고있는 아이템을 판매합니다.") + async def item_sold(self, interaction:discord.Interaction): + """ + 소지품을 상점에 판매합니다. + """ + await self.on_ready(interaction) + try: + user_id = interaction.user.id + code = self.user[user_id].charas + if len(self.invs[code].items) == 0: + return await interaction.response.send_message(embed=Embeds.general('판매 가능한 아이템이 없습니다.'), ephemeral=True) + item_view = Views.SoldItemView(self.invs[code].items, self.items) + await interaction.response.send_message(embed=Embeds.store("판매할 아이템을 선택해 주세요"), view=item_view, ephemeral=True) + await item_view.wait() + if item_view.cancel: + return await interaction.delete_original_response() + sold_items = item_view.select.values + result = 0 + for item in sold_items: + price = self.items[item].price + self.connector.sold_item(code, item, price) + result = result+price + await interaction.edit_original_response(embed=Embeds.sold_item(result), view=None) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(name="아이템부여", hidden=True) + async def spawn_item(self, ctx:commands.Context, + user:discord.Member=commands.parameter(displayed_name="대상 유저",default=None,description="아이템을 부여할 유저입니다.",displayed_default="@사용자")): + """ + 해당 유저에게 아이템을 부여합니다. + """ + try: + if not user: + return await ctx.send(embed=Embeds.warning('유저가 입력되지 않았습니다.','@로 유저를 입력해 주시기 바랍니다.')) + else: + try: + user.id + except: + return await ctx.send(embed=Embeds.warning('유저는 @태그로 입력해야 합니다.','@로 유저를 입력해 주시기 바랍니다.')) + select_view = Views.SpawnItem(self.items) + await ctx.send(embed=Embeds.setting("부여할 아이템을 선택해 주세요."), view=select_view) + await select_view.wait() + if select_view.cancel: return + items = [] + code = self.user[ctx.author.id].charas + for itemcode in select_view.select.values: + itemdata = self.items[itemcode] + self.connector.spawn_item(code, itemcode, itemdata) + items.append(itemdata.name) + await ctx.send(embed=Embeds.setting(f"{user.display_name}에게 아이템을 부여하였습니다.",f"부여한 아이템: {', '.join(items)}")) + except Exception as e: + print(e) + print(traceback.format_exc()) + raise e + +async def setup(bot:commands.Bot): + await bot.add_cog(Inventory(bot)) diff --git a/JAS/cogs/manage.py b/JAS/cogs/manage.py new file mode 100644 index 0000000..a4d78c5 --- /dev/null +++ b/JAS/cogs/manage.py @@ -0,0 +1,610 @@ +import discord, os +from discord import Permissions, Color, PermissionOverwrite +from discord.abc import GuildChannel +from JAS.resources.Exceptions import * +from JAS.resources.Base import commands, app_commands, CommandBase, AppCommandBase, Embeds, Views +from JAS.resources.Addons import resource_path + +class Manage(CommandBase): + @commands.Cog.listener() + async def on_member_join(self, member:discord.Member): + guild_id = member.guild.id + await self.__get_connection__(guild_id) + role = list(filter(lambda x:x.name==self.role.visitor, member.guild.roles))[0] + await member.add_roles(role) + + @commands.Cog.listener() + async def on_reaction_add(self, reaction:discord.Reaction, user:discord.Member): + if not user.bot and reaction.message.type == discord.MessageType.chat_input_command: + poll_msg = discord.utils.get(self.bot.cached_messages, id=reaction.message.id) + for reac in poll_msg.reactions: + async for reac_user in reac.users(): + if user == reac_user: + if str(reac.emoji) != str(reaction.emoji): + await poll_msg.remove_reaction(reac.emoji, user) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def sync(self, ctx:commands.Context): + try: + print('start sync') + waitmsg = await ctx.send(embed=Embeds.setting('/명령어 동기화를 시작합니다','잠시만 기다려 주세요')) + self.bot.tree.copy_global_to(guild=discord.Object(id=ctx.guild.id)) + comm_list = await self.bot.tree.sync(guild=ctx.guild) + print(comm_list) + print(f'sync complete on {ctx.guild.name}') + await waitmsg.edit(embed=Embeds.setting('/명령어가 동기화 되었습니다','지금부터 /명령어를 사용할 수 있습니다.')) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def total_reset(self, ctx:commands.Context): + msg:discord.Message = await ctx.send(embed=Embeds.setting('/명령어 초기화를 시작합니다','잠시만 기다려 주세요')) + self.bot.tree.clear_commands(guild=None) + await self.bot.tree.sync(guild=None) + for guild in self.bot.guilds: + self.bot.tree.clear_commands(guild=guild) + await self.bot.tree.sync(guild=guild) + await self.bot.reload_extensions(self) + print(list(map(lambda x: x.name, self.bot.commands))) + print(list(map(lambda x: x.name, await self.bot.tree.fetch_commands(guild=None)))) + await msg.edit(embed=Embeds.setting('/명령어가 초기화 되었습니다')) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def showDB(self, message:commands.Context, + category:str=None): + categories = [ + "setting", + "user", + "fishing", + "gather", + "items", + "merge", + "inventory", + "store", + "chara", + "npc", + ] + if category and category not in categories: + return await message.send(embed=Embeds.setting(f'사용 가능한 목록은 다음과 같습니다.{", ".join(categories)}')) + DB_data = self.connector.data + print(DB_data) + if category: + index = categories.index(category) + contents = [DB_data.showDB()[index]] + else: + contents = DB_data.showDB() + + for content in contents: + if len(content) > 1990: + rowmsg = '' + for row in content.split('\n'): + if len(rowmsg) + len(row) > 1980: + await message.send(f'```{rowmsg}```') + rowmsg = '' + rowmsg = rowmsg + row + '\n' + if rowmsg: + await message.send(f'```{rowmsg}```') + else: + await message.send(f'```{content}```') + + @commands.command(name="업데이트", hidden=True) + @commands.has_any_role('관리자') + async def update_data(self, ctx:commands.Context): + try: + guild_id = ctx.guild.id + + self.bot.var_manage[guild_id].data = self.bot.var_manage[guild_id].__get_data__() + await ctx.send(embed=Embeds.general('','업데이트가 완료되었습니다')) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(hidden=True) + @commands.has_any_role('관리자') + async def 서버정보(self, ctx:commands.Context): + """ + 현재 서버 정보를 표시합니다. + 표시되는 정보: 서버 ID + """ + await ctx.send(f'현재 서버 ID는 `{ctx.guild.id}`입니다.') + + async def get_user_list(self, data:list[discord.Member]): + result = [] + async for user in data: + if not user.bot: + name = user.display_name + id = user.id + result.append([id, name]) + return result + + #회원가입 + @app_commands.command(name="가입", description="커뮤에서 활동하기 위해 회원가입을 진행합니다. 가입 혀용 상태에서만 가능합니다.") + async def register_slash(self, interaction:discord.Interaction): + """ + 회원가입을 진행합니다. + 회원가입은 가입허용 상태에서만 진행 가능합니다. + """ + await self.on_ready(interaction, check_user=False, check_chara=False) + try: + if self.setting.accept_user: + username = interaction.user.display_name + user_id = interaction.user.id + + if self.connector.check_user(user_id): + await interaction.response.send_message(embed=Embeds.setting('이미 등록된 사용자입니다.'), ephemeral=True) + else: + reg_role = list(filter(lambda x:x.name==self.role.registered, interaction.guild.roles))[0] + visit_role = list(filter(lambda x:x.name==self.role.visitor, interaction.guild.roles))[0] + await interaction.user.remove_roles(visit_role) + await interaction.user.add_roles(reg_role) + id = list(filter(lambda x: x.name == self.channel.anon, interaction.guild.text_channels))[0].id + private = await interaction.guild.get_channel(id).create_thread( + name=f'{interaction.user.display_name} 전용 익명방', + type=discord.ChannelType.private_thread, + message=None, + invitable=False) + await private.send(embed=Embeds.guide_user(username)) + private.id + self.connector.set_user(user_id, username, private.id) + await private.add_user(interaction.user) + await interaction.response.send_message(embed=Embeds.setting(f'회원가입이 완료되었습니다. 환영합니다 {username}님!')) + else: + await interaction.delete_original_response() + await interaction.channel.send(embed=Embeds.warning('현재는 회원가입을 할 수 없습니다','허용이 되기 전까지 기다려 주시기 바랍니다.')) + except ConnectionError as e: + self.bot.logger.warn(e,'Error: register failed') + await interaction.response.send_message(embed=Embeds.warning('등록 중 오류가 발생하였습니다. 잠시 후 다시 시도해 주시기 바랍니다.')) + except Exception as e: + self.bot.logger.error(e) + + # 회원탈퇴 + @app_commands.command(name="탈퇴", description="탈퇴하여 회원정보를 삭제합니다. 해당 명령어는 복구할 수 없습니다.") + async def delete_user_slash(self, interaction:discord.Interaction): + """ + 탈퇴를 진행합니다. + 회원정보가 모두 삭제됩니다. + """ + await self.on_ready(interaction, check_chara=False) + try: + name = interaction.user.display_name + # thread = list(filter(lambda x: x.name == f"{name} 전용 익명방", interaction.guild.threads)) + thread = self.user[interaction.user.id].thread + for role in interaction.user.roles: + if role.name != '@everyone' and role.name != self.role.admin: + await interaction.user.remove_roles(role) + visitor = discord.utils.get(interaction.guild.roles, name=self.role.visitor) + await interaction.user.add_roles(visitor) + if thread: + await interaction.guild.get_channel_or_thread(thread).delete() + else: + print('thread not found') + self.connector.delete_user(interaction.user.id) + await interaction.response.send_message(embed=Embeds.setting(f"{name}의 탈퇴절차가 완료되었습니다."), ephemeral=True) + except Exception as e: + self.bot.logger.error(e) + + # 회원목록 + @commands.command() + async def 회원목록(self, ctx:commands.Context): + """ + 현재 서버 내의 유저 목록을 보여줍니다. + """ + user_list = self.connector.get_user_list() + result = '' + for user in user_list: + result = result+'\n'+user[1] + print(result) + await ctx.send(embed=Embeds.general('현재 등록된 사용자 입니다.',result)) + + @app_commands.command(name="공지", description="임베드된 공지를 작성합니다.") + @app_commands.rename(title="제목",body="내용", pin="고정여부") + @app_commands.choices(pin=[ + app_commands.Choice(name='등록',value='Y'), + app_commands.Choice(name='미등록',value='N'), + ]) + @app_commands.checks.has_role('관리자') + async def notice(self, inter:discord.Interaction, title:str, body:str, pin:str='Y'): + """ + 임베드된 공지를 작성합니다. + """ + msg = await inter.channel.send(embed=Embeds.general(title, body)) + if pin == 'Y': await msg.pin() + await inter.response.send_message('공지를 게시하였습니다.',delete_after=3,ephemeral=True) + + @app_commands.command(name="문의", description="익명 문의를 남깁니다. 해당 문의에 대한 답변은 QnA에 등재됩니다.") + @app_commands.rename(args="문의내용", attachment="첨부파일") + @app_commands.describe(args="문의하고자 하는 내용", attachment="문의 내용과 관련된 첨부파일") + async def submit_ticket(self, interaction: discord.Interaction, args:app_commands.Range[str,1,2000], attachment:discord.Attachment=None): + """ + 익명문의를 진행합니다. + 관리자에게 문의하고픈 내용을 작성하여 전송한다. + """ + await self.on_ready(interaction, check_chara=False, check_user=False) + try: + comment = args + if attachment: + file = await attachment.to_file() + else: + file = None + channel = discord.utils.get(interaction.guild.text_channels, name=self.channel.manage) + await interaction.response.send_message(embed=Embeds.setting("접수가 완료되었습니다."),ephemeral=True, delete_after=5) + response_view = Views.Ticket(comment) + await channel.send(embed=Embeds.setting('익명문의가 접수되었습니다',comment), file=file, view=response_view) + await response_view.wait() + if response_view.cancel: return + answer = response_view.modal.content + qna_channel = discord.utils.get(interaction.guild.text_channels, name=self.channel.qna) + await qna_channel.send(embed=Embeds.anon_qna(comment, answer)) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(name="봇이름", hidden=True) + @commands.has_any_role('관리자') + @commands.has_permissions(change_nickname=True) + async def change_bot_nick(self, ctx:commands.Context, + nick=commands.parameter(displayed_name="닉네임", description="표시할 봇의 이름")): + """ + 봇의 이름을 변경합니다. + 실제 이름이 아닌 표시되는 이름이 변경됩니다. + """ + if not nick: + return await ctx.send(embed=Embeds.general('닉네임이 입력되지 않았습니다','봇이 사용할 닉네임을 입력해 주시기 바랍니다.'), delete_after=10) + else: + await ctx.guild.me.edit(nick=nick) + await ctx.send(embed=Embeds.setting('봇의 이름이 변경되었습니다', f'이제부터 저를 {nick}이라고 불러주세요.')) + + async def set_channel_permission(self, channel:discord.TextChannel, overite:dict[discord.Role, discord.PermissionOverwrite]): + for role in list(overite.keys()): + await channel.set_permissions(role, overwrite=overite[role]) + + + @commands.command(name="관리", hidden=True) + @commands.has_any_role('관리자') + async def manage(self, ctx:commands.Context, *, + args:str=commands.parameter(displayed_name="메뉴명-",default=None, description="각 관리 카테고리. 바로 해당 카테고리로 이동 가능하다.")): + """ + !관리자 전용 + 서버 내 혹은 커뮤 봇의 각종 설정을 관리합니다. + + 사용법 + !관리 (메뉴명) + """ + try: + selectembeds={ + None : Embeds.setting('관리자 설정을 시작합니다','원하시는 대상을 선택해 주세요.'), + "유저" : Embeds.setting('유저 관리','각 유저에 대한 관리를 진행합니다.','[관리 ▶ 유저]'), + "유저 정보" : Embeds.setting('유저 정보 확인','확인하고자 하는 사용자를 선택해 주세요','[관리 ▶ 유저 ▶ 정보]'), + "인벤토리" : Embeds.setting('인벤토리 관리', '각 유저의 인벤토리를 확인하고 내용을 수정랍니다.','[관리 ▶ 인벤토리]'), + "인벤토리 골드" : Embeds.setting('유저 골드 추가', '골드를 지급할 유저와 금액을 선택해 주세요.','[관리 ▶ 인벤토리 ▶ 골드]'), + "인벤토리 크기" : Embeds.setting('유저 인벤토리 추가', '인벤토리 크기를 조정할 유저를 선택해 주세요','[관리 ▶ 인벤토리 ▶ 크기]'), + "스탯" : Embeds.setting('스탯 관리', '스탯과 관련된 관리를 진행합니다.','[관리 ▶ 스탯]'), + "스탯 변경" : Embeds.setting('스탯 변경', '스탯 명을 변경하시겠습니까?\n`입력방법:(스탯명),(스탯명),...`\n스탯을 사용하지 않는다면 공란으로 비워주시기 바랍니다.','[관리 ▶ 스탯 ▶ 변경]'), + "시스템" : Embeds.setting('시스템 관리', '봇의 각종 수치를 변경하고 확인합니다.','[관리 ▶ 시스템]'), + "시스템 확인" : Embeds.show_system("현재 설정을 표시합니다.", self.setting,'[관리 ▶ 시스템 ▶ 확인]'), + "시스템 회원가입" : Embeds.user_accept(self.connector,'[관리 ▶ 시스템 ▶ 회원가입]'), + "시스템 변경" : Embeds.setting('시스템 기본수치 변경', '변경할 설정 값을 선택해 주세요.','[관리 ▶ 시스템 ▶ 변경]'), + } + selectviews={ + None : Views.ManageView(), + "유저" : Views.UserManageView(), + "유저 정보": Views.UserInfo(self.connector, await self.get_user_list(ctx.guild.fetch_members(limit=None))), + "인벤토리" : Views.InventoryManageView(self.connector), + "인벤토리 골드" : Views.InventoryGoldChange(self.connector, await self.get_user_list(ctx.guild.fetch_members(limit=None))), + "인벤토리 크기" : Views.InventorySizeChange(self.connector, await self.get_user_list(ctx.guild.fetch_members(limit=None))), + "스탯" : Views.StatManageView(self.connector), + "스탯 변경" : Views.StatNameChange(self.connector), + "시스템" : Views.SystemManageView(), + "시스템 확인" : None, + "시스템 회원가입" : Views.RegView(self.connector), + "시스템 변경" : Views.ChangeSettingView(self.connector), + } + embeded=selectembeds[args] #or discord.Embed() + messageview=selectviews[args] #or view.ManageView() + + msg = await ctx.send(embed=embeded, view=messageview) + while True: + await msg.edit(embed=embeded, view=messageview) + if messageview: + await messageview.wait() + print(f'result: {messageview.result} | cancel: {messageview.cancel}') + if messageview.cancel: + await msg.delete() + break + if not messageview.result: + break + else: + break + key = messageview.result + embeded = selectembeds[key] + messageview = selectviews[key] + except KeyError as e: + return await ctx.send(embed=Embeds.warning('존재하지 않는 메뉴명입니다', f'사용 가능한 메뉴명은 다음과 같습니다.\n{", ".join(list(filter(lambda x: x ,selectembeds.keys())))}')) + except Exception as e: + self.bot.logger.error(e) + + # 커뮤 구축 + @commands.command(name="서버구축", hidden=True) + @commands.has_permissions(manage_channels=True, manage_guild=True, manage_messages=True, manage_roles=True) + @commands.bot_has_guild_permissions(manage_channels=True, manage_guild=True, manage_messages=True, manage_roles=True) + async def setup_guild(self, ctx:commands.Context, + arg=commands.parameter(displayed_name="재구축:", default=None, description="서버 재구축시 사용.")): + """ + 최초 서버 설정시 사용. + """ + print('start') + main_msg = await ctx.send(embed=Embeds.setting("서버구축을 시작합니다","대략 2~3분 정도의 시간이 소요되오니 잠시만 기다려 주세요.")) + guild = ctx.guild + cog_list = list(self.bot.cogs.keys()) + try: + channels = list(map(lambda x: x.name , guild.text_channels)) + categories = list(map(lambda x: x.name , guild.categories)) + forums = list(map(lambda x: x.name , guild.forums)) + roles = list(map(lambda x: x.name , guild.roles)) + print(f''' +{channels} +{categories} +{forums} +{roles} + ''') + + # 역할 설정 + everyone_permission = Permissions(view_channel=True, read_message_history=False) + + await guild.default_role.edit(permissions=everyone_permission) + + if "관리자" not in roles: + admin = await guild.create_role( + name="관리자", + color=Color.gold(), + permissions=Permissions.all(), + hoist=True, + mentionable=True) + ctx.author.add_roles(admin) + else: + admin = list(filter(lambda x: x.name=="관리자", guild.roles))[0] + await admin.edit(permissions=Permissions.all()) + + await ctx.author.add_roles(admin) + + if "가입자" not in roles: + user = await guild.create_role( + name="가입자",color=Color.green(), + permissions=Permissions(448891571264), + hoist=True) + else: + user = list(filter(lambda x: x.name=="가입자", guild.roles))[0] + await user.edit(permissions=Permissions(448891571264)) + + if "방문자" not in roles: + visitor = await guild.create_role( + name="방문자", + color=Color.greyple(), + permissions=Permissions.none()) + else: + visitor = list(filter(lambda x: x.name=="방문자", guild.roles))[0] + await visitor.edit(permissions=Permissions.none()) + + bot_role = discord.utils.get(guild.roles, name='JAS') + + # 권한 설정 + admin_overwrites = { + bot_role:PermissionOverwrite(administrator=True), + visitor:PermissionOverwrite(view_channel=False), + user:PermissionOverwrite(view_channel=False), + guild.default_role:PermissionOverwrite(view_channel=False), + } + announce_overwrite={ + visitor:PermissionOverwrite(view_channel=True, + read_message_history=True), + user:PermissionOverwrite(view_channel=True, + read_message_history=True, + send_messages=False), + } + general_overwrites={ + visitor:PermissionOverwrite(view_channel=False), + user:PermissionOverwrite(view_channel=True, + read_messages=True, + send_messages=True, + use_application_commands=True, + read_message_history=True, + embed_links=True, + attach_files=True, + add_reactions=True, + send_messages_in_threads=True), + } + join_overites={ + user:PermissionOverwrite(view_channel=False), + visitor:PermissionOverwrite(view_channel=True, + send_messages=True, + use_application_commands=True), + } + anon_overwrites={ + visitor:PermissionOverwrite(view_channel=False), + user:PermissionOverwrite(view_channel=True, + send_messages=False, + read_message_history=True, + use_application_commands=True, + attach_files=True, + send_messages_in_threads=True), + } + community_overwrites={ + visitor:PermissionOverwrite(view_channel=False), + user:PermissionOverwrite(view_channel=True, + send_messages=False, + read_message_history=True, + use_application_commands=True, + create_public_threads=True, + embed_links=True, + attach_files=True, + add_reactions=True, + send_messages_in_threads=True), + } + + # 카테고리 확인 + if "일반 채팅" not in categories: + chat_cat = await guild.create_category(name="일반 채팅", position=1, overwrites=general_overwrites) + else: + chat_cat = discord.utils.get(guild.categories, name="일반 채팅") + await self.set_channel_permission(chat_cat, general_overwrites) + if "공지사항" not in categories: + notice_cat = await guild.create_category(name="공지사항", position=0, overwrites=announce_overwrite) + else: + notice_cat = discord.utils.get(guild.categories, name="공지사항") + await self.set_channel_permission(notice_cat, announce_overwrite) + if self.channel.community not in categories and 'Community' in cog_list: + comm_cat = await guild.create_category(name=self.channel.community, position=2, overwrites=community_overwrites) + else: + comm_cat = discord.utils.get(guild.categories, name=self.channel.community) + await self.set_channel_permission(comm_cat, community_overwrites) + if 'Fishing' in cog_list: + if "낚시터" not in categories: + fishing_cat = await guild.create_category(name="낚시터", position=3) + else: + fishing_cat = discord.utils.get(guild.categories, name="낚시터") + await fishing_cat.edit(overwrites=general_overwrites) + if 'Gather' in cog_list: + if "채집터" not in categories: + gather_cat = await guild.create_category(name="채집터", position=4) + else: + gather_cat = discord.utils.get(guild.categories, name="채집터") + await gather_cat.edit(overwrites=general_overwrites) + if "관리자" not in categories: + admin_cat = await guild.create_category(name="관리자", position=5, overwrites=admin_overwrites) + else: + admin_cat = discord.utils.get(guild.categories, name="관리자") + await admin_cat.edit(overwrites=admin_overwrites) + + # 관리 생성 + if self.channel.manage not in channels: + administor = await guild.create_text_channel(name=self.channel.manage, category=admin_cat) + else: + administor = discord.utils.get(guild.text_channels, name=self.channel.manage) + # id = list(filter(lambda x: x.name == self.channel.manage, guild.text_channels))[0].id + # administor = guild.get_channel(id) + + # 공지 생성 + if "공지" not in channels: + public = await guild.create_text_channel(position=0, name="공지", category=notice_cat) + else: + public = discord.utils.get(guild.text_channels, name="공지") + await public.move(category=notice_cat, beginning=True) + # 규칙 생성 + if "규칙" not in channels: + rules = await guild.create_text_channel(position=1, name="규칙", category=notice_cat) + else: + rules = discord.utils.get(guild.text_channels, name="규칙") + await rules.move(category=notice_cat, beginning=True, offset=1) + # 세계관 생성 + if "세계관" not in channels: + world = await guild.create_text_channel(position=2, name="세계관", category=notice_cat) + else: + world = discord.utils.get(guild.text_channels, name="세계관") + await world.move(category=notice_cat, beginning=True, offset=2) + # 가입 생성 + if "가입" not in channels: + join = await guild.create_text_channel(position=3, name=self.channel.join, category=notice_cat, reason="/가입을 진행해 주세요", overwrites=join_overites) + else: + join = discord.utils.get(guild.text_channels, name=self.channel.join) + await join.edit(overwrites=join_overites) + await join.move(category=notice_cat, beginning=True, offset=3) + # QnA 생성 + if "질의응답" not in channels: + qna = await guild.create_text_channel(position=4,name="질의응답", category=notice_cat) + else: + qna = discord.utils.get(guild.text_channels, name="질의응답") + await qna.move(category=notice_cat, beginning=True, offset=4) + # 익명 + if "오너게시판" not in channels: + author = await guild.create_text_channel(name="오너게시판", category=chat_cat, + reason="개인 스레드에서 보낸 익명 메세지가 모이는 곳입니다.\n개인 스레드가 보이지 않는다면 우측 상단의 스레드를 통해 확인 바랍니다.") + msg = await author.send("""> ## 오너게시판 +> +> 자유롭게 대화할 수 있는 게시판 입니다. 텍관모집, 이벤트 조율 등등의 내용을 진핼할 때 이용 가능합니다. +""") + await msg.pin() + else: + author = discord.utils.get(guild.text_channels, name="오너게시판") + await author.move(category=chat_cat, beginning=True) + # 익명 + if "익명게시판" not in channels: + anon = await guild.create_text_channel(name="익명게시판", category=chat_cat, + reason="개인 스레드에서 보낸 익명 메세지가 모이는 곳입니다.\n개인 스레드가 보이지 않는다면 우측 상단의 스레드를 통해 확인 바랍니다.", + overwrites=anon_overwrites) + else: + anon = discord.utils.get(guild.text_channels, name=self.channel.anon) + await anon.edit(overwrites=anon_overwrites) + await anon.move(category=chat_cat, beginning=True, offset=1) + # 한역 + if "캐입역극" not in channels: + comm = await guild.create_text_channel(name="캐입역극", category=chat_cat, + reason="캐입역극을 진행 가능합니다.(=탐라대화)\n개인 간의 한역을 위한 스레드 생성 시 원하는 대화에 답글을 작성한 뒤 해당 답글을 바탕으로 스레드를 생성하시길 바랍니다.",) + else: + comm = discord.utils.get(guild.text_channels, name="캐입역극") + await comm.move(category=comm_cat, beginning=True) + # 상점 + if 'Store' in cog_list: + if "상점" not in channels: + store = await guild.create_text_channel(name="상점", category=chat_cat) + else: + store = discord.utils.get(guild.text_channels, name="상점") + await store.move(category=chat_cat, beginning=True, offset=2) + # 낚시터 + if 'Fishing' in cog_list: + if "기본-낚시터" not in channels: + fishing = await guild.create_text_channel(name="기본-낚시터", category=fishing_cat, reason="낚시 채널입니다. /낚시로 낚시를 시작해 보세요!") + msg = await fishing.send(content='''> ## 낚시 + > + > `/낚시` 명령어를 통해 낚시를 진행할 수 있습니다. + > `/물고기목록` 명령어를 통해 낚을 수 있는 물고기를 확인해 보세요! + ''') + await msg.pin() + else: + fishing = discord.utils.get(guild.text_channels, name="기본-낚시터") + await fishing.move(category=fishing_cat, beginning=True) + # 채집터 + if 'Gather' in cog_list: + if "기본-채집터" not in channels: + gather = await guild.create_text_channel(name="기본-채집터", category=gather_cat, reason="채집용 채널입니다! /채집을 진행해 보세요!") + msg = await gather.send(content='''> ## 채집 + > + > `/채집` 명령어를 통해 채집을 진행할 수 있습니다. + > `/채집목록` 명령어를 통해 수집 가능한 아이템을 확인해 보세요! + ''') + await msg.pin() + else: + gather = discord.utils.get(guild.text_channels, name="기본-채집터") + await gather.move(category=gather_cat, beginning=True) + + try: + # 조사게시판? + # 가입 가이드라인 출력 + await guild.edit( + community = True, + rules_channel=rules, + public_updates_channel=administor, + preferred_locale=discord.Locale.korean) + # 한역 생성 + if "토론" not in forums: + tags = ( + discord.ForumTag(name="마감"), + discord.ForumTag(name="이어가요"), + discord.ForumTag(name="중단해요") + ) + topic = """ + 토론을 진행할 수 있는 공간입니다. + """ + talk = await guild.create_forum(name="토론", topic=topic, category=chat_cat, available_tags=tags) + else: + id = list(filter(lambda x: x.name == "토론", guild.forums))[0].id + talk = guild.get_channel(id) + + await main_msg.edit(embed=Embeds.setting("서버구축이 완료되었습니다")) + except: + await main_msg.edit(embed=Embeds.setting("서버구축이 일부 완료되었습니다",'커뮤니티 설정이 완료되지 않았습니다.\n커뮤니티 기능을 사용하길 원하신다면 커뮤니티를 활성화 한 후 `!서버구축`을 실행해 주시기 바랍니다.')) + except Exception as e: + self.bot.logger.error(e) + +async def setup(bot:commands.Bot): + await bot.add_cog(Manage(bot)) \ No newline at end of file diff --git a/JAS/cogs/stat.py b/JAS/cogs/stat.py new file mode 100644 index 0000000..c8bdc00 --- /dev/null +++ b/JAS/cogs/stat.py @@ -0,0 +1,207 @@ +import discord +from JAS.resources.Exceptions import * +from JAS.resources.Base import commands, app_commands, CommandBase, AppCommandBase, Embeds, Views +from asyncio import sleep + +class Stat(CommandBase): + @app_commands.command(name="스탯", description="캐릭터의 스탯분배를 진행합니다.") + async def stat(self, interaction:discord.Interaction): + """ + 캐릭터의 스탯분배를 진행합니다. + """ + await self.on_ready(interaction) + try: + await interaction.response.defer(ephemeral=True) + code = self.user[interaction.user.id].charas + chara = self.charas[code] + stat_names = chara.stat.stat_names or self.setting.stat_names + point = chara.stat.point + base_view = Views.SpendStat(interaction.user) + stat1_view = Views.AllStat(1,chara.stat,interaction.user) + stat2_view = Views.AllStat(2,chara.stat,interaction.user) + base = await interaction.channel.send(embed=Embeds.setting("스탯포인트를 소모하여 스탯을 조정합니다", f"남은 포인트: {point}"), view = base_view) + stat1 = await interaction.channel.send(view = stat1_view) + stat2 = await interaction.channel.send(view = stat2_view) + process=True + while process: + if base_view.cancel or base_view.finish: + print("delete stat view") + await stat1.delete() + await stat2.delete() + process = False + else: + changed = False + if stat1_view.stat1_sub.clicked: + print("stat1 sub") + stat1_view.stat1_sub.clicked=False + if stat1_view.stat1_value > stat1_view.stat1_origin: + stat1_view.stat1_value -= 1 + stat1_view.point += 1 + stat1_view.stat1_current.label=f"{stat_names[0]}: {stat1_view.stat1_value}" + point = stat1_view.point + changed = True + if stat1_view.stat1_add.clicked: + print("stat1 add") + stat1_view.stat1_add.clicked=False + if stat1_view.point > 0: + stat1_view.stat1_value += 1 + stat1_view.point -= 1 + stat1_view.stat1_current.label=f"{stat_names[0]}: {stat1_view.stat1_value}" + point = stat1_view.point + changed = True + elif stat1_view.stat2_sub.clicked: + print("stat2 sub") + stat1_view.stat2_sub.clicked=False + if stat1_view.stat2_value > stat1_view.stat2_origin: + stat1_view.stat2_value -= 1 + stat1_view.point += 1 + stat1_view.stat2_current.label=f"{stat_names[1]}: {stat1_view.stat2_value}" + point = stat1_view.point + changed = True + elif stat1_view.stat2_add.clicked: + print("stat2 add") + stat1_view.stat2_add.clicked=False + if stat1_view.point > 0: + stat1_view.stat2_value += 1 + stat1_view.point -= 1 + stat1_view.stat2_current.label=f"{stat_names[1]}: {stat1_view.stat2_value}" + point = stat1_view.point + changed = True + elif stat1_view.stat3_sub.clicked: + print("stat3 sub") + stat1_view.stat3_sub.clicked=False + if stat1_view.stat3_value > stat1_view.stat3_origin: + stat1_view.stat3_value -= 1 + stat1_view.point += 1 + stat1_view.stat3_current.label=f"{stat_names[2]}: {stat1_view.stat3_value}" + point = stat1_view.point + changed = True + elif stat1_view.stat3_add.clicked: + print("stat3 add") + stat1_view.stat3_add.clicked=False + if stat1_view.point > 0: + stat1_view.stat3_value += 1 + stat1_view.point -= 1 + stat1_view.stat3_current.label=f"{stat_names[2]}: {stat1_view.stat3_value}" + point = stat1_view.point + changed = True + elif stat2_view.stat4_sub.clicked: + print("stat4 sub") + stat2_view.stat4_sub.clicked=False + if stat2_view.stat4_value > stat2_view.stat4_origin: + stat2_view.stat4_value -= 1 + stat2_view.point += 1 + stat2_view.stat4_current.label=f"{stat_names[3]}: {stat2_view.stat4_value}" + point = stat2_view.point + changed = True + elif stat2_view.stat4_add.clicked: + print("stat4 add") + stat2_view.stat4_add.clicked=False + if stat2_view.point > 0: + stat2_view.stat4_value += 1 + stat2_view.point -= 1 + stat2_view.stat4_current.label=f"{stat_names[3]}: {stat2_view.stat4_value}" + point = stat2_view.point + changed = True + elif stat2_view.stat5_sub.clicked: + print("stat5 sub") + stat2_view.stat5_sub.clicked=False + if stat2_view.stat5_value > stat2_view.stat5_origin: + stat2_view.stat5_value -= 1 + stat2_view.point += 1 + stat2_view.stat5_current.label=f"{stat_names[4]}: {stat2_view.stat5_value}" + point = stat2_view.point + changed = True + elif stat2_view.stat5_add.clicked: + print("stat5 add") + stat2_view.stat5_add.clicked=False + if stat2_view.point > 0: + stat2_view.stat5_value += 1 + stat2_view.point -= 1 + stat2_view.stat5_current.label=f"{stat_names[4]}: {stat2_view.stat5_value}" + point = stat2_view.point + changed = True + elif stat2_view.stat6_sub.clicked: + print("stat6 sub") + stat2_view.stat6_sub.clicked=False + if stat2_view.stat6_value > stat2_view.stat6_origin: + stat2_view.stat6_value -= 1 + stat2_view.point += 1 + stat2_view.stat6_current.label=f"{stat_names[5]}: {stat2_view.stat6_value}" + point = stat2_view.point + changed = True + elif stat2_view.stat6_add.clicked: + print("stat6 add") + stat2_view.stat6_add.clicked=False + if stat2_view.point > 0: + stat2_view.stat6_value += 1 + stat2_view.point -= 1 + stat2_view.stat6_current.label=f"{stat_names[5]}: {stat2_view.stat6_value}" + point = stat2_view.point + changed = True + if changed: + print("value changed") + stat1_view.point = point + stat2_view.point = point + # disable subtract button + stat1_view.stat1_sub.disabled = True if stat1_view.stat1_origin == stat1_view.stat1_value else False + stat1_view.stat2_sub.disabled = True if stat1_view.stat2_origin == stat1_view.stat2_value else False + stat1_view.stat3_sub.disabled = True if stat1_view.stat3_origin == stat1_view.stat3_value else False + stat2_view.stat4_sub.disabled = True if stat2_view.stat4_origin == stat2_view.stat4_value else False + stat2_view.stat5_sub.disabled = True if stat2_view.stat5_origin == stat2_view.stat5_value else False + stat2_view.stat6_sub.disabled = True if stat2_view.stat6_origin == stat2_view.stat6_value else False + # disable add button + stat1_view.stat1_add.disabled=True if point ==0 else False + stat1_view.stat2_add.disabled=True if point ==0 else False + stat1_view.stat3_add.disabled=True if point ==0 else False + stat2_view.stat4_add.disabled=True if point ==0 else False + stat2_view.stat5_add.disabled=True if point ==0 else False + stat2_view.stat6_add.disabled=True if point ==0 else False + await base.edit(embed=Embeds.setting("스탯", f"남은 포인트: {point}")) + await stat1.edit(view=stat1_view) + await stat2.edit(view=stat2_view) + await sleep(0.1) + await interaction.delete_original_response() + if base_view.finish: + point = stat1_view.point + stat1 = stat1_view.stat1_value + stat2 = stat1_view.stat2_value + stat3 = stat1_view.stat3_value + stat4 = stat2_view.stat4_value + stat5 = stat2_view.stat5_value + stat6 = stat2_view.stat6_value + # stat = f'{stat1},{stat2},{stat3},{stat4},{stat5},{stat6}' + self.connector.update_stat(code, stat_names, point, [stat1, stat2, stat3, stat4, stat5, stat6]) + await base.edit(embed=Embeds.show_stat_result(chara), view=None) + except Exception as e: + self.bot.logger.error(e) + + @commands.command(name="스탯초기화", hidden=True) + @commands.has_any_role('관리자') + async def clear_stat(self, ctx:commands.Context, user:discord.Member=None): + """ + 스탯을 초기화 합니다. + 전체 스탯을 1로 만들며 이미 투자한 스탯은 포인트로 전환됩니다. + """ + try: + if not user: + return await ctx.send(embed=Embeds.general('초기화를 진행할 사용자를 입력해 주시기 바랍니다')) + code = self.user[user.id].charas + chara = self.charas[code] + stat = chara.stat + all_stat = stat.stat1+stat.stat2+stat.stat3+stat.stat4+stat.stat5+stat.stat6 + point = stat.point + all_stat-6 + stat.stat1 = 1 + stat.stat2 = 1 + stat.stat3 = 1 + stat.stat4 = 1 + stat.stat5 = 1 + stat.stat6 = 1 + + self.connector.update_stat(code, stat.stat_names, point, 1,1,1,1,1,1) + await ctx.send(embed=Embeds.setting("스탯을 초기화 하였습니다.", f"총 스탯포인트: {point}")) + except Exception as e: + self.bot.logger.error(e) + +async def setup(bot:commands.Bot): + await bot.add_cog(Stat(bot)) \ No newline at end of file diff --git a/JAS/cogs/store.py b/JAS/cogs/store.py new file mode 100644 index 0000000..27f759a --- /dev/null +++ b/JAS/cogs/store.py @@ -0,0 +1,75 @@ +import discord +from JAS.resources.Exceptions import * +from JAS.resources.Base import commands, app_commands, CommandBase, AppCommandBase, Embeds, Views +from random import random, randrange, choice +from asyncio import sleep + +class Store(CommandBase): + @app_commands.command(name="랜덤박스", description="일정 금액을 소비하여 랜덤뽑기를 진행합니다.") + async def random_box(self, interaction:discord.Interaction): + """ + 일정 금액을 소비하여 뽑기를 진행합니다. + """ + await self.on_ready(interaction) + result_text = [ + "잭팟!\n넣은 금액의 네배를 얻습니다.", + "2등상!\n넣은 금액의 두배가 나왔습니다.", + "3등상\n본전은 찾았습니다.", + "참가상\n사용한 금액의 반을 돌려 받습니다.", + "꽝!\n랜덤박스는 잠잠합니다...", + ] + try: + price = self.setting.random_box + code = self.user[interaction.user.id].charas + gold = self.invs[code].gold + if price > gold: + return await interaction.response.send_message(embed=Embeds.general('금액이 부족합니다',f'현재 소지금은 {gold}입니다.'), ephemeral=True) + else: + self.connector.add_gold(code, -price) + dice = randrange(0,100) + if dice < 1: + result_title = "만세!" + result = result_text[0] + result_color = discord.Color.gold() + self.connector.add_gold(code, price*4) + elif dice < 5: + result_title = "야호!" + result = result_text[1] + result_color = discord.Color.green() + self.connector.add_gold(code, price*2) + elif dice < 20: + result_title = "괜찮아!" + result = result_text[2] + result_color = discord.Color.green() + self.connector.add_gold(code, price) + elif dice < 60: + result_title = "이런..." + result = result_text[3] + result_color = discord.Color.light_grey() + self.connector.add_gold(code, int(price/2)) + else: + result_title = "..." + result = result_text[4] + result_color = discord.Color(0) + + title_list = [ + "힘차게 돌아갑니다!", + "덜컹덜컹 돌아갑니다", + "힘없이 돌아갑니다...", + "수상한 소음을 내며 돌아갑니다...?", + ] + title = f"랜덤박스가 {choice(title_list)}" + embed_msg = Embeds.random_box(title, 0) + await interaction.response.send_message(embed=embed_msg) + for i in range(1,8): + embed_msg = Embeds.random_box(title, i) + await interaction.edit_original_response(embed=embed_msg) + await sleep(0.5) + await interaction.edit_original_response(embed=Embeds.general(result_title, result, result_color)) + except Exception as e: + self.bot.logger.error(e) + +async def setup(bot:commands.Bot): + await bot.add_cog(Store(bot)) + + diff --git a/JAS/mailing.py b/JAS/mailing.py new file mode 100644 index 0000000..3457d47 --- /dev/null +++ b/JAS/mailing.py @@ -0,0 +1,63 @@ +import smtplib, ssl, os +from email.header import Header +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication + +if __name__ != '__main__': + from COMUBOT.resources.Addons import MAIN_PATH +else: + from resources.Addons import MAIN_PATH + +class Mailer(): + def __init__(self, host, port,mail_ID, mail_PW) -> None: + smtp = smtplib.SMTP(host=host, port=port) + smtp.starttls() + self.sender = smtp + self.ID = mail_ID + self.PW = mail_PW + + def login(self): + self.sender.login(self.ID, self.PW) + + def exit(self): + self.sender.quit() + + def send_email(self, body_type, mail_body, mail_subject="[NOTICE] 디코봇으로 부터 발송된 메일입니다."): + self.login() + if body_type == 0: + body = MIMEText(mail_body, 'plain', 'utf-8') + elif body_type == 1: + body = MIMEText(mail_body, 'html', 'utf-8') + else: + raise + + message = MIMEMultipart() + message["subject"] = Header(s=mail_subject, charset='utf-8') + message["FROM"] = self.ID + message["TO"] = self.ID + message.attach(body) + self.get_log(message) + + self.sender.sendmail(self.ID, self.ID, message.as_string()) + self.exit() + + def get_log(self, message:MIMEMultipart): + files = [os.path.join(MAIN_PATH, 'discord.log'), os.path.join(MAIN_PATH, 'discord_debug.log')] + + for file in files: + if os.path.exists(file): + attachment = MIMEApplication(open(file, 'rb').read()) + attachment.add_header('Content-Disposition', 'attachment', filename=file.split('\\')[-1]) + message.attach(attachment) + + +if __name__ == '__main__': + from resources.Addons import read_json + json_data = read_json() + SMTP_SERVER = json_data['SMTP_SERVER'] + SMTP_PORT = json_data['SMTP_PORT'] + MAIL_ID = json_data['MAIL_ID'] + MAIL_PW = json_data['MAIL_PW'] + mail = Mailer(SMTP_SERVER, SMTP_PORT, MAIL_ID, MAIL_PW) + mail.send_email(0,'test') diff --git a/JAS/resources/Addons.py b/JAS/resources/Addons.py new file mode 100644 index 0000000..1e741ab --- /dev/null +++ b/JAS/resources/Addons.py @@ -0,0 +1,137 @@ +import math, discord, re, json +import os, sys, shutil +from PIL import Image +from io import BytesIO +from random import randrange +from traceback import format_exc + +PRJ_NAME = 'JAS' +if sys.platform.startswith('win'): + MAIN_PATH = os.path.join(os.getenv('APPDATA'), PRJ_NAME) +else: + MAIN_PATH = os.path.join(os.getcwd(), '..', 'DATA') +CONFIG_PATH = os.path.join(MAIN_PATH, 'config.json') +DB_FOLDER_PATH = os.path.join(MAIN_PATH, 'DB') +IMG_FOLDER_PATH = os.path.join(MAIN_PATH, 'IMG') + +def resource_path(*path): + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + + return os.path.join(base_path, *path) + +def prep_env(): + for path in [MAIN_PATH, DB_FOLDER_PATH, IMG_FOLDER_PATH]: + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + if path == MAIN_PATH: + data = { + "TOKEN":"", + "PROFILE":"가동", + "TEST_SERVER_ID":"", + "AUTH_JSON_PATH":"", + "SMTP_SERVER":"smtp.gmail.com", + "SMTP_PORT":587, + "MAIL_ID":"", + "MAIL_PW":"", + } + write_json(data) + if path == IMG_FOLDER_PATH: + for filename in ['img/sample.png']: + shutil.copy(resource_path(filename), os.path.join(IMG_FOLDER_PATH,filename.split('/')[-1])) + +def read_json(): + with open(CONFIG_PATH, 'r', encoding='UTF8') as f: + json_data:dict[str, str] = json.load(f) + f.close() + return json_data + +def write_json(data): + with open(CONFIG_PATH, 'w', encoding='UTF8') as f: + json_data = json.dumps(data, indent=2) + f.writelines(json_data) + f.close() + +def currency(type, gold): + content = f"G {gold}" + return content + +def filename(str:str) -> str: + filterlist = ['?','/','\\',':','*','"','<','>'] + replaceist = ['|']*len(filterlist) + result = str.translate(str.maketrans(''.join(filterlist), ''.join(replaceist))).replace('|', '') + return result + +async def resize_img(attachment:discord.Attachment, path): + size = (300,300) + image = await attachment.read(use_cached=True) + + with Image.open(BytesIO(image)) as img: + img.thumbnail(size) + img.save(path) + +def key_gen(key_len=8): + """ + 8자의 랜덤한 키를 생성합니다. + + Args: + length: 키의 길이 (기본값: 8) + + Returns: + 8자의 랜덤한 키 + """ + chars = "abcdefghijklmnopqrstuvwxyz0123456789" + + key = '' + for _ in range(key_len): + key = key + chars[randrange(len(chars))] + return key + +def dice(count:int, num:int) -> list[int]: + """ + 주사위를 굴려 결괏값을 리스트로 반환받습니다. + + Args: + count: 주사위의 개수 + num: 주사위의 눈수 + + Returns: + 각각의 주사위 결과가 담긴 리스트 + """ + result = randrange(num)+1 + return [result] + dice(count-1, num) if count != 1 else [result] + +def desc_converter(str:str): + result = str + match_bold = re.compile('\[[^]]*]') + match_dice = re.compile('\[[0-9]+d[0-9]+\]') + # print(re.findall(match_dice, result)) + for text in re.findall(match_dice, result): + # print(text) + if text: + dice_count = int(text[1:-1].split('d')[0]) + dice_num = int(text[1:-1].split('d')[1]) + dice_result = sum(dice(dice_count, dice_num)) + dice_result = f'[{dice_result}]' + result = result.replace(text, dice_result, 1) + # print(result) + for text in re.findall(match_bold, result): + if text: + bold_result = f'[**`{text[1:-1]}`**]' + result = result.replace(text, bold_result) + # print(result) + return result + +def open_image(path) -> Image.Image: + img = Image.open(path) + return img + +def merge_image(base:Image.Image, input:Image.Image, x, y) -> Image.Image: + base.paste(input, (x, y), input) + return base + +def crop_icon(base:Image.Image, x, y, width) -> Image.Image: + icon = base.crop((x, y, x+width, y+width)) + return icon \ No newline at end of file diff --git a/JAS/resources/Base.py b/JAS/resources/Base.py new file mode 100644 index 0000000..2f6bca6 --- /dev/null +++ b/JAS/resources/Base.py @@ -0,0 +1,421 @@ +import discord, os +import logging, logging.handlers, multiprocessing +from traceback import format_exc +from discord import app_commands +from discord.ext import commands, tasks +from JAS.resources import Embeds, Views +from JAS.resources.Connector import Connector +import JAS.resources.Connector as Conn +from JAS.resources.Addons import MAIN_PATH, resource_path +from asyncio import sleep + +class Logger: + PROFILE = '' + + def __init__(self, logger:logging.Logger) -> None: + self.logger = logger + # self.bot.logger.info('=====bot.Logger======') + + def info(self, message): + self.logger.info(message) + if self.PROFILE != '테스트': + print(message) + + def debug(self, message): + self.logger.debug(message) + if self.PROFILE != '테스트': + print(message) + + def warn(self, exception, status='Unkown Error Occured'): + self.logger.warn(status) + self.logger.warn(str(exception)) + self.logger.warn(format_exc()) + if self.PROFILE != '테스트': + print('>> ',status) + print(exception) + print(format_exc()) + + def error(self, exception, status='Unkown Error Occured'): + self.logger.error(status) + self.logger.error(str(exception)) + self.logger.error(format_exc()) + if self.PROFILE != '테스트': + print('>> ',status) + print(exception) + print(format_exc()) + raise exception + +logger_tmp = logging.getLogger('discord') +logger_tmp.setLevel(logging.DEBUG) +logging.getLogger('discord.http').setLevel(logging.DEBUG) + +handler = logging.handlers.RotatingFileHandler( + filename=os.path.join(MAIN_PATH, 'discord.log'), + encoding='utf-8', + maxBytes=32 * 1024 * 1024, # 32 MiB + backupCount=5, # Rotate through 5 files +) +dt_fmt = '%Y-%m-%d %H:%M:%S' +formatter = logging.Formatter('[{asctime}] [{levelname:<8}] {name}: {message}', dt_fmt, style='{') +handler.setFormatter(formatter) +logger_tmp.addHandler(handler) + +logger = Logger(logger_tmp) + + +class JAS(commands.Bot): + rec_queue:multiprocessing.Queue=None + send_queue:multiprocessing.Queue=None + TOKEN:str = '' + AUTH_JSON_PATH:str = '' + PROFILE:str = '' + TEST_SERVER_ID:int = 0 + _version = 'demo' + is_quit = False + + def __init__(self, *, intents: discord.Intents, command_prefix:str, activity:discord.CustomActivity, logger): + self.var_manage:dict['server_id':int, Connector] = {} + self.var_registered:bool = False + self.var_proceed:bool = False + self.ver_cog_commands:dict['cog_name':str,list['command_name':str]] = {} + self.ver_admin_commands:list['command_name':str] = [] + super().__init__(intents=intents, command_prefix=command_prefix, activity=activity) + self.logger:Logger = logger + self.tree.error(coro=self.on_app_command_error) + + # 확장코드 등재 + async def load_extensions(self, bot:commands.Bot): + for filename in os.listdir(resource_path('JAS','cogs')): + if filename.startswith('setup') or filename.startswith("_"): + continue + if filename.endswith('.py'): + await bot.load_extension(f'JAS.cogs.{filename[:-3]}') + self.logger.info(f'load complete: {filename[:-3]}') + + # 확장코드 등재 + async def reload_extensions(self, bot:commands.Bot): + for filename in os.listdir(resource_path('JAS','cogs')): + if filename.startswith('setup') or filename.startswith("_"): + continue + if filename.endswith('.py'): + await bot.load_extension(f'JAS.cogs.{filename[:-3]}') + self.logger.info(f'reload complete: {filename[:-3]}') + + # 시작시 서버 ID 확보 및 DB 설정 + async def setup_hook(self): + self.logger.info('-'*30) + await self.load_extensions(self) + self.logger.info('base install complete') + self.logger.info('-'*30) + await sleep(1) + + for command in self.commands: + command_name = command.name + if command.checks: + self.ver_admin_commands.append(command_name) + if command.cog: + cog_name = command.cog_name + if cog_name not in list(self.ver_cog_commands.keys()): + self.ver_cog_commands[cog_name] = [] + self.ver_cog_commands[cog_name].append(command_name) + + self.logger.info('admin commands') + self.logger.info('-'*20) + self.logger.info(self.ver_admin_commands) + self.logger.info('-'*30) + await sleep(1) + self.logger.info('cog commands') + self.logger.info('-'*20) + for cog in self.ver_cog_commands: + self.logger.info(cog) + self.logger.info(self.ver_cog_commands[cog]) + self.logger.info('-'*30) + await sleep(1) + self.logger.info('app commands') + self.logger.info('-'*20) + self.logger.info(list(map(lambda x: x.name, await self.tree.fetch_commands(guild=None)))) + self.logger.info(f'load {len(self.commands)} commands') + self.logger.info('-'*30) + + async def on_ready(self): + self.check_close.start() + self.logger.info(f'We have logged in as {self.user}') + self.logger.info('-'*30) + self.logger.info(f'ID: {self.user.id}') + self.logger.info(f'Discord version: {discord.__version__}') + self.logger.info('-'*30) + self.logger.info(f'PROFILE : {self.PROFILE}') + self.logger.info(f'TEST_SERVER_ID : {self.TEST_SERVER_ID}') + self.logger.info('-'*30) + + self.var_manage[self.TEST_SERVER_ID] = Connector(self.TEST_SERVER_ID) + + self.logger.info('get DB data complete') + self.logger.info('-'*20) + self.logger.info(self.var_manage) + self.logger.info('='*30) + if self.send_queue: + self.send_queue.put('started') + self.logger.info('>> bot on to go') + + async def on_message(self, message:discord.Message): + if message.author.bot: + return + + if message.guild.id != self.TEST_SERVER_ID: + print('This is only for test server') + return + + self.var_proceed = False + self.var_registered = False + + self.logger.debug(message.content) + + try: + self.var_manage[message.guild.id] + except: + try: + self.logger.info(f'Connect {message.guild.name}|{message.guild.id} DB') + self.var_manage[message.guild.id]=Connector(message.guild.id) + except Exception as e: + self.logger.error(e, 'DB error occured') + await message.channel.send(embed=Embeds.error(e)) + return + + connector:Connector = self.var_manage[message.guild.id] + command = message.content[1:].split(' ')[0] + + if not message.content.startswith('!'): + self.logger.info('-'*10) + if message.channel.parent.name == connector.data.setting.channel.anon: + self.var_proceed = True + self.var_registered = True + self.logger.debug('anon message') + return + elif message.channel.category.name in connector.data.setting.channel.community: + self.var_proceed = True + self.var_registered = True + self.logger.debug("chara message") + return + self.logger.debug('not a command') + return + + if not message.author.guild_permissions.administrator: + self.logger.debug('not a admin') + if command in self.ver_admin_commands: + await message.delete() + await message.channel.send(embed=Embeds.general('관리자 전용 명령어입니다.'), delete_after=5) + self.var_proceed = False + return + else: + if message.content.startswith('!서버구축'): + self.var_proceed = True + self.var_registered = True + return + + if command in self.ver_admin_commands: + if message.channel.name != connector.data.setting.channel.manage: + self.var_proceed = False + await message.delete() + await message.channel.send(embed=Embeds.general('관리자 전용 명령어는 관리채널에서 사용할 수 있습니다.'), delete_after=3) + return + else: + self.logger.debug('admin command') + self.var_proceed = True + self.var_registered = True + return + + if not connector.check_user(message.author.id) and not message.content.startswith("!회원가입"): + await message.channel.send(embed=Embeds.setting('아직 회원가입이 되지 않은 유저입니다.','`/가입`을 먼저 진행해 주시기 바랍니다.'), delete_after=5) + if not message.author.top_role.permissions.administrator: + await message.delete() + self.var_registered = False + return + elif connector.check_chara(message.author.id) == 0 and not message.content.startswith("!캐릭터등록"): + await message.channel.send(embed=Embeds.setting('아직 캐릭터 등록이 되지 않은 유저입니다.','`/캐릭터등록`을 먼저 진행해 주시기 바랍니다.'), delete_after=5) + if not message.author.top_role.permissions.administrator: + await message.delete() + self.var_registered = False + return + + self.var_proceed = True + self.var_registered = True + + if command == "help": + await message.delete() + if message.content == '!help': + return await self.process_commands(message) + + if message.content.split(' ')[1] in self.ver_admin_commands: + if message.author.top_role.permissions.administrator: + if not message.channel.name == connector.data.setting.channel.manage: + await message.channel.send(embed=Embeds.setting('해당 명령어는 관리 채널에서만 사용 가능합니다.'), delete_after=3) + else: + await self.process_commands(message) + else: + await message.channel.send(embed=Embeds.general('해당 내용은 관리자만 확인 가능합니다.'), delete_after=5) + else: + await self.process_commands(message) + return + + if command not in list(map(lambda x: x.name, self.commands)): + self.logger.debug("no match command") + await message.delete() + await self.process_commands(message) + return + + async def on_command_error(self, message:commands.Context, error): + self.logger.warn(error, 'command error occured') + if self.send_queue: + self.send_queue.put('warn') + self.send_queue.put(str(error)) + if isinstance(error, commands.CommandNotFound): + return await message.send(embed=Embeds.general("잘못된 명령어 입니다", "명령어 목록을 확인하고 싶으시다면 `!help`를 이용해 주세요"), delete_after=30) + elif isinstance(error, commands.MissingRole): + return await message.send(embed=Embeds.general("역할이 충분하지 않습니다", "역할을 확인해 주세요"), delete_after=30) + elif isinstance(error, commands.MissingRequiredArgument): + return await message.send(embed=Embeds.general("입력되지 않은 변수가 있습니다", f"`{error.args[0].replace('is a required argument that is missing', '`가 입력되지 않았습니다')}"), delete_after=30) + else: + return await message.send(embed=Embeds.error(error)) + + async def on_app_command_error(self, interaction:discord.Interaction, error:app_commands.AppCommandError): + self.logger.warn(error, 'app command error occured') + if self.send_queue: + self.send_queue.put('warn') + self.send_queue.put(str(error)) + if isinstance(error, app_commands.MissingAnyRole): + await interaction.response.send_message(embed=Embeds.general("사용할 수 없는 명령어 입니다", "관리자 전용 명령어입니다"), ephemeral=True, delete_after=30) + elif isinstance(error, app_commands.AppCommandError): + if 'NoUser' in error.args: + await interaction.response.send_message(embed=Embeds.setting('아직 회원가입이 되지 않은 유저입니다.','`/가입`을 먼저 진행해 주시기 바랍니다.'), ephemeral=True, delete_after=30) + elif 'NoChara' in error.args: + await interaction.response.send_message(embed=Embeds.setting('아직 캐릭터 등록이 되지 않은 유저입니다.','`/캐릭터등록`을 먼저 진행해 주시기 바랍니다.'), ephemeral=True, delete_after=30) + else: + await interaction.response.send_message(embed=Embeds.error(error)) + else: + await interaction.response.send_message(embed=Embeds.error(error)) + + @tasks.loop(seconds=5) + async def check_close(self): + queue_data = '' + + if self.rec_queue: + if not self.rec_queue.empty(): + queue_data = self.rec_queue.get() + + if 'stop bot' == queue_data: + if self.send_queue: + self.send_queue.put('closed') + print('>> stop bot') + self.is_quit = True + await self.close() + exit(0) + +class Data: + connector:Connector + setting:Conn.Vars + channel:Conn.Channel + role:Conn.Roles + user:dict[int, Conn.User] + charas:dict[str, Conn.Chara] + npc:dict[str, Conn.NPC] + invs:dict[str, Conn.Backpack] + items:dict[str, Conn.Item] + fish_data:dict[str, list[str]] + fishes:dict[str, Conn.Fish] + +def __connection__(self, guild_id): + # connection + self.connector = self.bot.var_manage[guild_id] + # server infos + self.setting = self.connector.data.setting.data + self.channel = self.connector.data.setting.channel + self.role = self.connector.data.setting.role + self.user = self.connector.data.user.data + # chara data + self.charas = self.connector.data.chara.data + self.npc = self.connector.data.npc.data + self.invs = self.connector.data.inventory.data + self.items = self.connector.data.items.data + # fishing data + self.fish_data = self.connector.data.fishing.data + self.fishes = self.connector.data.fishing.fish + # print('connection complete') + +async def __on_ready__(self, interaction:discord.Interaction, check_user=True, check_chara=True): + self.bot.logger.debug('-'*10) + try: + command_name = f'{interaction.command.parent.name} {interaction.command.name}' + except: + command_name = interaction.command.name + self.bot.logger.debug(command_name) + await self.__get_connection__(interaction.guild_id) + user_id = interaction.user.id + + if not check_user or not check_chara: + return + + if user_id not in list(self.user.keys()): + raise app_commands.AppCommandError('NoUser','회원가입이 진행되지 않은 유저입니다.') + + if not self.user[user_id].charas: + raise app_commands.AppCommandError('NoChara','캐릭터등록이 진행되지 않은 유저입니다.') + + +class CommandBase(commands.Cog, Data): + def __init__(self, bot:JAS) -> None: + self.bot:JAS = bot + super().__init__() + + async def __get_connection__(self, guild_id): + __connection__(self, guild_id) + + async def on_ready(self, interaction:discord.Interaction, check_user=True, check_chara=True): + await __on_ready__(self, interaction, check_user, check_chara) + + @commands.Cog.listener() + async def on_message(self, message:discord.Message): + if message.author.bot: + return + + if not (self.bot.var_registered and self.bot.var_proceed): + return + + command_list = self.bot.ver_cog_commands[self.__cog_name__] + + await self.__get_connection__(message.guild.id) + command = message.content[1:].split(' ')[0] + if command in command_list: + self.bot.logger.debug('-'*10) + await message.delete() + await self.bot.process_commands(message) + return + + if self.__cog_name__ == "Community" and not message.content.startswith('!'): + if message.channel.category.name in self.channel.community and message.channel.type.value == 0 : + self.bot.logger.debug('send chara message') + await message.delete() + self.bot.logger.debug('-'*10) + await self.chara_talk(message) + return + + if message.channel.parent.name == self.channel.anon: + prefix = self.bot.command_prefix + if not message.content.startswith(prefix) and '전용 익명방' not in message.content: + self.bot.logger.debug('send anon message') + self.bot.logger.debug('-'*10) + await self.anon_message(message) + return + +@app_commands.default_permissions(send_messages=True) +class AppCommandBase(app_commands.Group, Data): + def __init__(self, bot, name, description): + super().__init__(name=name, description=description) + self.bot:JAS = bot + + async def __get_connection__(self, guild_id): + __connection__(self, guild_id) + + async def on_ready(self, interaction:discord.Interaction, check_user=True, check_chara=True): + await __on_ready__(self, interaction, check_user, check_chara) + \ No newline at end of file diff --git a/JAS/resources/Buttons.py b/JAS/resources/Buttons.py new file mode 100644 index 0000000..81ab516 --- /dev/null +++ b/JAS/resources/Buttons.py @@ -0,0 +1,49 @@ +from typing import Optional, Union +from discord.emoji import Emoji +from discord.partial_emoji import PartialEmoji +from discord.ui import Button +from discord.ext import commands +from discord import ButtonStyle, Interaction + +class TransferButton(Button): + def __init__(self): + super().__init__(label="송금", style=ButtonStyle.primary, row=1) + +class cancel(Button): + def __init__(self, row=None): + super().__init__(label="취소", style=ButtonStyle.secondary, row=row) + + async def callback(self, Interaction): + await Interaction.response.send_message('작업을 취소합니다.') + +class sub_stat(Button): + def __init__(self, row, user): + self.clicked=False + self.user_id = user.id + super().__init__(label="-", style=ButtonStyle.red, row=row) + + async def callback(self, interaction:Interaction): + if self.user_id != interaction.user.id: + return await interaction.response.send_message('스탯은 본인만 조정할 수 있습니다', ephemeral=True) + print("sub button click") + await interaction.response.defer() + self.clicked=True + +class add_stat(Button): + def __init__(self, row, user): + self.clicked=False + self.user_id = user.id + super().__init__(label="+", style=ButtonStyle.green, row=row) + + async def callback(self, interaction:Interaction): + if self.user_id != interaction.user.id: + return await interaction.response.send_message('스탯은 본인만 조정할 수 있습니다', ephemeral=True) + print("add button click") + await interaction.response.defer() + self.clicked=True + +class current_stat(Button): + def __init__(self, name, stat, row=0): + super().__init__(label=f'{name} : {stat}', style=ButtonStyle.secondary, row=row, disabled=True) + + async def callback(self, interaction:Interaction): ... diff --git a/JAS/resources/Connector.py b/JAS/resources/Connector.py new file mode 100644 index 0000000..5df32c8 --- /dev/null +++ b/JAS/resources/Connector.py @@ -0,0 +1,1083 @@ +import sqlite3, gspread +from JAS.setup import Setup +from JAS.resources.Exceptions import * +from copy import deepcopy +from random import random + +def return_string(string): + result = [] + result.append('='*30) + result.extend(string) + result.append('='*30) + return '\n'.join(result) + +class Connector: + def __init__(self, guild_id): + self.id = guild_id + self.conn = None + self.cur = None + self.__connect__() + self.data:Data = self.__get_data__() + + def __str__(self): + return str(self.data) + + def __connect__(self, update:bool=False, isDict:bool=False): + if not self.conn or update: + self.conn = Setup().setup(self.id) + else: + self.conn = Setup().connect(self.id) + self.conn.row_factory = sqlite3.Row if isDict else None + self.cur = self.conn.cursor() + + def __commit__(self): + self.conn.commit() + + def close(self): + self.cur.close() + self.conn.close() + + def __get_data__(self): + return Data(self, self.id) + + # General + # getter + def get_table_list(self): + self.__connect__() + tables = self.cur.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + self.close() + return tables + + # setter + def __reset_table_data__(self, tablename, data): + self.__connect__() + self.cur.execute(f'DELETE FROM {tablename}') + script = f'INSERT INTO {tablename} VALUES({",".join(["?"]*len(data[0]))})' + self.cur.executemany(script, data) + self.__commit__() + self.close() + + # 관리 + # getter + # 유저존재 확인 + def check_user(self, id): + self.__connect__() + user = self.cur.execute(f'SELECT count(*) FROM user WHERE id ={id} ').fetchone()[0] + self.close() + if user != 0: + return True + else: + return False + + def get_user_info(self): + self.__connect__() + userdata = self.cur.execute(f'SELECT * from user').fetchall() + self.close() + return userdata + + # 유저 목록 + def get_user_list(self): + self.__connect__() + userdata = self.cur.execute('SELECT * FROM user').fetchall() + self.close() + return userdata + + # 회원가입 가능여부 + def check_register_available(self): + self.__connect__() + result = self.cur.execute('SELECT value from setting WHERE type="accept_user"').fetchone()[0] + self.close() + return True if result == "True" else False + + # 설정정보 + def get_setting_info(self): + self.__connect__() + result = self.cur.execute('SELECT * FROM setting').fetchall() + self.close() + return result + + def __get_setting_value__(self, name): + self.__connect__() + value = self.cur.execute('SELECT value FROM setting WHERE type = (?)',(name,)).fetchone()[0] + self.close() + return value + + # 서버 관련 정보 가져오기 + def get_server_info(self): + self.__connect__() + self.close() + + # setter + # 설정 + def __set_setting_value__(self, name, value): + print('setting value set') + try: + self.__connect__() + self.cur.execute('UPDATE setting SET value=(?) WHERE type = (?)',(value, name)) + self.__commit__() + self.close() + + self.data.setting.__data__[name] = value + print('setting complete') + except Exception as e: + print(e) + print(traceback.format_exc()) + print('error occured') + raise ConnectionError('설정 변경 실패', name) + + # 유저정보 등록/갱신 + def set_user(self, id, name, private_thread_id): + self.__connect__() + self.cur.execute(f'INSERT INTO user VALUES(?,?,?,?)',(id, name, private_thread_id, "")) + self.__commit__() + self.close() + + self.data.user.data[id] = User((id, name, private_thread_id, '')) + + def update_user(self, id, name): + self.__connect__() + self.cur.execute(f'UPDATE user SET name={name} WHERE id = {id}') + + self.__commit__() + self.close() + + self.data.user.data[id].name = name + + # 유저정보 삭제 + def delete_user(self, id): + if self.check_user(id): + self.__connect__() + self.cur.execute(f'DELETE FROM user WHERE id = {id}') + self.__commit__() + self.close() + + if self.data.user.data[id].charas: + self.delete_charactor_data(self.data.user.data[id].charas, id) + del self.data.user.data[id] + + # 낚시 관련 + # getter + # 물고기 정보 가져오기 + def get_fish_data(self): + self.__connect__() + fish_data = self.cur.execute('SELECT * FROM fishing_data').fetchall() + self.close() + return fish_data + + # 최대 낚시 횟수 확인 + def get_max_fishing(self): + self.__connect__() + max_count = int(self.cur.execute('SELECT value FROM setting WHERE type = "max_fishing"').fetchone()[0]) + self.close() + return max_count + + # 낚시 이력 확인 + def get_fishing_history(self, id, channel, date): + try: + self.__connect__() + count = self.cur.execute(f"SELECT COUNT(*) FROM fishing_history WHERE user = {id} and fishdate LIKE '{date}%'").fetchone()[0] + self.close() + return count + except Exception as e: + print(e) + print(traceback.format_exc()) + raise ConnectionError('낚시 이력 조회 실패') + + # 물고기 존재 확인 + def check_fish(self, name): + self.__connect__() + result = self.cur.execute('SELECT count(*) FROM fishign_data WHERE name=(?)',(name)).fetchone()[0] + self.close() + return True if result > 0 else False + + # setter + # 낚시 결과 기록 + def set_fishing_history(self, now, user_id, loc, name, length): + try: + self.__connect__() + print('connect DB') + self.cur.execute('INSERT INTO fishing_history VALUES(?, ?, ?, ?, ?)', (now, user_id, loc, name, length)) + self.__commit__() + self.close() + print('connection closed') + except Exception as e: + print(e) + print(traceback.format_exc()) + raise FishingError('낚시 이력 등록 실패') + + # 최대 낚시 횟수 설정 + def set_max_fishing(self, value): + try: + self.__set_setting_value__("max_fishing", value) + except Exception as e: + print(e) + print(traceback.format_exc()) + raise ConnectionError('최대 낚시 횟수 등록 실패') + + # 물고기 추가 + def add_fish_data(self, name, desc, min, max, baseprice, loc): + try: + self.__connect__() + count = self.cur.execute('SELECT count(*) FROM fishing_data').fetchone()[0] + + code = str(count+1001) + code_list = self.cur.execute(f'SELECT code FROM fishing_data ORDER BY code').fetchall() + if code in code_list: + recent_code = code_list.pop()[0] + code = str(int(recent_code)+1) + + self.cur.execute('INSERT INTO fishing_data VALUES(?,?,?,?,?,?)',(code, name, min, max, baseprice, loc)) + self.cur.execute('INSERT INTO item VALUES(?,?,?,?,?)', (code, name, desc, 1, baseprice)) + self.__commit__() + self.close() + self.reg_item(code, name, desc, 1, baseprice) + + locs = list(self.data.fishing.data.keys()) + for location in loc.split(','): + if location in locs: + self.data.fishing.data[location] = [] + self.data.fishing.data[location].append(code) + self.data.fishing.fish[code] = Fish((code, name, min, max, baseprice, loc)) + except Exception as e: + print(e) + print(traceback.format_exc()) + raise CannotAddFish('물고기 추가 실패') + + # 물고기 변경 + def change_fish_data(self, code, desc, min, max, baseprice, loc): + try: + self.__connect__() + self.cur.execute('UPDATE fishing_data SET min=(?), max=(?), baseprice=(?), loc=(?) WHERE code = (?)',(min, max, baseprice, loc, code)) + self.cur.execute('UPDATE item SET description=(?), price=(?) WHERE code=(?)', (desc, baseprice, code)) + self.__commit__() + self.close() + locs = list(self.data.fishing.data.keys()) + for location in loc.split(','): + if location in locs : + if code not in self.data.fishing.data[location]: + self.data.fishing.data[location].append(code) + else: + self.data.fishing.data[location] = [] + self.data.fishing.data[location].append(code) + fish = self.data.fishing.fish[code] + fish.desc = desc + fish.min = min + fish.max = max + fish.baseprice = baseprice + item = self.data.items.data[code] + item.desc = desc + item.baseprice = baseprice + except Exception as e: + print(e) + print(traceback.format_exc()) + raise CannotAddFish('물고기 변경 실패') + + # 물고기 삭제 + def delete_fish(self, code): + self.__connect__() + self.cur.execute(f'DELETE FROM fishing_data WHERE code={code}') + self.cur.execute(f'DELETE FROM item WHERE code={code}') + self.__commit__() + self.close() + self.delete_item_from_inventory(code) + + for loc in list(self.data.fishing.data.keys()): + if code in self.data.fishing.data[loc]: + self.data.fishing.data[loc].remove(code) + del self.data.fishing.fish[code] + + # 낚시 이력 삭제 + def delete_fishing_history(self, id): + try: + self.__connect__() + self.cur.execute(f"DELETE FROM fishing_history WHERE user={id}") + self.__commit__() + self.close() + return True + except Exception as e: + print(e) + print(traceback.format_exc()) + raise ConnectionError('낚시 이력 삭제 실패') + + # 낚시터 변경 + def change_fishing_channel(self, before, after): + self.__connect__() + list = self.cur.execute("SELECT code, loc FROM fishing_data WHERE loc LIKE '(?)'",(f'%{before}%',)).fetchall() + for item in list: + code = item[0] + loc = item[1] + after_loc = loc.replace(before, after) + self.cur.execute("UPDATE fishing_data SET loc=(?) WHERE code=(?)",(after_loc, code)) + self.__commit__() + self.close() + + self.data.fishing.data[after] = deepcopy(self.data.fishing.data[before]) + del self.data.fishing.data[before] + + # 아이템 관련 + # getter + # 아이템 정보 수집 + def get_items_info(self): + self.__connect__() + item = self.cur.execute(f'SELECT * FROM item').fetchall() + self.close() + return item + + # 아이템 정보 확인 + def get_item_info(self, code): + self.__connect__() + item = self.cur.execute(f'SELECT * FROM item WHERE code={code}').fetchone() + self.close() + return item + + # setter + # 아이템 등록 + def reg_item(self, code, name, desc, number, price): + self.__connect__() + self.cur.execute('INSERT INTO item VALUES(?,?,?,?,?)',(code, name, desc, number, price)) + self.__commit__() + self.close() + + self.data.items.data[code] = Item((code, name, desc, number, price)) + + # 아이템 수정 + def update_item(self, code, name, desc, number, price): + self.__connect__() + self.cur.execute('UPDATE item SET name=(?), description=(?), price=(?) WHERE code=(?)',(name, desc, price, code)) + self.__commit__() + self.close() + + self.data.items.data[code].name = name + self.data.items.data[code].desc = desc + self.data.items.data[code].number = number + self.data.items.data[code].price = price + + # 인벤토리 관련 + # getter + # 인벤토리 정보 확인 + def get_inventory_data(self): + try: + self.__connect__() + result = self.cur.execute('SELECT * FROM inventory').fetchall() + self.close() + return result + except Exception as e: + print(e) + print(traceback.format_exc()) + raise ConnectionError('인벤토리 정보 확인 실패') + + # setter + # 아이템 보관 + def store_item(self, code, itemcode, num): + inventory = self.data.inventory.data[code] + before_items = ','.join(inventory.items) + items = f'{before_items},{itemcode}' if before_items else f'{itemcode}' + self.__connect__() + self.cur.execute(f'UPDATE inventory SET item=? WHERE code=?',(items, code)) + self.__commit__() + self.close() + + self.data.inventory.data[code].items.append(itemcode) + + # 아이템사용 + def use_item(self, code, itemcode): + self.__connect__() + item = self.cur.execute(f'SELECT item from inventory WHERE code=?',(code,)).fetchone() + items = item[0].split(',') + items.remove(itemcode) + item_after = ','.join(items) + self.cur.execute(f"UPDATE inventory SET item=(?) WHERE code=(?)",(item_after, code)) + self.cur.execute(f"DELETE FROM item WHERE code=(?)",(itemcode,)) + self.__commit__() + self.close() + + self.data.inventory.data[code].items.remove(itemcode) + + # 아이템 부여 + def spawn_item(self, code, itemcode, item): + itemcode = f'{itemcode}-1-{str(random())[2:]}' + name = item.name + desc = item.desc + price = item.price + self.reg_item(itemcode, name, desc, 1, price) + self.store_item(code, itemcode, 1) + + # 아이템판매 + def sold_item(self, code, itemcode, gold): + self.use_item(code, itemcode) + self.add_gold(code, gold) + + # 골드 추가 + def add_gold(self, code, gold): + try: + before_gold = self.data.inventory.data[code].gold + print(before_gold) + after_gold = before_gold + float(gold) + self.__connect__() + self.cur.execute('UPDATE inventory SET gold = (?) WHERE code = (?)', (after_gold, code)) + self.__commit__() + self.close() + + self.data.inventory.data[code].gold = after_gold + except Exception as e: + print(e) + print(traceback.format_exc()) + raise SellingError('골드 추가 실패') + + # 인벤토리 업데이트 + def update_chara_inv_size(self, code, size): + try: + self.__connect__() + before_size = self.data.inventory.data[code].size + after_size = before_size+size + self.cur.execute(f'UPDATE inventory SET size=(?) WHERE code=(?)',(after_size, code)) + self.__commit__() + self.close() + + self.data.inventory.data[code].size = after_size + except Exception as e: + print(e) + print(traceback.format_exc()) + raise SellingError('인벤토리 업데이트 실패') + + # 인벤토리에서 아이템 삭제 + def delete_item_from_inventory(self, code): + self.__connect__() + self.cur.execute(f"DELETE FROM item WHERE code LIKE '{'%-'+code+'-%'}'") + self.__commit__() + inventory = self.cur.execute(f"SELECT code, item FROM inventory WHERE items LIKE '{'%-'+code+'-%'}'").fetchall() + + for inv in inventory: + items = inv[1].split(',') + mod_items = [] + for item in items: + if f'-{code}-' not in item: + mod_items.append(item) + self.cur.execute(f'UPDATE inventory SET item={",".join(mod_items)} WHERE code={inv[0]}') + self.__commit__() + self.close() + + for code in list(self.data.inventory.data.keys()): + itemcodes = list(filter(lambda x: code.split('_')[0] == code,list(self.data.inventory.data[code].keys()))) + if len(itemcodes) > 0: + for itemcode in itemcodes: + del self.data.inventory[code][itemcode] + + # 캐릭터 관련 + # getter + # NPC존재 확인 + def check_NPC(self, type): + self.__connect__() + result = self.cur.execute("SELECT count(*) FROM chara WHERE code LIKE '(?)'",(f'%{type}%',)).fetchall()[0] + self.close() + return True if result > 0 else False + + # 캐릭터 존재 확인 + def check_chara(self, id): + self.__connect__() + code = self.cur.execute('SELECT charas FROM user WHERE id=(?)',(id,)).fetchone()[0] + result = self.cur.execute(f"SELECT count(*) FROM chara WHERE code=?",(code,)).fetchone()[0] + self.close() + return result + + # NPC정보 가져오기 + def get_NPC_info(self): + self.__connect__() + info = self.cur.execute("SELECT * FROM NPC").fetchall() + vers = self.cur.execute("SELECT * FROM NPC_vers").fetchall() + self.close() + return info, vers + + # 캐릭터 정보 가져오기 + def get_charactor_data(self): + self.__connect__() + data = self.cur.execute("SELECT * FROM chara").fetchall() + self.close() + return data + + # 유저 캐릭터 가져오기 + def get_user_chara(self, id): + self.__connect__() + data = self.cur.execute("SELECT code FROM chara WHERE code LIKE '(?)'",(f'{id}%',)).fetchall()[0] + self.close() + return data + + # setter + # NPC정보 입력 + def set_NPC_info(self, code, name, color=0): + self.__connect__() + self.cur.execute("INSERT INTO NPC VALUES(?,?,?)",(code, name, color)) + self.__commit__() + self.close() + + self.data.npc.data[code] = NPC((code, name, color), []) + + # NPC대사 입력 + def set_NPC_vers(self, code, keyword, vers): + self.__connect__() + self.cur.execute("INSERT INTO NPC_vers VALUES(?,?,?)",(code, keyword, vers)) + self.__commit__() + self.close() + + self.data.npc.data[code].number += 1 + self.data.npc.data[code].case = self.data.npc.data[code].case + f',{keyword}' if self.data.npc.data[code].case else keyword + self.data.npc.data[code].vers.append(vers) + + # NPC정보 수정 + def update_NPC_info(self, code, name, color): + self.__connect__() + self.cur.execute("UPDATE NPC SET name=(?), color=(?) WHERE code=(?)",(name, color, code)) + self.__commit__() + self.close() + + self.data.npc.data[code].name = name + self.data.npc.data[code].color = color + + # NPC대사 입력 + def update_NPC_vers(self, code, before_keyword, after_keyword, vers): + self.__connect__() + self.cur.execute("UPDATE NPC_vers SET keyword=(?), vers=(?) WHERE code=(?) and keyword=(?)",(after_keyword, vers, code, before_keyword)) + self.__commit__() + self.close() + + self.data.npc.data[code].case = ','.join([after_keyword if case == before_keyword else case for case in self.data.npc.data[code].case.split(',')]) + self.data.npc.data[code].vers[self.data.npc.data[code].case.split(',').index(after_keyword)] = vers + + # NPC 정보 삭제 + def delete_NPC_data(self, code): + self.__connect__() + self.cur.execute("DELETE FROM NPC WHERE code=(?)",(code,)) + self.cur.execute(f'DELETE FROM NPC_vers WHERE code=(?)',(code,)) + self.__commit__() + self.close() + + del self.data.npc.data[code] + + # 캐릭터 정보 입력 + def set_charactor_data(self, id, code, name, keyword, desc, link): + stat = [1,1,1,1,1,1] + point = 4 + gold = self.data.setting.data.base_inv_gold + size = self.data.setting.data.base_inv_size + self.__connect__() + self.cur.execute("INSERT INTO chara VALUES(?,?,?,?,?,?)",(code, name, keyword, desc, link, None)) + self.cur.execute("INSERT INTO stat VALUES(?,?,?,?,?,?,?,?,?)",(code, point, None, *stat)) + self.cur.execute(f'INSERT INTO inventory VALUES(?,?,?,?)',(code, gold, size, "")) + self.cur.execute("UPDATE user SET charas=(?) WHERE id=(?)",(code, id)) + self.__commit__() + self.close() + + self.data.user.data[id].charas = code + self.data.chara.data[code] = Chara((code, name, keyword, desc, link, None),(code, point, None, *stat),','.join(self.data.setting.data.stat_names)) + self.data.inventory.data[code] = Backpack((code, gold, size, "")) + + # 캐릭터 정보 갱신 + def update_charactor_data(self, code, name, keyword, desc, link): + self.__connect__() + self.cur.execute("UPDATE chara SET name=(?), keyword=(?), desc=(?), link=(?) WHERE code=(?)",(name, keyword, desc, link, code)) + self.__commit__() + self.close() + + self.data.chara.data[code].name = name + self.data.chara.data[code].keyword = keyword + self.data.chara.data[code].desc = desc + self.data.chara.data[code].link = link + + # 캐릭터 색상 갱신 + def update_charactor_color(self, code, color): + self.__connect__() + self.cur.execute("UPDATE chara SET color=(?) WHERE code=(?)",(color, code)) + self.__commit__() + self.close() + + self.data.chara.data[code].color = color + + # 캐릭터 인벤토리 갱신 + def update_charactor_inventory(self, code, gold=0, size=0): + self.__connect__() + basegold, basesize = self.cur.execute('SELECT gold, size FROM inventory WHERE code = (?)',(code,)).fetchone() + result_gold = basegold+gold + result_size = basesize+size + self.cur.execute('UPDATE inventory SET gold=(?), size=(?) WHERE code=(?)',(result_gold, result_size, code)) + self.__commit__() + self.close() + + self.data.inventory.data[code].gold = result_gold + self.data.inventory.data[code].size = result_size + + # 캐릭터 정보 삭제 + def delete_charactor_data(self, code, id): + self.__connect__() + self.cur.execute("DELETE FROM chara WHERE code=(?)",(code,)) + self.cur.execute(f'DELETE FROM inventory WHERE code = {code}') + self.cur.execute('UPDATE user SET charas=(?) WHERE id=(?)',(code, id)) + self.__commit__() + self.close() + + del self.data.inventory.data[id] + del self.data.chara.data[code] + self.data.user.data[id].charas = '' + + # 스탯 관련 + # getter + def get_stat_data(self): + self.__connect__() + result = self.cur.execute('SELECT * FROM stat').fetchall() + self.close() + return result + + def get_base_stat_names(self): + self.__connect__() + result = self.cur.execute("SELECT value FROM setting WHERE id LIKE 'stat%'").fetchall() + self.close() + return result + + # setter + def update_stat(self, code, names:list[str], point, stat:list[int]): + self.__connect__() + self.cur.execute('UPDATE stat SET stat1=(?), stat2=(?), stat3=(?), stat4=(?), stat5=(?), stat6=(?), point=(?), statname=(?) WHERE code=(?)', (*stat, point, ','.join(names), code)) + self.__commit__() + self.close() + + self.data.chara.data[code].stat.point = point + self.data.chara.data[code].stat.stat_names = names + self.data.chara.data[code].stat.stat1 = stat[0] + self.data.chara.data[code].stat.stat2 = stat[1] + self.data.chara.data[code].stat.stat3 = stat[2] + self.data.chara.data[code].stat.stat4 = stat[3] + self.data.chara.data[code].stat.stat5 = stat[4] + self.data.chara.data[code].stat.stat6 = stat[5] + + # 스탯명 변경 + def update_stat_name(self, statname): + if not statname: + for chara in list(self.data.chara.data.keys()): + self.data.chara.data[chara].stat=None + else: + if not self.data.setting.data.stat_names[0]: + for stat_data in self.get_stat_data(): + chara = stat_data[0] + self.data.chara.data[chara].stat = Stat(stat_data, statname) + self.__set_setting_value__('stat_names', statname) + +# Data Object +class Data(): + def __init__(self, conn:Connector, guild_id) -> None: + self.guild_id = guild_id + self.setting=Setting(conn.get_setting_info()) + self.user=Users(conn.get_user_info()) + self.fishing=Fishing(conn.get_fish_data()) + self.items=Items(conn.get_items_info()) + self.inventory=Inventory(conn.get_inventory_data()) + self.chara=Charas(conn.get_charactor_data(), conn.get_stat_data(), conn.__get_setting_value__('stat_names')) + self.npc=NPCs(conn.get_NPC_info()) + def __str__(self) -> str: + content = [] + content.append(str(self.setting)) + content.append(str(self.user)) + content.append(str(self.fishing)) + content.append(str(self.items)) + content.append(str(self.inventory)) + content.append(str(self.chara)) + content.append(str(self.npc)) + return '\n'.join(content) + def showDB(self)->list: + content = [] + content.append(str(self.setting)) + content.append(str(self.user)) + content.append(str(self.fishing)) + content.append(str(self.items)) + content.append(str(self.inventory)) + content.append(str(self.chara)) + content.append(str(self.npc)) + return content + +# User data +class Users(): + def __init__(self, data) -> None: + self.data : dict[int, User] = {} + for user in data: + code = user[0] + self.data[code] = User(user) + def __str__(self) -> str: + content = [] + content.append('User data') + for code in list(self.data.keys()): + content.append('-'*30) + content.append(str(self.data[code])) + return return_string(content) + +class User(): + def __init__(self, data) -> None: + self.id:int = data[0] + self.name:str = data[1] + self.thread:int = data[2] + self.charas:str = data[3] + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('id', self.id)) + content.append('{0:<20} : {1}'.format('name', self.name)) + content.append('{0:<20} : {1}'.format('thread', self.thread)) + content.append('{0:<20} : {1}'.format('charas', self.charas)) + return '\n'.join(content) + +# setting +class Setting(): + def __init__(self, data) -> None: + self.__data__:dict[str, str] = {} + + for item in data: + key, value = item + self.__data__[key] = value + + self.data=Vars(self.__data__) + self.channel=Channel(self.__data__) + self.role=Roles(self.__data__) + def __str__(self) -> str: + content = [] + content.append('Current Setting') + content.append('-'*30) + content.append(str(self.data)) + content.append('-'*30) + content.append(str(self.channel)) + content.append('-'*30) + content.append(str(self.role)) + return return_string(content) + +class Vars: + def __init__(self, data) -> None: + self.__data__:dict[str, str] = data + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('max_fishing', self.max_fishing)) + content.append('{0:<20} : {1}'.format('max_gather', self.max_gather)) + content.append('{0:<20} : {1}'.format('base_inv_size', self.base_inv_size)) + content.append('{0:<20} : {1}'.format('base_inv_gold', self.base_inv_gold)) + content.append('{0:<20} : {1}'.format('accept_user', self.accept_user)) + content.append('{0:<20} : {1}'.format('random_box', self.random_box)) + content.append('{0:<20} : {1}'.format('gspread_url', self.gspread_url)) + content.append('{0:<20} : {1}'.format('stat_names', self.stat_names)) + return '\n'.join(content) + + @property + def max_fishing(self) -> int: + return int(self.__data__["max_fishing"]) + @property + def max_gather(self) -> int: + return int(self.__data__["max_gather"]) + @property + def base_inv_size(self) -> int: + return int(self.__data__["base_inv_size"]) + @property + def base_inv_gold(self) -> int: + return int(self.__data__["base_inv_gold"]) + @property + def accept_user(self) -> bool: + return True if self.__data__["accept_user"]=="True" else False + @property + def random_box(self) -> int: + return int(self.__data__["random_box"]) + @property + def gspread_url(self) -> str: + return self.__data__["gspread_url"] + @property + def stat_names(self) -> list[str]: + return self.__data__["stat_names"].split(',') + +class Channel: + def __init__(self, data) -> None: + self.__data__:dict[str, str] = data + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('anon channel', self.anon)) + content.append('{0:<20} : {1}'.format('manage channel', self.manage)) + content.append('{0:<20} : {1}'.format('join channel', self.join)) + content.append('{0:<20} : {1}'.format('store channel', self.store)) + content.append('{0:<20} : {1}'.format('community channel', self.community)) + content.append('{0:<20} : {1}'.format('qna channel', self.qna)) + return '\n'.join(content) + + @property + def anon(self) -> str: + return self.__data__["anon"] + @property + def manage(self) -> str: + return self.__data__["manage"] + @property + def join(self) -> str: + return self.__data__["join"] + @property + def store(self) -> str: + return self.__data__["store"] + @property + def community(self) -> str: + return self.__data__["community"] + @property + def qna(self) -> str: + return self.__data__["qna"] + +class Roles: + def __init__(self, data) -> None: + self.__data__:dict[str, str] = data + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('visitor role', self.visitor)) + content.append('{0:<20} : {1}'.format('registered role', self.registered)) + content.append('{0:<20} : {1}'.format('admin role', self.admin)) + return '\n'.join(content) + + @property + def visitor(self) -> str: + return self.__data__["visitor"] + @property + def registered(self) -> str: + return self.__data__["registered"] + @property + def admin(self) -> str: + return self.__data__["admin"] + +# fishing data +class Fishing(): + def __init__(self, data) -> None: + self.data:dict[str, list[str]] = {} + self.fish:dict[str, Fish] = {} + for fish in data: + code = fish[0] + fishdata = Fish(fish) + locs = fish[5].split(',') + for loc in locs: + try: + self.data[loc].append(code) + except: + self.data[loc]=[] + self.data[loc].append(code) + self.fish[code] = fishdata + self.data = self.data + def __str__(self) -> str: + content = [] + content.append('Fish data') + for loc in list(self.data.keys()): + content.append('-'*30) + content.append(f'place: {loc}') + for fish in self.data[loc]: + content.append('-'*20) + content.append(f'[{fish}]') + content.append(str(self.fish[fish])) + return return_string(content) + +class Fish(): + def __init__(self, fish) -> None: + self.name:str = fish[1] + self.min:int = fish[2] + self.max:int = fish[3] + self.baseprice:int = fish[4] + self.loc:str = fish[5] + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('name', self.name)) + content.append('{0:<20} : {1}'.format('min', self.min)) + content.append('{0:<20} : {1}'.format('max', self.max)) + content.append('{0:<20} : {1}'.format('baseprice', self.baseprice)) + content.append('{0:<20} : {1}'.format('loc', self.loc)) + return '\n'.join(content) + +# Item data +class Items(): + def __init__(self, data) -> None: + self.data:dict[str,Item] = {} + for item in data: + code = item[0] + itemdata = Item(item) + self.data[code] = itemdata + def __str__(self) -> str: + content = [] + content.append('Item data') + for code in list(self.data.keys()): + content.append('-'*30) + content.append(f'[{code}]') + content.append(str(self.data[code])) + return return_string(content) + +class Item(): + def __init__(self, data) -> None: + self.name:str = data[1] + self.desc:str = data[2] + self.number:int = data[3] + self.price:int = data[4] + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('name', self.name)) + content.append('{0:<20} : {1}'.format('desc', self.desc)) + content.append('{0:<20} : {1}'.format('number', self.number)) + content.append('{0:<20} : {1}'.format('price', self.price)) + return '\n'.join(content) + +# Inventory data +class Inventory(): + def __init__(self, data) -> None: + self.data:dict[str, Backpack] = {} + for invdata in data: + id = invdata[0] + self.data[id] = Backpack(invdata) + def __str__(self) -> str: + content = [] + content.append('Inventory data') + for user in list(self.data.keys()): + content.append('-'*30) + content.append(f'User: {user}') + content.append('-'*20) + content.append(str(self.data[user])) + return return_string(content) + +class Backpack(): + def __init__(self, inv_data) -> None: + self.gold:int = inv_data[1] + self.size:int = inv_data[2] + self.items:list[str] = [] + if inv_data[3] != '': + for itemcode in inv_data[3].split(','): + code = itemcode.strip() + self.items.append(code) + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('gold', self.gold)) + content.append('{0:<20} : {1}'.format('max size', self.size)) + content.append('{0:<20} :'.format('items')) + content.append('-'*15) + for item in self.items: + content.append('{0:<15} : {1}'.format('code', item)) + return '\n'.join(content) + +# Store data +class Store(): + def __init__(self, data, iteminfo) -> None: + self.data:dict[str, StoreItem] = {} + for item in data: + code = item[0] + self.data[code] = StoreItem(item, iteminfo) + def __str__(self) -> str: + content = [] + content.append('Store data') + for code in list(self.data.keys()): + content.append('-'*30) + content.append(f'[{code}]') + content.append(str(self.data[code])) + return return_string(content) + +class StoreItem(): + def __init__(self, data, iteminfo) -> None: + code = data[0] + self.name:str = iteminfo[code].name + self.sale:bool = True if data[1]=='True' else False + self.desc:str = iteminfo[code].desc + self.price:int = data[2] + self.discount:int = data[3] + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('name', self.name)) + content.append('{0:<20} : {1}'.format('sale', self.sale)) + content.append('{0:<20} : \n{1}'.format('desc', self.desc)) + content.append('{0:<20} : {1}'.format('price', self.price)) + content.append('{0:<20} : {1}'.format('discount', self.discount)) + return '\n'.join(content) + +# NPC data +class NPCs(): + def __init__(self, data) -> None: + self.data:dict[str,NPC] = {} + info = data[0] + vers = data[1] + for chara in info: + code = chara[0] + npc_vers = list(filter(lambda x: x[0]==code,vers)) + self.data[code] = NPC(chara, npc_vers) + def __str__(self) -> str: + content = [] + content.append('NPC data') + for code in list(self.data.keys()): + content.append('-'*30) + content.append(f'[{code}]') + content.append(str(self.data[code])) + return return_string(content) + +class NPC(): + def __init__(self, data, vers:list) -> None: + self.name:str = data[1] + self.color:int = data[2] + self.number:int = len(vers) + self.case:list[str] = [] + self.vers:list[str] = [] + if vers: + for item in vers: + self.case.append(item[1]) + self.vers.append(item[2]) + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('name', self.name)) + content.append('-'*15) + content.append('{0:<15} : {1}'.format('number', self.number)) + content.append('{0:<15} :'.format('vers')) + for i in range(len(self.vers)): + content.append(self.case[i] + '-'*10) + for vers in self.vers[i].split('\n'): + content.append(f' {vers}') + return '\n'.join(content) + +# Chara data +class Charas(): + def __init__(self, data, stat, stat_default) -> None: + self.data:dict[str,Chara] = {} + for chara in data: + code = chara[0] + stat_data = list(filter(lambda x: x[0]==code ,stat))[0] + self.data[code] = Chara(chara, stat_data, stat_default) + def __str__(self) -> str: + content = [] + content.append('Chara data') + for code in list(self.data.keys()): + content.append('-'*30) + content.append(f'[{code}]') + content.append(str(self.data[code])) + return return_string(content) + +class Stat(): + def __init__(self, data, default) -> None: + self.stat1:int = int(data[3]) + self.stat2:int = int(data[4]) + self.stat3:int = int(data[5]) + self.stat4:int = int(data[6]) + self.stat5:int = int(data[7]) + self.stat6:int = int(data[8]) + self.point:int = data[1] + self.stat_names = data[2].split(',') if data[2] else default.split(',') + def __str__(self) -> str: + content = [] + content.append('{0:<15} : {1}'.format(self.stat_names[0], self.stat1)) + content.append('{0:<15} : {1}'.format(self.stat_names[1], self.stat2)) + content.append('{0:<15} : {1}'.format(self.stat_names[2], self.stat3)) + content.append('{0:<15} : {1}'.format(self.stat_names[3], self.stat4)) + content.append('{0:<15} : {1}'.format(self.stat_names[4], self.stat5)) + content.append('{0:<15} : {1}'.format(self.stat_names[5], self.stat6)) + content.append('='*5) + content.append('{0:<15} : {1}'.format('point', self.point)) + return '\n'.join(content) + +class Chara(): + def __init__(self, data, stat, stat_default) -> None: + self.name:str = data[1] + self.keyword:str = data[2] + self.desc:str = data[3] + self.link:str = data[4] + self.color:int = data[5] + self.stat:Stat = Stat(stat, stat_default) if stat_default else None + def __str__(self) -> str: + content = [] + content.append('{0:<20} : {1}'.format('name', self.name)) + content.append('{0:<20} : {1}'.format('keyword', self.keyword)) + content.append('{0:<20} : \n{1}'.format('desc', self.desc)) + content.append('{0:<20} : {1}'.format('link', self.link)) + if self.stat: + content.append('-'*15) + content.append(str(self.stat)) + return '\n'.join(content) + \ No newline at end of file diff --git a/JAS/resources/Embeds.py b/JAS/resources/Embeds.py new file mode 100644 index 0000000..598fb3e --- /dev/null +++ b/JAS/resources/Embeds.py @@ -0,0 +1,267 @@ +from discord import Embed, Color, File +from JAS.resources.Addons import os, currency, desc_converter, IMG_FOLDER_PATH, filename +import JAS.resources.Connector as Conn + +# 공용 +def general(title, desc=None, color=Color.dark_grey(), footer=None): + embed = Embed(title=title, description=desc, color=color) + if footer: embed.set_footer(text=footer) + return embed + +def warning(title, desc=None): + return Embed(title=title, description=desc, color=Color.dark_red()) + +def error(errorcode=None): + title = "예상치 못한 오류가 발생하였습니다." + desc = "지속적으로 발생하는 경우 관리자에게 문의 바랍니다." + if errorcode: + desc = desc+f'\n오류 내용: {errorcode}' + return Embed(title=title, description=desc, color=Color.red()) + +def store(title, desc=None): + return Embed(title=title, description=desc, color=Color.teal()) + +# 관리 +def setting(title, desc=None, footer=None): + embed = Embed(title=title, description=desc, color=Color.gold()) + if footer: + embed.set_footer(text=footer) + return embed + +def show_user(manage:Conn.Connector, id): + user_name = manage.data.user.data[id].name + code = manage.data.user.data[id].charas + chara_data = manage.data.chara.data[code] + chara_name = chara_data.name + title = f'{user_name}의 정보입니다.' + desc = f'id: {id}' + embed = Embed(title=title, description=desc, color=Color.gold()) + embed.add_field(name="캐릭터",value=f'{chara_name}\n==========\n{str(chara_data)}') + return embed + +def show_system(title, manage:Conn.Vars, footer): + embed = Embed(title=title, color=Color.gold()) + data = { + "회원가입 가능여부": "허용" if manage.accept_user else "차단", + "최대 낚시 횟수": manage.max_fishing, + "최대 채집 횟수": manage.max_gather, + "기본 가방 크기": manage.base_inv_size, + "최초 소지 골드": manage.base_inv_gold, + "랜덤 박스 가격": manage.random_box, + "구글 연동 링크": manage.gspread_url or '_', + "스탯명": str(manage.stat_names), + } + for key in list(data.keys()): + embed.add_field(name=key, value=f'`{data[key]}`', inline=True) + embed.set_footer(text=footer) + return embed + +def user_accept(manage:Conn.Connector, footer): + title = "회원가입 가능여부를 설정" + desc = "회원가입을 허용하시겠습니까?" + embed = Embed(title=title, description=desc, color=Color.gold()) + value="허용" if manage.data.setting.data.accept_user else "차단" + embed.add_field(name="현재 설정", value=value) + embed.set_footer(text=footer) + return embed + +def guide_user(name): + title = f"{name}님 환영합니다." + desc = "해당 익명 스레드의 사용법을 설명드리겠습니다." + embed = Embed(title=title, description=desc, color=Color.gold()) + guide_list={ + "익명 게시판 작성":"해당 스레드에 메세지를 보내시면 해당 메세지는 익명게시판에 작성됩니다.", + "익명 문의":"/문의 명령어를 이용하시면 비공개 문의를 진행할 수 있습니다.", + "명령어 사용":"다른 게시판에서와 마찬가지로 명령어의 사용이 가능합니다." + } + for name in list(guide_list.keys()): + value = guide_list[name] + embed.add_field(name=name, value=value, inline=False) + + return embed + +# 낚시 +def fishing(type:int): + types = { + 0:["자리를 준비하는 중...",Color.dark_grey()], + 1:["낚싯대를 드리우는 중...", Color.green()], + 2:["흐르는 물을 바라보는 중...", Color.blue()], + 3:["스쳐 지나가는 물고기 그림자를 바라보는 중...", Color.teal()], + 4:["월척의 꿈을 꾸는 중...",Color.green()], + 9:["앗 느낌이?!",Color.gold()] + } + title = types[type][0] + color = types[type][1] + embed = Embed(title=title, description=None, color=color) + return embed + +def fishing_result(title, desc, color, result=None): + image = None + embed = Embed(title=title, description=desc, color=color) + context = "" + if result: + for item in result: + context = context + ' / ' + str(item) if context else "🐟 "+str(item) + embed.set_footer(text = context) + # embed.add_field(name='크기', value=result["length"], inline=True) + # embed.add_field(name='가격', value=result["price"], inline=True) + # file = File(f'resources/img/{image}') + return embed + +def fish_data(title, data:dict[str, list['fishcode':str]], fishinfo:dict[str, Conn.Fish], isOnePlace): + embed = Embed(title=title, description=None, color=Color.green()) + for place in list(data.keys()): + context = "" + name = "물고기 목록" if isOnePlace else place + for fish in data[place]: + context = context+','+ fishinfo[fish].name if context else fishinfo[fish].name + embed.add_field(name=name, value=context, inline=False) + return embed + +def add_fish(title, name, desc, min, max, baseprice, loc): + embed = Embed(title=title,color=Color.yellow()) + embed.add_field(name="이름",value=name, inline=False) + embed.add_field(name="설명",value=desc, inline=False) + embed.add_field(name="최소 길이",value=min, inline=False) + embed.add_field(name="최고 길이",value=max, inline=False) + embed.add_field(name="기본 가격",value=baseprice, inline=False) + embed.add_field(name="등장위치",value=loc, inline=False) + return embed + +# 인텐토리 +def inventory(title, desc, inventory:Conn.Backpack, items:dict[str, Conn.Item]): + embed = Embed(title=title, description=desc, color=Color.teal()) + # index = 0 + gold = inventory.gold + if inventory.items: + for item in inventory.items: + itemcode = item.split('_')[0] + name = items[itemcode].name + price = '가격: '+currency(0, str(items[itemcode].price)) if items[itemcode].price else "" + embed.add_field(name=name, value=price, inline=False) + else: + name = "" + value = "" + # embed.add_field(name=name, value=value) + embed.set_footer(text=f"{currency(0, gold)}") + return embed + +def sold_item(gold): + title="아이템을 판매하였습니다." + desc=f"총 가격: {gold}" + color = Color.teal() + return Embed(title=title, description=desc, color=color) + +def random_box(title, count:int): + spin = [ + "/","―","\\","|" + ] + desc = spin[count%4] + embed = Embed(title=title, description=desc, color=Color.brand_red()) + return embed + +# 캐릭터 +def chara_talk(data:Conn.Chara, iconpath, content, color): + name = data.name + # embed = Embed(title=name, description=desc_converter(content), color=color) + embed = Embed(title=None, description=desc_converter(content), color=color) + icon = 'icon.png' + try: + file = File(iconpath, filename=icon) + except: + file = File(os.path.join(IMG_FOLDER_PATH, 'sample.png'), filename=icon) + embed.set_thumbnail(url=f"attachment://{icon}") + return embed, file + +def show_chara(data:Conn.Chara, imgpath="img/sample.png"): + name = data.name + keyword = data.keyword.replace(',',' / ') + desc = data.desc + link = f'[신청서 링크]({data.link})' + color = data.color or Color.light_grey().value + embed = Embed(title=name, color=Color(color)) + icon = 'icon.png' + try: + file = File(imgpath, filename=icon) + except: + file = File("img/sample.png", filename=icon) + embed.set_image(url=f"attachment://{icon}") + embed.add_field(name="키워드", value=keyword, inline=False) + embed.add_field(name="소개", value=desc, inline=False) + if data.stat: + stat_names = data.stat.stat_names + embed.add_field(name=stat_names[0], value=data.stat.stat1, inline=True) + embed.add_field(name=stat_names[1], value=data.stat.stat2, inline=True) + embed.add_field(name=stat_names[2], value=data.stat.stat3, inline=True) + embed.add_field(name=stat_names[3], value=data.stat.stat4, inline=True) + embed.add_field(name=stat_names[4], value=data.stat.stat5, inline=True) + embed.add_field(name=stat_names[5], value=data.stat.stat6, inline=True) + embed.add_field(name="스탯포인트", value=data.stat.point, inline=False) + embed.add_field(name="신청서", value=link, inline=False) + return embed, file + +def show_npc(data:Conn.NPC): + name = data.name + number = data.number + cases = data.case.split(',') + vers = data.vers + color = data.color or Color.light_grey().value + desc = '총 대사 수: ' + str(number) + embed = Embed(title=name, description=desc, color=Color(color)) + for case in cases: + embed.add_field(name=case, value=vers[cases.index(case)], inline=False) + return embed + +def show_stat_result(data:Conn.Chara): + title = f"{data.name}의 스탯적용을 완료하였습니다." + desc = f"남은 포인트: {data.stat.point}" + embed = Embed(title=title, description=desc, color=Color.brand_green()) + embed.add_field(name='체력', value=data.stat.stat1) + embed.add_field(name='힘', value=data.stat.stat2) + embed.add_field(name='지능', value=data.stat.stat3) + embed.add_field(name='관찰', value=data.stat.stat4) + embed.add_field(name='민첩', value=data.stat.stat5) + embed.add_field(name='운', value=data.stat.stat6) + return embed + +def anon_qna(question, answer): + title = "익명문의" + embed = Embed(title=title, description=question, color=Color.dark_gold()) + embed.add_field(name="답변", value=answer) + return embed + +# 투표 +def vote(timeout:int, op1:str, op2:str, op3:str, op4:str, op5:str): + embed = Embed(title='투표', color=Color.brand_green()) + if '/' in op1: + embed.add_field(name='1. '+op1.split('/')[0], value=op1.split('/')[1], inline=False) + else: + embed.add_field(name='1. '+op1.split('/')[0], value='', inline=False) + if '/' in op2: + embed.add_field(name='2. '+op2.split('/')[0], value=op2.split('/')[1], inline=False) + else: + embed.add_field(name='2. '+op2.split('/')[0], value='', inline=False) + if op3: + if '/' in op3: + embed.add_field(name='3. '+op3.split('/')[0], value=op3.split('/')[1], inline=False) + else: + embed.add_field(name='3. '+op3.split('/')[0], value='', inline=False) + if op4: + if '/' in op4: + embed.add_field(name='4. '+op4.split('/')[0], value=op4.split('/')[1], inline=False) + else: + embed.add_field(name='4. '+op4.split('/')[0], value='', inline=False) + if op5: + if '/' in op5: + embed.add_field(name='5. '+op5.split('/')[0], value=op5.split('/')[1], inline=False) + else: + embed.add_field(name='5. '+op5.split('/')[0], value='', inline=False) + time = '{0}시간 {1}분'.format(*divmod(timeout,60)) if timeout >= 60 else f'{timeout}분' + embed.set_footer(text=f'진행시간: {time}') + return embed + +def vote_result(total:int, data:dict[str, list[int, int]]): + embed = Embed(title="투표 결과 입니다", description=f"총 투표수: {total}", color=Color.light_grey()) + for key in list(data.keys()): + embed.add_field(name=f'{key} ({data[key][0]}%)', value='`'+'■'*(data[key][0]//10)+'□'*(10-data[key][0]//10)+' '+str(data[key][1])+'`', inline=False) + return embed diff --git a/JAS/resources/Exceptions.py b/JAS/resources/Exceptions.py new file mode 100644 index 0000000..f1429c9 --- /dev/null +++ b/JAS/resources/Exceptions.py @@ -0,0 +1,181 @@ +import traceback + +def exception_msg(code, str): + codeline = f'>> Error: {code}' + errorline = f'==={str[0]}===' + return codeline+' : '+errorline + +# General 00 +class ConnectionError(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E00-000' + def __str__(self): + return exception_msg(self.code, self.args) + +class NoToken(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E00-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class IncorrectPlace(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E00-003' + def __str__(self): + return exception_msg(self.code, self.args) + +# Manage 09 +class NoUser(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E09-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class VaildationError(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E09-002' + def __str__(self): + return exception_msg(self.code, self.args) + +# fishing 01 +class FishingLimit(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E01-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class FishingError(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E01-002' + def __str__(self): + return exception_msg(self.code, self.args) + +class CannotAddFish(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E01-003' + def __str__(self): + return exception_msg(self.code, self.args) + +class CannotChangeFish(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E01-004' + def __str__(self): + return exception_msg(self.code, self.args) + +class CannotDeleteFish(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E01-005' + def __str__(self): + return exception_msg(self.code, self.args) + +class FishNotFOund(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E01-404' + def __str__(self): + return exception_msg(self.code, self.args) + +# gather 02 +class GatherLimit(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E02-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class GatherError(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E02-002' + def __str__(self): + return exception_msg(self.code, self.args) + +class CannotAddGather(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E02-003' + def __str__(self): + return exception_msg(self.code, self.args) + +class CannotChangeGather(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E02-004' + def __str__(self): + return exception_msg(self.code, self.args) + +class CannotDeleteGather(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E02-005' + def __str__(self): + return exception_msg(self.code, self.args) + +# inventory 03 +class NotEnoughGold(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E03-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class NotEnoughSpace(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E03-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class SellingError(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E03-002' + def __str__(self): + return exception_msg(self.code, self.args) + +class ItemNotFound(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E03-003' + def __str__(self): + return exception_msg(self.code, self.args) + +# Community 05 +class NoChara(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E05-001' + def __str__(self): + return exception_msg(self.code, self.args) + +# Licensing 90 +class NoLicense(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E90-000' + def __str__(self): + return exception_msg(self.code, self.args) + +class IncorrectLicense(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E90-001' + def __str__(self): + return exception_msg(self.code, self.args) + +class ExpiredLicense(Exception): + def __init__(self, *args: object): + self.args = args + self.code = 'E90-002' + def __str__(self): + return exception_msg(self.code, self.args) \ No newline at end of file diff --git a/JAS/resources/Selects.py b/JAS/resources/Selects.py new file mode 100644 index 0000000..baf8c83 --- /dev/null +++ b/JAS/resources/Selects.py @@ -0,0 +1,301 @@ +import discord +from discord import Interaction +from discord.components import SelectOption +from discord.interactions import Interaction +from discord.ui import Select + +class UserSelect(Select): + def __init__(self, data): + options = self.options(data) + super().__init__(placeholder="대상을 선택해 주세요", options=options, row=0) + + def options(self, data): + options = [] + for user in data: + label = user[1] + value = user[0] + option = discord.SelectOption(label=label, value=value) + options.append(option) + return options + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class UserManageSelect(Select): + def __init__(self): + menus = {"유저 정보":"유저정보 확인"} + options = [] + for i in menus.keys(): + label = menus[i] + option = SelectOption(label=label, value=i) + options.append(option) + + super().__init__(options=options) + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class InventoryManageSelect(Select): + def __init__(self): + menus = {"인벤토리 골드":"유저 골드 추가", "인벤토리 크기":"인벤토리 크기 조정"} + options = [] + for i in menus.keys(): + label = menus[i] + option = SelectOption(label=label, value=i) + options.append(option) + + super().__init__(options=options) + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class InventoryGoldSelect(Select): + def __init__(self): + placeholder = "지급할 금액을 선택해 주세요" + + options = [] + for i in range(20,-21, -5): + if i != 0: + label = str(i) + option = SelectOption(label=label, value=i) + options.append(option) + + super().__init__(options=options, placeholder=placeholder, row=1) + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class InventorySizeSelect(Select): + def __init__(self): + placeholder = "변경할 인벤토리 크기를 선택해 주세요" + + options = [] + for i in range(5,-6, -1): + if i != 0: + label = str(i) + option = SelectOption(label=label, value=i) + options.append(option) + + super().__init__(options=options, placeholder=placeholder, row=1) + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class StatManageSelect(Select): + def __init__(self): + menus = {"스탯 변경":"스탯명 변경", } + options = [] + for i in menus.keys(): + label = menus[i] + option = SelectOption(label=label, value=i) + options.append(option) + + super().__init__(options=options) + + async def callback(self, interaction:Interaction): + print(self.values) + await interaction.response.defer() + +class SystemManageSelect(Select): + def __init__(self): + menus = {"시스템 확인":"설정확인", "시스템 회원가입":"화원가입 허용 여부 변경", "시스템 변경":"기본수치 변경"} + options = [] + for i in menus.keys(): + label = menus[i] + option = SelectOption(label=label, value=i) + options.append(option) + + super().__init__(options=options) + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class ValueChange(Select): + def __init__(self, type, manage): + if type == 1: + placeholder = "최대 낚시 횟수" + base=manage.max_fishing + valuerange = range(1,11) + elif type == 2: + placeholder = "최대 수집 횟수" + base=manage.max_gather + valuerange = range(1,11) + elif type == 3: + placeholder = "기본 가방 크기" + base=manage.base_inv_size + valuerange = range(5,21,5) + elif type == 4: + placeholder = "최초 소지 골드" + base=manage.base_inv_gold + valuerange = range(5,31,5) + + options = [] + for i in valuerange: + label = str(i) + default = True if i == base else False + option = SelectOption(label=label, default=default) + options.append(option) + + super().__init__(options=options, placeholder=placeholder) + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +class SettingValueSelect(Select): + def __init__(self): + placeholder = '변경하고자 하는 항목을 선택해 주세요.' + options = self.options() + super().__init__(placeholder=placeholder, options=options, row=0) + + def options(self): + data = [ + ['구글 연동 링크','gspread_key','DB 데이터가 연동될 구글 스프레드 시트 링크'], + ['최대 낚시 횟수','max_fishing','하루에 최대로 낚시 가능한 기본 횟수'], + ['최대 채집 횟수','max_gather','하루에 최대로 채집 가능한 기본 횟수'], + ['기본 가방 크기','base_inv_size','캐릭터 생성시 부여받게되는 기본 인벤토리 크기'], + ['최초 소지 골드','base_inv_gold','캐릭터 생성시 부여받게되는 기본 골드'], + ['랜덤 박스 가격','random_box','랜덤 박스 가격'], + ] + + options = [] + for item in data: + options.append(SelectOption(label=item[0], value=item[1], description=item[2])) + return options + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +# NPC 선택 +class NPCSelect(Select): + def __init__(self, data) -> None: + placeholder = 'NPC를 선택해 주세요' + options = self.options(data) + super().__init__(placeholder=placeholder, options=options, row=0) + + def options(self, data): + options = [] + for item in data: + options.append(SelectOption(label=item[0], value=item[1])) + return options + + async def callback(self, interaction:Interaction): + print(self.values) + await interaction.response.defer() + +# 물고기 선택 +class FishSelect(Select): + def __init__(self, manage) -> None: + self.manage = manage + placeholder = "물고기를 선택해 주세요." + options = self.get_options() + super().__init__(placeholder=placeholder, min_values=1, max_values=1, options=options, row=0) + + def get_options(self): + options = [] + for fish in list(self.manage.keys()): + label = self.manage[fish].name + code = fish + options.append(SelectOption(label=label, value=code)) + return options + + async def callback(self, interaction): + print(self.values) + await interaction.response.defer() + +# 인벤토리 아이템 선택 +class InventoryItemSelect(Select): + def __init__(self, data, items, count=None) -> None: + placeholder = "아이템을 선택해 주세요" + options = self.options(data, items) + max_values = count or len(data) + super().__init__(placeholder=placeholder, min_values=1, max_values=max_values, options=options, row=0) + + def options(self, data:dict, items): + options = [] + for item in data: + label = items[item].name + value = item + desc = f'{items[item].price} {items[item].desc}' + option = SelectOption(label=label,value=value,description=desc) + options.append(option) + return options + + async def callback(self, interaction: Interaction) -> any: + print(self.values) + await interaction.response.defer() + +# 전체 아이템 선택 +class ItemSelect(Select): + def __init__(self, items) -> None: + placeholder = "아이템을 선택해 주세요" + options = self.options(items) + max_values = 5 + super().__init__(placeholder=placeholder, min_values=1, max_values=max_values, options=options, row=0) + + def options(self, items): + options = [] + print(items) + for item in items: + if "_" in item: + continue + label = items[item].name + value = item + desc = f'{items[item].price} {items[item].desc}' + option = SelectOption(label=label,value=value,description=desc) + options.append(option) + return options + + async def callback(self, interaction: Interaction) -> any: + print(self.values) + await interaction.response.defer() + +# 색상 선택 +class ColorSelect(Select): + def __init__(self): + placeholder = "원하는 색상을 선택해 주세요" + options = self.options() + super().__init__(placeholder=placeholder, min_values=1, max_values=1, options=options, row=0) + + def options(self): + return [ + SelectOption(label="teal",value=discord.Color.teal().value), + SelectOption(label="dark_teal",value=discord.Color.dark_teal().value), + SelectOption(label="brand_green",value=discord.Color.brand_green().value), + SelectOption(label="green",value=discord.Color.green().value), + SelectOption(label="dark_green",value=discord.Color.dark_green().value), + SelectOption(label="blue",value=discord.Color.blue().value), + SelectOption(label="purple",value=discord.Color.purple().value), + SelectOption(label="dark_purple",value=discord.Color.dark_purple().value), + SelectOption(label="magenta",value=discord.Color.magenta().value), + SelectOption(label="dark_magenta",value=discord.Color.dark_magenta().value), + SelectOption(label="gold",value=discord.Color.gold().value), + SelectOption(label="dark_gold",value=discord.Color.dark_gold().value), + SelectOption(label="orange",value=discord.Color.orange().value), + SelectOption(label="dark_orange",value=discord.Color.dark_orange().value), + SelectOption(label="brand_red",value=discord.Color.brand_red().value), + SelectOption(label="lighter_grey",value=discord.Color.lighter_grey().value), + SelectOption(label="dark_grey",value=discord.Color.dark_grey().value), + SelectOption(label="light_grey",value=discord.Color.light_grey().value), + SelectOption(label="darker_grey",value=discord.Color.darker_grey().value), + SelectOption(label="og_blurple",value=discord.Color.og_blurple().value), + SelectOption(label="blurple",value=discord.Color.blurple().value), + SelectOption(label="greyple",value=discord.Color.greyple().value), + SelectOption(label="fuchsia",value=discord.Color.fuchsia().value), + SelectOption(label="yellow",value=discord.Color.yellow().value), + SelectOption(label="pink",value=discord.Color.pink().value), + ] + + async def callback(self, interaction: Interaction) -> any: + print(self.values[0]) + await interaction.response.defer() + # color = discord.Color(int(self.values[0])) + # emb = discord.Embed(title='원하시는 색상을 선택해 주세요',color=color) + # await interaction.edit_original_response(embed=emb) diff --git a/JAS/resources/Views.py b/JAS/resources/Views.py new file mode 100644 index 0000000..8258e7a --- /dev/null +++ b/JAS/resources/Views.py @@ -0,0 +1,1040 @@ +import discord +from discord.enums import ButtonStyle +from discord.interactions import Interaction +from discord.ui import View, TextInput, Modal, Button +from discord.ext.commands import Cog +from discord import ButtonStyle, TextStyle, ui +from JAS.resources import Buttons, Embeds, Selects +from JAS.resources.Exceptions import * +import JAS.resources.Connector as Conn + +async def cancel_message(interaction:Interaction): + await interaction.message.delete() + # return await interaction.response.send_message(embed=Embeds.general('작업을 취소합니다.'), delete_after=5, ephemeral=True) + +async def delete_message(interaction:Interaction): + await interaction.response.defer() + await interaction.message.delete() + +timeout = 300 + +class templateView(View): + def __init__(self): + super().__init__(timeout=timeout) + + @ui.button(label="선택", style=ButtonStyle.primary, row=1) + async def add_callback(self, interaction:Interaction, button): + self.finish = True + await delete_message(interaction) + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction:Interaction, button): + self.stop() + +class templateModal(Modal): + def __init__(self): + title = "제목" + super().__init__(title=title, timeout=timeout) + + self.item = TextInput( + label="제목", + placeholder="설명", + max_length=100, + required=True, + ) + self.add_item(self.item) + + async def on_submit(self, interaction:Interaction) -> None: + await interaction.response.defer() + self.stop() + +# 송금 뷰 +class TransferView(View): + def __init__(self): + super().__init__(timeout=timeout) + self.cancel = False + # self.select = select.UserSelect(data) + # self.add_item(self.select) + + @ui.button(label="송금",row=1) + async def button_callback(self, interaction=discord.Interaction, button=Buttons): + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary,row=1) + async def select_callback(self, interaction=Interaction, button=Buttons): + self.cancel = True + # await interaction.edit_original_response(embed=embed.general('작업을 취소합니다.'), delete_after=5) + self.stop() + +class ManageView(View): + def __init__(self): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + + @ui.button(label="유저 관리", custom_id="User Management", row=0) + async def user_manage_callback(self, interaction:Interaction, button): + await interaction.response.defer() + self.result="유저" + self.stop() + + @ui.button(label="인벤토리 관리", custom_id="Inventory Management", row=1) + async def inventory_manage_callback(self, interaction:Interaction, button): + await interaction.response.defer() + self.result="인벤토리" + self.stop() + + @ui.button(label="스탯 관리", custom_id="Stat Management", row=1) + async def inventory_manage_callback(self, interaction:Interaction, button): + await interaction.response.defer() + self.result="스탯" + self.stop() + + # @ui.button(label="이벤트 관리", custom_id="Event Management", row=2) + # async def event_manage_callback(self, interaction:Interaction, button): + # await interaction.response.defer() + # self.result="이벤트" + # self.stop() + + @ui.button(label="시스템 관리", custom_id="System Management", row=3) + async def system_manage_callback(self, interaction:Interaction, button): + await interaction.response.defer() + self.result="시스템" + self.stop() + + @ui.button(label="취소", custom_id="cancel", row=4) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +# sub Views +# 유저 관리 뷰 +class UserManageView(View): + def __init__(self): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.select = Selects.UserManageSelect() + self.add_item(self.select) + + @ui.button(label="확인", style=ButtonStyle.primary, row=1) + async def confirm_callback(self, interaction:Interaction, button): + self.result = self.select.values[0] + await interaction.response.defer() + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=1) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +class UserInfo(View): + def __init__(self, manage:Conn.Connector, data): + self.result = None + self.cancel = None + self.manage = manage + super().__init__(timeout=timeout) + self.select_user = Selects.UserSelect(data) + self.add_item(self.select_user) + + @ui.button(label="선택", style=ButtonStyle.primary, row=1) + async def select_callback(self, interaction:Interaction, button): + selected_user=self.select_user.values[0] + id = int(selected_user.split(',')[0]) + await interaction.response.edit_message(embed=Embeds.show_user(self.manage, id), view=None) + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=1) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +# 인벤토리 관리 뷰 +class InventoryManageView(View): + def __init__(self, manage:Conn.Connector): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + self.select = Selects.InventoryManageSelect() + self.add_item(self.select) + + @ui.button(label="확인", style=ButtonStyle.primary, row=1) + async def confirm_callback(self, interaction:discord.Interaction, button): + self.result = self.select.values[0] + await interaction.response.defer() + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=1) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +class InventoryGoldChange(View): + def __init__(self, manage:Conn.Connector, data): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + self.data = data + self.user = Selects.UserSelect(data) + self.gold = Selects.InventoryGoldSelect() + self.add_item(self.user) + self.add_item(self.gold) + + @ui.button(label="확인", style=ButtonStyle.primary, row=2) + async def confirm_callback(self, interaction:discord.Interaction, button): + user = int(self.user.values[0]) + # name = list(filter(lambda x:x[0]==user, self.data))[0][1] + code = self.manage.data.user.data[user].charas + chara = self.manage.data.chara.data[code] + name = chara.name + gold = int(self.gold.values[0]) + self.manage.update_charactor_inventory(code=code, gold=gold) + after_gold = self.manage.data.inventory.data[code].gold + await interaction.response.edit_message(embed=Embeds.setting(f'{name}에게 G {gold}를 전달하였습니다', f'소지금액: {after_gold}'), view=None) + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=2) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +class InventorySizeChange(View): + def __init__(self, manage:Conn.Connector, data): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + self.data = data + self.user = Selects.UserSelect(data) + self.size = Selects.InventorySizeSelect() + self.add_item(self.user) + self.add_item(self.size) + + @ui.button(label="확인", style=ButtonStyle.primary, row=2) + async def confirm_callback(self, interaction:discord.Interaction, button): + user = int(self.user.values[0]) + # name = list(filter(lambda x:x[0]==user, self.data))[0][1] + code = self.manage.data.user.data[user].charas + chara = self.manage.data.chara.data[code] + name = chara.name + size = int(self.size.values[0]) + self.manage.update_charactor_inventory(code=code, size=size) + after_size = self.manage.data.inventory.data[code].size + await interaction.response.edit_message(embed=Embeds.setting(f'{name}의 가방 공간이 {size} 변경 되었습니다.', f'가방크기: {after_size}'), view=None) + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=2) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +# 스탯 관리 뷰 +class StatManageView(View): + def __init__(self, manage:Conn.Connector): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + self.select = Selects.StatManageSelect() + self.add_item(self.select) + + @ui.button(label="확인", style=ButtonStyle.primary, row=1) + async def confirm_callback(self, interaction:Interaction, button): + self.result = self.select.values[0] + await interaction.response.defer() + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=1) + async def cancel_callback(self, interaction, button): + self.cancel=True + await cancel_message(interaction) + self.stop() + +class StatNameChange(View): + def __init__(self, manage:Conn.Connector): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + + @ui.button(label="확인", style=ButtonStyle.primary, row=1) + async def confirm_callback(self, interaction:Interaction, button): + modal = ChangeStatName(','.join(self.manage.data.setting.data.stat_names)) + await interaction.response.send_modal(modal) + await modal.wait() + if modal.finish: + values = [name.strip() for name in modal.setting.value.split(',')] + if len(values) != 6 and not (len(values) == 1 and not values[0]): + for _ in range(len(values),6): + values.append('-') + value = ','.join(values) + self.manage.update_stat_name(value) + await interaction.followup.edit_message(interaction.message.id, embed=Embeds.setting('스탯 변경완료',f'변경된 스탯이 적용되었습니다.\n{value}'), view=None) + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=1) + async def cancel_callback(self, interaction, button): + self.cancel=True + await cancel_message(interaction) + self.stop() + +class ChangeStatName(Modal): + def __init__(self, default) -> None: + self.finish = False + super().__init__(title="스탯명 변경", timeout=timeout) + + self.setting = TextInput( + label='스탯명\n쉼표로 구분하여 기입해 주세요', + required=False, + max_length=300, + default=default + ) + self.add_item(self.setting) + + async def on_submit(self, interaction:Interaction): + self.finish = True + await interaction.response.defer() + + +# 시스템 관리 뷰 +class SystemManageView(View): + def __init__(self): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.add_item(Selects.SystemManageSelect()) + + @ui.button(label="확인", style=ButtonStyle.primary, row=1) + async def confirm_callback(self, interaction:Interaction, button): + self.result = self.children[2].values[0] + await interaction.response.defer() + self.stop() + + @ui.button(label="취소", style=ButtonStyle.secondary, row=1) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +class RegView(View): + def __init__(self, manage:Conn.Connector): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + + @ui.button(label="O") + async def open_callback(self, interaction:Interaction, button): + self.manage.__set_setting_value__("accept_user", "True") + await interaction.response.edit_message(embed=Embeds.setting('회원가입을 허용합니다.'),view=None) + self.stop() + + @ui.button(label="X") + async def close_callback(self, interaction:Interaction, button): + self.manage.__set_setting_value__("accept_user", "False") + await interaction.response.edit_message(embed=Embeds.setting('회원가입을 차단합니다.'),view=None) + self.stop() + +class ChangeSettingView(View): + def __init__(self, manage:Conn.Connector): + self.result = None + self.cancel = None + super().__init__(timeout=timeout) + self.manage = manage + self.select = Selects.SettingValueSelect() + self.add_item(self.select) + + @ui.button(label="확인", row=1) + async def open_callback(self, interaction:Interaction, button): + key = self.select.values[0] + values = { + "gspread_key":[self.manage.data.setting.data.gspread_key,'구글 연동 링크',2000], + "max_fishing":[self.manage.data.setting.data.max_fishing,'최대 낚시 횟수',2], + "max_gather":[self.manage.data.setting.data.max_gather,'최대 채집 횟수',2], + "base_inv_size":[self.manage.data.setting.data.base_inv_size,'기본 인벤토리 크기',2], + "base_inv_gold":[self.manage.data.setting.data.base_inv_gold,'기본 인벤토리 골드',2], + "random_box":[self.manage.data.setting.data.random_box,'랜덤박스 비용',2], + } + default, label, max_length = values[key] + modal = ChangeSetting(default, label, max_length) + await interaction.response.send_modal(modal) + await modal.wait() + if modal.finish: + await interaction.message.delete() + # if key == 'gspread_key': + # result = modal.setting.value + # else: + # result = int(modal.setting.value) + self.manage.__set_setting_value__(key, modal.setting.value) + await interaction.channel.send(embed=Embeds.show_system('설정 적용이 완료되었습니다.', self.manage.data.setting.data, '[관리 ▶ 시스템 ▶ 변경]')) + self.stop() + + @ui.button(label="취소", row=1) + async def close_callback(self, interaction:Interaction, button): + self.cancel = True + await interaction.response.defer() + self.stop() + +class ChangeSetting(Modal): + def __init__(self, default, label, max_length) -> None: + self.finish = False + super().__init__(title="설정 변경", timeout=timeout) + + self.setting = TextInput( + label=label, + required=True, + max_length=max_length, + default=default + ) + self.add_item(self.setting) + + async def on_submit(self, interaction:Interaction): + self.finish = True + await interaction.response.defer() + +# 낚시 +class FishingView(View): + label = "label" + style = ButtonStyle.secondary + + def __init__(self, hooked): + super().__init__(timeout=1.5) + self.hooked=hooked + self.result = False + if self.hooked: + self.children[0].label = "지금이야!" + self.children[0].style = ButtonStyle.primary + + @ui.button(label = "대기중...") + async def callback(self, interaction, button): + self.stop() + self.result = True + +class GetFishInfo(Modal): + def __init__(self, data=None) -> None: + title = "물고기 정보" + super().__init__(title=title, timeout=timeout) + + self.desc = TextInput( + label="물고기 설명", + placeholder="물고기에 대한 설명을 입력해 주세요", + default=data.desc if data else None, + style=TextStyle.paragraph, + max_length=100, + required=True, + ) + self.min = TextInput( + label="최소 길이", + placeholder="물고기의 최소 길이를 입력해 주세요", + default=data.min if data else None, + max_length=3, + required=True, + ) + self.max = TextInput( + label="최대 길이", + placeholder="물고기의 최대 길이를 입력해 주세요", + default=data.max if data else None, + max_length=3, + required=True + ) + self.baseprice = TextInput( + label="기본 가격", + placeholder="물고기가 팔릴 가격을 입력해 주세요", + default=data.baseprice if data else None, + max_length=20, + min_length=1, + required=True + ) + self.loc = TextInput( + label="등장위치(쉼표로 구분)", + placeholder="물고기가 등장할 낚시터 채널명을 입력해 주세요.", + default=data.loc if data else None, + required=True, + max_length=20 + ) + self.add_item(self.desc) + self.add_item(self.min) + self.add_item(self.max) + self.add_item(self.baseprice) + self.add_item(self.loc) + + async def on_submit(self, interaction) -> None: + await interaction.response.defer() + self.stop() + +class AddFishView(View): + def __init__(self): + super().__init__(timeout=timeout) + self.cancel = False + self.modal = GetFishInfo() + + @ui.button(label="추가", style=ButtonStyle.primary) + async def add_callback(self, interaction, button): + await interaction.message.delete() + await interaction.response.send_modal(self.modal) + await self.modal.wait() + self.stop() + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class SelectFishView(View): + def __init__(self, manage): + super().__init__(timeout=timeout) + self.fish = Selects.FishSelect(manage) + self.add_item(self.fish) + self.cancel = False + + @ui.button(label="선택", style=ButtonStyle.primary, row=1) + async def add_callback(self, interaction:Interaction, button): + await delete_message(interaction) + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + + +class ChangeFishView(View): + def __init__(self, data): + super().__init__(timeout=timeout) + self.modal = GetFishInfo(data) + self.data = data + self.cancel = False + + @ui.button(label="변경", style=ButtonStyle.primary) + async def change_callback(self, interaction, button): + await interaction.message.delete() + await interaction.response.send_modal(self.modal) + await self.modal.wait() + self.stop() + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class ConfirmFishDelete(View): + def __init__(self): + self.cancel = False + super().__init__(timeout=timeout) + + @ui.button(label="삭제", style=ButtonStyle.danger) + async def delete_callback(self, interaction, button): + await delete_message(interaction) + self.stop() + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +# 캐릭터 +class CharaInfo(Modal): + def __init__(self, title, data=None) -> None: + self.finish = False + super().__init__(title=title, timeout=timeout) + + self.name = TextInput( + label="이름", + placeholder="캐릭터의 이름", + default=data.name if data else None, + required=True, + max_length=20 + ) + self.keyword = TextInput( + label="키워드(쉼표로 구분)", + placeholder="캐릭터를 나타내는 키워드를 입력해 주세요", + default=data.keyword if data else None, + style=TextStyle.paragraph, + required=False, + max_length=100 + ) + self.desc = TextInput( + label="간단설명", + placeholder="캐릭터에 대한 간단한 설명 및 소개말", + default=data.desc if data else None, + style=TextStyle.paragraph, + required=False, + max_length=1000 + ) + self.link = TextInput( + label="신청서 링크", + placeholder="신청서 링크", + default=data.link if data else None, + required=True, + max_length=1000 + ) + self.add_item(self.name) + self.add_item(self.keyword) + self.add_item(self.desc) + self.add_item(self.link) + + async def on_submit(self, interaction) -> None: + self.finish = True + await interaction.response.defer() + self.stop() + + +class AddChara(View): + def __init__(self): + title = f'캐릭터 정보' + self.info = CharaInfo(title) + self.cancel = False + super().__init__(timeout=timeout) + + @ui.button(label="등록", style=ButtonStyle.primary) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.message.delete() + await interaction.response.send_modal(self.info) + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class ChangeChara(View): + def __init__(self, name, data): + self.cancel = False + title = f'{name}를 변경합니다.' + self.info = CharaInfo(title, data) + super().__init__(timeout=timeout) + + @ui.button(label="변경", style=ButtonStyle.primary) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.message.delete() + await interaction.response.send_modal(self.info) + await self.info.wait() + self.stop() + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class RemoveChara(View): + def __init__(self, name): + self.name = name + self.delete = False + self.cancel = False + super().__init__(timeout=timeout) + + @ui.button(label="삭제", style=ButtonStyle.danger) + async def add_callback(self, interaction:discord.Interaction, button): + self.delete = True + await interaction.response.edit_message(embed=Embeds.setting(f"{self.name}을/를 삭제하였습니다."), view=None) + self.stop() + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class NPCInfo(Modal): + def __init__(self, data:Conn.NPC=None) -> None: + title = "NPC 정보" + self.finish=False + self.data = data + vers_placeholder = '(상황)/(대사) 와 같은 형식으로 기입해 주세요' + super().__init__(title=title, timeout=timeout) + + self.name = TextInput( + label="NPC 이름", + default=data.name if data else '', + required=True, + max_length=20 + ) + self.vers1 = TextInput( + label="캐릭터의 대사1", + style=TextStyle.paragraph, + default='' if not data else data.case.split(',')[0]+'/'+data.vers[0] if data.number>=0 else '', + placeholder=vers_placeholder, + max_length=500 + ) + self.vers2 = TextInput( + label="캐릭터의 대사2", + style=TextStyle.paragraph, + default='' if not data else data.case.split(',')[1]+'/'+data.vers[1] if data.number>=1 else '', + placeholder=vers_placeholder, + required=False, + max_length=500 + ) + self.vers3 = TextInput( + label="캐릭터의 대사3", + style=TextStyle.paragraph, + default='' if not data else data.case.split(',')[2]+'/'+data.vers[2] if data.number>=2 else '', + placeholder=vers_placeholder, + required=False, + max_length=500 + ) + self.vers4 = TextInput( + label="캐릭터의 대사4", + style=TextStyle.paragraph, + default='' if not data else data.case.split(',')[3]+'/'+data.vers[3] if data.number>=3 else '', + placeholder=vers_placeholder, + required=False, + max_length=500 + ) + self.add_item(self.name) + self.add_item(self.vers1) + self.add_item(self.vers2) + self.add_item(self.vers3) + self.add_item(self.vers4) + + async def on_submit(self, interaction:Interaction): + self.finish = True + await interaction.response.defer() + self.stop() + +class AddNPC(View): + def __init__(self): + title = f'캐릭터 정보' + self.info = CharaInfo(title) + self.cancel = False + super().__init__(timeout=timeout) + + @ui.button(label="등록", style=ButtonStyle.primary) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.message.delete() + await interaction.response.send_modal(self.info) + await self.info.wait() + self.stop() + + @ui.button(label="취소") + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class UpdateNPC(View): + def __init__(self, npc_list, npc_data): + self.cancel = False + self.data = npc_data + self.select = Selects.NPCSelect(npc_list) + super().__init__(timeout=timeout) + self.add_item(self.select) + + @ui.button(label="선택", style=ButtonStyle.primary, row=1) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.message.delete() + self.modal = NPCInfo(self.data[self.select.values[0]]) + await interaction.response.send_modal(self.modal) + await self.modal.wait() + if not self.modal.finish: + self.cancel = True + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction:discord.Interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class RemoveNPC(View): + def __init__(self, npc_list): + self.cancel = False + self.select = Selects.NPCSelect(npc_list) + super().__init__(timeout=timeout) + self.add_item(self.select) + + @ui.button(label="삭제", style=ButtonStyle.danger, row=1) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.message.delete() + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction:discord.Interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class SelectNPC(View): + def __init__(self, npc_list): + self.cancel = False + self.select = Selects.NPCSelect(npc_list) + super().__init__(timeout=timeout) + self.add_item(self.select) + + @ui.button(label="선택", style=ButtonStyle.primary, row=1) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.message.delete() + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction:discord.Interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class CharaColor(View): + def __init__(self): + self.finish = False + super().__init__(timeout=300) + self.select = Selects.ColorSelect() + self.add_item(self.select) + + @ui.button(label="선택", style=ButtonStyle.primary, row=1) + async def finish_callback(self, interaction:Interaction, button): + self.finish = True + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction, button): + self.stop() + +# 인벤토리 +class SpawnItem(View): + def __init__(self, items): + self.cancel = False + super().__init__(timeout=timeout) + self.select = Selects.ItemSelect(items) + self.add_item(self.select) + + @ui.button(label="부여", style=ButtonStyle.primary, row=1) + async def confirm_callback(self, interaction:Interaction, button): + await delete_message(interaction) + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + +class SoldItemView(View): + def __init__(self, data, items): + self.cancel = False + super().__init__(timeout=timeout) + self.select = Selects.InventoryItemSelect(data, items) + self.add_item(self.select) + + @ui.button(label="판매", style=ButtonStyle.primary, row=1) + async def buy_callback(self, interaction:Interaction, button:Button): + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction:Interaction, button): + self.cancel = True + self.stop() + +# 스탯 +class SpendStat(View): + def __init__(self, user:discord.Member): + self.cancel = False + self.finish = False + self.user_id = user.id + super().__init__(timeout=timeout) + + @ui.button(label="적용", style=ButtonStyle.primary, row=1) + async def add_callback(self, interaction:discord.Interaction, button): + if self.user_id != interaction.user.id: + return await interaction.response.send_message('스탯은 본인만 조정할 수 있습니다', ephemeral=True) + self.finish = True + await interaction.response.defer() + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction:discord.Interaction, button): + if self.user_id != interaction.user.id: + return await interaction.response.send_message('스탯은 본인만 조정할 수 있습니다', ephemeral=True) + self.cancel = True + await cancel_message(interaction) + self.stop() + +class AllStat(View): + def __init__(self, type, data:Conn.Stat, user:discord.Member): + super().__init__(timeout=timeout) + self.user_id = user.id + self.point = data.point + if type == 1: + self.stat1_origin = data.stat1 + self.stat1_value = data.stat1 + self.stat2_origin = data.stat2 + self.stat2_value = data.stat2 + self.stat3_origin = data.stat3 + self.stat3_value = data.stat3 + self.stat1_sub = Buttons.sub_stat(0, user) + self.stat1_current = Buttons.current_stat(data.stat_names[0], self.stat1_value,0) + self.stat1_add = Buttons.add_stat(0, user) + self.stat2_sub = Buttons.sub_stat(1, user) + self.stat2_current = Buttons.current_stat(data.stat_names[1],self.stat2_value,1) + self.stat2_add = Buttons.add_stat(1, user) + self.stat3_sub = Buttons.sub_stat(2, user) + self.stat3_current = Buttons.current_stat(data.stat_names[2],self.stat3_value,2) + self.stat3_add = Buttons.add_stat(2, user) + if self.stat1_origin==self.stat1_value: + self.stat1_sub.disabled=True + if self.stat2_origin==self.stat2_value: + self.stat2_sub.disabled=True + if self.stat3_origin==self.stat3_value: + self.stat3_sub.disabled=True + if self.point==0: + self.stat1_add.disabled=True + self.stat2_add.disabled=True + self.stat3_add.disabled=True + self.add_item(self.stat1_sub) + self.add_item(self.stat1_current) + self.add_item(self.stat1_add) + self.add_item(self.stat2_sub) + self.add_item(self.stat2_current) + self.add_item(self.stat2_add) + self.add_item(self.stat3_sub) + self.add_item(self.stat3_current) + self.add_item(self.stat3_add) + elif type == 2: + self.stat4_origin = data.stat4 + self.stat4_value = data.stat4 + self.stat5_origin = data.stat5 + self.stat5_value = data.stat5 + self.stat6_origin = data.stat6 + self.stat6_value = data.stat6 + self.stat4_sub = Buttons.sub_stat(0, user) + self.stat4_current = Buttons.current_stat(data.stat_names[3], self.stat4_value,0) + self.stat4_add = Buttons.add_stat(0, user) + self.stat5_sub = Buttons.sub_stat(1, user) + self.stat5_current = Buttons.current_stat(data.stat_names[4],self.stat5_value,1) + self.stat5_add = Buttons.add_stat(1, user) + self.stat6_sub = Buttons.sub_stat(2, user) + self.stat6_current = Buttons.current_stat(data.stat_names[5],self.stat6_value,2) + self.stat6_add = Buttons.add_stat(2, user) + if self.stat4_origin==self.stat4_value: + self.stat4_sub.disabled=True + if self.stat5_origin==self.stat5_value: + self.stat5_sub.disabled=True + if self.stat6_origin==self.stat6_value: + self.stat6_sub.disabled=True + if self.point==0: + self.stat4_add.disabled=True + self.stat5_add.disabled=True + self.stat6_add.disabled=True + self.add_item(self.stat4_sub) + self.add_item(self.stat4_current) + self.add_item(self.stat4_add) + self.add_item(self.stat5_sub) + self.add_item(self.stat5_current) + self.add_item(self.stat5_add) + self.add_item(self.stat6_sub) + self.add_item(self.stat6_current) + self.add_item(self.stat6_add) + elif type == 3: + self.stat1_origin = data.stat1 + self.stat1_value = data.stat1 + self.stat2_origin = data.stat2 + self.stat2_value = data.stat2 + self.stat3_origin = data.stat3 + self.stat3_value = data.stat3 + self.stat4_origin = data.stat4 + self.stat4_value = data.stat4 + self.stat5_origin = data.stat5 + self.stat5_value = data.stat5 + self.stat1_sub = Buttons.sub_stat(0, user) + self.stat1_current = Buttons.current_stat(data.stat_names[0], self.stat1_value,0) + self.stat1_add = Buttons.add_stat(0, user) + self.stat2_sub = Buttons.sub_stat(1, user) + self.stat2_current = Buttons.current_stat(data.stat_names[1],self.stat2_value,1) + self.stat2_add = Buttons.add_stat(1, user) + self.stat3_sub = Buttons.sub_stat(2, user) + self.stat3_current = Buttons.current_stat(data.stat_names[2],self.stat3_value,2) + self.stat3_add = Buttons.add_stat(2, user) + self.stat4_sub = Buttons.sub_stat(0, user) + self.stat4_current = Buttons.current_stat(data.stat_names[3], self.stat4_value,0) + self.stat4_add = Buttons.add_stat(0, user) + self.stat5_sub = Buttons.sub_stat(1, user) + self.stat5_current = Buttons.current_stat(data.stat_names[4],self.stat5_value,1) + self.stat5_add = Buttons.add_stat(1, user) + if self.stat1_origin==self.stat1_value: + self.stat1_sub.disabled=True + if self.stat2_origin==self.stat2_value: + self.stat2_sub.disabled=True + if self.stat3_origin==self.stat3_value: + self.stat3_sub.disabled=True + if self.stat4_origin==self.stat4_value: + self.stat4_sub.disabled=True + if self.stat5_origin==self.stat5_value: + self.stat5_sub.disabled=True + if self.point==0: + self.stat1_add.disabled=True + self.stat2_add.disabled=True + self.stat3_add.disabled=True + self.stat4_add.disabled=True + self.stat5_add.disabled=True + self.add_item(self.stat1_sub) + self.add_item(self.stat1_current) + self.add_item(self.stat1_add) + self.add_item(self.stat2_sub) + self.add_item(self.stat2_current) + self.add_item(self.stat2_add) + self.add_item(self.stat3_sub) + self.add_item(self.stat3_current) + self.add_item(self.stat3_add) + self.add_item(self.stat4_sub) + self.add_item(self.stat4_current) + self.add_item(self.stat4_add) + self.add_item(self.stat5_sub) + self.add_item(self.stat5_current) + self.add_item(self.stat5_add) + +class Ticket(View): + def __init__(self, data): + self.cancel = False + super().__init__(timeout=timeout) + self.modal = self.Response(data) + + @ui.button(label="답변", style=ButtonStyle.primary, row=1) + async def add_callback(self, interaction:discord.Interaction, button): + await interaction.response.send_modal(self.modal) + await self.modal.wait() + if self.modal.finish: + await interaction.message.edit(view = None) + self.stop() + + @ui.button(label="취소", row=1) + async def cancel_callback(self, interaction, button): + self.cancel = True + await cancel_message(interaction) + self.stop() + + class Response(Modal): + def __init__(self, data=None) -> None: + self.finish = False + title = "답변 작성" + super().__init__(title=title, timeout=timeout) + + self.question = TextInput( + label="질문내용", + default=data, + style=TextStyle.paragraph, + ) + self.content = TextInput( + label="답변", + placeholder="질문에 대한 답변", + style=TextStyle.paragraph, + required=True, + max_length=1000 + ) + self.add_item(self.question) + self.add_item(self.content) + + async def on_submit(self, interaction:Interaction) -> None: + await interaction.response.defer() + self.finish = True + self.stop() \ No newline at end of file diff --git a/JAS/resources/__init__.py b/JAS/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/JAS/setup.py b/JAS/setup.py new file mode 100644 index 0000000..858e6c8 --- /dev/null +++ b/JAS/setup.py @@ -0,0 +1,461 @@ +import discord, os, sqlite3, traceback, shutil +from JAS.resources.Addons import DB_FOLDER_PATH, IMG_FOLDER_PATH, resource_path, filename + +class Setup: + def __init__(self): + pass + + def check(self, guild_id): + if os.path.exists(os.path.join(DB_FOLDER_PATH, f'{guild_id}.db')): + return True + else: + print('creating guild folder') + os.makedirs(os.path.join(IMG_FOLDER_PATH, str(guild_id)), exist_ok=True) + return False + + def connect(self, guild_id): + return sqlite3.connect(os.path.join(DB_FOLDER_PATH, f'{guild_id}.db')) + + def update(self, conn:sqlite3.Connection, field:dict, table): + """ + DB TABLE 업데이트 + """ + conn.row_factory = sqlite3.Row + cur = conn.cursor() + data_cur = cur.execute(f'SELECT * FROM {table}') + cols = [col[0] for col in data_cur.description] + data = [dict(row) for row in data_cur.fetchall()] + + if list(field.keys())==cols: + return + + temp_name = f'{table}_temp' + cur.execute(f'ALTER TABLE {table} rename to {temp_name}') + + try: + script = "" + column = list(field.keys()) + for col in column: + script = script + f'{col} {field[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE {table}({script})") + values = [] + script = f'INSERT INTO {table} VALUES({",".join(["?"]*len(column))})' + for i in range(len(data)): + row = data[i] + temp_val = [] + for colname in column: + value = row[colname] if colname in cols else None + temp_val.append(value) + values.append(temp_val) + print(f'Script: {script}') + print(values) + cur.executemany(script, values) + cur.execute(f'DROP TABLE {temp_name}') + conn.commit() + except Exception as e: + cur.execute(f'ALTER TABLE {temp_name} rename to {table}') + + + def setup(self, guild_id): + self.check(guild_id) + print(' check complete') + self.create_DB(guild_id) + print(' create db complete') + return self.connect(guild_id) + + def create_DB(self,guild_id): + conn = None + try: + print('start Setup Process') + conn = sqlite3.connect(os.path.join(DB_FOLDER_PATH, f'{guild_id}.db')) + self.creat_user(conn) + self.create_chara(conn) + self.create_NPC(conn, guild_id) + self.create_store(conn) + self.default_setting(conn) + self.create_fishing(conn) + self.creat_gather(conn) + self.create_merge(conn) + print('finish Setup Process') + except Exception as e: + print(e) + print(traceback.format_exc()) + finally: + if conn: + conn.close() + + def creat_user(self, conn:sqlite3.Connection): + cur = conn.cursor() + user_list = ("id","user","loc","fish","size") + inventory_list = ("id","gold","size","item") + user = { + "id":"integer primary key", + "name":"text", + "thread":"integer", + "charas":"text", + } + check_user = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='user'").fetchone()[0] + if check_user != 1: + print('creat user') + script = "" + for col in list(user.keys()): + script = script + f'{col} {user[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE user({script})") + conn.commit() + else: + self.update(conn, user, 'user') + + def create_chara(self, conn:sqlite3.Connection): + cur = conn.cursor() + chara_list = ("id","name","keyword","desc") + chara={ + "code":"text primary key", + "name":"real", + "keyword":"integer", + "desc":"text", + "link":"text", + "color":"integer", + } + stat = { + "code":"text primary key", + "point":"integer", + "statname":"text", + "stat1":"integer", + "stat2":"integer", + "stat3":"integer", + "stat4":"integer", + "stat5":"integer", + "stat6":"integer", + } + inventory={ + "code":"text primary key", + "gold":"real", + "size":"integer", + "item":"text" + } + check_chara = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='chara'").fetchone()[0] + check_stat = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='stat'").fetchone()[0] + check_inventory = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='inventory'").fetchone()[0] + if check_chara != 1: + print('creat chara') + script = "" + for col in list(chara.keys()): + script = script + f'{col} {chara[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE chara({script})") + conn.commit() + else: + self.update(conn, chara, 'chara') + + if check_stat != 1: + print('creat stat') + script = "" + for col in list(stat.keys()): + script = script + f'{col} {stat[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE stat({script})") + conn.commit() + else: + self.update(conn, stat, 'stat') + + if check_inventory != 1: + print('creat inventory') + script = "" + for col in list(inventory.keys()): + script = script + f'{col} {inventory[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE inventory({script})") + conn.commit() + else: + self.update(conn, inventory, 'inventory') + + def create_NPC(self, conn:sqlite3.Connection, guils_id): + cur = conn.cursor() + NPC = { + "code":"text primary key", + "name":"text", + "color":"integer", + } + NPC_vers = { + "code":"text", + "type":"text", + "vers":"text" + } + check_NPC = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='NPC'").fetchone()[0] + check_NPC_vers = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='NPC_vers'").fetchone()[0] + if check_NPC != 1: + print('creat NPC') + script = "" + for col in list(NPC.keys()): + script = script + f'{col} {NPC[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE NPC({script})") + + code = "NPC_store001" + name = "상점NPC" + + cur.execute('INSERT INTO NPC VALUES(?,?,?)',(code, name, 0)) + cases = ["환영","판매성공","구매취소","잔액부족"] + + for case in cases: + shutil.copy(resource_path('img','sample.png'), os.path.join(IMG_FOLDER_PATH,str(guils_id),filename(f'{name}_{case}.png'))) + conn.commit() + else: + self.update(conn, NPC, 'NPC') + + if check_NPC_vers != 1: + print('creat NPC') + script = "" + for col in list(NPC_vers.keys()): + script = script + f'{col} {NPC_vers[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE NPC_vers({script})") + + code = "NPC_store001" + + cur.executescript(f''' + INSERT INTO NPC_vers VALUES("{code}","환영","상점에 온걸 환영해!\n사고 싶은걸 골라봐~"); + INSERT INTO NPC_vers VALUES("{code}","판매성공","구매해 줘서 고마워!"); + INSERT INTO NPC_vers VALUES("{code}","구매취소","다음에 또 방문해줘"); + INSERT INTO NPC_vers VALUES("{code}","잔액부족","앗! 금액이 충분하지 않아!\n다음에 다시 찾아와줘~"); + ''') + conn.commit() + else: + self.update(conn, NPC_vers, 'NPC_vers') + + def create_store(self, conn:sqlite3.Connection): + cur = conn.cursor() + # cur.execute('CREATE TABLE item(code text, name text, description text, number integer, price real)') + # cur.execute('CREATE TABLE store(code text, sale text, price integer, discount real)') + + item_list = ("code","name","description","number","price") + store_list = ("code","sale","price","discount") + item = { + "code":"text primary key", + "name":"text", + "description":"str", + "number":"text", + "price":"integer" + } + store={ + "code":"text primary key", + "sale":"text", + "price":"integer", + "discount":"integer" + } + check_item = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='item'").fetchone()[0] + check_store = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='store'").fetchone()[0] + if check_item != 1: + print('creat item data') + script = "" + for col in list(item.keys()): + script = script + f'{col} {item[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE item({script})") + conn.commit() + else: + self.update(conn, item, 'item') + + if check_store != 1: + print('creat store data') + script = "" + for col in list(store.keys()): + script = script + f'{col} {store[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE store({script})") + + cur.executescript(''' + INSERT INTO store values("3001", "True", 10, 1); + INSERT INTO store values("3002", "True", 20, 1); + INSERT INTO item values("3001", "칫솔", "빳빳한 칫솔\n양치질은 중요해!", 1, 5); + INSERT INTO item values("3002", "보석", "아름다운 보석\n선물해 주기 딱 좋다.", 1, 10); + ''') + conn.commit() + else: + self.update(conn, store, 'store') + + def create_fishing(self, conn:sqlite3.Connection): + cur = conn.cursor() + history_list = ("fishdate","user","loc","fish","size") + data_list = ("code","name","min","max","baseprice","loc") + fishing_history = { + "fishdate":"text", + "user":"integer", + "loc":"str", + "fish":"text", + "size":"real" + } + fishing_data={ + "code":"text primary key", + "name":"text", + "min":"integer", + "max":"integer", + "baseprice":"integer", + "loc":"text" + } + check_data = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='fishing_data'").fetchone()[0] + check_history = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='fishing_history'").fetchone()[0] + if check_data != 1: + print('creat fishing data') + script = "" + for col in list(fishing_data.keys()): + script = script + f'{col} {fishing_data[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE fishing_data({script})") + + cur.executescript(''' + INSERT INTO fishing_data values("1001", "멸치", 1, 3, 5, "기본 낚시터"); + INSERT INTO fishing_data values("1002", "고등어", 2, 10, 20, "기본 낚시터"); + INSERT INTO item values("1001", "멸치", "작은 멸치\n볶음으로 만들어 먹기에 딱 좋은 크기이다.", 1, 5); + INSERT INTO item values("1002", "고등어", "고등어\n구워먹으면 맛있다.", 1, 20); + ''') + conn.commit() + else: + self.update(conn, fishing_data, 'fishing_data') + + if check_history != 1: + print('creat fishing history') + script = "" + for col in list(fishing_history.keys()): + script = script + f'{col} {fishing_history[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE fishing_history({script})") + conn.commit() + else: + self.update(conn, fishing_history, 'fishing_history') + + def creat_gather(self, conn:sqlite3.Connection): + cur = conn.cursor() + history_list = ("gatherdate","user","loc","item","size") + data_list = ("code","name","min","max","baseprice","loc") + gather_history = { + "gatherdate":"text", + "user":"integer", + "loc":"str", + "item":"text", + "size":"real" + } + gather_data={ + "code":"text primary key", + "name":"text", + "min":"integer", + "max":"integer", + "baseprice":"integer", + "loc":"text" + } + check_data = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='gather_data'").fetchone()[0] + if check_data != 1: + print('creat gather data') + script = "" + for col in list(gather_data.keys()): + script = script + f'{col} {gather_data[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE gather_data({script})") + cur.executescript(''' + INSERT INTO gather_data values("2001", "사과", 1, 1, 5, "기본 채집터"); + INSERT INTO gather_data values("2002", "복숭아", 1, 1, 10, "기본 채집터"); + INSERT INTO item values("2001", "사과", "맛있어 보이는 사과\n밤에는 먹지말자.", 1, 5); + INSERT INTO item values("2002", "복숭아", "복숭아\n털이 많다.", 1, 10); + ''') + + conn.commit() + else: + self.update(conn, gather_data, 'gather_data') + + check_history = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='gather_history'").fetchone()[0] + if check_history != 1: + print('creat gather history') + script = "" + for col in list(gather_history.keys()): + script = script + f'{col} {gather_history[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE gather_history({script})") + conn.commit() + else: + self.update(conn, gather_data, 'gather_data') + + def create_merge(self, conn:sqlite3.Connection): + cur = conn.cursor() + store_list = ("code","sale","price","discount") + recipe = { + "code":"text primary key", + "name":"text", + "merge":"text", + "rate":"integer" + } + check_recipe = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='recipe'").fetchone()[0] + if check_recipe != 1: + print('creat recipe data') + script = "" + for col in list(recipe.keys()): + script = script + f'{col} {recipe[col]}, ' + script = script[:-2] + cur.execute(f"CREATE TABLE recipe({script})") + + cur.executescript(''' + INSERT INTO recipe values("4001","물고기죽","1001,1002",100); + INSERT INTO item values("4001", "물고기죽", "여러 물고기를 섞어 끓인 탕.\n맛은 보장할 수 없다.", 1, 10); + ''') + conn.commit() + else: + self.update(conn, recipe, 'recipe') + + def default_setting(self, conn:sqlite3.Connection): + cur = conn.cursor() + setting = { + "type":"text primary key", + "value":"text" + } + + check_history = cur.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='setting'").fetchone()[0] + if check_history != 1: + print('creat setting') + script = "" + for col in list(setting.keys()): + script = script + f'{col} {setting[col]}, ' + script = script[:-2] + + cur.execute(f"CREATE TABLE setting({script})") + else: + self.update(conn, setting, 'setting') + + values = { + "max_fishing":"3", + "max_gather":"3", + "base_inv_size":"10", + "base_inv_gold":"10", + "accept_user":"True", + "random_box":"5", + "gspread_url":"", + "anon":"익명게시판", + "manage":"관리", + "join":"가입", + "store":"상점", + "community":"캐입역극", + "qna":"질의응답", + "visitor":"방문자", + "registered":"가입자", + "admin":"관리자", + "stat_names":"체력,힘,지능,관찰,민첩,운", + } + + for type in list(values.keys()): + try: + cur.execute(f'INSERT INTO setting VALUES("{type}","{values[type]}")') + except Exception as e: + # ignore unique error + e + + conn.commit() + + # def get_db_list(self, conn): + # tables = [] + # cur = conn.cursor() + # cur.execute("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' UNION ALL SELECT name FROM sqlite_temp_master WHERE type IN ('table', 'view') ORDER BY 1;") + + # for table in cur: + # tables.append(table[0]) + + # return tables diff --git a/app.py b/app.py new file mode 100644 index 0000000..06fec89 --- /dev/null +++ b/app.py @@ -0,0 +1,90 @@ +import os, shutil, sys, json +import sqlite3 +import logging, logging.handlers, multiprocessing +from io import BytesIO +from PIL import Image +from random import choice, randrange, random +from math import floor +from asyncio import sleep +from copy import deepcopy +from time import strftime,localtime, sleep +from traceback import format_exc + +from discord import CustomActivity, Intents +from JAS.resources.Addons import read_json + +def run_discord(receive_queue:multiprocessing.Queue, send_queue:multiprocessing.Queue): + from JAS.resources.Base import JAS, logger, handler + while True: + if not receive_queue.empty(): + queuedata = receive_queue.get() + print('from main discord process >>',queuedata) + if 'stop process' == queuedata: + print(' stop process') + break + elif 'start bot' == queuedata: + try: + print('startup process') + data = read_json() + + intents = Intents.default() + intents.message_content = True + intents.members = True + intents.guilds = True + + activity = CustomActivity(name="Running DEMO...") + bot = JAS(intents=intents, command_prefix='!', activity=activity, logger=logger) + + bot.rec_queue = receive_queue + bot.send_queue = send_queue + bot.TOKEN = data["TOKEN"] + bot.PROFILE = data["PROFILE"] + bot.TEST_SERVER_ID = int(data["TEST_SERVER_ID"]) + bot.AUTH_JSON_PATH = data["AUTH_JSON_PATH"] + print(bot.TOKEN, bot.PROFILE, bot.TEST_SERVER_ID, bot.AUTH_JSON_PATH) + print('launching discord bot') + bot.run(bot.TOKEN, log_handler=handler) + send_queue.put_nowait('bot terminated') + print('bot terminated') + except Exception as e: + send_queue.put('error') + send_queue.put(str(e)) + else: + if queuedata != 'stop bot': + receive_queue.put(queuedata) + else: + sleep(1) + print('end discord process') + pass + +def run_GUI(receive_queue:multiprocessing.Queue, send_queue:multiprocessing.Queue): + from JAS.UI import QApplication, ComuBotAPP + app = QApplication(sys.argv) + main_window = ComuBotAPP() + main_window.rec_queue = receive_queue + main_window.send_queue = send_queue + main_window.show() + + app.exec() + send_queue.close() + receive_queue.close() + print('end GUI process') + pass + + +if __name__ == '__main__': + multiprocessing.freeze_support() + + # construct env + from JAS.resources.Addons import prep_env + prep_env() + + queue1 = multiprocessing.Queue() + queue2 = multiprocessing.Queue() + discord_process = multiprocessing.Process(target=run_discord, args=(queue1,queue2)) + pyGUI_process = multiprocessing.Process(target=run_GUI, args=(queue2,queue1)) + + pyGUI_process.start() + discord_process.start() + discord_process.join() + pyGUI_process.join() diff --git a/discord_app.py b/discord_app.py new file mode 100644 index 0000000..b37f85d --- /dev/null +++ b/discord_app.py @@ -0,0 +1,40 @@ +#run.py +import discord, traceback, os, logging, logging.handlers +from discord.ext import tasks + +#get libs +from JAS.resources.Exceptions import * +from JAS.resources.Addons import read_json, prep_env + +if __name__ == '__main__': + prep_env() + from JAS.resources.Base import JAS, logger, handler + from JAS.mailing import Mailer + + #intents + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + intents.guilds = True + while True: + data = read_json() + SMTP_SERVER = data['SMTP_SERVER'] + SMTP_PORT = data['SMTP_PORT'] + MAIL_ID = data['MAIL_ID'] + MAIL_PW = data['MAIL_PW'] + mail_sender = Mailer(SMTP_SERVER, SMTP_PORT, MAIL_ID, MAIL_PW) + + activity = discord.CustomActivity(name="⚙️ 위이잉 치킨") + bot = JAS(intents=intents, command_prefix='!', activity=activity, logger=logger) + bot.TOKEN = data["TOKEN"] + bot.PROFILE = data["PROFILE"] + bot.TEST_SERVER_ID = int(data["TEST_SERVER_ID"]) + bot.AUTH_JSON_PATH = data["AUTH_JSON_PATH"] + + bot.run(bot.TOKEN, log_handler=handler) + + if bot.is_quit: + break + else: + # print('error') + mail_sender.send_email(0,'봇이 의도치 않게 중단되었습니다. 로그를 확인하여 주시기 바랍니다.') \ No newline at end of file diff --git a/img/UI/disk.png b/img/UI/disk.png new file mode 100644 index 0000000000000000000000000000000000000000..51bf067f56306da742d08c589dc65d7137896229 GIT binary patch literal 14182 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 VXDzB50=|)hD3*2>)d#3&|33zshxq^i literal 0 HcmV?d00001 diff --git a/img/UI/exit.png b/img/UI/exit.png new file mode 100644 index 0000000000000000000000000000000000000000..1e42767b3eb05a90b6204066d18e5a1622012de6 GIT binary patch literal 8495 zcmYLvc|4R|^!PIiBijhsmt<_ontdB(9lf@Rlr1G?2}4A(jFbr}4N}>PsgR<@lENdT zq-CU%C4*N9F}6%)`QB&V@9+Ej!^d;aJ=;C^+;h)4cQV{u9EA8~_#p@qB01WUAqWjV z(GVXm__q|__XqsjL~%TD0)m9wkv~*dWW^Bxl#a36AG0^|XiVIp=pZOAE>1fnBJ4!q zAxe;TWc0DZSqm8m+6s|ucX`AY{Tx1(rxJ9kYd&%$;%ZX*n?rf$cO13dBqFiPy-j_) zOdFL?PbyHfeDftGRV9Zsl1b=ARYIFzdB-{1H0457{zK5_w)1>vZtU2eb)!sIaBybN z_e)dhM{gHA_4v4V&S&JE>APz`O0La*Z;|}-d$?h0pU};wH@qdhGr}{xGlDaGGX}q= z4+kjeqIXFBHD08KPe;U9Yo*wPvHS21!tMRrlUeU@(YG+XhIU`~ z0`UuPM3=E-X@+P>eio@q+#=mhH*)WBUACfY`#QVf3hvvn? zzu@bW_5=+{XER)YHpQqjDCt(l{pX`Pq+6j*&{ce*bi6x2Wa!u%ZzVkmd9Fm2SC1I^ zJ2PqD=NLxyqvkWS9my_bagYQ?67!d5<>t3P1mP6YcE5jx;kWPv?4N2`@nxU;5O(qy zf*DcvmA6rzZ78>X{p;x{&K|PLwv&(qx`3sZ;yh=@dIJAJlyQi~QJ;vbtv28IHh1^2 z1y=67*G4X*yYCd=4NM}6)_S`EWuic9B?OJP!xcf!d#9;h%B1cWgBgn2`yNg2Vs$#w zA4Un*W^hn}h7I?5e%aFVvYXXiCNmXu_w5YZLXpuGAd!tEPz|EQBh}wNZf9Y{3pn`% zo{A|lYy%&Fdox>FdLKz}Bs#N`TFw@%@t9*?os`s6ZRUw6DKg7V> zSP20Q^wx-}PiQY_1bd$mZ}@EcV`UQEY3bCMt8SRnut1_C%%T4$3bmNztUXx|2@Oz;$gV-nd)!Mx18!EdCOKB-X|3D@@9|Y5(I4tba(L$3n`X>#SND#Y0X6blRg?D@_?4pUyL|~H zdbGYGM`a%Kv?n2@Qj0qMdh=;d ztIeMLTX&hDvBw5>?!eFLPd$z<0MRM2`3$Gc&pxSa<{X$jCMimMhP$VOY7f%6Kp;IC z$X}Y{#98`(K1gS%P4=+Ar|cf6DN=+k^6Yak-bNyCx2K*ypTaW|8U8}{uOq$hQF(^q zSX!U-giZ#5^!u;qyv?(brpsgXzzmhRMU9Jv4)}%SP-TkS?>&Ip>bBV?c5rXA;c}h6(H85n-0c^EO^3|2SU7ocHbVA+pO&x%I$|>`&gd?3H{m)5Z z>&&@gMV_x#zwQ!9Q%n)|iLrxkR=g!W9!LT7&h-xbqJP-V+JjrNlt|?J^%hw6LXI}? zq9-eTQWb3Fms=gJWpj1@`#X07-6fP^~=*clO~tR2vs8 z+57~(Uw-)Pe+Z|~0t8JEqX-(Y@&QJ*gr>_1zFQFT%4x_|xi}6wj=i6On6fXHI$T~j z*`@_oakcfs^=ma{C=5v<$flE!xq~q?ZO#EiC$g{^?80lrJ}6q z42dbo+}@aZs}9C+wMrB2yhq(jNr}M#$1TJ3WJZ+j;f25<>_ydQgKs<%@W|RP=6G_> z)G>v?DzoxVLZ7&Tw8{LCED~UQ-k|Go^%)ViA7zz_%~25-K@`?!qJ&ZYd=|L=tg#kd zv4bM_Q4zQZ-WHxj`No#Fs-DeE+t34^0$WL1^B$}E0{D3D8E%O#o{Wy(d=S`}rj=ET7o&gKdlESIgInxhQ$5HS&%5jVDto1>gtyLR<*r|*`@R=jV6Z#9a! zNpp$41MFhNwnVGl*v*x~%UYdO-43|fahK$3Z|s(ap-xDlow!T(!z5}pWyr*v+&F@u zk5n>+`a0TCD*8a$A8p)Q;A*O~*t`y4fm87Ul*kw{LttDi!0m2Nf@P`MZApEd#Z(kx zPQ{Hq55(R}%wh$REjneI`>RNt4~&i&T=w4g5$#A$cTrmZezG`LYZklIArDx5_yHeu zFm_kgk?2zf%1^u&xuJn@icE)`y8(~iJF&rN#&or*c<|U*w31F;1GX?@y|R#y}J$-KSHL2wTHp;`{r>)U;F`%%iGx`|^ywhgA~R^t`is&zIW8 zetH5sYG@jto5TeJ+}LT4vG4B1%OL?3dHIF5OKD)I=n2gFV(LoXqvJkV$pMHlY6c|G zUp?MFv~*ue1Kvmf!@Mn0s}LV$`ubPnBn~($Vfn7}cWgCuQs>y0MA;kQ_=YEBv4r8@ zaFKosqHtc5k)Ixg$Vv-JdN8Raghv6(zQ5g$s?iB-D^olRFg&i*7fEr{+0$t|@@Qb~ zrXjCKsi)v$siVoQi1G3Y>(5ecq}fsSk%94i0RNm(kyx#8{61pVxGx;+v_WnfM z8iyjg`N|O6(KyyM%oy9mroSVn$Ea4@ldQ* zfYNa!B>+eciJs$G*K5iUU2XG75oxv@RRYTG6+#^oC1P-uhVV%rD_vwk*9X))Ghzzt zjP^QoeV!hXeJ$5MaZ9alMA*zj*{_6i89 zAx4ycJ6sW0occ;cZAcE=miimbV-T|8LBVIz{UXDTivIfZX_qUs#gCe^KBw9R8V2(X z@m?hc@5Nkqh$F65c&hmwVc!-1yg67Gct2Txh<6cN2^6(Jk z`0^h2>%m(n=b~&Xlgod95TH(i@#Td%grGy`P+PSm?ZBnq{oD!Yw!V|A&@A9Gk-)l+ z5WIT&4$3K6L2#VD`teei5~Ul>nl_Qh;NsgfAXtPwo{1Q)x>N>ON1k1R(2%@wLxGUv z=hLZKh!~ac`vP)NTm1^Dfz*vG1UpX=0>-SDG2wUR&m+VF2^m}9tklIa*~xMn+GJos3 zQ>Ntm$ZGa!kBO@J5h-Y4Wh?vI1N45rnN2e$zy6M*TIz`u0ld5KO}%dQ&PBP(DaA%| zuHfZapIRO1jcnA7B!%1k@*paHkA;=MsTtK~IgBgDBgcDqaFdB;Q-+mRDTkX|&L&~| zO~KgD;D1k%>7i=!V;ST?S^DrN>NjPed~Kt8myny4;2eqSC~N6W9*81`a9=eB!s`K zfz_((#RvwLh|w;b!6O1JweO7L*&4T>768cn*Mucg1{@MVpm2aZu*CqAZiw^=#81>z;9L#F_KPhbpUU?C1 zBbwEHZJiD@?=v#Rr%>bK77LJz+2~ag=b*J@&3czzt1ELF+%Z!*gMU{Ws+YwZv}U<6 z)ad0}J`wIp9sK|CI6pdNg2+FFpXq;H)`$WAdO+6yH>L<(J^6nz-7~mdbHkmL`Jih+ z42glJp|3SJmf=E;GuEz%;GxGsKtp-TiPjC1gz4ut0QB&2&|pg5*xGvgnsy!%y?r15 z$lqv(r>V|s$tbW!4P3a)Iuyw>E4P$o8+i!1jSS8kh5x-o_GRA@XX25JY|3jeePe}c z8nmN`2EzTq3dxX@YXw1hDLM!Wst#pp&W zT&*t=X~x7O@%_4!EN!C`o&gY5CcY1W{NqDQ(UAP}!+t5v5YW{B%dXkAgfRl2s?nU! zgg?7vfv|D8b;HbL7P#g_O;*Ds*I0;S(Nd_BJV;Lc)O0n?R_6>*eBkw{qI!Z7FlC>M z@J0YL>k!D+e?@VOt(VS#YogLmZe3_|vpV1til?Ojq6-L7+vtccGb<3Fu9~jOswZ%B zdShCOjhznS8w(Tc$lA$sNTOQ;=p~!?1N!bu?L-t)0Hkk>XC|v+o123XAg!IK#1sJ5 zU|0a4SaUo8I+eB)cQFM(e#3Zdx+=D%`8)zFXeS4Dd}aB{lI{4zOmnVENuf;s(d21E}i8c41B^c zx1D%`nH3I*zJb;oSUoa-KyF=YbMquZFHB3xvekJ8R(Bn&UZ0s22~gvOHzYEX(ZG7* zuSZA7OjpFDV=N;t(*^-l&; zept$_C!Cp7Lo(@)Cpcl6Ojjf)t(steW|Wl@fP$V~qcs|KM0bQ&di%i-naK}rwHwv%tS9eT7qERsN8WT>X^X`jq#Q7-H6iXO}popM^JUZHcDi`PqxN%eIWeSoF%NybnfdIJt&F{ z!|^Nz$XdtPI+uZ#y}yJ99xTtXb&%w@w@$57tXRXZ z1{8B(fF!>f&h6rJYwz;gTMq-poIaGmCz;M3Km?MG+zP3gMVX3somk*nnK-nCLSTP@6%r>(MF8EzxB_bntb=Y@Nx?Xbvt84H5f&CO zEVrqoV{j{`3mq1=TC#oD()_k}D6qv^(0W>Eigpd}*ZrR{MvRFS0_mHynf@|pXIXlu@VA;hO?^@ zl;MPceKE>@{i-1A=Fyd^Q^hNX%#M9826wVM|6wE>)ALM1`tCv;qKm(mgTkhw! zoB24s;UIw7KBQyJnJI(t`)y3iL{_9Wk*o`a>I@V_EeiPkjH4m zj<28{e^^j$xRkNQ4-Q7+f0h~3kmQIw^#TJs-}GDhrW=q9QbfY7;}+16)PP;&Q54O= z7_{WN0fG=fm~G&a2iXL+YGW0O*ij#baTYxNQp4v#*Gpyu6))Aev-Xm|`01Qb=LYbBV?Uq(1GyX^2sj6<%UU&5{aIW7Y$u?-HaxXdLeuDw>(nL75DERQ30K; zp$>U!HvG{&dnqJ^WykGiU4JW~cu=CZGhiJ_Jn7_>!j8!474JfglYpLFnocdSV87^8V0FOTw|HSe^0a`!4M#t%O7NhqjIiwLthtgA@W~3N`zALZ)fj?D%3O%Qq zoP#Ihq0?7dw$lkgvz6j;n=UB)o7N{Ju4d>&XDB3QbCj%ti{CAMHwWEQEjiLEq*HE1 zE8xFEs8stbMucBitmHN8f^Di844UROB7857!| zj~(*kBrZU#R)}JIuDLJh{9)s96wAD=87LFKNCknCzojRESR$oP3ccz7Ri+{JaB$1x zGXHHBiv2R%pN_JV*TzNR1m?_RurZtIdMDT`T`t1(CE(e z=2<;-YL5zu?0-=ydB4|zW2nVFK3XKYezeAXT2uEQB}#C}T`;X$(m6gS__EGu?+ot_ z?T|NM*PnNtSL}`L<7YWr-zf^p1?73x%kQFX!+js&MW+L%B)aH|(-`i=aI6Z&LpnZ$;a1oeU>?|~!6}AU-qdkk8*M(V0Tatbb-79ZozmE^ceuBc33#Z!C z#GlGFfJev?)IQot0;y_9grYFtHG1z`pBXEfr3DLvQm0O&@fxQs(6Htcq=-6cN6)); z48m_FQmT)B3S}7t7||Isa?Vk5<_Z1#-`tx{y#+`Xk9FXs?|xax;fZ^=i?(!+%BH!)#<)filV{u)CqX&es0ZNS z@NA0+d8Q{T&~C)O0?oO>qLDB(MCXJT+<%BZ&haF8in=C3*}--MPkI-c z|KAfKlz46|G_}DNXb_(Oip{)SRUAYuOg)DA4q%k5|L`m!$iivfs5TBQn|9j0hzA5} zvJ06Hax2=jp3FK0UjonzNT(b?H$meq7&*RGhZyCmVzskQ4V9r}u$ahdlJ62dD_B^Qou(_?y7KdP#H1B-%wN5^hfJIp(VyW$zK z23jJOWGig#Em~>=suh)})hL-ieFiC*f0&#Z%MV<->lMZxkOjfBR*ND^!Oz=(H(u?p zQ{cX+-18`vh?`YOz5yPB1yoO2sgR~#S+Hs-4qNjV6h!C@a`>Joy_>S<6{A1>yDY-) zt1Pi5(RuZ-&%`;~JG0ZvXeG<>%}1|)ccwFXEw-3skI^47+iQL$R6Gy+ zpm@W!iwf!Cci)j`dK~HEM~x^+3{C?0!9XqYTL5v2MCb5i87**jdGkV(Nj^aXvzQu~ z>WYdJBknj+RVV&A4BS=ozfdkW8)w)y9eAe4^iz1<@_sXTP=Am5#q*UvQGuh^DL+$2 z3(K8$VL3NW=#Z2wzEqhlYDE0=cy7O?K)+c3xiQ;zqX9u*-b7)}w$5T%6%tAiS{~%> z-_oB{eFZYV_|Ziy8DA^kdBT7;cD5t0nqgwF5Xh2X6~doW|86U!6Sk}?I+?7GslKu; Q0=h#aI~Uvgy93Vr4>td#i2wiq literal 0 HcmV?d00001 diff --git a/img/UI/folder.png b/img/UI/folder.png new file mode 100644 index 0000000000000000000000000000000000000000..b45470c5e43a0f1daf08dc7b41a0241f2d7e7e55 GIT binary patch literal 11265 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 z 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 wg1~~*a^jysL)~$qevw* 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 literal 0 HcmV?d00001 diff --git a/img/UI/stop.png b/img/UI/stop.png new file mode 100644 index 0000000000000000000000000000000000000000..568fe72e0f145d04717ee3f2bdc18828913b83c0 GIT binary patch literal 7393 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 literal 0 HcmV?d00001 diff --git a/img/sample.png b/img/sample.png new file mode 100644 index 0000000000000000000000000000000000000000..82b2882da8ffb78d09ff6a2932c6f4caa2bb5f52 GIT binary patch literal 918 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4kn<;H+e}VAjMc5RtvY3H^?=T269?xHq!oa|+?djqeQW5v|vLPb_1JB_FKh5u+Ji#ot zgHe-HQBCoPO3REEfyn|%LQXzT9iAM9qk^LWGC~u>m2Y2Wp1VH9Ab2$}FEMz!`njxg HN@xNAv~L8V literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8a0d265 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +discord=2.3.2 +Pillow=10.0.0 +PySide6=6.5.3 +gspread=5.11.2 +clipboard=0.0.4 +cryptography=41.0.4 \ No newline at end of file