├── .gitignore ├── cogs ├── preferences │ ├── __init__.py │ └── user.py ├── clan │ ├── response.py │ ├── cbre.py │ ├── options.py │ ├── api.py │ ├── embeds.py │ └── __init__.py ├── tasks │ └── __init__.py ├── rubiBank │ └── __init__.py ├── common │ ├── sta.py │ ├── gacha.py │ └── __init__.py ├── response.py ├── newsForward │ └── __init__.py ├── character │ ├── __init__.py │ └── embeds.py ├── admin.py └── profileCard │ └── __init__.py ├── Dockerfile ├── requirements.txt ├── utils ├── custom_id.py ├── custom_slash_command.py ├── download.py ├── state_manger.py ├── __init__.py ├── errors.py ├── game_data.py └── fetch_rank.py ├── LICENSE ├── .github └── workflows │ └── main.yml ├── config.json.example ├── README.md ├── main.py └── config.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode 3 | __pycache__ 4 | gameDB 5 | bot.log 6 | config.json -------------------------------------------------------------------------------- /cogs/preferences/__init__.py: -------------------------------------------------------------------------------- 1 | from cogs.preferences.user import UserPreferences 2 | 3 | 4 | def setup(bot): 5 | bot.add_cog(UserPreferences(bot)) 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | ENV NO_LOG_FILE=Yes 4 | ENV TZ=Asia/Taipei 5 | 6 | COPY . /app 7 | 8 | WORKDIR /app 9 | 10 | RUN pip3 install -r requirements.txt 11 | 12 | CMD ["python3", "main.py"] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofile==3.5.1 2 | aiohttp==3.7.4.post0 3 | async-timeout==3.0.1 4 | attrs==21.2.0 5 | Brotli==1.0.9 6 | caio==0.8.4 7 | chardet==4.0.0 8 | discord-py-slash-command==2.3.2 9 | discord.py==1.7.3 10 | expiringdict==1.2.1 11 | idna==3.2 12 | idna-ssl==1.1.0 13 | motor==2.4.0 14 | multidict==5.1.0 15 | pymongo==3.12.0 16 | typing-extensions==3.10.0.0 17 | yarl==1.6.3 18 | -------------------------------------------------------------------------------- /utils/custom_id.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlencode, parse_qsl 3 | 4 | 5 | def pref_custom_id(custom_id: str, data: dict): 6 | """ 7 | Convert dict to string. 8 | 9 | Args: 10 | custom_id (str): Name of custom_id. 11 | data (dict): Data you want to convert. 12 | 13 | Raises: 14 | ValueError: Data can not contain '__'. 15 | 16 | Returns: 17 | A string custom_id 18 | """ 19 | data_str = urlencode(data) 20 | if "__" in data_str: 21 | raise ValueError("Data can not contain '__'") 22 | 23 | return f"pref_{custom_id}__{data_str}" 24 | 25 | 26 | def un_pref_custom_id(custom_id: str, data: str): 27 | """ 28 | Convert string to dict. 29 | 30 | Args: 31 | custom_id (str): Name of custom_id. 32 | data (str): String to parse. 33 | 34 | Returns: 35 | A dict converted from data. 36 | """ 37 | start = len(custom_id) + 7 38 | return {i: int(j) if j.isdigit() else j for i, j in parse_qsl(data[start:])} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 IanDesuyo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cogs/clan/response.py: -------------------------------------------------------------------------------- 1 | from utils import errors 2 | from main import AIKyaru 3 | from discord import Message 4 | from discord.ext import commands 5 | import re 6 | 7 | chinese2num = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "壹": 1, "貳": 2, "參": 3, "肆": 4, "伍": 5} 8 | 9 | 10 | class ClanResponse(commands.Cog): 11 | def __init__(self, bot: AIKyaru): 12 | self.bot = bot 13 | 14 | @commands.Cog.listener() 15 | async def on_message(self, message: Message): 16 | if message.guild and str(self.bot.user.id) in message.content: # mention and guild filter 17 | reg_1 = re.search(r"([1-5一二三四五])王死(了|ㄌ|囉)$", message.content) 18 | if reg_1: 19 | try: 20 | boss = int(reg_1.group(1)) 21 | except ValueError: 22 | boss = chinese2num[reg_1.group(1)] 23 | guild_state = await self.bot.stateManger.get_guild(message.guild.id) 24 | clan = guild_state.get("clan") 25 | if not clan: 26 | raise errors.FormNotSet 27 | 28 | week = clan.get("week") 29 | boss += 1 30 | if boss == 6: 31 | boss = 1 32 | week += 1 33 | 34 | await self.bot.stateManger.set_guild(message.guild.id, {"clan.boss": boss, "clan.week": week}) 35 | await message.channel.send(f":thumbsup: 現在為{week}周{boss}王") 36 | 37 | 38 | def setup(bot): 39 | bot.add_cog(ClanResponse(bot)) 40 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to server 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build and Deploy 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Executing remote ssh commands using ssh key 14 | uses: appleboy/ssh-action@master 15 | with: 16 | host: ${{ secrets.HOST }} 17 | username: ${{ secrets.USERNAME }} 18 | key: ${{ secrets.KEY }} 19 | port: ${{ secrets.PORT }} 20 | script: | 21 | cd repos/AIKyaru 22 | git pull origin master 23 | COMMIT_ID=${{ github.sha }} 24 | docker build -t aikyaru . 25 | CONTAINER_ID=$(docker ps -a --format "table {{.ID}} {{.Names}}" -f "status=running" | awk 'NR>1 && $2 ~ /^aikyaru/ {print$1}') 26 | [[ ! -z "$CONTAINER_ID" ]] && docker stop $CONTAINER_ID 27 | docker run -d \ 28 | --name aikyaru_${COMMIT_ID:0:7} \ 29 | -e BOT_TOKEN=${{ secrets.BOT_TOKEN }} \ 30 | -v ${{ secrets.CONFIG_PATH }}/config.json:/app/config.json \ 31 | -v ${{ secrets.CONFIG_PATH }}/gameDB:/app/gameDB \ 32 | --restart unless-stopped \ 33 | --network mongo-network \ 34 | aikyaru 35 | 36 | - name: Send message to Discord 37 | uses: sarisia/actions-status-discord@v1 38 | with: 39 | webhook: ${{ secrets.DISCORD_WEBHOOK }} 40 | title: "deploy" 41 | color: 0x4BB543 42 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "status": "online", 3 | "activity": { 4 | "type": 3, 5 | "prefix": ".help|", 6 | "default_text": "ヤバイわよ!!" 7 | }, 8 | "prefix": ".", 9 | "cogs": [ 10 | "cogs.admin", 11 | "cogs.rubiBank", 12 | "cogs.character", 13 | "cogs.profileCard", 14 | "cogs.common", 15 | "cogs.newsForward", 16 | "cogs.preferences", 17 | "cogs.clan", 18 | "cogs.response", 19 | "cogs.tasks" 20 | ], 21 | "helpTitle": "哈囉~ 歡迎使用 AI キャル", 22 | "AssetsURL": "https://randosoru.me/static/assets", 23 | "RediveJP_DB": [ 24 | "https://redive.estertion.win/db/redive_jp.db.br", 25 | "https://redive.estertion.win/last_version_jp.json" 26 | ], 27 | "RediveTW_DB": [ 28 | "https://randosoru.me/redive_db/redive_tw.db.br", 29 | "https://randosoru.me/redive_db/version.json" 30 | ], 31 | "keywordGSheet": { 32 | "key": "", 33 | "gid": "" 34 | }, 35 | "DEBUG_CHANNEL": null, 36 | "MONGODB_URL": "mongodb://username:password@host:port", 37 | "GUILD_API_URL": "https://guild.randosoru.me/api", 38 | "GUILD_API_TOKEN": "GUILD_API_TOKEN", 39 | "GAME_API_URL": "https://example.com", 40 | "GAME_API_TOKEN": "GAME_API_TOKEN", 41 | "PCRwiki": "https://pcredivewiki.tw/Character/Detail", 42 | "EmojiServers": { 43 | "1": [], 44 | "2": [], 45 | "3": [], 46 | "Pickup": [] 47 | }, 48 | "RankData": { 49 | "emonight": { 50 | "key": "", 51 | "gid": "", 52 | "sql": "select%20*" 53 | }, 54 | "nonplume": { 55 | "key": "", 56 | "gid": "", 57 | "sql": "select%20*" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cogs/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from main import AIKyaru 3 | import discord 4 | from discord.ext import commands 5 | import asyncio 6 | from datetime import datetime, time 7 | 8 | 9 | class Tasks(commands.Cog): 10 | def __init__(self, bot: AIKyaru): 11 | self.bot = bot 12 | self.logger = logging.getLogger("AIKyaru.Tasks") 13 | self.loop = asyncio.get_event_loop() 14 | 15 | self.task_update_checker = self.loop.create_task(self.update_checker()) 16 | 17 | def cog_unload(self): 18 | self.task_update_checker.cancel() 19 | 20 | async def update_checker(self): 21 | try: 22 | while True: 23 | await asyncio.sleep(0) 24 | now = datetime.now() 25 | # Runs only at 5 or 16 o'clock and skip first time 26 | if now.hour not in (5, 16) or not self.bot.is_ready(): 27 | wait_until = datetime.combine( 28 | now, time(hour=now.hour + 1, minute=5, second=0) 29 | ) # wait until XX:05:00 30 | wait_seconds = (wait_until - now).seconds 31 | self.logger.debug(f"Need wait: {wait_seconds}") 32 | 33 | await asyncio.sleep(wait_seconds) 34 | continue 35 | 36 | self.logger.info("Starting update task...") 37 | await self.bot.config.update() 38 | await asyncio.sleep(5) 39 | await self.bot.gameData.tw.analytics() 40 | await self.bot.gameData.jp.analytics() 41 | await self.bot.config.update_gacha_emojis(self.bot) 42 | await asyncio.sleep(3600) # wait 1 hour 43 | 44 | except asyncio.CancelledError: 45 | self.logger.info("Update task cancelled.") 46 | 47 | 48 | def setup(bot): 49 | bot.add_cog(Tasks(bot)) -------------------------------------------------------------------------------- /cogs/rubiBank/__init__.py: -------------------------------------------------------------------------------- 1 | from main import AIKyaru 2 | from datetime import datetime 3 | from typing import Union 4 | from discord.ext import commands 5 | from discord.ext.commands import Context 6 | from discord_slash import cog_ext, SlashContext 7 | 8 | 9 | class RubiBank(commands.Cog): 10 | def __init__(self, bot: AIKyaru): 11 | self.bot = bot 12 | 13 | @commands.group(name="rubi", hidden=True) 14 | async def cmd_rubi(self, ctx: Context): 15 | if ctx.invoked_subcommand is None: 16 | await self._balance(ctx) 17 | 18 | @cmd_rubi.command(name="sign", brief="簽到", description="每日簽到") 19 | async def cmd_sign(self, ctx: Context): 20 | await self._sign(ctx) 21 | 22 | @cog_ext.cog_subcommand( 23 | base="rubi", 24 | name="sign", 25 | description="每日簽到", 26 | ) 27 | async def cog_sign(self, ctx: SlashContext): 28 | await self._sign(ctx) 29 | 30 | async def _sign(self, ctx: Union[Context, SlashContext]): 31 | bank = await ctx.state.get_user(keys=["bank"]) or {"rubi": 0, "signDate": ""} 32 | today = datetime.now().strftime("%y%m%d") 33 | 34 | if bank.get("signDate") == today: 35 | return await ctx.send(f"你今天已經簽到過了...") 36 | 37 | bank["rubi"] += 100 38 | bank["signDate"] = today 39 | await ctx.state.set_user({"bank": bank}) 40 | 41 | return await ctx.send(f"簽到成功, 你現在有 {bank['rubi']}盧幣") 42 | 43 | @cmd_rubi.command(name="balance", brief="查看盧幣", description="查看你有多少盧幣") 44 | async def cmd_balance(self, ctx: Context): 45 | await self._balance(ctx) 46 | 47 | @cog_ext.cog_subcommand( 48 | base="rubi", 49 | name="balance", 50 | description="查看你有多少盧幣", 51 | ) 52 | async def cog_balance(self, ctx: SlashContext): 53 | await self._balance(ctx) 54 | 55 | async def _balance(self, ctx: Union[Context, SlashContext]): 56 | bank = await ctx.state.get_user(keys=["bank"]) or {"rubi": 0} 57 | return await ctx.send(f"你現在有 {bank['rubi']}盧幣") 58 | 59 | 60 | def setup(bot): 61 | bot.add_cog(RubiBank(bot)) 62 | -------------------------------------------------------------------------------- /cogs/clan/cbre.py: -------------------------------------------------------------------------------- 1 | from main import AIKyaru 2 | import discord 3 | from discord.ext import commands 4 | from discord.ext.commands import Context 5 | import re 6 | from utils import damage_converter 7 | import utils 8 | 9 | 10 | class Cbre(commands.Cog): 11 | def __init__(self, bot: AIKyaru): 12 | self.bot = bot 13 | self.damage2regex = re.compile(r"(\d+[kKwW\d])(\((\d\d)\)|)") 14 | 15 | @commands.command(brief="補償計算機", aliases=["補償計算"], usage="<目標血量> <第一刀傷害> <第二刀傷害(剩餘秒數)>") 16 | async def cbre(self, ctx: Context, hp: damage_converter, damage1: damage_converter, damage2="10000"): 17 | try: 18 | damage2out = self.damage2regex.search(damage2) 19 | damage2 = damage_converter(damage2out.group(1)) 20 | damage2time = int(damage2out.group(3)) if damage2out.group(3) else 0 21 | if hp == 0 or damage1 == 0 or damage2 == 0: 22 | return await ctx.send("血量和傷害不可為0") 23 | except: 24 | raise commands.BadArgument 25 | 26 | if (damage1 + damage2) < hp: 27 | return await ctx.send("兩刀還殺不掉王啊...") 28 | if hp < damage1: 29 | return await ctx.send("一刀就能殺掉了...") 30 | retime = 90 - (hp - damage1) / (damage2 / (90 - damage2time)) + 20 31 | retime = 90 if retime > 90 else retime 32 | if retime == 20: 33 | return await ctx.send("可能殺不死喔, 靠暴擊吧") 34 | redmg = round((damage2 / 90) * retime / 10000, 1) 35 | 36 | embed = utils.create_embed( 37 | author={"name": "補償計算", "icon_url": f"{self.bot.config.get(['AssetsURL'])}/item/99002.webp"}, 38 | footer={"text": "出刀打王有賺有賠, 此資料僅供參考"}, 39 | ) 40 | 41 | embed.add_field(name="目標血量", value=f"{int(hp/10000)}萬", inline=True) 42 | embed.add_field(name="第一刀傷害", value=f"{int(damage1/10000)}萬", inline=True) 43 | embed.add_field(name="第二刀傷害", value=f"{int(damage2/10000)}萬", inline=True) 44 | embed.add_field(name="第二刀補償時間", value=f"{retime}秒", inline=True) 45 | embed.add_field(name="理想補償傷害", value=f"{redmg}萬", inline=True) 46 | 47 | await ctx.send(embed=embed) 48 | -------------------------------------------------------------------------------- /cogs/common/sta.py: -------------------------------------------------------------------------------- 1 | from main import AIKyaru 2 | import utils 3 | import discord 4 | from discord.ext import commands 5 | from discord.ext.commands import Context 6 | from discord_slash import cog_ext, SlashContext 7 | from discord_slash.utils.manage_commands import create_option 8 | from datetime import datetime, timedelta 9 | from typing import Optional, Union 10 | 11 | 12 | class Sta(commands.Cog): 13 | def __init__(self, bot: AIKyaru): 14 | self.bot = bot 15 | 16 | @commands.command(name="sta", brief="體力計算機", aliases=["s", "體力計算"], usage="<目前體力(預設為0)> <主角等級(預設為台版上限)>") 17 | async def cmd_sta(self, ctx: Context, sta: int = 0, lv: Optional[int] = None): 18 | await self._sta(ctx, sta, lv) 19 | 20 | @cog_ext.cog_slash( 21 | name="sta", 22 | description="體力計算機", 23 | options=[ 24 | create_option(name="當前體力", description="當前的體力, 預設為0", option_type=4, required=False), 25 | create_option( 26 | name="主角等級", 27 | description="主角等級, 預設為台版上限", 28 | option_type=4, 29 | required=False, 30 | ), 31 | ], 32 | connector={"當前體力": "sta", "主角等級": "lv"}, 33 | ) 34 | async def cog_sta(self, ctx: SlashContext, sta: int = 0, lv: int = None): 35 | await self._sta(ctx, sta, lv) 36 | 37 | async def _sta(self, ctx: Union[Context, SlashContext], sta: int = 0, lv: int = None): 38 | if not lv: 39 | lv = self.bot.gameData.tw.max_lv 40 | elif lv > self.bot.gameData.jp.max_lv: 41 | return await ctx.send(f"目前最高等級是{self.bot.gameData.jp.max_lv}, 你突破限制了嗎?") 42 | elif lv < 25: 43 | return await ctx.send("不支援主角等級低於25 :(") 44 | 45 | # calculate max stamina 46 | if lv >= 27: 47 | max_sta = 58 + lv 48 | else: 49 | max_sta = 85 - (26 - lv) * 5 50 | if sta > max_sta: 51 | return await ctx.send(f"目前體力不能大於最大體力(`{max_sta}`)...") 52 | 53 | # calculate full time 54 | full_time = datetime.now() + timedelta(minutes=(max_sta - sta) * 5) 55 | full_time = full_time.strftime("%m/%d %H:%M") 56 | 57 | embed = utils.create_embed( 58 | author={"name": "體力計算", "icon_url": f"{self.bot.config.get(['AssetsURL'])}/item/93001.webp"} 59 | ) 60 | embed.add_field(name="主角等級", value=lv, inline=True) 61 | embed.add_field(name="目前體力", value=sta, inline=True) 62 | embed.add_field(name="最大體力", value=max_sta, inline=True) 63 | embed.add_field(name="體力全滿時間", value=full_time, inline=True) 64 | 65 | await ctx.send(embed=embed) 66 | -------------------------------------------------------------------------------- /utils/custom_slash_command.py: -------------------------------------------------------------------------------- 1 | from discord_slash import SlashCommand 2 | 3 | 4 | def _get_val(d: dict, key): 5 | try: 6 | value = d[key] 7 | except KeyError: 8 | value = d[None] 9 | return value 10 | 11 | 12 | class CustomSlashCommand(SlashCommand): 13 | """ 14 | A rewrite of :class:`SlashCommand` to inject :class:`State`. 15 | """ 16 | 17 | def __init__(self, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | 20 | async def invoke_command(self, func, ctx, args): 21 | """ 22 | Invokes command. 23 | 24 | :param func: Command coroutine. 25 | :param ctx: Context. 26 | :param args: Args. Can be list or dict. 27 | """ 28 | try: 29 | await self._discord.invoke_slash_command(func, ctx, args) 30 | except Exception as ex: 31 | if not await self._handle_invoke_error(func, ctx, ex): 32 | await self.on_slash_command_error(ctx, ex) 33 | 34 | async def invoke_component_callback(self, func, ctx): 35 | """ 36 | Invokes component callback. 37 | 38 | :param func: Component callback object. 39 | :param ctx: Context. 40 | """ 41 | try: 42 | await self._discord.invoke_component_callback(func, ctx) 43 | except Exception as ex: 44 | if not await self._handle_invoke_error(func, ctx, ex): 45 | await self.on_component_callback_error(ctx, ex) 46 | 47 | def get_component_callback( 48 | self, 49 | message_id: int = None, 50 | custom_id: str = None, 51 | component_type: int = None, 52 | ): 53 | """ 54 | Returns component callback (or None if not found) for specific combination of message_id, custom_id, component_type. 55 | 56 | :param message_id: If specified, only removes the callback for the specific message ID. 57 | :type message_id: Optional[.model] 58 | :param custom_id: The ``custom_id`` of the component. 59 | :type custom_id: Optional[str] 60 | :param component_type: The type of the component. See :class:`.model.ComponentType`. 61 | :type component_type: Optional[int] 62 | 63 | :return: Optional[model.ComponentCallbackObject] 64 | """ 65 | message_id_dict = self.components 66 | 67 | try: 68 | custom_id_dict = _get_val(message_id_dict, message_id) 69 | 70 | # add prefix support, e.g. "pref_custom.name__some.data" will call the function which custom_id equal to "pref_custom.name" 71 | if custom_id.startswith("pref_"): 72 | custom_id = custom_id[: custom_id.rfind("__")] 73 | 74 | component_type_dict = _get_val(custom_id_dict, custom_id) 75 | callback = _get_val(component_type_dict, component_type) 76 | 77 | except KeyError: # there was no key in dict and no global fallback 78 | pass 79 | else: 80 | return callback 81 | -------------------------------------------------------------------------------- /utils/download.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from aiofile import async_open 3 | import aiohttp 4 | import re 5 | import json 6 | import os 7 | import brotli 8 | 9 | HEADER = { 10 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36" 11 | } 12 | 13 | 14 | async def rank() -> dict: 15 | async with async_open("db/RecommendedRank.json", "r") as f: 16 | data = json.loads(await f.read()) 17 | return data 18 | 19 | 20 | async def GSheet(key: str, gid: str, sql: str = "select%20*"): 21 | """ 22 | Download google sheet and convert to a dict. 23 | 24 | Args: 25 | key (str): Between the slashes after spreadsheets/d. 26 | gid (str): The gid value at query. 27 | sql (str, optional): Query sql. Defaults to "select%20*". 28 | 29 | Raises: 30 | e: Exceptions caused by ClientSession. 31 | 32 | Returns: 33 | A converted dict. 34 | """ 35 | url = f"https://docs.google.com/spreadsheets/u/0/d/{key}/gviz/tq?gid={gid}&tqx=out:json&tq={sql}" 36 | async with aiohttp.ClientSession() as session: 37 | try: 38 | fetchData = await session.get(url, headers=HEADER, timeout=aiohttp.ClientTimeout(total=10.0)) 39 | data = re.search(r"(\{.*\})", await fetchData.text()) 40 | data = json.loads(data.group(1)) 41 | 42 | rows = data["table"]["rows"] 43 | cols = [i["label"].strip() for i in data["table"]["cols"]] 44 | result = {} 45 | for row in rows: 46 | temp = {} 47 | for i in range(1, len(cols)): 48 | if isinstance(row["c"][i]["v"], float): 49 | temp[cols[i]] = int(row["c"][i]["v"]) 50 | else: 51 | temp[cols[i]] = row["c"][i]["v"] 52 | result[int(row["c"][0]["v"])] = temp 53 | return result 54 | 55 | except Exception as e: 56 | logging.error(f"Download Google Sheets failed. {key}({gid})") 57 | raise e 58 | 59 | 60 | async def gameDB(url: str, filename: str, isBrotli: bool = True): 61 | """ 62 | Download game database from url. 63 | 64 | Args: 65 | url (str): Url of the file. 66 | filename (str): The name that should be saved as. 67 | isBrotli (bool, optional): Should it be decompressed by brotli. Defaults to True. 68 | 69 | Raises: 70 | e: Exceptions caused by ClientSession. 71 | """ 72 | async with aiohttp.ClientSession() as session: 73 | try: 74 | fetch = await session.get(url, headers=HEADER, timeout=aiohttp.ClientTimeout(total=10.0)) 75 | async with async_open(os.path.join("./gameDB", filename), "wb+") as f: 76 | if isBrotli: 77 | await f.write(brotli.decompress(await fetch.content.read())) 78 | else: 79 | await f.write(await fetch.content.read()) 80 | 81 | except Exception as e: 82 | logging.error(f"Download gameDB failed. ({filename}, {url})") 83 | raise e 84 | 85 | 86 | async def json_(url: str): 87 | async with aiohttp.ClientSession() as session: 88 | try: 89 | f = await session.get(url, headers=HEADER, timeout=aiohttp.ClientTimeout(total=10.0)) 90 | return await f.json() 91 | except Exception as e: 92 | logging.error(f"Download json failed. ({url})") 93 | raise e 94 | -------------------------------------------------------------------------------- /cogs/preferences/user.py: -------------------------------------------------------------------------------- 1 | from discord_slash.model import ButtonStyle 2 | from main import AIKyaru 3 | from discord.ext import commands 4 | from discord_slash.context import ComponentContext 5 | from discord_slash import cog_ext, SlashContext 6 | from discord_slash.utils.manage_components import create_actionrow, create_button, create_select, create_select_option 7 | from copy import deepcopy 8 | 9 | 10 | character_options = create_actionrow( 11 | *[ 12 | create_select( 13 | placeholder="角色資訊預設頁面", 14 | custom_id="user.preferences.character_default_type", 15 | options=[ 16 | create_select_option( 17 | label="簡介", 18 | description="預設顯示角色簡介", 19 | value="1", 20 | emoji={"name": "info", "id": 850732950600679464}, 21 | ), 22 | create_select_option( 23 | label="專武", 24 | description="預設顯示角色專用武器", 25 | value="2", 26 | emoji={"name": "ue", "id": 850732950642884608}, 27 | ), 28 | create_select_option( 29 | label="技能", 30 | description="預設顯示角色技能", 31 | value="3", 32 | emoji={"name": "skill", "id": 850732950847881226}, 33 | ), 34 | create_select_option( 35 | label="攻擊", 36 | description="預設顯示角色攻擊模式", 37 | value="4", 38 | emoji={"name": "icon_skill_attack", "id": 605337612835749908}, 39 | ), 40 | create_select_option( 41 | label="RANK推薦", 42 | description="預設顯示推薦的Rank", 43 | value="5", 44 | emoji={"name": "rank", "id": 850732950525575178}, 45 | ), 46 | ], 47 | ) 48 | ] 49 | ) 50 | unlink_button = create_actionrow(*[create_button(style=ButtonStyle.red, label="解除遊戲ID綁定", custom_id="user.unlink_uid")]) 51 | 52 | 53 | class UserPreferences(commands.Cog): 54 | def __init__(self, bot: AIKyaru): 55 | self.bot = bot 56 | 57 | @cog_ext.cog_slash(name="preferences", description="偏好設定") 58 | async def cog_preferences(self, ctx: SlashContext): 59 | type = await ctx.state.get_user(keys=["config", "character_default_type"]) or 1 60 | 61 | options = deepcopy(character_options) 62 | options["components"][0]["options"][type - 1]["default"] = True 63 | 64 | components = [options] 65 | 66 | if await ctx.state.get_user(keys=["linked_uid"]): 67 | components.append(unlink_button) 68 | 69 | await ctx.send(content="**偏好設定**", components=components, hidden=True) 70 | 71 | @cog_ext.cog_component(components="user.preferences.character_default_type") 72 | async def preferences_default_type(self, ctx: ComponentContext): 73 | type = int(ctx.selected_options[0]) 74 | user_config = await ctx.state.get_user(keys=["config"]) or {} 75 | user_config["character_default_type"] = type 76 | await ctx.state.set_user({"config": user_config}) 77 | 78 | options = deepcopy(character_options) 79 | options["components"][0]["options"][type - 1]["default"] = True 80 | 81 | components = [options] 82 | 83 | if await ctx.state.get_user(keys=["linked_uid"]): 84 | components.append(unlink_button) 85 | 86 | await ctx.edit_origin(content="**偏好設定**", components=components) 87 | 88 | @cog_ext.cog_component(components="user.unlink_uid") 89 | async def unlink_uid(self, ctx: ComponentContext): 90 | await ctx.state.set_user({"linked_uid": ""}, unset=True) 91 | 92 | type = await ctx.state.get_user(keys=["config", "character_default_type"]) or 1 93 | options = deepcopy(character_options) 94 | options["components"][0]["options"][type - 1]["default"] = True 95 | 96 | await ctx.edit_origin(content="**偏好設定**", components=[options]) 97 | await ctx.send("已解除綁定", hidden=True) 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI キャル v3 2 | 3 | 為公主連結所設計的 Discord 聊天機器人 4 | 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/discord.py?style=for-the-badge) 6 | [![Discord](https://img.shields.io/discord/605314314768875520?style=for-the-badge)](https://discord.gg/cwFc4qh) 7 | [![Invite bot](https://img.shields.io/badge/Invite-bot-blue?style=for-the-badge)](https://discordapp.com/oauth2/authorize?client_id=594885334232334366&permissions=8&scope=applications.commands%20bot) 8 | [![Notion document](https://img.shields.io/badge/Notion-Docs-lightgrey?style=for-the-badge)](https://iandesuyo.notion.site/AI-v3-baec83903f764b7f95d0186f105190ee) 9 | 10 | # 安裝方式 11 | 12 | ## 前置需求 13 | 14 | 在開始安裝前, 請先完成下列步驟 15 | 16 | - 於[Discord Developer Portal](https://discord.com/developers/applications/)建立一個Bot 17 | - 擁有一個[mongoDB](https://www.mongodb.com/), 並建立名為`AIKyaru`的 Database 及名為`guild`, `user`的 Collections 18 | - 閱讀 [設定說明](#設定說明) 來完成`config.json`之設置 19 | 20 | ## Docker 21 | 22 | 要使用 Docker 運行, 你可以直接使用下列指令來建置與運行 23 | 24 | ```bash 25 | docker build -t ai_kyaru . 26 | 27 | docker run -d \ 28 | --name ai_kyaru \ 29 | -e BOT_TOKEN=YOUR_BOT_TOKEN \ 30 | -v /path/to/config.json:/app/config.json \ 31 | ai_kyaru 32 | ``` 33 | 34 | ## 手動安裝 35 | 36 | 在開始安裝前, 建議先使用 virtualenv 創建虛擬環境, 並設置環境變數`TOKEN` 37 | 38 | ```bash 39 | pip3 install -r requirements.txt 40 | 41 | BOT_TOKEN=YOUR_BOT_TOKEN 42 | 43 | python3 main.py 44 | ``` 45 | 46 | # 設定說明 47 | 48 | 除了 Discord 機器人的 Token 外, 其餘設定皆儲存於`config.json` 49 | 50 | 首次設定時, 請先參考`config.json.example`內的格式並修改設定值 51 | 52 | ```javascript 53 | { 54 | "status": "online", // 機器人的上線狀態 55 | "activity": { 56 | // 根據下方設定, 結果為 "正在看 .help|ヤバイわよ!!" 57 | "type": 3, // 機器人的狀態, playing=0, streaming=1, listening=2, watching=3 58 | "prefix": ".help|", // 機器人狀態的前綴 59 | "default_text": "ヤバイわよ!!" // 預設狀態文字 60 | }, 61 | "prefix": ".", // 指令前綴 62 | "cogs": [ 63 | // 需要載入的Cogs 64 | "cogs.admin", // 管理員功能 65 | "cogs.rubiBank", // 盧幣銀行 66 | "cogs.character", // 角色相關資訊查詢 67 | "cogs.profileCard", // 個人檔案 68 | "cogs.common", // 基本功能, 如體力計算及抽卡模擬 69 | "cogs.newsForward", // 簡易設定公告轉發 70 | "cogs.preferences", // 偏好設定 71 | "cogs.clan", // 戰隊系統 72 | "cogs.response", // 基本回覆 73 | "cogs.tasks" // 排程任務 74 | ], 75 | "helpTitle": "哈囉~ 歡迎使用 AI キャル", // 使用help時的主要提示文字 76 | "AssetsURL": "https://randosoru.me/static/assets", // 圖片素材之網址 77 | "RediveJP_DB": [ 78 | "https://redive.estertion.win/db/redive_jp.db.br", // 日版資料庫 79 | "https://redive.estertion.win/last_version_jp.json" // 日版資料庫版本 80 | ], 81 | "RediveTW_DB": [ 82 | "https://randosoru.me/redive_db/redive_tw.db.br", // 台版資料庫 83 | "https://randosoru.me/redive_db/version.json" // 台版資料庫版本 84 | ], 85 | "keywordGSheet": { 86 | // 於Google Sheets上的角色關鍵字匹配表 87 | "key": "12QiLoCODWr4TRVGqwXROCYenh2xz-ZXqhDMgzUi7Gz4", 88 | "gid": "755815818" 89 | }, 90 | "DEBUG_CHANNEL": null, // Discord文字頻道ID, 供傳送錯誤訊息用 91 | "MONGODB_URL": "mongodb://username:password@host:port", // MongoDB網址 92 | "GUILD_API_URL": "https://guild.randosoru.me/api", // 戰隊管理協會API 93 | "GUILD_API_TOKEN": "GUILD_API_TOKEN", // API Token 94 | "GAME_API_URL": "https://example.com", // 個人檔案API 95 | "GAME_API_TOKEN": "GAME_API_TOKEN", // API Token 96 | "PCRwiki": "https://pcredivewiki.tw/Character/Detail", // 蘭德索爾圖書館 角色資訊頁面之網址 97 | "EmojiServers": { 98 | // 抽卡模擬時所使用之emojis, emoji名稱需為4位數unit_id 99 | "1": [802234826413178920], // 1星角色之Discord伺服器ID 100 | "2": [802241470878187570], // 2星角色之Discord伺服器ID 101 | "3": [802241643121999883], // 3星角色之Discord伺服器ID 102 | "Pickup": [850065739590139957] // Pickup角色之Discord伺服器ID 103 | }, 104 | "RankData": { 105 | // Rank推薦來源 106 | "emonight": { 107 | // 漪夢奈特emonight所提供之Google Sheets表格 108 | "key": "", 109 | "gid": "", 110 | "sql": "select%20*" 111 | }, 112 | "nonplume": { 113 | // 無羽nonplume所提供之Google Sheets表格 114 | "key": "", 115 | "gid": "", 116 | "sql": "select%20*" 117 | } 118 | } 119 | } 120 | ``` 121 | 122 | 備註: 123 | 124 | - 上方 Google Sheets 之`key`與`gid`可於網址內獲取, 例如` https://docs.google.com/spreadsheets/d/{key}/edit#gid={gid}` 125 | - 4 位數 unit_id 之格式為`1xxx`, 如凱留為`1060` 126 | - 若需使用戰隊管理協會 API, 請聯繫我申請 127 | -------------------------------------------------------------------------------- /cogs/response.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | import random 4 | import re 5 | 6 | class Response(commands.Cog): 7 | def __init__(self, bot): 8 | self.bot = bot 9 | 10 | @commands.Cog.listener() 11 | async def on_message(self, message): 12 | if message.author.bot: 13 | return 14 | 15 | if re.match(rf"^<@(!|){self.bot.user.id}>$", message.content): 16 | return await message.channel.send("幹..幹嘛啦...別亂叫我") 17 | if re.match(rf"^<@(!|){self.bot.user.id}>", message.content): 18 | text = message.content.lower() 19 | if re.match(r".*覺得呢(\?|)$", text): 20 | return await message.channel.send( 21 | random.choice( 22 | ["我..我怎麼知道啦~", "自己決定好嗎?", "隨便啦", "我覺得可以", "我覺得不行", "好像還可以", "不行", "可以", "好", "可以不要問我嗎...",] 23 | ) 24 | ) 25 | elif re.match(r".*(可以|能).*(嗎|ㄇ)(\?|)$", text): 26 | return await message.channel.send(random.choice(["可以", "不行", "不可以"])) 27 | elif re.match(r".*是.*(嗎|ㄇ)(\?|)$", text): 28 | return await message.channel.send(random.choice(["是", "不是", "應該是吧?", "好像是"])) 29 | elif re.match(r".*(接|換)頭.*$", text): 30 | return await message.channel.send("能不能不要什麼都P個我的頭") 31 | elif re.match(r".*(道|ㄅ)(歉|欠).*$", text): 32 | return await message.channel.send(random.choice(["對不起", "對ㄅ起", "?", "不要啊"])) 33 | elif "可可蘿" in text: 34 | return await message.channel.send("可蘿仔") 35 | elif "凱留醬" in text: 36 | return await message.channel.send("怎樣啦!") 37 | elif "佩可" in text: 38 | return await message.channel.send("才..才不喜歡呢") 39 | elif re.match(r".*對不起(ue|優依|優衣).*$/i", text): 40 | return await message.channel.send("你才對不起UE") 41 | elif "考" in text: 42 | return await message.channel.send(random.choice(["蛤?", "考三小"])) 43 | elif "關起來" in text or "945" in text: 44 | return await message.channel.send(random.choice(["蛤?", "嗯?"])) 45 | elif "喵" in text: 46 | return await message.channel.send(random.choice(["喵..喵喵?", "喵..喵喵? 幹嘛啦~", "喵?", "?"])) 47 | elif "貓咪" in text or "喵咪" in text: 48 | return await message.channel.send(random.choice(["可愛", "可愛 <3"])) 49 | elif "婆" in text: 50 | return await message.channel.send(random.choice(["誰..誰是你婆啦!", "別睡了, 快醒來", "醒醒好嗎?"])) 51 | elif re.match(r".*喜歡(你|妳).*$", text): 52 | return await message.channel.send(random.choice(["你...在說什麼...啦...", "那個...不要亂說話啦...真是的"])) 53 | elif "結婚" in text: 54 | return await message.channel.send(random.choice(["別睡了, 快醒來", "醒醒好嗎?"])) 55 | elif "摸" in text: 56 | return await message.channel.send(random.choice(["你..別亂摸啊!", "誰..誰會喜歡被你摸啊?"])) 57 | elif "舔" in text: 58 | return await message.channel.send("你..別亂舔啊!") 59 | elif "臭鼬" in text: 60 | return await message.channel.send(random.choice(["你才臭鼬", "我看你是欠揍"])) 61 | elif "ちぇる" in text: 62 | return await message.channel.send( 63 | random.choice(["ち...ちぇる? ちぇらりば、ちぇりり?", "ち..ちぇる? 你再說什麼啊...", "ちぇろっば、ちぇぽられ、ちぇろらろ",]) 64 | ) 65 | elif "切嚕" in text: 66 | return await message.channel.send( 67 | random.choice(["切嚕~♪", "切..切嚕?", "切..切嚕? 你在說什麼啊...", "切嚕切嚕、切切嚕啪、切嚕嚕嚕嚕"]) 68 | ) 69 | elif "喂" in text: 70 | return await message.channel.send(random.choice(["怎樣啦", "幹嘛", "蛤"])) 71 | elif "吃蟲" in text: 72 | return await message.channel.send("我不要吃蟲啦!") 73 | elif "天氣怎樣" in text: 74 | return await message.channel.send(random.choice(["你不會自己Google嗎?", "自己去Google啦!"])) 75 | elif "吃什麼" in text: 76 | return await message.channel.send("去問佩可啦!") 77 | elif "沒錢" in text or "手頭很緊" in text: 78 | return await message.channel.send("這個我也幫不了你") 79 | elif "運勢如何" in text: 80 | return await message.channel.send("我怎麼知道") 81 | elif "小小甜心" in text: 82 | return await message.channel.send("你想表達什麼?") 83 | else: 84 | return await message.channel.send("?") 85 | 86 | 87 | def setup(bot): 88 | bot.add_cog(Response(bot)) 89 | -------------------------------------------------------------------------------- /cogs/clan/options.py: -------------------------------------------------------------------------------- 1 | from discord_slash.utils.manage_commands import create_choice 2 | from discord_slash.utils.manage_components import create_button, create_actionrow, create_select, create_select_option 3 | from discord_slash.model import ButtonStyle 4 | 5 | status_choices = [ 6 | create_choice(value=1, name="正式刀"), 7 | create_choice(value=2, name="補償刀"), 8 | create_choice(value=3, name="凱留刀"), 9 | create_choice(value=11, name="戰鬥中"), 10 | create_choice(value=12, name="等待中"), 11 | create_choice(value=13, name="等待@mention"), 12 | create_choice(value=21, name="完成(正式)"), 13 | create_choice(value=22, name="完成(補償)"), 14 | create_choice(value=23, name="暴死"), 15 | create_choice(value=24, name="求救"), 16 | ] 17 | 18 | status_options = create_actionrow( 19 | *[ 20 | create_select( 21 | placeholder="紀錄狀態", 22 | options=[ 23 | create_select_option( 24 | label="正式刀", 25 | value="1", 26 | emoji="\u26AA", 27 | ), 28 | create_select_option( 29 | label="補償刀", 30 | value="2", 31 | emoji="\U0001F7E4", 32 | ), 33 | create_select_option( 34 | label="凱留刀", 35 | value="3", 36 | emoji="\U0001F7E3", 37 | ), 38 | create_select_option( 39 | label="戰鬥中", 40 | value="11", 41 | emoji="\U0001F7E0", 42 | ), 43 | create_select_option( 44 | label="等待中", 45 | value="12", 46 | emoji="\U0001F535", 47 | ), 48 | create_select_option( 49 | label="等待@mention", 50 | value="13", 51 | emoji="\U0001F535", 52 | ), 53 | create_select_option( 54 | label="完成(正式)", 55 | value="21", 56 | emoji="\U0001F7E2", 57 | ), 58 | create_select_option( 59 | label="完成(補償)", 60 | value="22", 61 | emoji="\U0001F7E2", 62 | ), 63 | create_select_option( 64 | label="暴死", 65 | value="23", 66 | emoji="\U0001F534", 67 | ), 68 | create_select_option( 69 | label="求救", 70 | value="24", 71 | emoji="\U0001F534", 72 | ), 73 | ], 74 | ) 75 | ] 76 | ) 77 | 78 | damage_buttons = create_actionrow( 79 | *[ 80 | create_button( 81 | style=ButtonStyle.red, 82 | label="物理一刀", 83 | emoji="\U0001F52A", 84 | ), 85 | create_button( 86 | style=ButtonStyle.blue, 87 | label="魔法一刀", 88 | emoji="\U0001F52E", 89 | ), 90 | create_button( 91 | style=ButtonStyle.gray, 92 | label="自訂", 93 | emoji="\u270F", 94 | ), 95 | ] 96 | ) 97 | 98 | 99 | boss_choices = [ 100 | create_choice(value=1, name="一王"), 101 | create_choice(value=2, name="二王"), 102 | create_choice(value=3, name="三王"), 103 | create_choice(value=4, name="四王"), 104 | create_choice(value=5, name="五王"), 105 | ] 106 | 107 | boss_buttons = create_actionrow( 108 | *[ 109 | create_button( 110 | style=ButtonStyle.gray, 111 | label="一王", 112 | ), 113 | create_button( 114 | style=ButtonStyle.gray, 115 | label="二王", 116 | ), 117 | create_button( 118 | style=ButtonStyle.gray, 119 | label="三王", 120 | ), 121 | create_button( 122 | style=ButtonStyle.gray, 123 | label="四王", 124 | ), 125 | create_button( 126 | style=ButtonStyle.gray, 127 | label="五王", 128 | ), 129 | ] 130 | ) 131 | 132 | week_buttons = create_actionrow( 133 | *[ 134 | create_button( 135 | style=ButtonStyle.blue, 136 | label="上一周", 137 | ), 138 | create_button( 139 | style=ButtonStyle.blue, 140 | label="下一周", 141 | ), 142 | ] 143 | ) 144 | -------------------------------------------------------------------------------- /utils/state_manger.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union 3 | from discord.ext.commands import Context, NoPrivateMessage 4 | from discord_slash import SlashContext, ComponentContext 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | from expiringdict import ExpiringDict 7 | import logging 8 | 9 | 10 | class StateManger: 11 | """ 12 | A simple state manger to get or set the state of user (or guild). 13 | """ 14 | 15 | def __init__(self, mongo_url: str, database: str = "AIKyaru"): 16 | """ 17 | The `user` and `guild` collections should be created before init. 18 | 19 | Args: 20 | mongo_url (str): A full mongodb URI. 21 | database (str): Your database name. 22 | """ 23 | self.logger = logging.getLogger("AIKyaru.StateManger") 24 | client = AsyncIOMotorClient(mongo_url, serverSelectionTimeoutMS=5000) 25 | self.db = client[database] 26 | self.user = self.db["user"] 27 | self.guild = self.db["guild"] 28 | self.cache = ExpiringDict( 29 | max_len=1000, max_age_seconds=300 30 | ) # Discord utilizes snoflakes, so ids should always be unique. 31 | 32 | def clean_cache(self, id: int = None): 33 | """ 34 | Clear specific id or all caches. 35 | 36 | Args: 37 | id (int, optional): User or guild id. Defaults to None. 38 | """ 39 | if id: 40 | self.logger.debug(f"{id} cache cleared") 41 | self.cache.pop(id) 42 | else: 43 | self.logger.info("All caches have been cleared") 44 | self.cache.clear() 45 | 46 | async def get_user(self, id: int): 47 | cached = self.cache.get(id) 48 | if cached: 49 | self.logger.debug(f"get_user {id} cached") 50 | return cached 51 | 52 | self.logger.debug(f"get_user {id}") 53 | res = await self.user.find_one({"id": id}) or {} 54 | self.cache[id] = res 55 | return res 56 | 57 | async def set_user(self, id: int, data: dict = {}, unset: bool = False): 58 | if unset: 59 | self.logger.info(f"unset_user {id} | {data}") 60 | await self.user.update_one({"id": id}, {"$unset": data}, upsert=True) 61 | else: 62 | self.logger.info(f"set_user {id} | {data}") 63 | await self.user.update_one({"id": id}, {"$set": data}, upsert=True) 64 | 65 | self.cache.pop(id) 66 | 67 | async def get_guild(self, id: int): 68 | cached = self.cache.get(id) 69 | if cached: 70 | self.logger.debug(f"get_guild {id} cached") 71 | return cached 72 | 73 | self.logger.debug(f"get_guild {id}") 74 | res = await self.guild.find_one({"id": id}) or {} 75 | self.cache[id] = res 76 | return res 77 | 78 | async def set_guild(self, id: int, data: dict = {}, unset: bool = False): 79 | if unset: 80 | self.logger.info(f"unset_guild {id} | {data}") 81 | await self.guild.update_one({"id": id}, {"$unset": data}, upsert=True) 82 | else: 83 | self.logger.info(f"set_guild {id} | {data}") 84 | await self.guild.update_one({"id": id}, {"$set": data}, upsert=True) 85 | 86 | self.cache.pop(id) 87 | 88 | 89 | class State: 90 | """ 91 | A class that will be set to ctx.state. 92 | """ 93 | 94 | def __init__(self, sm: StateManger, ctx: Union[Context, SlashContext, ComponentContext]): 95 | self.sm = sm 96 | self.user_id = getattr(ctx, "author_id", ctx.author.id) 97 | self.guild_id = getattr(ctx, "guild_id", getattr(ctx.guild, "id", None)) 98 | 99 | async def get_user(self, keys: list = []): 100 | data = await self.sm.get_user(self.user_id) 101 | for i in keys: 102 | data = data.get(i) 103 | if data == None: 104 | break 105 | 106 | return data 107 | 108 | async def set_user(self, data: dict = {}, unset: bool = False): 109 | await self.sm.set_user(self.user_id, data, unset) 110 | 111 | async def get_guild(self, keys: list = []): 112 | if not self.guild_id: 113 | raise NoPrivateMessage() 114 | 115 | data = await self.sm.get_guild(self.guild_id) 116 | for i in keys: 117 | data = data.get(i) 118 | if data == None: 119 | break 120 | return data 121 | 122 | async def set_guild(self, data: dict = {}, unset: bool = False): 123 | if not self.guild_id: 124 | raise NoPrivateMessage() 125 | 126 | await self.sm.set_guild(self.guild_id, data, unset) 127 | -------------------------------------------------------------------------------- /cogs/newsForward/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from main import AIKyaru 3 | from typing import Union 4 | from discord.ext import commands 5 | from discord.ext.commands import Context 6 | from discord_slash import cog_ext, SlashContext 7 | from discord_slash.utils.manage_commands import create_option 8 | from aiohttp import ClientSession, ClientTimeout 9 | import asyncio 10 | 11 | 12 | class NewsForward(commands.Cog): 13 | def __init__(self, bot: AIKyaru): 14 | self.bot = bot 15 | self.logger = logging.getLogger("AIKyaru.NewsForward") 16 | self.apiUrl = "https://randosoru.me/api/newsForward" 17 | self.session = ClientSession(headers={"User-Agent": "AIKyaru v3"}, timeout=ClientTimeout(total=10)) 18 | 19 | def cog_unload(self): 20 | asyncio.get_event_loop().run_until_complete(self.session.close()) 21 | 22 | # 23 | # Functions 24 | # 25 | async def get_webhook(self, ctx: Union[Context, SlashContext], create=True): 26 | webhooks = await ctx.channel.webhooks() 27 | webhook = None 28 | 29 | for i in webhooks: 30 | if i.user.id == self.bot.user.id: 31 | webhook = i 32 | break 33 | 34 | if not webhook and create: 35 | webhook = await ctx.channel.create_webhook( 36 | name="公告轉發", reason=f"By {ctx.author.name}#{ctx.author.discriminator}" 37 | ) 38 | 39 | return webhook 40 | 41 | async def set(self, id: int, token: str, tw: bool = False, jp: bool = False): 42 | async with self.session.post( 43 | self.apiUrl, json={"id": id, "token": token, "tw": tw, "jp": jp, "custom": False} 44 | ) as resp: 45 | data = await resp.json() 46 | 47 | self.logger.info(f"set tw:{tw} jp:{jp}") 48 | if resp.status != 200: 49 | self.logger.error(f"API responsed with {resp.status}") 50 | self.logger.error(f"Webhook id:{id} token:{token} tw:{tw} jp:{jp}") 51 | self.logger.error(data) 52 | raise ValueError(f"NewsForward {resp.status}") 53 | 54 | return data 55 | 56 | # 57 | # Commands 58 | # 59 | @commands.group(name="newsforward", hidden=True) 60 | @commands.bot_has_permissions(manage_webhooks=True) 61 | @commands.guild_only() 62 | async def cmd_newsforward(self, ctx: Context): 63 | if ctx.invoked_subcommand is None: 64 | return 65 | 66 | @cmd_newsforward.command(name="subscribe", description="訂閱公告轉發") 67 | async def cmd_subscribe(self, ctx: Context, tw: bool = False, jp: bool = False): 68 | await self._subscribe(ctx, tw, jp) 69 | 70 | @cog_ext.cog_subcommand( 71 | base="newsforward", 72 | name="subscribe", 73 | description="訂閱公告轉發", 74 | options=[ 75 | create_option(name="台版", description="是否訂閱台版公告轉發", option_type=5, required=True), 76 | create_option(name="日版", description="是否訂閱日版公告轉發", option_type=5, required=True), 77 | ], 78 | connector={"台版": "tw", "日版": "jp"}, 79 | ) 80 | @commands.bot_has_permissions(manage_webhooks=True) 81 | @commands.guild_only() 82 | async def cog_subscribe(self, ctx: SlashContext, tw: bool, jp: bool): 83 | await self._subscribe(ctx, tw, jp) 84 | 85 | async def _subscribe(self, ctx: Union[Context, SlashContext], tw: bool, jp: bool): 86 | if not tw and not jp: 87 | return self._unsubscribe(ctx) 88 | 89 | await ctx.defer() 90 | webhook = await self.get_webhook(ctx) 91 | await self.set(webhook.id, webhook.token, tw, jp) 92 | await ctx.send(":white_check_mark: 訂閱成功") 93 | 94 | @cmd_newsforward.command(name="unsubscribe", description="取消訂閱公告轉發") 95 | async def cmd_unsubscribe(self, ctx: Context): 96 | await self._unsubscribe(ctx) 97 | 98 | @cog_ext.cog_subcommand( 99 | base="newsforward", 100 | name="unsubscribe", 101 | description="取消訂閱公告轉發", 102 | ) 103 | @commands.bot_has_permissions(manage_webhooks=True) 104 | @commands.guild_only() 105 | async def cog_unsubscribe(self, ctx: SlashContext): 106 | await self._unsubscribe(ctx) 107 | 108 | async def _unsubscribe(self, ctx: Union[Context, SlashContext]): 109 | await ctx.defer() 110 | webhook = await self.get_webhook(ctx, False) 111 | if not webhook: 112 | return await ctx.send(":warning: 尚未訂閱") 113 | await self.set(webhook.id, webhook.token, False, False) 114 | await webhook.delete() 115 | await ctx.send(":white_check_mark: 已取消訂閱") 116 | 117 | 118 | def setup(bot): 119 | bot.add_cog(NewsForward(bot)) 120 | -------------------------------------------------------------------------------- /cogs/common/gacha.py: -------------------------------------------------------------------------------- 1 | from main import AIKyaru 2 | import utils 3 | import discord 4 | from discord.ext import commands 5 | from discord.ext.commands import Context 6 | from discord_slash import cog_ext, SlashContext 7 | from discord_slash.utils.manage_commands import create_choice, create_option 8 | import random 9 | from typing import Optional, Union 10 | 11 | 12 | class Gacha(commands.Cog): 13 | def __init__(self, bot: AIKyaru): 14 | self.bot = bot 15 | 16 | @commands.command( 17 | name="gacha", brief="抽卡模擬", description="一時抽卡一時爽, 一直抽卡一直爽", aliases=["抽卡", "抽", "r"], usage="<抽數(1~300)>" 18 | ) 19 | async def cmd_status(self, ctx: Context, num: Optional[int] = 10): 20 | await self._gacha(ctx, num, 1) 21 | 22 | @cog_ext.cog_slash( 23 | name="gacha", 24 | description="一時抽卡一時爽, 一直抽卡一直爽", 25 | options=[ 26 | create_option(name="次數", description="抽卡次數, 1~300抽", option_type=4, required=False), 27 | create_option( 28 | name="卡池", 29 | description="目標抽取卡池", 30 | option_type=4, 31 | required=False, 32 | choices=[create_choice(value=1, name="精選轉蛋"), create_choice(value=2, name="白金轉蛋")], 33 | ), 34 | ], 35 | connector={"次數": "count", "卡池": "pool"}, 36 | ) 37 | async def cog_gacha( 38 | self, 39 | ctx: SlashContext, 40 | count: int = 10, 41 | pool: int = 1, 42 | ): 43 | await self._gacha(ctx, count, pool) 44 | 45 | async def _gacha(self, ctx: Union[Context, SlashContext], num: int, pool: int): 46 | if num < 1: 47 | return await ctx.send("你是要抽幾次...") 48 | if num > 300: 49 | num = 300 50 | 51 | if isinstance(ctx.channel, discord.channel.DMChannel): 52 | sender = ctx.author.name 53 | else: 54 | sender = ctx.author.nick or ctx.author.name 55 | 56 | x = [random.uniform(0, 1) for i in range(num)] 57 | rarity3_double = 2 if (pool == 1 and self.bot.gameData.tw.rarity3_double) else 1 58 | megami = 0 59 | count = 0 60 | 61 | if num > 10: 62 | res = [0, 0, 0] 63 | pickup = [0 for i in self.bot.gameData.tw.featured_gacha["unit_ids"]] 64 | for i in x: 65 | count += 1 66 | if i <= 0.007 * rarity3_double: 67 | megami += 50 68 | pickup[random.randrange(len(pickup))] += 1 69 | elif i <= 0.025 * rarity3_double: 70 | megami += 50 71 | res[2] += 1 72 | elif i <= 0.18 or count % 10 == 0: 73 | megami += 10 74 | res[1] += 1 75 | else: 76 | megami += 1 77 | res[0] += 1 78 | msg = ( 79 | f"經過了{num}抽的結果,\n{sender} 的石頭變成了{megami}個<:0:605373812619345931>\n" 80 | + " ".join( 81 | [ 82 | f"<:4:{self.bot.config.gacha_emojis.get('Pickup')[i]['id']}> x{pickup[i]}" 83 | for i in range(len(pickup)) 84 | ] 85 | ) 86 | + f" + 保底x1\n" 87 | + f"<:3_star:607908043962712066> x{res[2]}\n" 88 | + f"<:2_star:607908043031838722> x{res[1]}\n" 89 | + f"<:1_star:607908043954323467> x{res[0]}" 90 | ) 91 | return await ctx.send(msg) 92 | 93 | res = [] 94 | pickup = 0 95 | for i in x: 96 | count += 1 97 | if i <= 0.007 * rarity3_double: 98 | res.append(f"<:4:{random.choice(self.bot.config.gacha_emojis.get('Pickup'))['id']}>") 99 | megami += 50 100 | pickup += 1 101 | elif i <= 0.025 * rarity3_double: 102 | res.append(f"<:3:{random.choice(self.bot.config.gacha_emojis.get(3))['id']}>") 103 | megami += 50 104 | elif i <= 0.18 or count == 9: 105 | res.append(f"<:2:{random.choice(self.bot.config.gacha_emojis.get(2))['id']}>") 106 | megami += 10 107 | else: 108 | res.append(f"<:1:{random.choice(self.bot.config.gacha_emojis.get(1))['id']}>") 109 | megami += 1 110 | 111 | msg = f"{sender} 的石頭變成了{megami}個<:0:605373812619345931>\n" 112 | if pickup > 0: 113 | msg += "**你抽到了這次的Pick UP!**\n" 114 | if megami == 19: 115 | msg += random.choice(["保..保底...", "非洲人484", "保底 ㄏㄏ", "石頭好好玩", "可憐哪"]) 116 | elif megami > 200: 117 | msg += random.choice(["歐洲人4你", "騙人的吧...", "..."]) 118 | elif megami > 100: 119 | msg += random.choice(["還不錯喔", "你好棒喔"]) 120 | await ctx.send(msg) 121 | await ctx.send(" ".join(res[0:5]) + "\n" + " ".join(res[5:10])) 122 | -------------------------------------------------------------------------------- /cogs/common/__init__.py: -------------------------------------------------------------------------------- 1 | from main import AIKyaru 2 | import utils 3 | from typing import Union 4 | from discord.ext import commands 5 | from discord.ext.commands import Context 6 | from discord_slash import cog_ext, SlashContext 7 | import time 8 | from cogs.common.gacha import Gacha 9 | from cogs.common.sta import Sta 10 | 11 | 12 | class Common(commands.Cog): 13 | def __init__(self, bot: AIKyaru): 14 | self.bot = bot 15 | 16 | def create_footer_links(self): 17 | links = [ 18 | {"title": "幫助頁面", "url": "https://iandesuyo.notion.site/AI-v3-baec83903f764b7f95d0186f105190ee"}, 19 | { 20 | "title": "邀請AI キャル", 21 | "url": f"https://discordapp.com/oauth2/authorize?client_id={self.bot.user.id}&permissions=8&scope=applications.commands%20bot", 22 | }, 23 | {"title": "Discord群組", "url": "https://discord.gg/cwFc4qh"}, 24 | {"title": "巴哈姆特哈拉版文章", "url": "https://forum.gamer.com.tw/Co.php?bsn=30861&sn=165061"}, 25 | {"title": "Github", "url": "https://github.com/IanDesuyo/AIKyaru"}, 26 | ] 27 | return " | ".join(["[{title}]({url})".format(**i) for i in links]) 28 | 29 | # 30 | # Help 31 | # 32 | @commands.command(name="help", brief="幫助頁面", description="顯示幫助訊息", aliases=["幫助"]) 33 | async def cmd_help(self, ctx: Context): 34 | await self._help(ctx) 35 | 36 | @cog_ext.cog_slash( 37 | name="help", 38 | description="顯示幫助訊息", 39 | ) 40 | async def cog_help(self, ctx: SlashContext): 41 | await self._help(ctx) 42 | 43 | async def _help(self, ctx: Union[Context, SlashContext]): 44 | embed = utils.create_embed( 45 | title=self.bot.config.get(["helpTitle"]), 46 | description=f"指令前綴為`{self.bot.config.get(['prefix'])}`, 也可以標註我<@{self.bot.user.id}>喔~", 47 | author={"name": "幫助", "icon_url": f"{self.bot.config.get(['AssetsURL'])}/logo.png"}, 48 | footer={"text": "©2021 AIキャル | 資料均取自互聯網,商標權等屬原提供者(Cygames, So-net Taiwan)"}, 49 | ) 50 | 51 | embed.add_field( 52 | name="\u200b", 53 | value=self.create_footer_links(), 54 | inline=False, 55 | ) 56 | await ctx.send(embed=embed) 57 | 58 | # 59 | # Ping 60 | # 61 | @commands.command(name="ping", brief="Ping", description="Pong!") 62 | async def cmd_ping(self, ctx: Context): 63 | await self._ping(ctx) 64 | 65 | async def _ping(self, ctx: Union[Context, SlashContext]): 66 | before = time.monotonic() 67 | message = await ctx.send("Pong!") 68 | ping = (time.monotonic() - before) * 1000 69 | await message.edit(content=f"Pong! `{int(ping)}ms`") 70 | 71 | # 72 | # Status 73 | # 74 | @commands.command(name="status", brief="機器人狀態", description="查看機器人狀態") 75 | async def cmd_status(self, ctx: Context): 76 | await self._status(ctx) 77 | 78 | @cog_ext.cog_slash( 79 | name="status", 80 | description="查看機器人狀態", 81 | ) 82 | async def cog_status(self, ctx: SlashContext): 83 | await self._status(ctx) 84 | 85 | async def _status(self, ctx: Union[Context, SlashContext]): 86 | embed = utils.create_embed( 87 | description="Developed by [Ian#5898](https://ian.randosoru.me)", 88 | color=0x1BAED8, 89 | author={"name": "機器人狀態", "icon_url": f"{self.bot.config.get(['AssetsURL'])}/logo.png"}, 90 | footer={"text": "©2021 AIキャル | 資料均取自互聯網,商標權等屬原提供者(Cygames, So-net Taiwan)"}, 91 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/106061.webp", 92 | ) 93 | embed.add_field(name="所在伺服器數", value=len(self.bot.guilds), inline=True) 94 | embed.add_field(name="Gateway延遲", value=round(self.bot.latency, 2), inline=True) 95 | chance = " | 三星加倍中!" if self.bot.gameData.tw.rarity3_double else "" 96 | embed.add_field( 97 | name="模擬抽卡Pickup", 98 | value=" ".join([f"<:4:{i['id']}>" for i in [*self.bot.config.gacha_emojis["Pickup"]]]) + chance, 99 | inline=True, 100 | ) 101 | embed.add_field(name="機器人版本", value=self.bot.__version__, inline=True) 102 | embed.add_field(name="台版資料庫版本", value=self.bot.config.game_data_version.tw["TruthVersion"], inline=True) 103 | embed.add_field(name="日版資料庫版本", value=self.bot.config.game_data_version.jp["TruthVersion"], inline=True) 104 | embed.add_field(name="資料庫更新時間", value=self.bot.config.game_data_version.updateTime, inline=True) 105 | embed.add_field(name="Rank推薦更新時間", value=self.bot.config.rank_data["source"]["updateTime"], inline=True) 106 | embed.add_field( 107 | name="\u200b", 108 | value=self.create_footer_links(), 109 | inline=False, 110 | ) 111 | await ctx.send(embed=embed) 112 | 113 | 114 | def setup(bot): 115 | bot.add_cog(Common(bot)) 116 | bot.add_cog(Gacha(bot)) 117 | bot.add_cog(Sta(bot)) 118 | -------------------------------------------------------------------------------- /cogs/character/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from discord_slash.context import ComponentContext 3 | from discord_slash.model import ButtonStyle 4 | from cogs.character.embeds import Embeds 5 | from main import AIKyaru 6 | from utils import Unit 7 | from utils.custom_id import pref_custom_id, un_pref_custom_id 8 | from discord.ext import commands 9 | from discord.ext.commands import Context 10 | from discord_slash import cog_ext, SlashContext 11 | from discord_slash.utils.manage_commands import create_option 12 | from discord_slash.utils.manage_components import create_button, create_actionrow 13 | from copy import deepcopy 14 | 15 | type_buttons = create_actionrow( 16 | *[ 17 | create_button( 18 | style=ButtonStyle.green, 19 | label="簡介", 20 | emoji={"name": "info", "id": 850732950600679464}, 21 | ), 22 | create_button( 23 | style=ButtonStyle.gray, 24 | label="專武", 25 | emoji={"name": "ue", "id": 850732950642884608}, 26 | ), 27 | create_button( 28 | style=ButtonStyle.blue, 29 | label="技能", 30 | emoji={"name": "skill", "id": 850732950847881226}, 31 | ), 32 | create_button( 33 | style=ButtonStyle.gray, 34 | label="攻擊", 35 | emoji={"name": "icon_skill_attack", "id": 605337612835749908}, 36 | ), 37 | create_button( 38 | style=ButtonStyle.red, 39 | label="RANK推薦", 40 | emoji={"name": "rank", "id": 850732950525575178}, 41 | ), 42 | ] 43 | ) 44 | 45 | 46 | class Character(commands.Cog): 47 | def __init__(self, bot: AIKyaru): 48 | self.bot = bot 49 | self.embedMaker = Embeds(bot) 50 | 51 | @commands.command(name="info", brief="角色資訊", description="查詢角色資訊", aliases=["i"], usage="<角色關鍵字>") 52 | async def cmd_info(self, ctx: Context, *, keyword: str): 53 | await self._init_embed(ctx, keyword, 1) 54 | 55 | @commands.command(name="ue", brief="角色專武", description="查詢角色專屬武器", usage="<角色關鍵字>") 56 | async def cmd_ue(self, ctx: Context, *, keyword: str): 57 | await self._init_embed(ctx, keyword, 2) 58 | 59 | @commands.command(name="skill", brief="角色技能", description="查詢角色技能", usage="<角色關鍵字>") 60 | async def cmd_skill(self, ctx: Context, *, keyword: str): 61 | await self._init_embed(ctx, keyword, 3) 62 | 63 | @commands.command(name="attack", brief="角色攻擊模式", description="查詢角色攻擊模式", aliases=["atk"], usage="<角色關鍵字>") 64 | async def cmd_attack(self, ctx: Context, *, keyword: str): 65 | await self._init_embed(ctx, keyword, 4) 66 | 67 | @commands.command(name="rank", brief="RANK推薦", description="查詢RANK推薦", usage="<角色關鍵字>") 68 | async def cmd_rank(self, ctx: Context, *, keyword: str): 69 | await self._init_embed(ctx, keyword, 5) 70 | 71 | @cog_ext.cog_slash( 72 | name="character", 73 | description="查詢角色資訊", 74 | options=[create_option(name="角色", description="可以是角色名稱或關鍵字", option_type=3, required=True)], 75 | connector={"角色": "keyword"}, 76 | ) 77 | async def cog_menu(self, ctx: SlashContext, keyword: str): 78 | type = await ctx.state.get_user(keys=["config", "character_default_type"]) or 1 79 | 80 | await self._init_embed(ctx, keyword, type) 81 | 82 | @cog_ext.cog_component() 83 | async def pref_character(self, ctx: ComponentContext): 84 | await self._handle_button(ctx) 85 | 86 | async def _init_embed(self, ctx: Union[Context, SlashContext], keyword: str, type: int): 87 | unit = self.bot.config.get_character(keyword) 88 | if not unit: 89 | return await ctx.send(f"找不到跟`{keyword}`有關的角色...") 90 | 91 | await ctx.send(**self.create_embed(unit, type)) 92 | 93 | async def _handle_button(self, ctx: ComponentContext): 94 | # i = unit_id, t = type 95 | data = un_pref_custom_id(custom_id="character", data=ctx.custom_id) 96 | unit = self.bot.config.get_character_by_id(data["i"]) 97 | 98 | await ctx.edit_origin(**self.create_embed(unit, data["t"])) 99 | 100 | def create_embed(self, unit: Unit, type: int): 101 | if type == 1: 102 | embed = self.embedMaker.profile(unit) 103 | elif type == 2: 104 | embed = self.embedMaker.unique_equipment(unit) 105 | elif type == 3: 106 | embed = self.embedMaker.skill(unit) 107 | elif type == 4: 108 | embed = self.embedMaker.atk_pattern(unit) 109 | elif type == 5: 110 | embed = self.embedMaker.rank(unit) 111 | 112 | # set button 113 | buttons = deepcopy(type_buttons) 114 | buttons["components"][type - 1]["disabled"] = True 115 | 116 | for i, j in enumerate(buttons["components"]): 117 | # i = unit_id, t = type 118 | j["custom_id"] = pref_custom_id(custom_id="character", data={"i": unit.id, "t": i + 1}) 119 | 120 | return {"embed": embed, "components": [buttons]} 121 | 122 | 123 | def setup(bot): 124 | bot.add_cog(Character(bot)) 125 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | from discord import User, Embed 2 | from datetime import datetime 3 | import random 4 | from typing import NamedTuple 5 | from utils.fetch_rank import FetchRank 6 | 7 | 8 | class Unit(NamedTuple): 9 | id: int 10 | keyword: str 11 | name: str 12 | color: int 13 | 14 | 15 | class GameDataVersion(NamedTuple): 16 | jp: dict 17 | tw: dict 18 | updateTime: str 19 | 20 | 21 | class Counter: 22 | """ 23 | Usage counter 24 | """ 25 | 26 | def __init__(self): 27 | self._count = { 28 | "message_received": 0, 29 | "slash_received": 0, 30 | "component_received": 0, 31 | } # let received counts show first 32 | 33 | def add(self, target: str): 34 | if self._count.get(target): 35 | self._count[target] += 1 36 | else: 37 | self._count.update({target: 1}) 38 | 39 | def get(self, target: str): 40 | return self._count.get(target) or 0 41 | 42 | def set(self, target: str, num: int): 43 | self._count[target] = num 44 | 45 | def as_list(self): 46 | return self._count.items() 47 | 48 | def keys(self): 49 | return self.count.keys() 50 | 51 | 52 | def create_embed( 53 | title: str = "", 54 | description: str = Embed.Empty, 55 | color="default", 56 | url: str = Embed.Empty, 57 | author: dict = None, 58 | footer: dict = None, 59 | thumbnail: str = Embed.Empty, 60 | ): 61 | if color == "default": 62 | color = 0x1BAED8 63 | elif color == "error": 64 | color = 0xE82E2E 65 | embed = Embed(title=title, description=description, color=color, url=url) 66 | if author: 67 | embed._author = author 68 | if footer: 69 | embed._footer = footer 70 | if thumbnail: 71 | embed._thumbnail = {"url": thumbnail} 72 | return embed 73 | 74 | 75 | def error_embed( 76 | title: str, 77 | description: str = Embed.Empty, 78 | author: User = None, 79 | timestamp: datetime = None, 80 | tracking_uuid: str = "", 81 | ): 82 | if not timestamp: 83 | timestamp = datetime.utcnow() 84 | embed = Embed(title=title, color=0xE82E2E, description=description, timestamp=timestamp) 85 | embed.set_author(name="\U0001f6a8 ERROR Report") 86 | if author: 87 | embed.set_footer( 88 | text=f"Triggered by {author.name}#{author.discriminator}\n{tracking_uuid}", icon_url=author.avatar_url 89 | ) 90 | 91 | return embed 92 | 93 | 94 | def notice_embed(title: str, description: str = Embed.Empty, author: User = None, timestamp: datetime = None): 95 | if not timestamp: 96 | timestamp = datetime.utcnow() 97 | embed = Embed(title=title, color=0x1BAED8, description=description, timestamp=timestamp) 98 | embed.set_author(name="System") 99 | if author: 100 | embed.set_footer(text=f"by {author.name}#{author.discriminator}", icon_url=author.avatar_url) 101 | 102 | return embed 103 | 104 | 105 | def how_to_use(correct: str): 106 | msgs = [f"請使用 `{correct}`", f"這功能要這樣用喔 `{correct}`", f"用錯方法了啦, 正確的用法是{correct}"] 107 | return random.choice(msgs) 108 | 109 | 110 | def damage_converter(x: str = "0"): 111 | val = 0 112 | num_map = {"K": 1000, "W": 10000, "M": 1000000, "B": 1000000000} 113 | if x.isdigit(): 114 | val = int(x) 115 | else: 116 | if len(x) > 1: 117 | val = float(x[:-1]) * num_map.get(x[-1].upper(), 1) 118 | return int(val) 119 | 120 | 121 | def create_profile_embed(data: dict): 122 | if data["favorite_unit"]["unit_rarity"] == 6: 123 | unit_rarity_type = 60 124 | elif data["favorite_unit"]["unit_rarity"] >=3: 125 | unit_rarity_type = 30 126 | else: 127 | unit_rarity_type = 10 128 | 129 | unit_id = data["favorite_unit"]["id"] + unit_rarity_type 130 | embed = create_embed( 131 | title=data["user_info"]["user_name"], 132 | description=data["user_info"]["user_comment"], 133 | author={"name": "個人檔案"}, 134 | thumbnail=f"https://randosoru.me/static/assets/character_unit/{unit_id}.webp", 135 | footer={"text": "此資料已快取且僅供參考"}, 136 | ) 137 | embed.add_field(name="主角等級", value=data["user_info"]["team_level"], inline=True) 138 | embed.add_field(name="全角色戰力", value=f'{data["user_info"]["total_power"]:,}', inline=True) 139 | embed.add_field(name="所屬戰隊", value=data["clan_name"] or "無所屬", inline=True) 140 | embed.add_field(name="解放角色數", value=data["user_info"]["unit_num"], inline=True) 141 | embed.add_field(name="解放劇情數", value=data["user_info"]["open_story_num"], inline=True) 142 | embed.add_field( 143 | name="露娜之塔完成樓層", 144 | value=f'{"-" if data["user_info"]["tower_cleared_floor_num"] == -1 else data["user_info"]["tower_cleared_floor_num"]} / {"-" if data["user_info"]["tower_cleared_ex_quest_count"] == -1 else data["user_info"]["tower_cleared_ex_quest_count"]}', 145 | inline=True, 146 | ) 147 | embed.add_field( 148 | name="戰鬥競技場排名", 149 | value=data["user_info"]["arena_rank"] or "-", 150 | inline=True, 151 | ) 152 | embed.add_field( 153 | name="公主競技場排名", 154 | value=data["user_info"]["grand_arena_rank"] or "-", 155 | inline=True, 156 | ) 157 | # embed.timestamp = datetime.utcfromtimestamp(data["cacheTs"]) 158 | return embed 159 | 160 | 161 | async def fakeDefer(): 162 | pass 163 | -------------------------------------------------------------------------------- /cogs/admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from utils.errors import create_debug_embed 3 | from main import AIKyaru 4 | import discord 5 | from discord.ext import commands 6 | from discord.ext.commands import Context 7 | import utils 8 | import asyncio 9 | import uuid 10 | 11 | 12 | class Admin(commands.Cog): 13 | def __init__(self, bot: AIKyaru): 14 | self.bot = bot 15 | self.logger = logging.getLogger("AIKyaru.Admin") 16 | 17 | @commands.command(brief="呼叫凱留", aliases=["呼叫凱留"], usage="<訊息(選填)>", hidden=True) 18 | async def callHelp(self, ctx: Context, *, msg: str = None): 19 | def check(reaction, user): 20 | return user == ctx.author and str(reaction.emoji) == "\u2705" 21 | 22 | is_guild = isinstance(ctx.channel, discord.TextChannel) 23 | if is_guild: 24 | message = await ctx.send("請點擊下方 :white_check_mark: 來同意機器人蒐集必要資料以供除錯") 25 | await message.add_reaction("\u2705") 26 | 27 | try: 28 | await self.bot.wait_for("reaction_add", timeout=60.0, check=check) 29 | except asyncio.TimeoutError: 30 | return await message.delete() 31 | 32 | await message.edit(content="獲取資料中...") 33 | await message.clear_reactions() 34 | else: 35 | message = await ctx.send("獲取資料中...") 36 | 37 | tracking_uuid = str(uuid.uuid4()) 38 | embed = await create_debug_embed(self.bot, ctx, tracking_uuid=tracking_uuid, message=msg) 39 | await self.bot.send_debug(embed=embed) 40 | await message.edit(content=f"已呼叫, 追蹤碼: `{tracking_uuid}`") 41 | 42 | @commands.is_owner() 43 | @commands.group(hidden=True) 44 | async def admin(self, ctx: Context): 45 | """ 46 | Admin commands 47 | """ 48 | if ctx.invoked_subcommand is None: 49 | return 50 | 51 | @admin.command(aliases=["r"]) 52 | async def reply(self, ctx: Context, channel_id: int, *, msg: str): 53 | await self.bot.get_channel(channel_id).send(msg) 54 | 55 | @admin.command() 56 | async def activity(self, ctx: Context, *, text: str): 57 | """ 58 | Change bot's activity 59 | """ 60 | await self.bot.change_presence( 61 | status=discord.Status.online, 62 | activity=discord.Activity( 63 | type=self.config.get(["activity", "type"]), 64 | name=f"{self.bot.config.get(['activity','prefix'])}{text}", 65 | ), 66 | ) 67 | await self.bot.send_debug(embed=utils.notice_embed(f"Activity has been changed", text, ctx.author)) 68 | await ctx.send(":white_check_mark: Sucess") 69 | 70 | @admin.command(aliases=["uc"]) 71 | async def usage(self, ctx: Context): 72 | """ 73 | Show command usage counts 74 | """ 75 | counts = self.bot.counter.as_list() 76 | 77 | count_str = "```autohotkey\n" 78 | for i, j in counts: 79 | temp = f"{i}: {j}\n" 80 | if len(count_str) + len(temp) >= 2000: 81 | await ctx.send(count_str + "```") 82 | count_str = "```autohotkey\n" 83 | count_str += temp 84 | 85 | await ctx.send(count_str + "```") 86 | 87 | @admin.command() 88 | async def reload(self, ctx: Context, cog: str): 89 | """ 90 | Reload cog 91 | """ 92 | try: 93 | self.bot.reload_extension(cog) 94 | await ctx.send(":white_check_mark: Sucess") 95 | except Exception as e: 96 | await self.bot.send_debug(embed=utils.error_embed(f"Failed when reloading cog: {cog}", str(e), ctx.author)) 97 | 98 | @admin.command() 99 | async def load(self, ctx: Context, cog: str): 100 | """ 101 | Load cog 102 | """ 103 | try: 104 | self.bot.load_extension(cog) 105 | await ctx.send(":white_check_mark: Sucess") 106 | except Exception as e: 107 | await self.bot.send_debug(embed=utils.error_embed(f"Failed when loading cog: {cog}", str(e), ctx.author)) 108 | 109 | @admin.command() 110 | async def unload(self, ctx: Context, cog: str): 111 | """ 112 | Unload cog 113 | """ 114 | try: 115 | self.bot.unload_extension(cog) 116 | await ctx.send(":white_check_mark: Sucess") 117 | except Exception as e: 118 | await self.bot.send_debug(embed=utils.error_embed(f"Failed when unloading cog: {cog}", str(e), ctx.author)) 119 | 120 | @admin.command() 121 | async def stop(self, ctx: Context): 122 | """ 123 | Stop the bot :( 124 | """ 125 | await self.bot.send_debug(embed=utils.notice_embed(f"Bot has been stopped", author=ctx.author)) 126 | await ctx.send(":thumbsup: Bye~") 127 | await self.bot.close() 128 | 129 | @admin.command() 130 | async def exec(self, ctx: Context, *, command: str): 131 | """ 132 | Execute command 133 | """ 134 | self.logger.info(f"Execute: {command}") 135 | if command.startswith("await "): 136 | exe = await eval(command[6:]) 137 | else: 138 | print(command) 139 | exe = eval(command) 140 | self.logger.info(f"Execute result: {exe}") 141 | 142 | @admin.command() 143 | async def clean_cache(self, ctx: Context, id: int = None): 144 | """ 145 | Clear specific id or all state caches. 146 | """ 147 | self.bot.stateManger.clean_cache(id) 148 | await ctx.send(":white_check_mark: Sucess") 149 | 150 | 151 | def setup(bot): 152 | bot.add_cog(Admin(bot)) 153 | -------------------------------------------------------------------------------- /cogs/clan/api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | from discord import User 4 | from main import AIKyaru 5 | from aiohttp import ClientSession, ClientTimeout 6 | from expiringdict import ExpiringDict 7 | from utils import errors 8 | from copy import deepcopy 9 | import re 10 | 11 | 12 | class Api: 13 | def __init__(self, bot: AIKyaru): 14 | self.bot = bot 15 | self.logger = logging.getLogger("AIKyaru.ClanApi") 16 | self.apiUrl = self.bot.config.get(["GUILD_API_URL"]) 17 | self.session = ClientSession( 18 | headers={ 19 | "User-Agent": "AIKyaru v3", 20 | "x-token": self.bot.config.get(["GUILD_API_TOKEN"]), 21 | }, 22 | timeout=ClientTimeout(total=10), 23 | ) 24 | self.user_cache = ExpiringDict(max_len=1000, max_age_seconds=86400) # cache user data for 1 day 25 | self.form_cache = ExpiringDict(max_len=1000, max_age_seconds=600) # cache form data for 10 mins 26 | self.record_cache = ExpiringDict( 27 | max_len=100, max_age_seconds=10 28 | ) # cache records for 10 secs to prevent high api use 29 | 30 | 31 | def form_id_check(self, form_id: str): 32 | if not re.match(r"^[0-9a-fA-F]{32}$", form_id): 33 | raise errors.IncorrectFormId 34 | 35 | async def get_user(self, user: User): 36 | cached = self.user_cache.get(user.id) 37 | if cached: 38 | return cached 39 | 40 | async with self.session.get( 41 | f"{self.apiUrl}/bot/isRegister", 42 | params={"platform": 1, "user_id": user.id}, 43 | ) as resp: 44 | data = await resp.json() 45 | 46 | self.logger.debug(f"isRegister {user.id}: {resp.status}") 47 | 48 | if resp.status == 404: 49 | async with self.session.post( 50 | f"{self.apiUrl}/bot/register", 51 | json={"platform": 1, "user_id": user.id, "avatar": str(user.avatar_url), "name": user.name}, 52 | ) as resp: 53 | data = await resp.json() 54 | 55 | if resp.status == 400: 56 | self.logger.error(f"register {user.id}: {data}") 57 | raise ValueError("重複註冊") 58 | 59 | self.logger.debug(f"register {user.id}: {resp.status}") 60 | 61 | 62 | self.user_cache[user.id] = data 63 | return data 64 | 65 | async def get_form(self, form_id: str): 66 | cached = self.form_cache.get(form_id) 67 | if cached: 68 | return cached 69 | 70 | async with self.session.get(f"{self.apiUrl}/forms/{form_id}") as resp: 71 | data = await resp.json() 72 | 73 | self.logger.debug(f"get_form {form_id}: {resp.status}") 74 | if resp.status == 404: 75 | raise errors.FormNotFound(form_id) 76 | 77 | self.form_cache[form_id] = data 78 | return data 79 | 80 | async def create_form(self, user: User, title: str, month: int = None): 81 | if not month: 82 | month = datetime.now().strftime("%Y%m") 83 | 84 | user_data = await self.get_user(user) 85 | async with self.session.post( 86 | f"{self.apiUrl}/bot/forms/create", 87 | params={"user_id": user_data["id"]}, 88 | json={"month": month, "title": title}, 89 | ) as resp: 90 | data = await resp.json() 91 | 92 | self.logger.debug(f"create_form {title}: {resp.status}") 93 | return data 94 | 95 | async def get_record(self, form_id: str, week: int = None, boss: int = None): 96 | path = "/all" 97 | if week: 98 | path = f"/week/{week}" 99 | if boss: 100 | path += f"/boss/{boss}" 101 | 102 | cached = self.record_cache.get(path) 103 | if cached: 104 | return cached 105 | 106 | async with self.session.get(f"{self.apiUrl}/forms/{form_id}{path}") as resp: 107 | data = await resp.json() 108 | 109 | self.logger.debug(f"get_record /{form_id}/{week}/{boss}: {len(data)} records") 110 | self.record_cache[path] = data 111 | return data 112 | 113 | async def get_boss(self, form_id: str, week: int, boss: int): 114 | form = await self.get_form(form_id) 115 | boss_data = deepcopy(form["boss"][boss - 1]) 116 | 117 | if week >= 45: 118 | stage = 5 119 | elif week >= 35: 120 | stage = 4 121 | elif week >= 11: 122 | stage = 3 123 | elif week >= 4: 124 | stage = 2 125 | else: 126 | stage = 1 127 | 128 | boss_data["hp"] = boss_data["hp"][stage - 1] 129 | 130 | return boss_data 131 | 132 | async def post_record( 133 | self, 134 | form_id: str, 135 | week: int, 136 | boss: int, 137 | status: int, 138 | user_id: str, 139 | damage: int = None, 140 | comment: str = None, 141 | record_id: int = None, 142 | month: int = None, 143 | ): 144 | async with self.session.post( 145 | f"{self.apiUrl}/bot/forms/{form_id}/week/{week}/boss/{boss}", 146 | params={"user_id": user_id}, 147 | json={"id": record_id, "status": status, "damage": damage, "comment": comment, "month": month}, 148 | ) as resp: 149 | data = await resp.json() 150 | 151 | if resp.status == 404: 152 | raise errors.RecordDeleted 153 | 154 | self.logger.debug(f"post_record /{form_id}/{week}/{boss}: {resp.status}") 155 | return data -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import discord 3 | from discord import Message, Embed 4 | from discord.ext import commands 5 | from discord.ext.commands import AutoShardedBot, Context 6 | from discord_slash import SlashContext, ComponentContext 7 | import logging 8 | import os 9 | from datetime import datetime 10 | from config import BotConfig 11 | from utils.custom_slash_command import CustomSlashCommand 12 | from utils import Counter, fakeDefer 13 | from utils.errors import handle_error 14 | from utils.game_data import GameDataServer 15 | from utils.state_manger import StateManger, State 16 | 17 | __author__ = "IanDesuyo" 18 | __version__ = "3.1.9" 19 | 20 | 21 | class AIKyaru(AutoShardedBot): 22 | """ 23 | A Discord bot for Princess Connect Re:Dive. 24 | 25 | Kyaru best waifu <3 26 | """ 27 | 28 | def __init__(self): 29 | self.__version__ = __version__ 30 | self.logger = logging.getLogger("AIKyaru") 31 | self.logger.info("Starting...") 32 | self.config = BotConfig() 33 | self.gameData = GameDataServer() 34 | self.counter = Counter() 35 | self.stateManger = StateManger(self.config.get(["MONGODB_URL"])) 36 | super().__init__( 37 | command_prefix=commands.when_mentioned_or(self.config.get(["prefix"])), 38 | case_insensitive=True, 39 | help_command=None, 40 | ) 41 | 42 | def init_(self): 43 | """ 44 | Load Cogs after :class:`SlashCommand` loaded. 45 | 46 | Raises: 47 | ExtensionAlreadyLoaded: Extension already loaded. 48 | """ 49 | for i in self.config.get(["cogs"]): 50 | try: 51 | self.load_extension(i) 52 | self.logger.info(f"{i} loaded.") 53 | except commands.ExtensionAlreadyLoaded: 54 | self.logger.warning(f"{i} already loaded.") 55 | except Exception as e: 56 | self.logger.error(f"Error when loading {i}.") 57 | raise e 58 | 59 | async def on_ready(self): 60 | if not hasattr(self, "uptime"): 61 | # Only run once 62 | self.uptime = datetime.utcnow() 63 | await self.config.update_gacha_emojis(self) 64 | await self.change_presence( 65 | status=discord.Status.online, 66 | activity=discord.Activity( 67 | type=self.config.get(["activity", "type"]), 68 | name=f"{self.config.get(['activity','prefix'])}{self.config.get(['activity','default_text'])}", 69 | ), 70 | ) 71 | 72 | self.logger.info(f"Ready: {self.user} (ID: {self.user.id})") 73 | 74 | async def on_shard_ready(self, shard_id: int): 75 | self.logger.info(f"Shard ready, ID: {shard_id}") 76 | 77 | async def on_shard_disconnect(self, shard_id: int): 78 | self.logger.warning(f"Shard disconnected, ID: {shard_id}") 79 | 80 | async def close(self): 81 | for i in list(self.extensions): 82 | self.unload_extension(i) 83 | self.logger.info(f"{i} unloaded.") 84 | await super().close() 85 | 86 | async def send_debug(self, content: str = None, embed: Embed = None): 87 | await self.get_channel(self.config.get(["DEBUG_CHANNEL"])).send(content=content, embed=embed) 88 | if content: 89 | self.logger.warning(f"Debug message sent: {content}") 90 | elif embed: 91 | self.logger.warning(f"Debug message sent: {embed.title}") 92 | 93 | async def on_message(self, message: Message): 94 | """ 95 | Overwrite :func:`on_message` to inject :class:`State`. 96 | """ 97 | if message.author.bot: 98 | return 99 | 100 | self.counter.add("message_received") 101 | ctx = await self.get_context(message) 102 | if ctx.command: 103 | ctx.state = State(self.stateManger, ctx) 104 | ctx.defer = fakeDefer # Avoid errors when using defer() 105 | self.counter.add(ctx.command.name) 106 | await self.invoke(ctx) 107 | 108 | async def invoke_slash_command(self, func, ctx: SlashContext, args): 109 | """ 110 | Overwrite :func:`slash_command` to inject :class:`State`. 111 | """ 112 | ctx.state = State(self.stateManger, ctx) 113 | self.counter.add("slash_received") 114 | self.counter.add("slash_" + ctx.name) 115 | await func.invoke(ctx, **args) 116 | 117 | async def invoke_component_callback(self, func, ctx: ComponentContext): 118 | """ 119 | Overwrite :func:`invoke_component_callback` to inject :class:`State`. 120 | """ 121 | ctx.state = State(self.stateManger, ctx) 122 | self.counter.add("component_received") 123 | self.counter.add("component_" + ctx.custom_id) 124 | await func.invoke(ctx) 125 | 126 | async def on_slash_command_error(self, ctx: SlashContext, error): 127 | await handle_error(self, ctx, error) 128 | 129 | async def on_component_callback_error(self, ctx: ComponentContext, error): 130 | await handle_error(self, ctx, error) 131 | 132 | async def on_command_error(self, ctx: Union[Context, SlashContext], error): 133 | await handle_error(self, ctx, error) 134 | 135 | 136 | if __name__ == "__main__": 137 | log_handlers = [logging.StreamHandler()] 138 | if not os.environ.get("NO_LOG_FILE"): 139 | log_handlers.append(logging.FileHandler("bot.log")) 140 | 141 | logging.basicConfig( 142 | level=logging.INFO, 143 | format="[%(levelname)s][%(asctime)s][%(name)s] %(message)s", 144 | datefmt="%Y/%m/%d %H:%M:%S", 145 | handlers=log_handlers, 146 | ) 147 | 148 | for folder in ["./gameDB"]: 149 | os.makedirs(folder, exist_ok=True) 150 | 151 | bot = AIKyaru() 152 | slash = CustomSlashCommand(bot, sync_commands=True, sync_on_cog_reload=False) 153 | bot.init_() 154 | 155 | bot.run(os.environ.get("BOT_TOKEN")) 156 | -------------------------------------------------------------------------------- /cogs/profileCard/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import aiohttp 3 | from discord_slash.context import ComponentContext 4 | from discord_slash.model import ButtonStyle 5 | from discord_slash.utils.manage_components import create_actionrow, create_button 6 | from main import AIKyaru 7 | from utils.custom_id import pref_custom_id, un_pref_custom_id 8 | from typing import Union 9 | from discord.ext import commands 10 | from discord.ext.commands import Context 11 | from discord_slash import cog_ext, SlashContext 12 | from discord_slash.utils.manage_commands import create_choice, create_option 13 | import asyncio 14 | import secrets 15 | from aiohttp import ClientSession, ClientTimeout 16 | from utils import create_profile_embed, errors 17 | import re 18 | from copy import deepcopy 19 | from datetime import datetime 20 | 21 | verify_buttons = create_actionrow(*[create_button(style=ButtonStyle.green, label="點我驗證")]) 22 | 23 | 24 | class ProfileCard(commands.Cog): 25 | def __init__(self, bot: AIKyaru): 26 | self.bot = bot 27 | self.logger = logging.getLogger("AIKyaru.ProfileCard") 28 | self.apiUrl = self.bot.config.get(["GAME_API_URL"]) 29 | self.session = ClientSession( 30 | headers={"User-Agent": "AIKyaru v3", "x-token": self.bot.config.get(["GAME_API_TOKEN"])}, 31 | timeout=ClientTimeout(total=10), 32 | ) 33 | 34 | def cog_unload(self): 35 | asyncio.get_event_loop().run_until_complete(self.session.close()) 36 | 37 | # 38 | # Functions 39 | # 40 | async def get_profile(self, server: int, uid: int, cache: bool = True): 41 | async with self.session.get( 42 | f"{self.apiUrl}/profile", 43 | params={"server": server, "uid": str(uid).zfill(9), "cache": "true" if cache else "false"}, 44 | ) as resp: 45 | try: 46 | data = await resp.json() 47 | except: 48 | raise errors.GameApiError 49 | 50 | self.logger.info(f"get_profile /{server}/{uid:09d}: {resp.status}") 51 | 52 | if resp.status == 404: 53 | raise errors.ProfileNotFound 54 | if resp.status == 500: 55 | raise errors.GameApiError 56 | 57 | return data 58 | 59 | # 60 | # Commands 61 | # 62 | @commands.group(name="profile", brief="個人檔案", description="查看個人檔案") 63 | async def cmd_profile(self, ctx: Context): 64 | if ctx.invoked_subcommand is None: 65 | await self._profile(ctx) 66 | 67 | @cog_ext.cog_slash( 68 | name="profile", 69 | description="查看個人檔案", 70 | ) 71 | async def cog_profile(self, ctx: SlashContext): 72 | await self._profile(ctx) 73 | 74 | async def _profile(self, ctx: Union[Context, SlashContext]): 75 | linked_uid = await ctx.state.get_user(keys=["linked_uid"]) 76 | if not linked_uid: 77 | return await ctx.send("尚未綁定") 78 | 79 | await ctx.defer() 80 | data = await self.get_profile(**linked_uid) 81 | await ctx.send(embed=create_profile_embed(data)) 82 | 83 | @cmd_profile.command(name="bind", brief="綁定遊戲ID", description="將Discord帳號與遊戲ID綁定", usage="<伺服器(1~4)> <遊戲ID(9位數)>") 84 | async def cmd_bind(self, ctx: Context, server: int, uid: str): 85 | if server < 1 or server > 4: 86 | return ctx.send("伺服器錯誤, 請輸入1~4") 87 | await self._bind(ctx, server, uid) 88 | 89 | @cog_ext.cog_slash( 90 | name="profile_bind", 91 | description="綁定遊戲內個人檔案", 92 | options=[ 93 | create_option( 94 | name="伺服器", 95 | description="伺服器編號(1~4)", 96 | option_type=4, 97 | choices=[ 98 | create_choice(value=1, name="美食殿堂"), 99 | create_choice(value=2, name="真步真步王國"), 100 | create_choice(value=3, name="破曉之星"), 101 | create_choice(value=4, name="小小甜心"), 102 | # Currently unsupported 103 | # create_choice(value=0, name="日版"), 104 | ], 105 | required=True, 106 | ), 107 | create_option(name="遊戲id", description="9位數ID", option_type=3, required=True), 108 | ], 109 | connector={"伺服器": "server", "遊戲id": "uid"}, 110 | ) 111 | async def cog_bind(self, ctx: SlashContext, server: int, uid: str): 112 | await self._bind(ctx, server, uid) 113 | 114 | async def _bind(self, ctx: Union[Context, SlashContext], server: int, uid: str): 115 | uid = uid.zfill(9) 116 | if not re.match(r"^\d{9}$", uid): 117 | if isinstance(ctx, SlashContext): 118 | return await ctx.send("遊戲ID錯誤", hidden=True) 119 | else: 120 | return await ctx.send("遊戲ID錯誤") 121 | 122 | verify_code = secrets.token_hex(3).upper() 123 | buttons = deepcopy(verify_buttons) 124 | # s = server, i = uid, v = verify_code, t = created_at 125 | buttons["components"][0]["custom_id"] = pref_custom_id( 126 | custom_id="user.link_uid", 127 | data={"s": server, "i": uid, "v": verify_code, "t": int(datetime.now().timestamp())}, 128 | ) 129 | if isinstance(ctx, SlashContext): 130 | await ctx.send(f"請在遊戲內個人簡介內加入以下代碼 `{verify_code}` , 有效時間2分鐘.", components=[buttons], hidden=True) 131 | else: 132 | await ctx.send(f"請在遊戲內個人簡介內加入以下代碼 `{verify_code}` , 有效時間2分鐘.", components=[buttons]) 133 | 134 | @cog_ext.cog_component(components="pref_user.link_uid") 135 | async def pref_user_link_uid(self, ctx: ComponentContext): 136 | # s = server, i = uid, v = verify_code, t = created_at 137 | data = un_pref_custom_id(custom_id="user.link_uid", data=ctx.custom_id) 138 | 139 | if datetime.now().timestamp() > data["t"] + 120: 140 | return await ctx.edit_origin(content="此驗證已過期", components=None) 141 | 142 | await ctx.defer(edit_origin=True) 143 | profile_data = await self.get_profile(data["s"], data["i"], False) 144 | 145 | if str(data["v"]) in profile_data["user_info"]["user_comment"]: 146 | profile_data["user_info"]["user_comment"] = profile_data["user_info"]["user_comment"].replace( 147 | str(data["v"]), "" 148 | ) 149 | await ctx.state.set_user({"linked_uid": {"server": data["s"], "uid": data["i"]}}) 150 | return await ctx.edit_origin( 151 | content=":white_check_mark: 驗證成功", embed=create_profile_embed(profile_data), components=None 152 | ) 153 | 154 | await ctx.edit_origin(content="驗證失敗", components=None) 155 | 156 | 157 | def setup(bot): 158 | bot.add_cog(ProfileCard(bot)) 159 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import os 4 | import json 5 | from utils import download, GameDataVersion, Unit, FetchRank 6 | from datetime import datetime 7 | from discord.ext.commands import Bot 8 | import re 9 | 10 | 11 | class BotConfig: 12 | """ 13 | Bot config 14 | """ 15 | 16 | def __init__(self): 17 | """ 18 | Load config from `./config.json` and download all required data. 19 | 20 | Raises: 21 | FileNotFoundError: Cound not find `./config.json` 22 | """ 23 | self.logger = logging.getLogger("AIKyaru.config") 24 | self.gacha_emojis = {} 25 | 26 | if os.path.exists("./config.json"): 27 | with open("./config.json") as f: 28 | self._config = json.load(f) 29 | else: 30 | raise FileNotFoundError("./config.json") 31 | 32 | asyncio.get_event_loop().run_until_complete(self.update()) 33 | 34 | async def update(self, reload_config=False): 35 | if reload_config: 36 | with open("./config.json") as f: 37 | self._config = json.load(f) 38 | self.logger.info("Downloading character keywords...") 39 | self.characters_keyword = await download.GSheet(**self.get(["keywordGSheet"])) 40 | self.logger.info(f"Done, characters: {len(self.characters_keyword)}.") 41 | 42 | self.logger.info("Downloading recommended ranks...") 43 | fr = FetchRank(self.characters_keyword, self.get(["RankData", "emonight"]), self.get(["RankData", "nonplume"])) 44 | self.rank_data = await fr.run() 45 | self.logger.info(f"Done, characters: {len(self.rank_data)-1}.") # -1 for remove source data 46 | 47 | # check missing characters 48 | keyword_ids = list(self.characters_keyword.keys()) 49 | rank_ids = list(self.rank_data.keys()) 50 | rank_ids.remove("source") 51 | for i in rank_ids: 52 | keyword_ids.remove(int(i)) 53 | 54 | if keyword_ids: 55 | self.logger.warning(f"Missing recommended ranks for {', '.join([str(i) for i in keyword_ids])}.") 56 | 57 | self.logger.info("Checking gameDB version...") 58 | await self.update_gameDB() 59 | self.logger.info("Done.") 60 | 61 | async def update_gameDB(self): 62 | lastVersion = {"jp": None, "tw": None} 63 | updated = False 64 | if os.path.exists("./gameDB/version.json"): 65 | with open("./gameDB/version.json") as f: 66 | lastVersion = json.load(f) 67 | self.logger.info(f"Last version: {lastVersion}") 68 | 69 | try: 70 | jp_ver = await download.json_(self.get(["RediveJP_DB"])[1]) 71 | if lastVersion["jp"] != jp_ver: 72 | self.logger.info(f"Downloading RediveJP database ({jp_ver})...") 73 | await download.gameDB(self.get(["RediveJP_DB"])[0], "redive_jp.db") 74 | lastVersion["jp"] = jp_ver 75 | updated = True 76 | except: 77 | self.logger.warning("Download failed.") 78 | 79 | try: 80 | tw_ver = await download.json_(self.get(["RediveTW_DB"])[1]) 81 | if lastVersion["tw"] != tw_ver: 82 | self.logger.info(f"Downloading RediveTW database ({tw_ver})...") 83 | await download.gameDB(self.get(["RediveTW_DB"])[0], "redive_tw.db") 84 | lastVersion["tw"] = tw_ver 85 | updated = True 86 | except: 87 | self.logger.warning("Download failed.") 88 | 89 | if updated: 90 | with open("./gameDB/version.json", "w+") as f: 91 | json.dump(lastVersion, f) 92 | self.logger.info(f"Newest version: {lastVersion}") 93 | 94 | self.game_data_version = GameDataVersion( 95 | lastVersion["jp"], lastVersion["tw"], datetime.now().strftime("%y/%m/%d %H:%M") 96 | ) 97 | 98 | async def update_gacha_emojis(self, bot: Bot): 99 | self.logger.info("Fetching gacha emojis...") 100 | pickup_units = bot.gameData.tw.featured_gacha["unit_ids"] 101 | pickup_emojis = [] 102 | pickup_ids = [] 103 | for rarity in range(1, 4): 104 | rarity_emojis = [] 105 | 106 | for i in self.get(["EmojiServers", rarity]): 107 | guild = await bot.fetch_guild(i) 108 | emojis = await guild.fetch_emojis() 109 | self.logger.debug(f"Found {len(emojis)} emojis at {i}") 110 | for emoji in emojis: 111 | if int(emoji.name) in pickup_units: 112 | pickup_emojis.append({"name": int(emoji.name), "id": emoji.id}) 113 | pickup_ids.append(int(emoji.name)) 114 | else: 115 | rarity_emojis.append({"name": int(emoji.name), "id": emoji.id}) 116 | 117 | self.gacha_emojis[rarity] = rarity_emojis 118 | 119 | for i in self.get(["EmojiServers", "Pickup"]): 120 | guild = await bot.fetch_guild(i) 121 | emojis = await guild.fetch_emojis() 122 | self.logger.debug(f"Found {len(emojis)} emojis at {i}") 123 | 124 | for emoji in emojis: 125 | if int(emoji.name) in pickup_units and int(emoji.name) not in pickup_ids: 126 | pickup_emojis.append({"name": int(emoji.name), "id": emoji.id}) 127 | 128 | if len(pickup_emojis) == 0: 129 | pickup_emojis.append({"name": 0000, "id": 852602645419130910}) 130 | 131 | self.gacha_emojis["Pickup"] = pickup_emojis 132 | self.logger.info(f"Done, gacha emojis: {', '.join([f'{i}: {len(j)}' for i,j in self.gacha_emojis.items()])}") 133 | 134 | def get(self, keys: list = []): 135 | if not keys: 136 | return self._config 137 | 138 | x = self._config 139 | for i in keys: 140 | x = x.get(str(i)) 141 | if not x: 142 | return None 143 | return x 144 | 145 | def get_character(self, keyword: str): 146 | """ 147 | Find character by keyword 148 | 149 | Args: 150 | keyword (str): character keyword. 151 | 152 | Returns: 153 | :class:`Unit` if character can be found. 154 | """ 155 | if len(keyword) > 20: 156 | return 157 | keyword = keyword.upper() 158 | for i, j in self.characters_keyword.items(): 159 | if re.match(j["keyword"], keyword): 160 | return Unit(id=i, keyword=j["keyword"], name=j["display_name"], color=int(j["color"], 16)) 161 | 162 | self.logger.debug(f"Cound not find the character matching {keyword}") 163 | 164 | def get_character_by_id(self, unit_id: int): 165 | """ 166 | Find character by id. 167 | 168 | Args: 169 | unit_id (int): 4 code id. 170 | 171 | Returns: 172 | :class:`Unit` if character can be found. 173 | """ 174 | unit = self.characters_keyword.get(unit_id) 175 | if unit: 176 | return Unit(id=unit_id, keyword=unit["keyword"], name=unit["display_name"], color=int(unit["color"], 16)) 177 | 178 | self.logger.debug(f"Cound not get character by id {unit_id}") 179 | -------------------------------------------------------------------------------- /utils/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import discord 3 | from discord import Embed 4 | from discord.ext import commands 5 | from discord.ext.commands import Bot, Context 6 | from discord.ext.commands.errors import BotMissingPermissions 7 | from discord_slash import SlashContext, ComponentContext 8 | from datetime import datetime 9 | from utils import how_to_use 10 | import uuid 11 | import utils 12 | import traceback 13 | import asyncio 14 | 15 | 16 | class GameApiError(Exception): 17 | pass 18 | 19 | 20 | class ProfileNotFound(Exception): 21 | pass 22 | 23 | 24 | class FormNotSet(Exception): 25 | pass 26 | 27 | 28 | class FormNotFound(Exception): 29 | pass 30 | 31 | 32 | class IncorrectFormId(Exception): 33 | pass 34 | 35 | 36 | class IncorrectWeek(Exception): 37 | pass 38 | 39 | 40 | class IncorrectBoss(Exception): 41 | pass 42 | 43 | 44 | class IncorrectDamage(Exception): 45 | pass 46 | 47 | 48 | class IncorrectComment(Exception): 49 | pass 50 | 51 | 52 | class ReportNotFinish(Exception): 53 | pass 54 | 55 | 56 | class RecordDeleted(Exception): 57 | pass 58 | 59 | 60 | class GameProfileNotLinked(Exception): 61 | pass 62 | 63 | 64 | async def handle_error(bot: Bot, ctx: Union[Context, SlashContext, ComponentContext], error): 65 | if isinstance(ctx, ComponentContext): 66 | # don't send new message, edit the origin one. 67 | async def do_edit_origin(content: str = "", embed=None, components=None): 68 | await ctx.edit_origin(content=content, embed=embed, components=components) 69 | 70 | ctx.send = do_edit_origin 71 | 72 | if isinstance(error, (commands.CommandNotFound, commands.NotOwner, discord.Forbidden, discord.NotFound)): 73 | return 74 | if isinstance(error, (commands.BadArgument, commands.MissingRequiredArgument)): 75 | return await ctx.send( 76 | how_to_use(f"{bot.config.get(['prefix'])}{ctx.command.name} {getattr(ctx.command, 'usage','')}") 77 | ) 78 | if isinstance(error, commands.CommandOnCooldown): 79 | return await ctx.send(f"等..等一下啦! 太快了... (`{int(error.retry_after)}s`)") 80 | if isinstance(error, commands.MissingPermissions): 81 | return await ctx.send(f"你沒有權限... 需要`{','.join(error.missing_perms)}`") 82 | if isinstance(error, BotMissingPermissions): 83 | return await ctx.send(f"我沒有權限... 需要`{','.join(error.missing_perms)}`") 84 | if isinstance(error, commands.NoPrivateMessage): 85 | return await ctx.send("此功能僅限群組使用") 86 | if isinstance(error, asyncio.TimeoutError): 87 | return await ctx.send("與伺服器連線超時 :(") 88 | 89 | 90 | # game api errors 91 | 92 | if isinstance(error, GameProfileNotLinked): 93 | return await ctx.send("此用戶尚未綁定遊戲ID") 94 | if isinstance(error, ProfileNotFound): 95 | return ctx.send("找不到此玩家... 你還記得你的ID嗎?") 96 | if isinstance(error, GameApiError): 97 | return ctx.send("看起來查詢用的水晶球暫時故障了... 晚點再試試看吧") 98 | 99 | # clan errors 100 | 101 | if isinstance(error, FormNotSet): 102 | return await ctx.send("此群組還沒綁定報名表...") 103 | if isinstance(error, FormNotFound): 104 | return await ctx.send("找不到此報名表... 或許你打錯字了?") 105 | if isinstance(error, IncorrectFormId): 106 | return await ctx.send("你是不是打錯字了? 報名表ID應該要是32字的UUID才對") 107 | if isinstance(error, IncorrectWeek): 108 | return await ctx.send("周次要在1~200之內喔") 109 | if isinstance(error, IncorrectDamage): 110 | return await ctx.send("這什麼傷害? 要在1~40,000,000之內喔") 111 | if isinstance(error, IncorrectComment): 112 | return await ctx.send("備註太長啦! 要40字以內喔") 113 | if isinstance(error, ReportNotFinish): 114 | return await ctx.send("此功能僅限於傷害回報選擇`自訂`時可使用喔!") 115 | if isinstance(error, RecordDeleted): 116 | return await ctx.send("<:scared:605380139760615425> 紀錄消失了...", embed=None, components=None) 117 | 118 | tracking_uuid = str(uuid.uuid4()) 119 | embed = await create_debug_embed(bot, ctx, tracking_uuid, error=error) 120 | 121 | await bot.send_debug(embed=embed) 122 | try: 123 | await ctx.send(f":rotating_light: 發生錯誤, 追蹤碼: `{tracking_uuid}`") 124 | except discord.Forbidden: 125 | pass 126 | 127 | bot.logger.error(f"ERROR: {tracking_uuid}") 128 | raise error 129 | 130 | 131 | async def create_debug_embed( 132 | bot: Bot, 133 | ctx: Union[Context, SlashContext, ComponentContext], 134 | tracking_uuid: str, 135 | message: str = Embed.Empty, 136 | error: Exception = None, 137 | ): 138 | if error: 139 | description = str(error) 140 | if isinstance(ctx, Context): 141 | title = f"Context: `{ctx.command}`\n`{ctx.message.content}`" 142 | ts = ctx.message.created_at 143 | elif isinstance(ctx, SlashContext): 144 | title = f"SlashContext: `{ctx.command}`\n`{ctx.data}`" 145 | ts = ctx.created_at 146 | elif isinstance(ctx, ComponentContext): 147 | title = f"ComponentContext: `{ctx.custom_id}`\n`{ctx.data}`" 148 | ts = ctx.created_at 149 | else: 150 | title = None 151 | description = message 152 | ts = datetime.now() 153 | 154 | is_guild = getattr(ctx, "guild", None) 155 | author_state = await ctx.state.get_user() 156 | 157 | if is_guild: 158 | bot_permissions = ctx.channel.permissions_for(ctx.guild.me if ctx.guild is not None else ctx.bot.user) 159 | author_permissions = ctx.channel.permissions_for(ctx.author) 160 | guild_state = await ctx.state.get_guild() 161 | 162 | embed = utils.create_embed( 163 | title=title or "Call for help", 164 | description=description, 165 | color=0xE82E2E if error else "default", 166 | author={"name": "\U0001f6a8 ERROR Report" if error else "Debug Report"}, 167 | footer={ 168 | "text": f"Triggered by {ctx.author.name}#{ctx.author.discriminator}\n{tracking_uuid}", 169 | "icon_url": str(ctx.author.avatar_url), 170 | }, 171 | ) 172 | embed.timestamp = ts 173 | embed.add_field(name="Author", value=f"{ctx.author.name}#{ctx.author.discriminator}\n{ctx.author.id}") 174 | embed.add_field(name="Guild", value=is_guild and f"{ctx.guild.name}\n{ctx.guild.id}") 175 | embed.add_field(name="Channel", value=is_guild and f"{ctx.channel.name}\n{ctx.channel.id}") 176 | embed.add_field(name="Author State", value=author_state, inline=False) 177 | embed.add_field(name="Guild State", value=is_guild and guild_state, inline=False) 178 | embed.add_field( 179 | name="Bot Permissions", 180 | value=is_guild 181 | and f"[{bot_permissions.value}](https://discordapi.com/permissions.html#{bot_permissions.value})", 182 | ) 183 | embed.add_field( 184 | name="Author Permissions", 185 | value=is_guild 186 | and f"[{author_permissions.value}](https://discordapi.com/permissions.html#{author_permissions.value})", 187 | ) 188 | if error: 189 | try: 190 | raise error 191 | except: 192 | tb = traceback.format_exc(limit=2) 193 | if len(tb) <= 1000: 194 | embed.add_field(name="Traceback", value=f"```\n{tb}```", inline=False) 195 | else: 196 | embed.add_field(name="Traceback", value=f"```\n{str(error)}```", inline=False) 197 | 198 | return embed -------------------------------------------------------------------------------- /utils/game_data.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import re 3 | 4 | 5 | class GameData: 6 | def __init__(self, db_path: str): 7 | self.con = sqlite3.connect(db_path) 8 | self.con.row_factory = self.dict_factory 9 | self.cur = self.con.cursor() 10 | self.analytics() 11 | 12 | def dict_factory(self, cursor, row): 13 | d = {} 14 | for idx, col in enumerate(cursor.description): 15 | d[col[0]] = row[idx] 16 | return d 17 | 18 | def analytics(self): 19 | featured_gacha = self.get_featured_gacha_pickup() 20 | self.rarity3_double = featured_gacha["rarity3_double"] == 1 21 | self.featured_gacha = featured_gacha 22 | self.max_lv = self.get_max_lv() 23 | self.max_enhance_lv = self.get_max_enhance_lv() 24 | self.tower_schedule = self.get_tower_schedule() 25 | 26 | def get_featured_gacha_pickup(self) -> dict: 27 | """ 28 | Returns: 29 | A dict with :str:`gacha_name`, :str:`description`, :str:`start_time`, :str:`end_time`, :list:`unit_ids` and :int:`rarity3_double`. 30 | """ 31 | data = self.cur.execute( 32 | """ 33 | SELECT a.gacha_name, a.description, a.start_time, a.end_time, 34 | (SELECT group_concat(b.unit_id, ',') FROM gacha_exchange_lineup b WHERE b.exchange_id = a.exchange_id) AS unit_ids, 35 | CASE a.rarity_odds WHEN 600000 THEN 1 ELSE 0 END rarity3_double 36 | FROM gacha_data a 37 | WHERE a.exchange_id != 0 AND a.gacha_times_limit10 != 1 AND (a.prizegacha_id != 0 OR a.gacha_bonus_id != 0) AND REPLACE(a.start_time, '/', '-') <= datetime('now', '+8 hours') 38 | ORDER BY a.start_time DESC, a.gacha_bonus_id DESC LIMIT 1 39 | """, 40 | ).fetchone() 41 | data["unit_ids"] = [int(i[:4]) for i in data.get("unit_ids").split(",")] 42 | return data 43 | 44 | def get_max_lv(self) -> int: 45 | data = self.cur.execute("SELECT stat AS lv FROM sqlite_stat1 WHERE tbl = 'skill_cost'").fetchone() 46 | return int(data["lv"]) 47 | 48 | def get_max_enhance_lv(self) -> int: 49 | data = self.cur.execute("SELECT MAX(enhance_level) AS lv FROM unique_equipment_enhance_data LIMIT 1").fetchone() 50 | return int(data["lv"]) 51 | 52 | def get_tower_schedule(self) -> dict: 53 | """ 54 | Returns: 55 | A dict with :int:`max_floor`, :int:`max_ex_floor`, :str:`start_time` and :str:`end_time`. 56 | """ 57 | data = self.cur.execute( 58 | """ 59 | SELECT MAX(a.max_floor_num) AS max_floor, MAX(a.tower_area_id) AS max_ex_floor, b.start_time, b.end_time 60 | FROM tower_area_data a, tower_schedule b 61 | WHERE b.max_tower_area_id = a.tower_area_id 62 | """ 63 | ).fetchone() 64 | return data 65 | 66 | def get_unit_profile(self, unit_id: int) -> dict: 67 | data = self.cur.execute("SELECT * FROM unit_profile WHERE unit_id = ? LIMIT 1", (unit_id * 100 + 1,)).fetchone() 68 | return data 69 | 70 | def get_unit_unique_equipment(self, unit_id: int): 71 | """ 72 | Each effect will returned as a list, e.g. `["Effect", "default_value(max_value)"]`. 73 | 74 | Returns: 75 | A dict with :int:`equipment_id`, :str:`equipment_name`, :str:`description` and :list:`effects`. 76 | """ 77 | data1 = self.cur.execute( 78 | "SELECT * FROM unique_equipment_data WHERE equipment_id = ? LIMIT 1", (f"13{unit_id-1000:03}1",) 79 | ).fetchone() 80 | 81 | if data1: 82 | data1_list = list(data1.values()) 83 | effect_names = [ 84 | "HP", 85 | "物理攻撃力", 86 | "魔法攻擊力", 87 | "物理防禦", 88 | "魔法防禦", 89 | "物理暴擊", 90 | "魔法暴擊", 91 | "HP自動回復", 92 | "TP自動回復", 93 | "迴避", 94 | "physical_penetrate", 95 | "magic_penetrate", 96 | "生命吸收", 97 | "回復量上升", 98 | "TP上升", 99 | "TP消耗減輕", 100 | "enable_donation", 101 | "命中", 102 | ] 103 | effects = [] 104 | data2_list = list( 105 | self.cur.execute( 106 | "SELECT * FROM unique_equipment_enhance_rate WHERE equipment_id = ? LIMIT 1", 107 | (f"13{unit_id-1000:03}1",), 108 | ) 109 | .fetchone() 110 | .values() 111 | ) 112 | for i in range(8, 26): 113 | if int(data1_list[i]) != 0: 114 | max_value = int(data1_list[i]) + 1 115 | if i <= 23: 116 | max_value = int(data1_list[i] + (data2_list[i - 4] * self.max_enhance_lv - 1)) + 1 117 | effects.append([effect_names[i - 8], f"{data1_list[i]}({max_value})"]) 118 | 119 | return { 120 | "equipment_id": data1["equipment_id"], 121 | "equipment_name": data1["equipment_name"], 122 | "description": data1["description"].replace("\\n", ""), 123 | "effects": effects, 124 | } 125 | 126 | def get_unit_skill(self, unit_id: int): 127 | """ 128 | Each skill will returned as a list, e.g. `["Skill name", "Description", "Icon type", skill_id]`. 129 | 130 | Returns: 131 | A list sorted by skill_id. 132 | """ 133 | data = self.cur.execute( 134 | # """ 135 | # SELECT a.skill_id, a.name, a.icon_type, b.* 136 | # FROM skill_data a, skill_action b 137 | # WHERE a.skill_id LIKE '1158%' AND b.action_id IN (a.action_1, a.action_2, a.action_3, a.action_4, a.action_5, a.action_6, a.action_7) 138 | # """, 139 | "SELECT skill_id, name, description, icon_type FROM skill_data WHERE skill_id LIKE ? ORDER BY skill_id DESC", 140 | (f"{unit_id}%",), 141 | ).fetchall() 142 | skills = [] 143 | s61 = False 144 | s62 = False 145 | for i in data: 146 | if str(i["skill_id"]).endswith("511"): 147 | skills.append([f"EX技能: {i['name']}", i["description"], i["icon_type"], 3]) 148 | if str(i["skill_id"]).endswith("012"): 149 | skills.append([f"技能1: {i['name']}", i["description"], i["icon_type"], 1]) 150 | s62 = True 151 | if str(i["skill_id"]).endswith("002") and not s62: 152 | skills.append([f"技能1: {i['name']}", i["description"], i["icon_type"], 1]) 153 | if str(i["skill_id"]).endswith("003"): 154 | skills.append([f"技能2: {i['name']}", i["description"], i["icon_type"], 2]) 155 | if str(i["skill_id"]).endswith("011"): 156 | skills.append([f"必殺技: {i['name']}", i["description"], i["icon_type"], 0]) 157 | s61 = True 158 | if str(i["skill_id"]).endswith("001") and not s61: 159 | skills.append([f"必殺技: {i['name']}", i["description"], i["icon_type"], 0]) 160 | skills.sort(key=lambda x: x[3]) 161 | return skills 162 | 163 | def get_unit_atk_pattern(self, unit_id: int): 164 | """ 165 | `1`, `100X` and `200Y` means normal attack, skill X and special skill Y. 166 | s 167 | Returns: 168 | A list contains start and loop lists of all patterns. 169 | """ 170 | data = self.cur.execute( 171 | "SELECT * FROM unit_attack_pattern WHERE unit_id = ? ORDER BY pattern_id", (unit_id * 100 + 1,) 172 | ).fetchall() 173 | if data: 174 | result = [] 175 | for i in data: 176 | pattern = list(i.values()) 177 | start = [x for x in pattern[4 : 3 + pattern[2]] if x != 0] 178 | loop = [x for x in pattern[3 + pattern[2] : 4 + pattern[3]] if x != 0] 179 | result.append({"start": start, "loop": loop}) 180 | return result 181 | 182 | 183 | class GameDataServer: 184 | """ 185 | Contains :class:`GameData` for tw and jp. 186 | """ 187 | 188 | def __init__(self, path: str = "./gameDB"): 189 | """ 190 | Load `redive_{tw/jp}.db` from :str:`path`. 191 | """ 192 | self.tw = GameData(f"{path}/redive_tw.db") 193 | self.jp = GameData(f"{path}/redive_jp.db") 194 | -------------------------------------------------------------------------------- /utils/fetch_rank.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from aiohttp import ClientSession, ClientTimeout 3 | import re 4 | import json 5 | import logging 6 | 7 | header = { 8 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36" 9 | } 10 | 11 | 12 | class FetchRank: 13 | def __init__(self, keywords: dict, emonight_sheet: dict, nonplume_sheet: dict): 14 | self.logger = logging.getLogger("AIKyaru.FetchRank") 15 | self.session = ClientSession( 16 | headers={ 17 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36" 18 | }, 19 | timeout=ClientTimeout(total=10), 20 | ) 21 | self.keywords = keywords 22 | self.result = {} 23 | self.source = {} 24 | self.emonight_sheet = emonight_sheet 25 | self.nonplume_sheet = nonplume_sheet 26 | 27 | async def run(self): 28 | await self.emonight() 29 | await self.nonplume() 30 | await self.session.close() 31 | self.check_data() 32 | self.source["updateTime"] = datetime.now().strftime("%m/%d %H:%M") 33 | return {**self.result, "source": self.source} 34 | 35 | async def emonight(self): 36 | async with self.session.get( 37 | f"https://docs.google.com/spreadsheets/u/0/d/{self.emonight_sheet['key']}/gviz/tq?gid={self.emonight_sheet['gid']}&tqx=out:json&tq={self.emonight_sheet['sql']}" 38 | ) as resp: 39 | data = re.search(r"(\{.*\})", await resp.text()) 40 | data = json.loads(data.group(1)) 41 | 42 | cols = self.column_check_emonight([i["label"].strip() for i in data["table"]["cols"]]) 43 | result = {} 44 | unique_check = [] 45 | for row in data["table"]["rows"]: 46 | row = row["c"] 47 | names = re.split(r"\n| ", row[cols["name"]]["v"].strip()) 48 | name = names[0] 49 | if len(names) > 1 and names[1].startswith("("): 50 | name += names[1] 51 | 52 | id, name = self.find_id(name) 53 | if not id: 54 | continue 55 | elif id in unique_check: 56 | self.logger.error(f"Non-unique {name} ({id})") 57 | continue 58 | else: 59 | unique_check.append(id) 60 | 61 | # fmt: off 62 | row_result = {} 63 | row_diff = {} 64 | row_result["name"] = name 65 | 66 | emonight = {} 67 | emonight["preferRank"] = row[cols["preferRank"]]["v"].replace("\n", " ") 68 | emonight["preferRarity"] = row[cols["preferRarity"]]["v"].replace("\n", " ") if cols.get("preferRarity") and row[cols["preferRarity"]] else "-" 69 | emonight["comment"] = row[cols["comment"]]["v"].replace("\n", "") if row[cols["comment"]] else "-" 70 | emonight["pvp"] = row[cols["pvp"]]["v"] if row[cols["pvp"]] else "天下無雙" 71 | emonight["cb"] = row[cols["cb"]]["v"] if row[cols["cb"]] else "天下無雙" 72 | row_result["emonight"] = emonight 73 | 74 | row_result["attackRange"] = row[cols["attackRange"]]["v"] if row[cols["attackRange"]] else "-" 75 | row_diff["hp"] = self.get_value(row[cols["hp"]]) 76 | row_diff["atk"] = self.get_value(row[cols["atk"]]) 77 | row_diff["magic_str"] = self.get_value(row[cols["magic_str"]]) 78 | row_diff["def"] = self.get_value(row[cols["def"]]) 79 | row_diff["magic_def"] = self.get_value(row[cols["magic_def"]]) 80 | row_diff["physical_critical"] = self.get_value(row[cols["physical_critical"]]) 81 | row_diff["magic_critical"] = self.get_value(row[cols["magic_critical"]]) 82 | row_diff["accuracy"] = self.get_value(row[cols["accuracy"]]) 83 | row_diff["dodge"] = self.get_value(row[cols["dodge"]]) 84 | row_diff["hp_steal"] = self.get_value(row[cols["hp_steal"]]) 85 | row_diff["energy_reduce_rate"] = self.get_value(row[cols["energy_reduce_rate"]]) 86 | row_diff["energy_recovery_rate"] = self.get_value(row[cols["energy_recovery_rate"]]) 87 | row_diff["wave_energy_recovery"] = self.get_value(row[cols["wave_energy_recovery"]]) 88 | row_diff["wave_hp_recovery"] = self.get_value(row[cols["wave_hp_recovery"]]) 89 | row_diff["hp_recovery_rate"] = self.get_value(row[cols["hp_recovery_rate"]]) 90 | # fmt: on 91 | 92 | row_result["diff"] = row_diff 93 | result[id] = row_result 94 | 95 | self.result = result 96 | self.source["emonight"] = f"https://docs.google.com/spreadsheets/d/{self.emonight_sheet['key']}/htmlview" 97 | 98 | async def nonplume(self): 99 | async with self.session.get( 100 | f"https://docs.google.com/spreadsheets/u/0/d/{self.nonplume_sheet['key']}/gviz/tq?gid={self.nonplume_sheet['gid']}&tqx=out:json&tq={self.nonplume_sheet['sql']}" 101 | ) as resp: 102 | data = re.search(r"(\{.*\})", await resp.text()) 103 | data = json.loads(data.group(1)) 104 | 105 | cols = self.column_check_nonplume([i["label"].strip() for i in data["table"]["cols"]]) 106 | unique_check = [] 107 | for row in data["table"]["rows"]: 108 | row = row["c"] 109 | if not row[cols["name"]] or row[cols["name"]]["v"] == "名稱": 110 | continue 111 | names = re.split(r"\n| ", row[cols["name"]]["v"].strip()) 112 | name = names[0] 113 | if len(names) > 1 and names[1].startswith("("): 114 | name += names[1] 115 | 116 | id, name = self.find_id(name) 117 | if not id: 118 | continue 119 | elif id in unique_check: 120 | self.logger.error(f"Non-unique {name} ({id})") 121 | continue 122 | else: 123 | unique_check.append(id) 124 | 125 | if self.result[id]["name"] != name: 126 | self.logger.error(f"Error {name} ({id})") 127 | 128 | nonplume = {} 129 | try: 130 | nonplume["pvp"] = row[cols["pvp"]]["v"] 131 | nonplume["cb"] = row[cols["cb"]]["v"] 132 | nonplume["preferRank"] = row[cols["preferRank"]]["v"] if row[cols["preferRank"]] else "-" 133 | nonplume["preferRarity"] = row[cols["preferRarity"]]["v"] 134 | nonplume["comment"] = row[cols["comment"]]["v"] 135 | self.result[id]["nonplume"] = nonplume 136 | except: 137 | pass 138 | 139 | self.source["nonplume"] = f"https://docs.google.com/spreadsheets/d/{self.nonplume_sheet['key']}/htmlview" 140 | 141 | def get_value(self, i): 142 | if i: 143 | if i["v"] != None: 144 | return i["v"] 145 | return "-" 146 | 147 | def find_id(self, name): 148 | for i, j in self.keywords.items(): 149 | if re.match(j["keyword"], name): 150 | return i, j["display_name"] 151 | self.logger.warning(f"Character not found: {name}") 152 | return None, None 153 | 154 | # fmt: off 155 | def column_check_emonight(self, cols): 156 | correct_columns = {} 157 | for i, j in enumerate(cols): 158 | if j == "角色名": correct_columns["name"] = i 159 | elif j == "本次推薦": correct_columns["preferRank"] = i 160 | elif j == "星專建議": correct_columns["preferRarity"] = i 161 | elif j == "升Rank短評": correct_columns["comment"] = i 162 | elif j == "PVP評價": correct_columns["pvp"] = i 163 | elif j == "聯盟戰評價": correct_columns["cb"] = i 164 | elif j == "攻擊距離": correct_columns["attackRange"] = i 165 | elif j == "HP": correct_columns["hp"] = i 166 | elif j == "物攻": correct_columns["atk"] = i 167 | elif j == "魔攻": correct_columns["magic_str"] = i 168 | elif j == "物防": correct_columns["def"] = i 169 | elif j == "魔防": correct_columns["magic_def"] = i 170 | elif j == "物暴": correct_columns["physical_critical"] = i 171 | elif j == "魔暴": correct_columns["magic_critical"] = i 172 | elif j == "命中": correct_columns["accuracy"] = i 173 | elif j == "迴避": correct_columns["dodge"] = i 174 | elif j == "HP吸收": correct_columns["hp_steal"] = i 175 | elif j == "TP減輕": correct_columns["energy_reduce_rate"] = i 176 | elif j == "TP上升": correct_columns["energy_recovery_rate"] = i 177 | elif j == "TP自回": correct_columns["wave_energy_recovery"] = i 178 | elif j == "HP自回": correct_columns["wave_hp_recovery"] = i 179 | elif j == "回復上升": correct_columns["hp_recovery_rate"] = i 180 | 181 | if len(correct_columns.keys()) != 22: 182 | self.logger.warning(f"column_check_emonight | {correct_columns}") 183 | 184 | return correct_columns 185 | 186 | def column_check_nonplume(self, cols): 187 | correct_columns = {} 188 | for i, j in enumerate(cols): 189 | if j == "名稱": correct_columns["name"] = i 190 | elif j == "使用率 戰隊": correct_columns["cb"] = i 191 | elif j == "競技": correct_columns["pvp"] = i 192 | elif "RANK" in j: correct_columns["preferRank"] = i 193 | elif "星級" in j: correct_columns["preferRarity"] = i 194 | elif j == "個人說明欄": correct_columns["comment"] = i 195 | 196 | if len(correct_columns.keys()) != 6: 197 | self.logger.warning(f"column_check_nonplume | {correct_columns}") 198 | 199 | return correct_columns 200 | 201 | # fmt: on 202 | 203 | def check_data(self): 204 | for i, j in self.result.items(): 205 | if not "nonplume" in j: 206 | self.result[i]["nonplume"] = { 207 | "pvp": "-", 208 | "cb": "-", 209 | "preferRank": "-", 210 | "preferRarity": "-", 211 | "comment": "無", 212 | } 213 | -------------------------------------------------------------------------------- /cogs/clan/embeds.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from datetime import datetime 3 | from main import AIKyaru 4 | import utils 5 | from discord_slash.model import ButtonStyle 6 | from discord_slash.utils.manage_components import create_button, create_actionrow 7 | import random 8 | from utils.custom_id import pref_custom_id 9 | from cogs.clan.options import boss_buttons, week_buttons, damage_buttons, status_options 10 | 11 | finish_messages = [":tada: 蘭德索爾又度過了平穩的一天"] 12 | 13 | 14 | status_str = { 15 | 1: "正式刀 ", 16 | 2: "補償刀 ", 17 | 3: "凱留刀 ", 18 | 11: "戰鬥中 ", 19 | 12: "等待中 ", 20 | 13: "等待@mention", 21 | 21: "完成(正式) ", 22 | 22: "完成(補償) ", 23 | 23: "暴死 ", 24 | 24: "求救 ", 25 | } 26 | 27 | status_ids = list(status_str.keys()) 28 | 29 | status_pref = { 30 | 1: " \u26AA ", 31 | 2: " \U0001F7E4 ", 32 | 3: " \U0001F7E3 ", 33 | 11: " \U0001F7E0 ", 34 | 12: " \U0001F535 ", 35 | 13: " \U0001F535 ", 36 | 21: "+\U0001F7E2 ", 37 | 22: "+\U0001F7E2 ", 38 | 23: "-\U0001F534 ", 39 | 24: "-\U0001F534 ", 40 | } 41 | 42 | boss_str = {1: "一王", 2: "二王", 3: "三王", 4: "四王", 5: "五王"} 43 | 44 | 45 | class Embeds: 46 | def __init__(self, bot: AIKyaru): 47 | self.bot = bot 48 | self.author = { 49 | "name": "戰隊管理協會", 50 | "icon_url": "https://guild.randosoru.me/static/images/icon/64x64.png", 51 | "url": "https://guild.randosoru.me", 52 | } 53 | 54 | def create_author(self, title: str): 55 | author = { 56 | "name": f"戰隊管理協會 | {title}", 57 | "icon_url": "https://guild.randosoru.me/static/images/icon/64x64.png", 58 | "url": "https://guild.randosoru.me", 59 | } 60 | return author 61 | 62 | def form_sucess(self, form: dict): 63 | embed = utils.create_embed( 64 | title=f":white_check_mark: 當前的報名表已設定為「{form['title']}」", 65 | color="default", 66 | author=self.author, 67 | ) 68 | embed.add_field(name="月份", value=form["month"], inline=True) 69 | embed.add_field(name="報名表ID", value=form["id"], inline=False) 70 | 71 | components = create_actionrow( 72 | *[create_button(style=ButtonStyle.URL, label="點我前往", url=f"https://guild.randosoru.me/forms/{form['id']}")] 73 | ) 74 | 75 | return embed, components 76 | 77 | def record_boss(self, records: list, form: dict, week: int, boss_data: dict): 78 | content = "```diff\n" 79 | total_damage = 0 80 | if len(records) == 0: 81 | content += "# 尚無紀錄" 82 | else: 83 | for i, j in enumerate(records): 84 | status = status_str[j["status"]] 85 | name = j["user"]["name"] 86 | comment = j["comment"] 87 | last_modified = datetime.fromtimestamp(j["last_modified"]).strftime("%m/%d %H:%M") 88 | if j["damage"]: 89 | damage = f"DMG: {j['damage']:10,}" 90 | total_damage += j["damage"] 91 | else: 92 | damage = f"DMG: 未回報" 93 | 94 | record = status_pref[j["status"]] 95 | record += " | ".join([status, name]) + "\n ├ " 96 | record += " | ".join(filter(None, [damage, comment])) + "\n" 97 | record += f" {'└' if i == len(records)-1 else '├'} @ {last_modified}\n" 98 | content += record 99 | 100 | content += "```" 101 | 102 | damage_percent = int((total_damage / boss_data["hp"]) * 100) 103 | if damage_percent > 100: 104 | damage_percent = 100 105 | 106 | hpbar = ( 107 | ("\U0001F7E5" * round((100 - damage_percent) / 10)) 108 | + ("\u2B1C" * round(damage_percent / 10)) 109 | + f" {100-damage_percent}% {boss_data['hp']-total_damage:,}/{boss_data['hp']:,}\n" 110 | ) 111 | 112 | embed = utils.create_embed( 113 | title=f"{boss_data['name']} ({week}周{boss_data['boss']}王)", 114 | description=hpbar + content, 115 | thumbnail=boss_data["image"], 116 | url=f"https://guild.randosoru.me/forms/{form['id']}/week/{week}", 117 | author=self.create_author(form["title"]), 118 | footer={"text": f"{form['month']} | {form['id']} | 資料獲取時間"}, 119 | ) 120 | embed.timestamp = datetime.utcnow() 121 | 122 | components = self.create_record_buttons(form["id"], week, boss_data["boss"]) 123 | return embed, components 124 | 125 | def create_record_buttons(self, form_id: str, week: int, boss: int): 126 | # i = form_id, w = week, b = boss 127 | buttons = deepcopy(boss_buttons) 128 | week_btns = deepcopy(week_buttons) 129 | buttons["components"][boss - 1]["disabled"] = True 130 | 131 | for i, j in enumerate(buttons["components"]): 132 | j["custom_id"] = pref_custom_id(custom_id="clan.records", data={"i": form_id, "w": week, "b": i + 1}) 133 | 134 | if week == 1: 135 | week_btns["components"][0]["disabled"] = True 136 | else: 137 | week_btns["components"][0]["custom_id"] = pref_custom_id( 138 | custom_id="clan.records", data={"i": form_id, "w": week - 1, "b": 1} 139 | ) 140 | 141 | if week == 200: 142 | week_btns["components"][1]["disabled"] = True 143 | else: 144 | week_btns["components"][1]["custom_id"] = pref_custom_id( 145 | custom_id="clan.records", data={"i": form_id, "w": week + 1, "b": 1} 146 | ) 147 | 148 | return [buttons, week_btns] 149 | 150 | def new_report(self, form: dict, week: int, boss: int, boss_data: dict): 151 | embed = utils.create_embed( 152 | title=f"要在 {form['title']}\n創建一筆 {boss_data['name']} ({week}周{boss}王) 的記錄嗎?", 153 | description="請於下方選擇紀錄狀態", 154 | thumbnail=boss_data["image"], 155 | author=self.create_author(form["title"]), 156 | footer={"text": f"{form['month']} | {form['id']}"}, 157 | ) 158 | 159 | components = deepcopy(status_options) 160 | components["components"][0]["custom_id"] = pref_custom_id( 161 | custom_id="clan.report.new", data={"i": form["id"], "w": week, "b": boss} 162 | ) 163 | 164 | return embed, components 165 | 166 | def record_created(self, form: dict, week: int, boss_data: dict, status: int, record_id: int): 167 | embed = utils.create_embed( 168 | title=f"已在 {form['title']}\n創建一筆 {boss_data['name']} ({week}周{boss_data['boss']}王) 的紀錄!", 169 | thumbnail=boss_data["image"], 170 | author=self.create_author(form["title"]), 171 | footer={"text": f"{form['month']} | {form['id']}"}, 172 | ) 173 | embed.add_field(name="狀態", value=status_str[status].strip(), inline=True) 174 | embed.add_field(name="紀錄編號", value=record_id, inline=True) 175 | 176 | components = self.create_record_update_buttons(form["id"], week, boss_data["boss"], status, record_id) 177 | 178 | return embed, components 179 | 180 | def record_updated(self, form: dict, week: int, boss_data: dict, status: int, record_id: int): 181 | embed = utils.create_embed( 182 | title=f"{week}周{boss_data['boss']}王({boss_data['name']}) 的紀錄已更新!", 183 | thumbnail=boss_data["image"], 184 | author=self.create_author(form["title"]), 185 | footer={"text": f"{form['month']} | {form['id']}"}, 186 | ) 187 | embed.add_field(name="狀態", value=status_str[status].strip(), inline=True) 188 | embed.add_field(name="紀錄編號", value=record_id, inline=True) 189 | 190 | components = self.create_record_update_buttons(form["id"], week, boss_data["boss"], status, record_id) 191 | 192 | return embed, components 193 | 194 | def create_record_update_buttons(self, form_id: str, week: int, boss: int, status: int, record_id: int): 195 | if status < 20: 196 | components = deepcopy(status_options) 197 | components["components"][0]["options"][status_ids.index(status)]["default"] = True 198 | components["components"][0]["custom_id"] = pref_custom_id( 199 | custom_id="clan.report.update", data={"i": form_id, "w": week, "b": boss, "r": record_id} 200 | ) 201 | if status > 10: 202 | components["components"][0]["options"] = components["components"][0]["options"][3:] 203 | else: 204 | components = deepcopy(damage_buttons) 205 | for i in range(3): 206 | components["components"][i]["custom_id"] = pref_custom_id( 207 | custom_id="clan.report.finish", 208 | data={"i": form_id, "w": week, "b": boss, "c": i + 1, "s": status, "r": record_id}, 209 | ) 210 | 211 | return components 212 | 213 | def record_finish(self, form: dict, week: int, boss_data: dict, status: int, record: dict, user_data: dict): 214 | embed = utils.create_embed( 215 | title=f"{user_data['name']} 已完成出刀", 216 | description=random.choice(finish_messages), 217 | thumbnail=boss_data["image"], 218 | author=self.create_author(form["title"]), 219 | footer={"text": f"{form['month']} | {form['id']}"}, 220 | ) 221 | embed.add_field(name="周次/Boss", value=f"{boss_data['name']} {week}周{boss_data['boss']}王", inline=True) 222 | embed.add_field(name="狀態", value=status_str[status].strip(), inline=True) 223 | embed.add_field(name="傷害", value=f"{record['damage']:,}" if record["damage"] else "無", inline=True) 224 | embed.add_field(name="備註", value=record["comment"] or "無", inline=True) 225 | embed.add_field( 226 | name="最後更新時間", value=datetime.fromtimestamp(record["last_modified"]).strftime("%m/%d %H:%M:%S"), inline=True 227 | ) 228 | embed.add_field( 229 | name="紀錄創建時間", value=datetime.fromtimestamp(record["created_at"]).strftime("%m/%d %H:%M:%S"), inline=True 230 | ) 231 | embed.add_field(name="紀錄編號", value=record["id"], inline=True) 232 | 233 | return embed 234 | -------------------------------------------------------------------------------- /cogs/character/embeds.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from main import AIKyaru 3 | import utils 4 | import re 5 | from utils import Unit 6 | 7 | 8 | skill_emojis = { 9 | 1: "<:0:605337612835749908>", 10 | 1001: "<:1:605337612865241098>", 11 | 1002: "<:2:605337613616021514>", 12 | 2001: "<:1:863769653678440479>", 13 | 2002: "<:2:863769653662842932>", 14 | 2003: "<:3:863769653528363029>", 15 | } 16 | 17 | 18 | class Embeds: 19 | def __init__(self, bot: AIKyaru): 20 | self.bot = bot 21 | self.logger = logging.getLogger("AIKyaru.Character") 22 | 23 | def rank(self, unit: Unit): 24 | data = self.bot.config.rank_data.get(unit.id) 25 | if not data: 26 | embed = utils.create_embed( 27 | title="此角色尚無Rank推薦", 28 | description=f"**資料最後更新時間: {self.bot.config.rank_data['source']['updateTime']}**", 29 | color=unit.color, 30 | author={ 31 | "name": f"RANK推薦 {unit.name}", 32 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/equipment/102312.webp", 33 | }, 34 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 35 | ) 36 | 37 | else: 38 | # fmt: off 39 | embed = utils.create_embed( 40 | title=f"本次推薦\nE夢: {data['emonight']['preferRank']} / {data['emonight']['preferRarity']}\n" 41 | + f"無羽: {data['nonplume']['preferRank']} / {data['nonplume']['preferRarity']}星", 42 | description=f"**資料最後更新時間: {self.bot.config.rank_data['source']['updateTime']}**", 43 | color=unit.color, 44 | author={"name": f"RANK推薦 {unit.name}", "icon_url": f"{self.bot.config.get(['AssetsURL'])}/equipment/102312.webp"}, 45 | footer={"text": f"RANK為主觀想法, 結果僅供參考 資料來源: 無羽 / E夢 / 蘭德索爾圖書館"}, 46 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 47 | ) 48 | embed.add_field(name="PVP (E夢/無羽)", value=f"{data['emonight']['pvp']} / {data['nonplume']['pvp']}", inline=True) 49 | embed.add_field(name="戰隊戰 (E夢/無羽)", value=f"{data['emonight']['cb']} / {data['nonplume']['cb']}", inline=True) 50 | embed.add_field(name="HP", value=data["diff"]["hp"], inline=True) 51 | embed.add_field(name="物攻 / 魔攻", value=f"{data['diff']['atk']} / {data['diff']['magic_str']}", inline=True) 52 | embed.add_field(name="物防 / 魔防", value=f"{data['diff']['def']} / {data['diff']['magic_def']}", inline=True) 53 | embed.add_field(name="物暴 / 魔暴", value=f"{data['diff']['physical_critical']} / {data['diff']['magic_critical']}", inline=True) 54 | embed.add_field(name="HP吸收 / 回復上升", value=f"{data['diff']['hp_steal']} / {data['diff']['hp_recovery_rate']}", inline=True) 55 | embed.add_field(name="HP自回 / TP自回", value=f"{data['diff']['wave_hp_recovery']} / {data['diff']['wave_energy_recovery']}", inline=True) 56 | embed.add_field(name="TP上升 / TP減輕", value=f"{data['diff']['energy_recovery_rate']} / {data['diff']['energy_reduce_rate']}", inline=True) 57 | embed.add_field(name="命中/ 迴避", value=f"{data['diff']['accuracy']} / {data['diff']['dodge']}", inline=True) 58 | embed.add_field(name="說明 By E夢", value=f"{data['emonight']['comment']}\n[詳細內容]({self.bot.config.rank_data['source']['emonight']})", inline=False) 59 | embed.add_field(name="說明 By 無羽", value=f"{data['nonplume']['comment']}\n[詳細內容]({self.bot.config.rank_data['source']['nonplume']})", inline=False) 60 | # fmt: on 61 | 62 | return embed 63 | 64 | def profile(self, unit: Unit): 65 | data = self.bot.gameData.tw.get_unit_profile(unit.id) 66 | if not data: 67 | data = self.bot.gameData.jp.get_unit_profile(unit.id) 68 | if not data: 69 | # should not trigger this 70 | self.logger.error(f"Could not find profile for {unit.name}({unit.id})") 71 | embed = utils.create_embed( 72 | title="此角色尚無角色簡介", 73 | color=unit.color, 74 | author={ 75 | "name": f"角色簡介 {unit.name}", 76 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/item/31000.webp", 77 | }, 78 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 79 | ) 80 | 81 | else: 82 | embed = utils.create_embed( 83 | title=unit.name, 84 | description=data["catch_copy"], 85 | color=unit.color, 86 | url=f"{self.bot.config.get(['PCRwiki'])}/{data['unit_name']}", 87 | author={ 88 | "name": f"角色簡介 {data['guild']}", 89 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/item/31000.webp", 90 | }, 91 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 92 | footer={"text": re.sub(r"[\s\\n]", "", data["self_text"])}, 93 | ) 94 | embed.add_field(name="生日", value=f"{data['birth_month']}月{data['birth_day']}日", inline=True) 95 | embed.add_field(name="年齡", value=data["age"], inline=True) 96 | embed.add_field(name="身高", value=data["height"], inline=True) 97 | embed.add_field(name="體重", value=data["weight"], inline=True) 98 | embed.add_field(name="血型", value=data["blood_type"], inline=True) 99 | embed.add_field(name="種族", value=data["race"], inline=True) 100 | embed.add_field(name="喜好", value=data["favorite"], inline=True) 101 | embed.add_field(name="聲優", value=data["voice"], inline=True) 102 | embed.set_image(url=f"{self.bot.config.get(['AssetsURL'])}/character_card/{unit.id}31.webp") 103 | 104 | return embed 105 | 106 | def unique_equipment(self, unit: Unit): 107 | data = self.bot.gameData.tw.get_unit_unique_equipment(unit.id) 108 | if not data: 109 | data = self.bot.gameData.jp.get_unit_unique_equipment(unit.id) 110 | if not data: 111 | embed = utils.create_embed( 112 | title="此角色尚無專屬武器", 113 | color=unit.color, 114 | author={ 115 | "name": f"專用裝備 {unit.name}", 116 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/item/99002.webp", 117 | }, 118 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 119 | ) 120 | 121 | else: 122 | embed = utils.create_embed( 123 | title=data["equipment_name"], 124 | description=data["description"], 125 | color=unit.color, 126 | author={ 127 | "name": f"專用裝備 {unit.name}", 128 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/item/99002.webp", 129 | }, 130 | footer={"text": f"括號內為專用裝備滿等時(Lv.{self.bot.gameData.tw.max_enhance_lv})之數值"}, 131 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/equipment/{data['equipment_id']}.webp", 132 | ) 133 | 134 | for values in data["effects"]: 135 | embed.add_field(name=values[0], value=values[1], inline=True) 136 | 137 | return embed 138 | 139 | def skill(self, unit: Unit): 140 | data = self.bot.gameData.tw.get_unit_skill(unit.id) 141 | if not data: 142 | data = self.bot.gameData.jp.get_unit_skill(unit.id) 143 | if not data: 144 | # should not trigger this 145 | self.logger.error(f"Could not find skills for {unit.name}({unit.id})") 146 | embed = utils.create_embed( 147 | title="此角色尚無技能資訊", 148 | color=unit.color, 149 | author={ 150 | "name": f"技能資訊 {unit.name}", 151 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/skill/2022.webp", 152 | }, 153 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 154 | ) 155 | 156 | else: 157 | embed = utils.create_embed( 158 | color=unit.color, 159 | author={ 160 | "name": f"技能資訊 {unit.name}", 161 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/skill/2022.webp", 162 | }, 163 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 164 | ) 165 | 166 | for skill in data: 167 | embed.add_field(name=skill[0], value=skill[1], inline=False) 168 | 169 | return embed 170 | 171 | def atk_pattern(self, unit: Unit): 172 | data = self.bot.gameData.tw.get_unit_atk_pattern(unit.id) 173 | if not data: 174 | data = self.bot.gameData.jp.get_unit_atk_pattern(unit.id) 175 | if not data: 176 | # should not trigger this 177 | self.logger.error(f"Could not find attack pattern for {unit.name}({unit.id})") 178 | embed = utils.create_embed( 179 | title="此角色尚無攻擊模式", 180 | color=unit.color, 181 | author={ 182 | "name": f"攻擊模式 {unit.name}", 183 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/equipment/101011.webp", 184 | }, 185 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 186 | ) 187 | else: 188 | embed = utils.create_embed( 189 | color=unit.color, 190 | author={ 191 | "name": f"攻擊模式 {unit.name}", 192 | "icon_url": f"{self.bot.config.get(['AssetsURL'])}/equipment/101011.webp", 193 | }, 194 | thumbnail=f"{self.bot.config.get(['AssetsURL'])}/character_unit/{unit.id}31.webp", 195 | ) 196 | 197 | for i, j in enumerate(data): 198 | pattern = ( 199 | "起始:" 200 | + ("→".join([skill_emojis[x] for x in j["start"]])) 201 | + "\n循環:" 202 | + "→".join([skill_emojis[x] for x in j["loop"]]) 203 | + "↻" 204 | ) 205 | embed.add_field( 206 | name=f"模式{i+1}", 207 | value=pattern, 208 | inline=False, 209 | ) 210 | 211 | return embed 212 | -------------------------------------------------------------------------------- /cogs/clan/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from discord.ext.commands.cooldowns import BucketType 3 | from discord_slash.context import ComponentContext 4 | from cogs.character.embeds import Embeds 5 | from main import AIKyaru 6 | from utils import errors 7 | from utils.custom_id import un_pref_custom_id 8 | from discord.ext import commands 9 | from discord_slash import cog_ext, SlashContext 10 | from cogs.clan.api import Api 11 | from cogs.clan.embeds import Embeds 12 | from cogs.clan.options import boss_choices 13 | from cogs.clan.cbre import Cbre 14 | from discord_slash.utils.manage_commands import create_option 15 | from cogs.clan.response import ClanResponse 16 | import asyncio 17 | 18 | 19 | class Clan(commands.Cog): 20 | def __init__(self, bot: AIKyaru): 21 | self.bot = bot 22 | self.logger = logging.getLogger("AIKyaru.Clan") 23 | self.api = Api(bot) 24 | self.embedMaker = Embeds(bot) 25 | 26 | def cog_unload(self): 27 | asyncio.get_event_loop().run_until_complete(self.api.session.close()) 28 | 29 | async def get_form_state(self, ctx) -> dict: 30 | clan = await ctx.state.get_guild(keys=["clan"]) or {} 31 | if not clan.get("form_id"): 32 | raise errors.FormNotSet 33 | 34 | return clan 35 | 36 | async def set_form_state(self, ctx: SlashContext, form: dict): 37 | await ctx.state.set_guild({"clan": {"form_id": form["id"], "month": form["month"], "week": 1, "boss": 1}}) 38 | 39 | @cog_ext.cog_subcommand( 40 | base="clan", 41 | name="set", 42 | description="戰隊系統相關設定", 43 | options=[ 44 | create_option(name="報名表id", description="設定與此群組綁定的報名表", option_type=3, required=False), 45 | create_option(name="week", description="設定當前周次", option_type=4, required=False), 46 | create_option(name="boss", description="設定當前Boss", option_type=4, choices=boss_choices, required=False), 47 | ], 48 | connector={"報名表id": "form_id"}, 49 | ) 50 | @commands.guild_only() 51 | @commands.has_permissions(administrator=True) 52 | async def cog_clan_set(self, ctx: SlashContext, form_id: str = None, week: int = None, boss: int = None): 53 | if not form_id and not week and not boss: 54 | return await ctx.send("你想要我設定什麼...") 55 | 56 | content = [] 57 | embed = None 58 | components = [] 59 | 60 | if form_id: 61 | form_id = form_id.lower() 62 | self.api.form_id_check(form_id) 63 | 64 | await ctx.defer() 65 | form = await self.api.get_form(form_id) 66 | await self.set_form_state(ctx, form) 67 | 68 | embed, x = self.embedMaker.form_sucess(form) 69 | components.append(x) 70 | 71 | # check if form_id is set 72 | if not form_id and not await ctx.state.get_guild(keys=["clan", "form_id"]): 73 | raise errors.FormNotSet 74 | 75 | if week: 76 | if week < 0 or week > 200: 77 | raise errors.IncorrectWeek 78 | 79 | await ctx.state.set_guild({"clan.week": week}) 80 | content.append(f":white_check_mark: 當前周次已設為`{week}`") 81 | 82 | await ctx.state.set_guild({"clan.boss": boss}) 83 | content.append(f":white_check_mark: 當前Boss已設為`{boss}`") 84 | 85 | await ctx.send(content="\n".join(content) or None, embed=embed, components=components) 86 | 87 | @cog_ext.cog_subcommand( 88 | base="clan", 89 | name="create", 90 | description="創建新的報名表", 91 | options=[create_option(name="名稱", description="報名表名稱", option_type=3, required=False)], 92 | connector={"名稱": "form_name"}, 93 | ) 94 | @commands.guild_only() 95 | @commands.has_permissions(administrator=True) 96 | @commands.cooldown(1, 86400, BucketType.guild) 97 | async def cog_clan_create(self, ctx: SlashContext, form_name: str = None): 98 | if not form_name: 99 | form_name = ctx.guild.name 100 | 101 | await ctx.defer() 102 | 103 | form = await self.api.create_form(ctx.author, form_name) 104 | await self.set_form_state(ctx, form) 105 | 106 | embed, components = self.embedMaker.form_sucess(form) 107 | await ctx.send(embed=embed, components=[components]) 108 | 109 | @cog_ext.cog_subcommand( 110 | base="clan", 111 | name="records", 112 | description="查看報名列表", 113 | options=[ 114 | create_option(name="week", description="指定周次, 預設為群組當前周次", option_type=4, required=False), 115 | create_option( 116 | name="boss", description="指定Boss, 預設為群組當前Boss", option_type=4, choices=boss_choices, required=False 117 | ), 118 | ], 119 | ) 120 | @commands.guild_only() 121 | async def cog_records(self, ctx: SlashContext, week: int = None, boss: int = None): 122 | if week and (week < 0 or week > 200): 123 | raise errors.IncorrectWeek 124 | 125 | form_state = await self.get_form_state(ctx) 126 | form_id = form_state.get("form_id") 127 | week = week or form_state.get("week", 1) 128 | boss = boss or form_state.get("boss", 1) 129 | 130 | form = await self.api.get_form(form_id) 131 | boss_data = await self.api.get_boss(form_id, week, boss) 132 | records = await self.api.get_record(form_id, week, boss) 133 | 134 | embed, components = self.embedMaker.record_boss(records, form, week, boss_data) 135 | 136 | await ctx.send(embed=embed, components=components, hidden=True) 137 | 138 | @cog_ext.cog_component(components="pref_clan.records") 139 | async def pref_clan_record(self, ctx: ComponentContext): 140 | # i = form_id, w = week, b = boss 141 | data = un_pref_custom_id(custom_id="clan.records", data=ctx.custom_id) 142 | 143 | form = await self.api.get_form(data["i"]) 144 | boss_data = await self.api.get_boss(data["i"], data["w"], data["b"]) 145 | records = await self.api.get_record(data["i"], data["w"], data["b"]) 146 | 147 | embed, components = self.embedMaker.record_boss(records, form, data["w"], boss_data) 148 | 149 | await ctx.edit_origin(embed=embed, components=components) 150 | 151 | @cog_ext.cog_subcommand( 152 | base="clan", 153 | name="report", 154 | description="回報紀錄", 155 | options=[ 156 | create_option(name="week", description="指定周次, 預設為群組當前周次", option_type=4, required=False), 157 | create_option( 158 | name="boss", description="指定Boss, 預設為群組當前Boss", option_type=4, choices=boss_choices, required=False 159 | ), 160 | ], 161 | ) 162 | @commands.guild_only() 163 | async def cog_report(self, ctx: SlashContext, week: int = None, boss: int = None): 164 | if week and (week < 0 or week > 200): 165 | raise errors.IncorrectWeek 166 | 167 | form_state = await self.get_form_state(ctx) 168 | form_id = form_state.get("form_id") 169 | week = week or form_state.get("week", 1) 170 | boss = boss or form_state.get("boss", 1) 171 | 172 | form = await self.api.get_form(form_id) 173 | boss_data = await self.api.get_boss(form_id, week, boss) 174 | 175 | embed, components = self.embedMaker.new_report(form, week, boss, boss_data) 176 | 177 | await ctx.send(embed=embed, components=[components], hidden=True) 178 | 179 | @cog_ext.cog_component(components="pref_clan.report.new") 180 | async def pref_clan_report_new(self, ctx: ComponentContext): 181 | # i = form_id, w = week, b = boss, s = status 182 | data = un_pref_custom_id(custom_id="clan.report.new", data=ctx.custom_id) 183 | status = int(ctx.selected_options[0]) 184 | 185 | form = await self.api.get_form(data["i"]) 186 | boss_data = await self.api.get_boss(data["i"], data["w"], data["b"]) 187 | user_data = await self.api.get_user(ctx.author) 188 | 189 | record = await self.api.post_record( 190 | data["i"], data["w"], data["b"], status, user_data["id"], month=form["month"] 191 | ) 192 | 193 | embed, components = self.embedMaker.record_created(form, data["w"], boss_data, status, record["id"]) 194 | 195 | await ctx.edit_origin(content=None, embed=embed, components=[components]) 196 | 197 | @cog_ext.cog_component(components="pref_clan.report.update") 198 | async def pref_clan_report_update(self, ctx: ComponentContext): 199 | # i = form_id, w = week, b = boss, s = status, r = record_id 200 | data = un_pref_custom_id(custom_id="clan.report.update", data=ctx.custom_id) 201 | status = int(ctx.selected_options[0]) 202 | 203 | form = await self.api.get_form(data["i"]) 204 | boss_data = await self.api.get_boss(data["i"], data["w"], data["b"]) 205 | user_data = await self.api.get_user(ctx.author) 206 | 207 | record = await self.api.post_record( 208 | data["i"], data["w"], data["b"], status, user_data["id"], record_id=data["r"], month=form["month"] 209 | ) 210 | 211 | embed, components = self.embedMaker.record_updated(form, data["w"], boss_data, status, data["r"]) 212 | 213 | await ctx.edit_origin(content=None, embed=embed, components=[components]) 214 | 215 | @cog_ext.cog_component(components="pref_clan.report.finish") 216 | async def pref_clan_report_finish(self, ctx: ComponentContext): 217 | # i = form_id, w = week, b = boss, s = status, r = record_id, c = finish_type 218 | data = un_pref_custom_id(custom_id="clan.report.finish", data=ctx.custom_id) 219 | 220 | form = await self.api.get_form(data["i"]) 221 | boss_data = await self.api.get_boss(data["i"], data["w"], data["b"]) 222 | user_data = await self.api.get_user(ctx.author) 223 | 224 | if data["c"] == 3: # custom damage and comment 225 | await ctx.state.set_user({"clan.finish_record": data}) 226 | return await ctx.edit_origin(content=f"請使用`/clan finish`來完成回報!", embed=None, components=None) 227 | 228 | record = await self.api.post_record( 229 | data["i"], 230 | data["w"], 231 | data["b"], 232 | data["s"], 233 | user_data["id"], 234 | record_id=data["r"], 235 | month=form["month"], 236 | damage=boss_data["hp"], 237 | comment="物理一刀" if data["c"] == 1 else "魔法一刀", 238 | ) 239 | 240 | embed = self.embedMaker.record_finish(form, data["w"], boss_data, data["s"], record, user_data) 241 | 242 | await ctx.edit_origin(content=":white_check_mark: 已完成出刀", embed=None, components=None) 243 | await ctx.send(embed=embed) 244 | 245 | @cog_ext.cog_subcommand( 246 | base="clan", 247 | name="finish", 248 | description="完成自訂回報", 249 | options=[ 250 | create_option(name="傷害", description="對Boss造成的實際傷害, 不得大於40,000,000", option_type=4, required=False), 251 | create_option(name="備註", description="紀錄的備註, 上限40字", option_type=3, required=False), 252 | ], 253 | connector={"傷害": "damage", "備註": "comment"}, 254 | ) 255 | async def cog_finish(self, ctx: SlashContext, damage: int = None, comment: str = None): 256 | # i = form_id, w = week, b = boss, s = status, r = record_id, c = finish_type 257 | data = await ctx.state.get_user(keys=["clan", "finish_record"]) 258 | if not data: 259 | raise errors.ReportNotFinish 260 | 261 | if damage and (damage < 0 or damage > 40000000): 262 | raise errors.IncorrectDamage 263 | 264 | if comment and len(comment) > 40: 265 | raise errors.IncorrectComment 266 | 267 | form = await self.api.get_form(data["i"]) 268 | boss_data = await self.api.get_boss(data["i"], data["w"], data["b"]) 269 | user_data = await self.api.get_user(ctx.author) 270 | 271 | record = await self.api.post_record( 272 | data["i"], 273 | data["w"], 274 | data["b"], 275 | data["s"], 276 | user_data["id"], 277 | record_id=data["r"], 278 | month=form["month"], 279 | damage=damage, 280 | comment=comment, 281 | ) 282 | 283 | embed = self.embedMaker.record_finish(form, data["w"], boss_data, data["s"], record, user_data) 284 | 285 | await ctx.state.set_user({"clan.finish_record": ""}, unset=True) 286 | await ctx.send(embed=embed) 287 | 288 | 289 | def setup(bot): 290 | bot.add_cog(Clan(bot)) 291 | bot.add_cog(ClanResponse(bot)) 292 | bot.add_cog(Cbre(bot)) --------------------------------------------------------------------------------