diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..07d8430 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 180 +tab_width = 4 + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 diff --git a/Dockerfile b/Dockerfile index b4e187f..6aaa3d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ RUN groupadd -g 1000 discordbot \ USER discordbot COPY requirements.txt /app/requirements.txt -RUN pip install -r requirements.txt +COPY ./run.sh /run.sh +RUN pip install -r requirements.txt && chmod +x /run.sh -CMD python discord_bot.py +#CMD python discord_bot.py +ENTRYPOINT ['/run.sh', 'docker'] diff --git a/dbot/constants.py b/dbot/constants.py new file mode 100644 index 0000000..04e88ad --- /dev/null +++ b/dbot/constants.py @@ -0,0 +1,255 @@ +import re +from typing import NamedTuple + +from erepublik.constants import COUNTRIES + +__all__ = ["events", COUNTRIES, "FLAGS", "UTF_FLAG"] + +region = r"[\w\(\)\- ']+" +country = r"(Resistance force of )?[\w\(\)\- ]+" +citizen = r"[\w\(\)\-\. \d]+" + + +class EventKind(NamedTuple): + name: str + regex: re.Pattern + format: str + + +events = [ + EventKind("Region attacked", re.compile(rf"(?P{country}) attacked (?P{region}), (?P{country})"), "{invader} attacked {defender} ({region})"), + EventKind( + "Region secured", + re.compile(rf"(?P{region}) was secured by (?P{country}) in the war versus (?P{country})?"), + "{defender} defended {invader}'s attack ({region})", + ), + EventKind( + "Region conquered", + re.compile(rf"(?P{region}) was conquered by (?P{country}) in the war versus (?P{country})"), + "{invader} conquered {region} from {defender}", + ), + EventKind("War declared", re.compile(rf"(?P{country}) declared war on (?P{country})"), "{invader} declared war against {defender}"), + EventKind( + "War declaration", + re.compile(rf"President of (?P{country}) proposed a war declaration against (?P{country})"), + "{invader} proposed a war declaration on {defender}", + ), + EventKind( + "War rejected", re.compile(rf"The proposal for declaring war against (?P{country}) was rejected."), "{current_country} rejected war declaration on {defender}" + ), + EventKind("MPP proposed", re.compile(rf"President of (?P{country}) proposed an alliance with (?P{country})"), "{country} proposed MPP with {partner}"), + EventKind("MPP approved", re.compile(rf"(?P{country}) signed an alliance with (?P{country})"), "{country} signed a MPP with {partner}"), + EventKind( + "MPP rejected", re.compile(rf"The alliance between (?P{country}) and (?P{country}) was rejected"), "MPP between {country} and {partner} was rejected" + ), + EventKind( + "Airstrike proposed", + re.compile(rf"President of (?P{country}) proposed an airstrike against (?P{country})"), + "{invader} proposed an airstrike against {defender}", + ), + EventKind("Airstrike approved", re.compile(rf"(?P{country}) prepares an airstrike on (?P{country})"), "{invader} approved an airstrike against {defender}"), + EventKind("Airstrike rejected", re.compile(rf"The airstrike on (?P{country}) was rejected"), "{current_country} rejected the airstrike against {defender}"), + EventKind( + "NE proposed", + re.compile(rf"(?P{country}) has declared (?P{country}) as a Natural Enemy"), + "{invader} proposed Natural Enemy declaration against {defender}", + ), + EventKind("NE approved", re.compile(rf"(?P{country}) has been proposed as Natural Enemy"), "{current_country} declared {defender} as Natural Enemy"), + EventKind("NE rejected", re.compile(rf"(?P{country}) as new Natural Enemy proposal has been rejected"), "{current_country} rejected {defender} as Natural Enemy"), + EventKind("NE stopped", re.compile(rf"(?P{country}) is no longer a Natural Enemy for (?P{country})"), "{invader} removed Natural Enemy from {defender}"), + EventKind("NE cleared", re.compile(rf"(?P{country}) no longer has a Natural Enemy"), "{country} no longer has a Natural Enemy"), + EventKind("NE reset", re.compile("No Natural Enemy law has been proposed."), "{current_country} has proposed to clear Natural Enemy"), + EventKind( + "Peace proposal", + re.compile(rf"President of (?P{country}) proposed a peace in the war against (?P{country})"), + "{defender} proposed peace against {invader}", + ), + EventKind("Peace proposal", re.compile(rf"(?P{country}) proposed peace in the war against (?P{country})"), "{defender} proposed peace against {invader}"), + EventKind("Peace approved", re.compile(rf"(?P{country}) signed a peace treaty with (?P{country})"), "{invader} and {defender} is not in peace"), + EventKind( + "Peace rejected", + re.compile(rf"The proposed peace treaty between (?P{country}) and (?P{country}) was rejected"), + "{defender} and {invader} did not sign a peace treaty", + ), + EventKind( + "Embargo proposed", re.compile(rf"President of (?P{country}) proposed to stop the trade with (?P{country})"), "{major} proposed trade embargo against {minor}" + ), + EventKind("Embargo approved", re.compile(rf"(?P{country}) stopped trading with (?P{country})"), "{major} declared trade ambargo against {minor}"), + EventKind("Donation proposed", re.compile(rf"A congress donation to (?P{citizen}) was proposed"), "{current_country} proposed a donation to {org}"), + EventKind("Donation approved", re.compile(rf"(?P{country}) made a donation to (?P{citizen})"), "{current_country} approved a donation to {org}"), + EventKind("Donation rejected", re.compile(rf"The proposal for a congress donation to (?P{citizen}) was rejected"), "{current_country} rejected a donation to {org}"), + EventKind("RW started", re.compile(rf"A resistance has started in (?P{region})"), "Resistance war was opened in {region} ({current_country})"), + EventKind( + "Res Concession", + re.compile( + rf"A Resource Concession law to //www.erepublik.com(?P{country}) " + + rf'has been (?P.*?)' + ), + "Resource Concession law between {current_country} and {target} has been {result}", + ), + EventKind( + "CP impeachment", re.compile(rf"A president impeachment against (?P{citizen}) was proposed"), "Impeachment against {cp} president of {current_country} was proposed" + ), + EventKind("CP impeachment", re.compile("The president impeachment proposal has been rejected"), "Impeachment against president of {current_country} was rejected"), + EventKind("Minimum Wage", re.compile("A new minimum wage was proposed"), "A new minimum wage in {current_country} was proposed"), + EventKind("Minimum Wage", re.compile("The proposal for a minimum wage change was rejected"), "The new minimum wage proposal in {current_country} was rejected"), + EventKind("WorkTax", re.compile(rf"(?P{country}) now has a new Work Tax"), "{country} has new Work Tax"), + EventKind("WorkTax", re.compile("A new Work Tax was proposed"), "{country} proposed a new Work Tax"), + EventKind("WorkTax", re.compile("The proposal for a new Work Tax was rejected"), "{country} rejected new Work Tax"), + EventKind("Product Tax", re.compile(r"Taxes for (?P[\w ]+) changed"), "{current_country} changed taxes for {product}"), + EventKind("Product Tax", re.compile(r"Tax proposal of tax changes for (?P[\w ]+) were rejected"), "{current_country} rejected new taxes for {product}"), + EventKind("Product Tax", re.compile(r"New taxes for (?P[\w ]+) were proposed"), "{current_country} proposed new taxes for {product}"), +] + +UTF_FLAG = { + 1: "🇷🇴", + 9: "🇧🇷", + 10: "🇮🇹", + 11: "🇫🇷", + 12: "🇩🇪", + 13: "🇭🇺", + 14: "🇨🇳", + 15: "🇪🇸", + 23: "🇨🇦", + 24: "🇺🇸", + 26: "🇲🇽", + 27: "🇦🇷", + 28: "🇻🇪", + 29: "🇬🇧", + 30: "🇨🇭", + 31: "🇳🇱", + 32: "🇧🇪", + 33: "🇦🇹", + 34: "🇨🇿", + 35: "🇵🇱", + 36: "🇸🇰", + 37: "🇳🇴", + 38: "🇸🇪", + 39: "🇫🇮", + 40: "🇺🇦", + 41: "🇷🇺", + 42: "🇧🇬", + 43: "🇹🇷", + 44: "🇬🇷", + 45: "🇯🇵", + 47: "🇰🇷", + 48: "🇮🇳", + 49: "🇮🇩", + 50: "🇦🇺", + 51: "🇿🇦", + 52: "🇲🇩", + 53: "🇵🇹", + 54: "🇮🇪", + 55: "🇩🇰", + 56: "🇮🇷", + 57: "🇵🇰", + 58: "🇮🇱", + 59: "🇹🇭", + 61: "🇸🇮", + 63: "🇭🇷", + 64: "🇨🇱", + 65: "🇷🇸", + 66: "🇲🇾", + 67: "🇵🇭", + 68: "🇸🇬", + 69: "🇧🇦", + 70: "🇪🇪", + 71: "🇱🇻", + 72: "🇱🇹", + 73: "🇰🇵", + 74: "🇺🇾", + 75: "🇵🇾", + 76: "🇧🇴", + 77: "🇵🇪", + 78: "🇨🇴", + 79: "🇲🇰", + 80: "🇲🇪", + 81: "🇹🇼", + 82: "🇨🇾", + 83: "🇧🇾", + 84: "🇳🇿", + 164: "🇸🇦", + 165: "🇪🇬", + 166: "🇦🇪", + 167: "🇦🇱", + 168: "🇬🇪", + 169: "🇦🇲", + 170: "🇳🇬", + 171: "🇨🇺", +} +FLAGS = { + 1: "flag_ro", + 9: "flag_br", + 10: "flag_it", + 11: "flag_fr", + 12: "flag_de", + 13: "flag_hu", + 14: "flag_cn", + 15: "flag_es", + 23: "flag_ca", + 24: "flag_us", + 26: "flag_mx", + 27: "flag_ar", + 28: "flag_ve", + 29: "flag_gb", + 30: "flag_ch", + 31: "flag_nl", + 32: "flag_be", + 33: "flag_at", + 34: "flag_cz", + 35: "flag_pl", + 36: "flag_sk", + 37: "flag_no", + 38: "flag_se", + 39: "flag_fi", + 40: "flag_ua", + 41: "flag_ru", + 42: "flag_bg", + 43: "flag_tr", + 44: "flag_gr", + 45: "flag_jp", + 47: "flag_kr", + 48: "flag_in", + 49: "flag_id", + 50: "flag_au", + 51: "flag_za", + 52: "flag_md", + 53: "flag_pt", + 54: "flag_ie", + 55: "flag_de", + 56: "flag_ir", + 57: "flag_pk", + 58: "flag_il", + 59: "flag_th", + 61: "flag_si", + 63: "flag_hr", + 64: "flag_cl", + 65: "flag_rs", + 66: "flag_my", + 67: "flag_ph", + 68: "flag_sg", + 69: "flag_ba", + 70: "flag_ee", + 71: "flag_lv", + 72: "flag_lt", + 73: "flag_kp", + 74: "flag_uy", + 75: "flag_py", + 76: "flag_bo", + 77: "flag_pe", + 78: "flag_co", + 79: "flag_mk", + 80: "flag_me", + 81: "flag_tw", + 82: "flag_cy", + 83: "flag_by", + 84: "flag_nz", + 164: "flag_sa", + 165: "flag_eg", + 166: "flag_ae", + 167: "flag_al", + 168: "flag_ge", + 169: "flag_am", + 170: "flag_ng", + 171: "flag_cu", +} diff --git a/db.py b/dbot/db.py similarity index 85% rename from db.py rename to dbot/db.py index 4ed8bc6..ea7a51e 100644 --- a/db.py +++ b/dbot/db.py @@ -23,11 +23,15 @@ class DiscordDB: if "epic" not in self._db.table_names(): self._db.create_table("epic", {"id": int, "fake": bool}, pk="id", not_null={"id"}, defaults={"fake": False}) + if "rss_feed" not in self._db.table_names(): + self._db.create_table("rss_feed", {"id": int, "timestamp": float}, pk="id", not_null={"id", "timestamp"}) + self._db.vacuum() self.member = self._db.table("member") self.player = self._db.table("player") self.epic = self._db.table("epic") + self.rss_feed = self._db.table("rss_feed") # Player methods @@ -133,3 +137,15 @@ class DiscordDB: self.epic.insert({"id": division_id}) return True return False + + def get_rss_feed_timestamp(self, country_id: int) -> float: + try: + return self.rss_feed.get(country_id)["timestamp"] + except NotFoundError: + return 0 + + def set_rss_feed_timestamp(self, country_id: int, timestamp: float): + if self.get_rss_feed_timestamp(country_id): + self.rss_feed.update(country_id, {"timestamp": timestamp}) + else: + self.rss_feed.insert({"id": country_id, "timestamp": timestamp}) diff --git a/discord_bot.py b/dbot/discord_bot.py similarity index 54% rename from discord_bot.py rename to dbot/discord_bot.py index 56de01f..c22da1d 100644 --- a/discord_bot.py +++ b/dbot/discord_bot.py @@ -4,197 +4,53 @@ import json import logging import os import sys +import time from json import JSONDecodeError from typing import Union -import time -import pytz import discord -import requests -from discord.ext import commands -from dotenv import load_dotenv import feedparser - +import pytz +import requests +from constants import UTF_FLAG, events from db import DiscordDB -from map_events import events +from discord.ext import commands +from erepublik.constants import COUNTRIES APP_NAME = "discord_bot" os.chdir(os.path.abspath(os.path.dirname(sys.argv[0]))) -load_dotenv() +os.makedirs("debug", exist_ok=True) logger = logging.getLogger(APP_NAME) +logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") -file_logger = logging.FileHandler(f"./logging.log", "w") -file_logger.setLevel(logging.DEBUG) +file_logger = logging.FileHandler("debug/logging.log", "w") +file_logger.setLevel(logging.WARNING) file_logger.setFormatter(formatter) logger.addHandler(file_logger) stream_logger = logging.StreamHandler() +stream_logger.setLevel(logging.DEBUG) stream_logger.setFormatter(formatter) logger.addHandler(stream_logger) -os.makedirs("debug", exist_ok=True) - DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") DEFAULT_CHANNEL_ID = os.getenv("DEFAULT_CHANNEL_ID", 603527159109124096) -ADMIN_ID = os.getenv("DEFAULT_CHANNEL_ID", 220849530730577920) +ADMIN_ID = os.getenv("ADMIN_ID", 220849530730577920) DB_NAME = os.getenv("DB_NAME", "discord.db") +PRODUCTION = bool(os.getenv("PRODUCTION")) DB = DiscordDB(DB_NAME) -UTF_FLAG = { - 1: "🇷🇴", - 9: "🇧🇷", - 10: "🇮🇹", - 11: "🇫🇷", - 12: "🇩🇪", - 13: "🇭🇺", - 14: "🇨🇳", - 15: "🇪🇸", - 23: "🇨🇦", - 24: "🇺🇸", - 26: "🇲🇽", - 27: "🇦🇷", - 28: "🇻🇪", - 29: "🇬🇧", - 30: "🇨🇭", - 31: "🇳🇱", - 32: "🇧🇪", - 33: "🇦🇹", - 34: "🇨🇿", - 35: "🇵🇱", - 36: "🇸🇰", - 37: "🇳🇴", - 38: "🇸🇪", - 39: "🇫🇮", - 40: "🇺🇦", - 41: "🇷🇺", - 42: "🇧🇬", - 43: "🇹🇷", - 44: "🇬🇷", - 45: "🇯🇵", - 47: "🇰🇷", - 48: "🇮🇳", - 49: "🇮🇩", - 50: "🇦🇺", - 51: "🇿🇦", - 52: "🇲🇩", - 53: "🇵🇹", - 54: "🇮🇪", - 55: "🇩🇰", - 56: "🇮🇷", - 57: "🇵🇰", - 58: "🇮🇱", - 59: "🇹🇭", - 61: "🇸🇮", - 63: "🇭🇷", - 64: "🇨🇱", - 65: "🇷🇸", - 66: "🇲🇾", - 67: "🇵🇭", - 68: "🇸🇬", - 69: "🇧🇦", - 70: "🇪🇪", - 71: "🇱🇻", - 72: "🇱🇹", - 73: "🇰🇵", - 74: "🇺🇾", - 75: "🇵🇾", - 76: "🇧🇴", - 77: "🇵🇪", - 78: "🇨🇴", - 79: "🇲🇰", - 80: "🇲🇪", - 81: "🇹🇼", - 82: "🇨🇾", - 83: "🇧🇾", - 84: "🇳🇿", - 164: "🇸🇦", - 165: "🇪🇬", - 166: "🇦🇪", - 167: "🇦🇱", - 168: "🇬🇪", - 169: "🇦🇲", - 170: "🇳🇬", - 171: "🇨🇺", -} -FLAGS = { - 1: "flag_ro", - 9: "flag_br", - 10: "flag_it", - 11: "flag_fr", - 12: "flag_de", - 13: "flag_hu", - 14: "flag_cn", - 15: "flag_es", - 23: "flag_ca", - 24: "flag_us", - 26: "flag_mx", - 27: "flag_ar", - 28: "flag_ve", - 29: "flag_gb", - 30: "flag_ch", - 31: "flag_nl", - 32: "flag_be", - 33: "flag_at", - 34: "flag_cz", - 35: "flag_pl", - 36: "flag_sk", - 37: "flag_no", - 38: "flag_se", - 39: "flag_fi", - 40: "flag_ua", - 41: "flag_ru", - 42: "flag_bg", - 43: "flag_tr", - 44: "flag_gr", - 45: "flag_jp", - 47: "flag_kr", - 48: "flag_in", - 49: "flag_id", - 50: "flag_au", - 51: "flag_za", - 52: "flag_md", - 53: "flag_pt", - 54: "flag_ie", - 55: "flag_de", - 56: "flag_ir", - 57: "flag_pk", - 58: "flag_il", - 59: "flag_th", - 61: "flag_si", - 63: "flag_hr", - 64: "flag_cl", - 65: "flag_rs", - 66: "flag_my", - 67: "flag_ph", - 68: "flag_sg", - 69: "flag_ba", - 70: "flag_ee", - 71: "flag_lv", - 72: "flag_lt", - 73: "flag_kp", - 74: "flag_uy", - 75: "flag_py", - 76: "flag_bo", - 77: "flag_pe", - 78: "flag_co", - 79: "flag_mk", - 80: "flag_me", - 81: "flag_tw", - 82: "flag_cy", - 83: "flag_by", - 84: "flag_nz", - 164: "flag_sa", - 165: "flag_eg", - 166: "flag_ae", - 167: "flag_al", - 168: "flag_ge", - 169: "flag_am", - 170: "flag_ng", - 171: "flag_cu", -} +if PRODUCTION: + 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}'") MENTION_MAPPING = {1: "D1", 2: "D2", 3: "D3", 4: "D4", 11: "Air"} @@ -232,7 +88,7 @@ class MyClient(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 = self.timestamp - 43200 + self.last_event_timestamp = self.timestamp self.bg_task = self.loop.create_task(self.report_epics()) self.bg_rss_task = self.loop.create_task(self.report_latvian_events()) @@ -241,50 +97,81 @@ class MyClient(discord.Client): return int(time.time()) async def on_ready(self): - logger.debug("Client running") - logger.debug("------") + logger.info("Client running") + logger.info("------") async def on_error(self, event_method, *args, **kwargs): logger.warning(f"Ignoring exception in {event_method}") + async def send_msg(self, channel_id, *args, **kwargs): + if PRODUCTION: + return self.get_channel(channel_id).send(*args, **kwargs) + else: + return logger.debug(f"Sending message to: {channel_id}\nArgs: {args}\nKwargs{kwargs}") + async def report_latvian_events(self): await self.wait_until_ready() + feed_response = None while not self.is_closed(): try: - for entry in reversed(feedparser.parse(f"https://www.erepublik.com/en/main/news/military/all/Latvia/0/rss").entries): + for country in COUNTRIES.values(): + latest_ts = DB.get_rss_feed_timestamp(country.id) + rss_link = f"https://www.erepublik.com/en/main/news/military/all/{country.link}/1/rss" + feed_response = requests.get(rss_link) + feed_response.raise_for_status() + for entry in reversed(feedparser.parse(feed_response.text).entries): + entry_ts = time.mktime(entry["published_parsed"]) + entry_link = entry["link"] + # Check if event timestamp is after latest processed event for country + if entry_ts > latest_ts: + DB.set_rss_feed_timestamp(country.id, entry_ts) + title = text = "" + msg = entry["summary"] + dont_send = False + for kind in events: + match = kind.regex.search(msg) + if match: + values = match.groupdict() + # Special case for Dictator/Liberation wars + if "invader" in values and not values["invader"]: + values["invader"] = values["defender"] - entry_ts = time.mktime(entry["published_parsed"]) - if entry_ts > self.last_event_timestamp: - msg = entry["summary"] - title = "" - for kind in events: - match = kind.regex.search(msg) - if match: - text = kind.format.format(**dict(match.groupdict(), **{"current_country": "Latvia"})) - title = kind.name - break - else: - has_unknown = True - title = "Unable to parse" - logger.warning(f"Unable to parse: {str(entry)}") - text = msg + # Special case for resource concession + if "link" in values: + __link = values["link"] + entry_link = __link if __link.startswith("http") else f"https://www.erepublik.com{__link}" + logger.debug(kind.format.format(**dict(match.groupdict(), **{"current_country": country.name}))) - self.last_event_timestamp = entry_ts - entry_datetime = datetime.datetime.fromtimestamp(entry_ts, pytz.timezone("US/Pacific")) - embed = discord.Embed(title=title, url=entry["link"], description=text) - embed.set_author(name="eLatvia", icon_url="https://www.erepublik.com/images/flags/L/Latvia.gif") - embed.set_thumbnail(url="https://www.erepublik.net/images/modules/homepage/logo.png") - embed.set_footer(text=f"{entry_datetime.strftime('%F %T')} (eRepublik time)") + if country.id == 71 or any("Latvia" in v for v in values.values()): + text = kind.format.format(**dict(match.groupdict(), **{"current_country": country.name})) + title = kind.name + else: + dont_send = True + break + else: + logger.warning(f"Unable to parse: {str(entry)}") + continue - await self.get_channel(DEFAULT_CHANNEL_ID).send(embed=embed) + if dont_send: + continue + + entry_datetime = datetime.datetime.fromtimestamp(entry_ts, pytz.timezone("US/Pacific")) + embed = discord.Embed(title=title, url=entry_link, description=text) + embed.set_author(name=country.name, icon_url=f"https://www.erepublik.com/images/flags/L/{country.link}.gif") + # embed.set_thumbnail(url="https://www.erepublik.net/images/modules/homepage/logo.png") + embed.set_footer(text=f"{entry_datetime.strftime('%F %T')} (eRepublik time)") + + logger.debug(f"Message sent: {text}") + await self.send_msg(DEFAULT_CHANNEL_ID, embed=embed) + # await self.get_channel(DEFAULT_CHANNEL_ID).send(embed=embed) await asyncio.sleep((self.timestamp // 300 + 1) * 300 - self.timestamp) except Exception as e: logger.error("eRepublik event reader ran into a problem!", exc_info=e) try: with open(f"debug/{self.timestamp}.rss", "w") as f: - f.write(r.text) - except NameError: + f.write(feed_response.text) + except (NameError, AttributeError): logger.error("There was no Response object!", exc_info=e) await asyncio.sleep(10) @@ -311,7 +198,12 @@ class MyClient(discord.Client): url=f"https://www.erepublik.com/en/military/battlefield/{battle['id']}", description=f"Epic battle {UTF_FLAG[invader_id]} vs {UTF_FLAG[defender_id]}!", ) - embed.set_footer(f"Round time {s_to_human(self.timestamp - battle['start'])}") + embed.set_footer(text=f"Round time {s_to_human(self.timestamp - battle['start'])}") + logger.debug( + f"Epic battle {UTF_FLAG[invader_id]} vs {UTF_FLAG[defender_id]}! " + f"Round time {s_to_human(self.timestamp - battle['start'])} " + f"https://www.erepublik.com/en/military/battlefield/{battle['id']}" + ) await self.get_channel(DEFAULT_CHANNEL_ID).send(f"{role_mapping[MENTION_MAPPING[div['div']]]}", embed=embed) DB.add_epic(div.get("id")) @@ -335,10 +227,10 @@ bot = commands.Bot(command_prefix="!") @bot.event async def on_ready(): - logger.debug("Bot loaded") + logger.info("Bot loaded") # print(bot.user.name) # print(bot.user.id) - logger.debug("------") + logger.info("------") @bot.command() @@ -352,7 +244,9 @@ async def exit(ctx): 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() diff --git a/dbot/map_events.py b/dbot/map_events.py new file mode 100644 index 0000000..19686f3 --- /dev/null +++ b/dbot/map_events.py @@ -0,0 +1,40 @@ +import feedparser +from constants import COUNTRIES, events + + +def main(country): + page = 1 + has_unknown = False + while True: + for entry in feedparser.parse(f"https://www.erepublik.com/en/main/news/military/all/{country}/{page}/rss").entries: + msg = entry["summary"] + for kind in events: + match = kind.regex.search(msg) + if match: + values = match.groupdict() + if "invader" in values and not values["invader"]: + values["invader"] = values["defender"] + has_latvia = any("Latvia" in v for v in values.values()) + if has_latvia: + text = kind.format.format(**dict(match.groupdict(), **{"current_country": country})) + print(f"{kind.name:<20} -||- {text:<80} -||- {entry['link']:<64} -||- {entry['published']}") + break + else: + has_unknown = True + break + else: + page += 1 + if page > 5: + break + continue + break + if has_unknown: + print(page, entry) + raise ValueError(msg) + + +if __name__ == "__main__": + for c in sorted(COUNTRIES.values(), key=lambda _c: _c.id): + if c.id > 35: + main(c.link) + print("Finished", c) diff --git a/tests.py b/dbot/tests.py similarity index 85% rename from tests.py rename to dbot/tests.py index ab82175..d21ff30 100644 --- a/tests.py +++ b/dbot/tests.py @@ -2,7 +2,7 @@ import unittest from sqlite_utils.db import NotFoundError -from db import DiscordDB +from dbot.db import DiscordDB class TestDatabase(unittest.TestCase): @@ -38,3 +38,8 @@ class TestDatabase(unittest.TestCase): self.assertTrue(self.db.add_epic(123456)) self.assertFalse(self.db.add_epic(123456)) self.assertTrue(self.db.get_epic(123456)) + + def test_rss_feed(self): + self.assertEqual(self.db.get_rss_feed_timestamp(71), 0.0) + self.db.set_rss_feed_timestamp(71, 16000000) + self.assertEqual(self.db.get_rss_feed_timestamp(71), 16000000.0) diff --git a/docker_run.sh b/docker_run.sh index d3fd990..c50bf9f 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -1,6 +1,7 @@ #!/bin/sh docker rm -f discord_bot + set -e docker build --tag discord_epicbot . -docker run --detach -v $PWD:/app --restart=always --name discord_bot discord_epicbot +docker run --detach -v ./src:/app -v ./debug:/app/debug --env-file=".env" --restart=always --name discord_bot discord_epicbot diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..dbff743 --- /dev/null +++ b/logger.py @@ -0,0 +1,39 @@ +import datetime +import json +import logging +import os +import sys +from json import JSONDecodeError +from typing import Union +import time + +APP_NAME = "discord_bot" + +os.chdir(os.path.abspath(os.path.dirname(sys.argv[0]))) + +logger = logging.getLogger(APP_NAME) +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + +file_logger = logging.FileHandler(f"./logging.log", "w") +file_logger.setLevel(logging.WARNING) +file_logger.setFormatter(formatter) +logger.addHandler(file_logger) + +stream_logger = logging.StreamHandler() +stream_logger.setLevel(logging.INFO) +stream_logger.setFormatter(formatter) +logger.addHandler(stream_logger) + +logger.setLevel(logging.INFO) + + +def main(): + logger.info('Info message') + logger.debug('Debug message') + logger.warning('Warning message') + logger.error('Error message') + logger.critical('Critical message') + + +if __name__ == "__main__": + main() diff --git a/map_events.py b/map_events.py deleted file mode 100644 index d0fde95..0000000 --- a/map_events.py +++ /dev/null @@ -1,225 +0,0 @@ -import re -from typing import NamedTuple -import feedparser -from erepublik.constants import COUNTRIES - -region = "[\w\(\)\- ']+" -country = "(Resistance force of )?[\w\(\)\- ]+" -citizen = "[\w\(\)\-\. \d]+" - - -class EventKind(NamedTuple): - name: str - regex: re.Pattern - format: str - - -events = [ - EventKind( - "Region attacked", - re.compile(rf"(?P{country}) attacked (?P{region}), (?P{country})"), - "{invader} attacked {defender} ({region})", - ), - EventKind( - "Region secured", - re.compile(rf"(?P{region}) was secured by (?P{country}) in the war versus ?(?P{country})?"), - "{defender} defended {invader}'s attack ({region})", - ), - EventKind( - "Region conquered", - re.compile(rf"(?P{region}) was conquered by (?P{country}) in the war versus ?(?P{country})?"), - "{invader} conquered {region} from {defender}", - ), - EventKind( - "War approved", - re.compile(rf"(?P{country}) declared war on (?P{country})"), - "{invader} declared war against {defender}", - ), - EventKind( - "War declared", - re.compile(rf"President of (?P{country}) proposed a war declaration against (?P{country})"), - "{invader} proposed a war declaration on {defender}", - ), - EventKind( - "War rejected", - re.compile(rf"The proposal for declaring war against (?P{country}) was rejected."), - "{current_country} rejected war declaration on {defender}", - ), - EventKind( - "MPP proposed", - re.compile(rf"President of (?P{country}) proposed an alliance with (?P{country})"), - "{country} proposed MPP with {partner}", - ), - EventKind( - "MPP approved", - re.compile(rf"(?P{country}) signed an alliance with (?P{country})"), - "{country} signed a MPP with {partner}", - ), - EventKind( - "MPP rejected", - re.compile(rf"The alliance between (?P{country}) and (?P{country}) was rejected"), - "MPP between {country} and {partner} was rejected", - ), - EventKind( - "Airstrike proposed", - re.compile(rf"President of (?P{country}) proposed an airstrike against (?P{country})"), - "{invader} proposed an airstrike against {defender}", - ), - EventKind( - "Airstrike approved", - re.compile(rf"(?P{country}) prepares an airstrike on (?P{country})"), - "{invader} approved an airstrike against {defender}", - ), - EventKind( - "Airstrike rejected", - re.compile(rf"The airstrike on (?P{country}) was rejected"), - "{current_country} rejected the airstrike against {defender}", - ), - EventKind( - "NE proposed", - re.compile(rf"(?P{country}) has declared (?P{country}) as a Natural Enemy"), - "{invader} proposed Natural Enemy declaration against {defender}", - ), - EventKind( - "NE approved", - re.compile(rf"(?P{country}) has been proposed as Natural Enemy"), - "{current_country} declared {defender} as Natural Enemy", - ), - EventKind( - "NE rejected", - re.compile(rf"(?P{country}) as new Natural Enemy proposal has been rejected"), - "{current_country} rejected {defender} as Natural Enemy", - ), - EventKind( - "NE stopped", - re.compile(rf"(?P{country}) is no longer a Natural Enemy for (?P{country})"), - "{invader} removed Natural Enemy from {defender}", - ), - EventKind( - "NE cleared", re.compile(rf"(?P{country}) no longer has a Natural Enemy"), "{country} no longer has a Natural Enemy" - ), - EventKind("NE reset", re.compile("No Natural Enemy law has been proposed."), "{current_country} has proposed to clear Natural Enemy"), - EventKind( - "Peace proposal", - re.compile(rf"President of (?P{country}) proposed a peace in the war against (?P{country})"), - "{defender} proposed peace against {invader}", - ), - EventKind( - "Peace proposal", - re.compile(rf"(?P{country}) proposed peace in the war against (?P{country})"), - "{defender} proposed peace against {invader}", - ), - EventKind( - "Peace approved", - re.compile(rf"(?P{country}) signed a peace treaty with (?P{country})"), - "{invader} and {defender} is not in peace", - ), - EventKind( - "Peace rejected", - re.compile(rf"The proposed peace treaty between (?P{country}) and (?P{country}) was rejected"), - "{defender} and {invader} did not sign a peace treaty", - ), - EventKind( - "Embargo proposed", - re.compile(rf"President of (?P{country}) proposed to stop the trade with (?P{country})"), - "{major} proposed trade embargo against {minor}", - ), - EventKind( - "Embargo approved", - re.compile(rf"(?P{country}) stopped trading with (?P{country})"), - "{major} declared trade ambargo against {minor}", - ), - EventKind( - "Donation proposed", - re.compile(rf"A congress donation to (?P{citizen}) was proposed"), - "{current_country} proposed a donation to {org}", - ), - EventKind( - "Donation approved", - re.compile(rf"(?P{country}) made a donation to (?P{citizen})"), - "{current_country} approved a donation to {org}", - ), - EventKind( - "Donation rejected", - re.compile(rf"The proposal for a congress donation to (?P{citizen}) was rejected"), - "{current_country} rejected a donation to {org}", - ), - EventKind( - "RW started", - re.compile(rf"A resistance has started in (?P{region})"), - "Resistance war was opened in {region} ({current_country})", - ), - EventKind( - "Res Concession", - re.compile( - rf'A Resource Concession law to //www.erepublik.com(?P{country}) has been proposed' - ), - "{source} proposed resource concession to {target}", - ), - EventKind( - "Res Concession", - re.compile( - rf'A Resource Concession law to //www.erepublik.com(?P{country}) has been approved' - ), - "{source} approved resource concession to {target}", - ), - EventKind( - "CP impeachment", - re.compile(rf"A president impeachment against (?P{citizen}) was proposed"), - "Impeachment against {cp} president of {current_country} was proposed", - ), - EventKind( - "CP impeachment", - re.compile("The president impeachment proposal has been rejected"), - "Impeachment against president of {current_country} was rejected", - ), - EventKind("Minimum Wage", re.compile("A new minimum wage was proposed"), "A new minimum wage in {current_country} was proposed"), - EventKind( - "Minimum Wage", - re.compile("The proposal for a minimum wage change was rejected"), - "The new minimum wage proposal in {current_country} was rejected", - ), - EventKind("WorkTax", re.compile(rf"(?P{country}) now has a new Work Tax"), "{country} has new Work Tax"), - EventKind("Product Tax", re.compile(rf"Taxes for (?P[\w ]+) changed"), "{current_country} changed taxes for {product}"), - EventKind( - "Product Tax", - re.compile(rf"Tax proposal of tax changes for (?P[\w ]+) were rejected"), - "{current_country} rejected new taxes for {product}", - ), - EventKind( - "Product Tax", re.compile(rf"New taxes for (?P[\w ]+) were proposed"), "{current_country} proposed new taxes for {product}" - ), -] - - -def main(country): - page = 1 - has_unknown = False - while True: - for entry in feedparser.parse(f"https://www.erepublik.com/en/main/news/military/all/{country}/{page}/rss").entries: - msg = entry["summary"] - for kind in events: - match = kind.regex.search(msg) - if match: - text = kind.format.format(**dict(match.groupdict(), **{"current_country": country})) - print(f"{kind.name:<20} -||- {text:<80} -||- {entry['link']:<64} -||- {entry['published']}") - break - else: - has_unknown = True - break - else: - page += 1 - if page > 5: - break - continue - break - if has_unknown: - print(page, entry) - raise ValueError(msg) - - -if __name__ == "__main__": - for c in sorted(COUNTRIES.values(), key=lambda _c: _c.id): - if c.id == 71: - main(c.link) - print("Finished", c) diff --git a/pyproject.toml b/pyproject.toml index 7e28eca..7c40028 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ [tool.black] -line-length = 140 +line-length = 180 target-version = ['py38', 'py39'] diff --git a/run.sh b/run.sh index cda8ed5..f2f5c8e 100755 --- a/run.sh +++ b/run.sh @@ -1,10 +1,18 @@ #!/bin/bash -source venv/bin/activate +D=test "$1" = "docker" +if test !$D ; then + source venv/bin/activate +fi echo "Checking queries..." -python -m unittest +python -m unittest echo "Starting Discord bot..." -python discord_bot.py & -disown -h %1 -sleep 10 +if test !$D ; then + export $(sed ':a;N;$!ba;s/\n/ /g' .env) + python dbot/discord_bot.py + disown -h %1 + sleep 10 +else + /usr/local/bin/python /app/discord_bot.py +fi echo "Done!" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..178869d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,15 @@ +[flake8] +exclude = docs,.git,log,debug,venv +line_length = 180 +max-line-length = 180 +ignore = D100,D101,D102,D103,E203 + +[pycodestyle] +line_length = 180 +max-line-length = 180 +exclude = .git,log,debug,venv, build + +[isort] +multi_line_output = 2 +line_length = 180 +