diff --git a/.dockerignore b/.dockerignore index 96ef163..4e80672 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,4 +10,6 @@ __pycache__/ *.py[cod] **/*.py[cod] +.git +venv/ diff --git a/Dockerfile b/Dockerfile index aad969f..abee28c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,9 @@ RUN groupadd -g 1000 discordbot \ && chmod +x /run.sh USER discordbot +ENV PATH=$PATH:/home/discordbot/.local/bin COPY requirements.txt /app/requirements.txt RUN pip install -r requirements.txt ENV PYTHONPATH=$PYTHONPATH:/app/dbot #CMD python discord_bot.py -ENTRYPOINT ["/usr/local/bin/python", "/app/dbot/discord_bot.py"] +ENTRYPOINT ["/usr/local/bin/python", "/app/dbot/main.py"] diff --git a/dbot/base.py b/dbot/base.py index 4f27851..fb5e407 100644 --- a/dbot/base.py +++ b/dbot/base.py @@ -1,8 +1,9 @@ +import asyncio import logging import os import sys -from db import DiscordDB +from dbot.db import DiscordDB APP_NAME = "discord_bot" @@ -22,6 +23,7 @@ stream_logger = logging.StreamHandler() stream_logger.setLevel(logging.DEBUG) stream_logger.setFormatter(formatter) logger.addHandler(stream_logger) +logger.propagate = False DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") DEFAULT_CHANNEL_ID = os.getenv("DEFAULT_CHANNEL_ID", 603527159109124096) @@ -29,6 +31,7 @@ ADMIN_ID = os.getenv("ADMIN_ID", 220849530730577920) DB_NAME = os.getenv("DB_NAME", "discord.db") PRODUCTION = bool(os.getenv("PRODUCTION")) DB = DiscordDB(DB_NAME) +DB.load_base_data() MENTION_MAPPING = {1: "D1", 2: "D2", 3: "D3", 4: "D4", 11: "Air"} @@ -52,3 +55,5 @@ MESSAGES = dict( notifications_unset="✅ I won't notify about {} in this channel!", notifications_set="✅ I will notify about {} in this channel!", ) + +LOOP = loop = asyncio.get_event_loop() diff --git a/dbot/bot_commands.py b/dbot/bot.py similarity index 96% rename from dbot/bot_commands.py rename to dbot/bot.py index d057564..fc2f71e 100644 --- a/dbot/bot_commands.py +++ b/dbot/bot.py @@ -3,13 +3,14 @@ import sys from discord import Embed from discord.enums import ChannelType from discord.ext import commands +from erepublik.constants import COUNTRIES -from dbot.base import ADMIN_ID, DB, DIVISION_MAPPING, MESSAGES, NOTIFICATION_KINDS, logger +from dbot.base import ADMIN_ID, DB, DIVISION_MAPPING, LOOP, MESSAGES, NOTIFICATION_KINDS, logger from dbot.utils import check_battles, get_battle_page -__all__ = ["bot"] +__all__ = ["DiscordBot"] -bot = commands.Bot(command_prefix="!") +bot = DiscordBot = commands.Bot(command_prefix="!", loop=LOOP) def _process_member(member): @@ -75,12 +76,13 @@ async def control_order_set(ctx, battle_id, side): except IndexError: return await ctx.send(MESSAGES["command_failed"]) DB.set_battle_order(battle_id, side_id) - return await ctx.send(f"✅ Order has been set! {COUNTIRES[side_id].name} must win") + return await ctx.send(f"✅ Order has been set! {COUNTRIES[side_id].name} must win") return await ctx.send(MESSAGES["nothing_to_do"]) + async def control_order_unset(ctx, battle_id): if DB.delete_battle_order(battle_id): - return await ctx.send(f"✅ Order has been unset!") + return await ctx.send("✅ Order has been unset!") return await ctx.send(MESSAGES["nothing_to_do"]) @@ -89,6 +91,7 @@ async def control_order(ctx, action, *args): return await control_order_set(ctx, *args) return await ctx.send(MESSAGES["nothing_to_do"]) + @bot.event async def on_ready(): logger.info("Bot loaded") @@ -203,4 +206,3 @@ async def control(ctx: commands.Context, command: str, *args): async def control_error(ctx, error): logger.exception(error, exc_info=error) return await ctx.send(MESSAGES["command_failed"]) - diff --git a/dbot/discord_bot.py b/dbot/client.py similarity index 83% rename from dbot/discord_bot.py rename to dbot/client.py index abab8a2..9112c5e 100644 --- a/dbot/discord_bot.py +++ b/dbot/client.py @@ -1,41 +1,29 @@ import asyncio import datetime -import logging import time import discord import feedparser import pytz import requests -from constants import events from erepublik.constants import COUNTRIES -from dbot.base import ADMIN_ID, DB, DB_NAME, DEFAULT_CHANNEL_ID, DISCORD_TOKEN, PRODUCTION, logger -from dbot.bot_commands import bot +from dbot.base import ADMIN_ID, DB, DEFAULT_CHANNEL_ID, LOOP, PRODUCTION, logger +from dbot.constants import events from dbot.utils import check_battles, get_battle_page, timestamp -if PRODUCTION: - logger.warning("Production mode enabled!") - logger.setLevel(logging.INFO) - _ts = int(time.time()) - for c_id in COUNTRIES.keys(): - DB.set_rss_feed_timestamp(c_id, _ts) - del _ts -logger.debug(f"Active configs:\nDISCORD_TOKEN='{DISCORD_TOKEN}'\nDEFAULT_CHANNEL_ID='{DEFAULT_CHANNEL_ID}'\nADMIN_ID='{ADMIN_ID}'\nDB_NAME='{DB_NAME}'") - - -class MyClient(discord.Client): +class DiscordClient(discord.Client): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # create the background task and run it in the background self.last_event_timestamp = timestamp() async def on_ready(self): logger.info("Client running") logger.info("------") + # create the background task and run it in the background self.bg_task = self.loop.create_task(self.report_battle_events()) - # self.bg_rss_task = self.loop.create_task(self.report_rss_events()) + self.bg_rss_task = self.loop.create_task(self.report_rss_events()) async def on_error(self, event_method, *args, **kwargs): logger.warning(f"Ignoring exception in {event_method}") @@ -132,29 +120,33 @@ class MyClient(discord.Client): } for kind, div, data in check_battles(r.get("battles")): if kind == "epic" and not DB.check_epic(data["div_id"]): - embed_data = dict( + embed = discord.Embed( title=" ".join(data["extra"]["intensity_scale"].split("_")).title(), url=data["url"], description=f"Epic battle {' vs '.join(data['sides'])}!\nBattle for {data['region']}, Round {data['zone_id']}", - footer=f"Round time {data['round_time']}", ) - embed = discord.Embed.from_dict(embed_data) - logger.debug(f"{embed_data=}") + embed.set_footer(text=f"Round time {data['round_time']}") for channel_id in DB.get_kind_notification_channel_ids("epic"): - if role_id := DB.get_role_id_for_channel_division(kind="epic", channel_id=channel_id, division=div): + role_id = DB.get_role_id_for_channel_division(kind="epic", channel_id=channel_id, division=div) + logger.info(f"Sending epic d{div} message to {channel_id}, role to mention {role_id=}") + if role_id: await self.get_channel(channel_id).send(f"<@&{role_id}> epic battle detected!", embed=embed) else: await self.get_channel(channel_id).send(embed=embed) DB.add_epic(data["div_id"]) + logger.info(f"{data['div_id']} added to notified list") if kind == "empty" and data["round_time_s"] >= 85 * 60 and not DB.check_empty_medal(data["div_id"]): - empty_divisions[div].add_field( - name=f"**Battle for {data['region']} {' '.join(data['sides'])}**", value=f"[R{data['zone_id']} | Time {data['round_time']}]({data['url']})" - ) - DB.add_empty_medal(data["div_id"]) + if len(empty_divisions[div]) < 10: + empty_divisions[div].add_field( + name=f"**Battle for {data['region']} {' '.join(data['sides'])}**", + value=f"[R{data['zone_id']} | Time {data['round_time']}]({data['url']})", + ) + DB.add_empty_medal(data["div_id"]) for d, e in empty_divisions.items(): if e.fields: for channel_id in DB.get_kind_notification_channel_ids("empty"): + logger.info(f"Sending empty {d} message to {channel_id} with {len(e.fields)} battles: {e.fields}") if role_id := DB.get_role_id_for_channel_division(kind="empty", channel_id=channel_id, division=d): await self.get_channel(channel_id).send(f"<@&{role_id}> empty medals in late rounds!", embed=e) else: @@ -172,18 +164,4 @@ class MyClient(discord.Client): await self.get_channel(DEFAULT_CHANNEL_ID).send(f"<@{ADMIN_ID}> I've stopped, please restart") -loop = asyncio.get_event_loop() -client = MyClient() - - -def main(): - global loop - logger.info("Starting Bot loop") - loop.create_task(bot.start(DISCORD_TOKEN)) - logger.info("Starting Client loop") - loop.create_task(client.start(DISCORD_TOKEN)) - loop.run_forever() - - -if __name__ == "__main__": - main() +client = DiscordClient(loop=LOOP) diff --git a/dbot/db.py b/dbot/db.py index 83875c2..c872623 100644 --- a/dbot/db.py +++ b/dbot/db.py @@ -1,6 +1,8 @@ import logging +import time from typing import Dict, List, Optional, Union +from erepublik.constants import COUNTRIES from sqlite_utils import Database from sqlite_utils.db import NotFoundError @@ -10,6 +12,7 @@ class DiscordDB: _db: Database def __init__(self, db_name: str = ""): + self.logger = logging.getLogger(self.__class__.__name__) self._db = Database(db_name) if db_name else Database(memory=True) self.initialize() @@ -31,12 +34,12 @@ class DiscordDB: self._db.create_table("member_tmp", {"name": str, "pm_is_allowed": bool}, pk="id", not_null={"name", "pm_is_allowed"}, defaults={"pm_is_allowed": False}) for row in self._db.table("member").rows: self._db["member_tmp"].insert(row) - logging.info(f"Moving row {row} to tmp member table") + self.logger.info(f"Moving row {row} to tmp member table") self._db["member"].drop(True) self._db.create_table("member", {"name": str, "pm_is_allowed": bool}, pk="id", not_null={"name", "pm_is_allowed"}, defaults={"pm_is_allowed": False}) for row in self._db.table("member_tmp").rows: self._db["member"].insert(row) - logging.info(f"Moving row {row} from tmp member table") + self.logger.info(f"Moving row {row} from tmp member table") self._db["member_tmp"].drop(True) if "player" not in db_tables: @@ -45,7 +48,7 @@ class DiscordDB: try: self._db.create_table("channel", {"guild_id": int, "channel_id": int, "kind": str}, pk="id", not_null={"guild_id", "channel_id", "kind"}, defaults={"kind": "epic"}) self._db["channel"].create_index(("guild_id", "channel_id", "kind"), unique=True) - except: + except Exception: pass for row in self._db.table("notification_channel").rows: self._db["channel"].insert(row) @@ -53,17 +56,31 @@ class DiscordDB: self._db.create_table("role_mapping", {"channel_id": int, "division": int, "role_id": int}, pk="id", not_null={"channel_id", "division", "role_id"}) self._db["role_mapping"].add_foreign_key("channel_id", "channel", "id") self._db["role_mapping"].create_index(("channel_id", "division"), unique=True) - + else: + for row in self._db["role_mapping"].rows: + try: + self._db["channel"].get(row["channel_id"]) + except NotFoundError: + if any(self._db["channel"].rows_where("channel_id = ?", (row["channel_id"],))): + self._db["role_mapping"].update(row["id"], {"channel_id": next(self._db["channel"].rows_where("channel_id = ?", (row["channel_id"],)))["id"]}) + else: + self.logger.warning(f"RoleMapping contained unknown channel id {row['channel_id']}!") + self.logger.warning(f"DELETED:{row=}") + self._db["role_mapping"].delete(row["id"]) for table in self._db.table_names(): if table not in hard_tables: self._db.table(table).drop(ignore=True) self._db.create_table("division", {"division_id": int, "epic": bool, "empty": bool}, pk="id", defaults={"epic": False, "empty": False}, not_null={"division_id"}) self._db.create_table("rss_feed", {"timestamp": float}, pk="id", not_null={"timestamp"}) - self._db.create_table("battleorder", {"battle_id": int, "side": int}, pk="id", not_null={"battle_id","side"}, defaults={"side":71}) + self._db.create_table("battleorder", {"battle_id": int, "side": int}, pk="id", not_null={"battle_id", "side"}, defaults={"side": 71}) self._db.vacuum() + def load_base_data(self): + for country_id in COUNTRIES.keys(): + self.set_rss_feed_timestamp(country_id, time.time()) + # Player methods def get_player(self, pid: int) -> Optional[Dict[str, Union[int, str]]]: @@ -158,10 +175,7 @@ class DiscordDB: :param division_id: int Division ID :return: bool """ - try: - return bool(next(self.division.rows_where("division_id = ? and epic = ?", (division_id, True)))) - except StopIteration: - return False + return any(self.division.rows_where("division_id = ? and epic = ?", (division_id, True))) def add_epic(self, division_id: int) -> bool: """Register epic in division. @@ -280,7 +294,7 @@ class DiscordDB: for row in rows: return row["role_id"] - def set_battle_order(self, battle_id:int, side:int): + def set_battle_order(self, battle_id: int, side: int): if self.get_battle_order(battle_id): return False self.battleorder.insert(dict(battle_id=battle_id, side=side)) @@ -288,16 +302,16 @@ class DiscordDB: def get_battle_order(self, battle_id: int = None): if battle_id is None: - return list(sef.battleorder.rows) + return list(self.battleorder.rows) try: - row = next(self.battleorder.rows_where('battle_id = ?', (battle_id,))) + row = next(self.battleorder.rows_where("battle_id = ?", (battle_id,))) return row - except StopIterationError: + except StopIteration: return def delete_battle_order(self, battle_id: int): bo = self.get_battle_order(battle_id) if bo: - DB.battleorder.delete(bo['id']) + self.battleorder.delete(bo["id"]) return True return False diff --git a/dbot/main.py b/dbot/main.py new file mode 100644 index 0000000..ed2017a --- /dev/null +++ b/dbot/main.py @@ -0,0 +1,44 @@ +import asyncio +import logging +import time + +from erepublik.constants import COUNTRIES + +from dbot.base import ADMIN_ID, DB, DB_NAME, DEFAULT_CHANNEL_ID, DISCORD_TOKEN, LOOP, PRODUCTION, logger +from dbot.bot import bot +from dbot.client import client + +if PRODUCTION: + logger.warning("Production mode enabled!") + logger.setLevel(logging.INFO) + _ts = int(time.time()) + for c_id in COUNTRIES.keys(): + DB.set_rss_feed_timestamp(c_id, _ts) + del _ts + +logger.debug(f"Active configs:\nDISCORD_TOKEN='{DISCORD_TOKEN}'\nDEFAULT_CHANNEL_ID='{DEFAULT_CHANNEL_ID}'\nADMIN_ID='{ADMIN_ID}'\nDB_NAME='{DB_NAME}'") + + +def main(): + + logger.info("Starting Bot loop") + LOOP.create_task(bot.start(DISCORD_TOKEN)) + # asyncio.run(bot.start(DISCORD_TOKEN)) + + logger.info("Starting Client loop") + # asyncio.run(client.start(DISCORD_TOKEN)) + LOOP.create_task(client.start(DISCORD_TOKEN)) + + LOOP.run_forever() + + +async def run_main(): + logger.info("Starting Client loop") + logger.info("Starting Bot loop") + await asyncio.gather(bot.start(DISCORD_TOKEN), client.start(DISCORD_TOKEN)) + logger.info("Loops have finished") + + +if __name__ == "__main__": + # asyncio.run(run_main()) + main() diff --git a/dbot/map_events.py b/dbot/map_events.py index 19686f3..fd4c471 100644 --- a/dbot/map_events.py +++ b/dbot/map_events.py @@ -1,5 +1,6 @@ import feedparser -from constants import COUNTRIES, events + +from dbot.constants import COUNTRIES, events def main(country): diff --git a/docker_run.sh b/docker_run.sh index 680588c..9295f61 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -1,4 +1,11 @@ #!/bin/sh + +sh ./lint.sh +ret=$? +if test $ret != 0; then + exit 1 +fi + docker rm -f discord_bot set -e docker build --tag discord_epicbot . diff --git a/lint.sh b/lint.sh index a944a87..56f5233 100644 --- a/lint.sh +++ b/lint.sh @@ -1,4 +1,6 @@ #!/usr/bin/env sh +set -e isort dbot black dbot flake8 dbot +PYTHONPATH="$(python -c "import os.path; print(os.path.realpath('$1'))")/dbot" python -m unittest dbot/tests.py diff --git a/requirements.txt b/requirements.txt index 3de1d73..62ad90e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ black==21.7b0 discord.py==1.7.3 -eRepublik==0.25.1.5 +eRepublik==0.27.0 feedparser==6.0.8 flake8==3.9.2 isort==5.9.3 diff --git a/run.sh b/run.sh index f2f5c8e..1e05317 100755 --- a/run.sh +++ b/run.sh @@ -12,7 +12,7 @@ if test !$D ; then disown -h %1 sleep 10 else - /usr/local/bin/python /app/discord_bot.py + /usr/local/bin/python /app/main.py fi echo "Done!"