diff --git a/discord_bot.py b/discord_bot.py index b256ba6..56de01f 100644 --- a/discord_bot.py +++ b/discord_bot.py @@ -6,39 +6,119 @@ import os import sys 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 from db import DiscordDB +from map_events import events APP_NAME = "discord_bot" os.chdir(os.path.abspath(os.path.dirname(sys.argv[0]))) load_dotenv() -logging.basicConfig(level=logging.WARNING, filename="logging.log", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") + logger = logging.getLogger(APP_NAME) -logger.setLevel(logging.DEBUG) -logger.propagate = False -fh = logging.FileHandler(f"./logging.log", "w") -fh.setLevel(logging.DEBUG) -logger.addHandler(fh) -keep_fds = [fh.stream.fileno()] +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + +file_logger = logging.FileHandler(f"./logging.log", "w") +file_logger.setLevel(logging.DEBUG) +file_logger.setFormatter(formatter) +logger.addHandler(file_logger) + +stream_logger = logging.StreamHandler() +stream_logger.setFormatter(formatter) +logger.addHandler(stream_logger) os.makedirs("debug", exist_ok=True) -pidfile = "pid" -with open(pidfile, "w") as f: - f.write(str(os.getpid())) - DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") DEFAULT_CHANNEL_ID = os.getenv("DEFAULT_CHANNEL_ID", 603527159109124096) ADMIN_ID = os.getenv("DEFAULT_CHANNEL_ID", 220849530730577920) DB_NAME = os.getenv("DB_NAME", "discord.db") 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", @@ -152,19 +232,62 @@ 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.bg_task = self.loop.create_task(self.report_epics()) + self.bg_rss_task = self.loop.create_task(self.report_latvian_events()) @property def timestamp(self): - return int(datetime.datetime.now().timestamp()) + return int(time.time()) async def on_ready(self): - print("Client running") - print("------") + logger.debug("Client running") + logger.debug("------") async def on_error(self, event_method, *args, **kwargs): logger.warning(f"Ignoring exception in {event_method}") + async def report_latvian_events(self): + await self.wait_until_ready() + 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): + + 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 + + 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)") + + 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: + logger.error("There was no Response object!", exc_info=e) + await asyncio.sleep(10) + async def report_epics(self): await self.wait_until_ready() roles = [role for role in self.get_guild(300297668553605131).roles if role.name in MENTION_MAPPING.values()] @@ -183,11 +306,13 @@ class MyClient(discord.Client): json.dump(r, f) invader_id = battle["inv"]["id"] defender_id = battle["def"]["id"] - await self.get_channel(DEFAULT_CHANNEL_ID).send( - f"{role_mapping[MENTION_MAPPING[div['div']]]} Epic battle :{FLAGS[invader_id]}: vs :{FLAGS[defender_id]}:! " - f"Round time {s_to_human(self.timestamp - battle['start'])}\n" - f"https://www.erepublik.com/en/military/battlefield/{battle['id']}" + embed = discord.Embed( + title=" ".join(div.get("intensity_scale").split("_")).title(), + 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'])}") + await self.get_channel(DEFAULT_CHANNEL_ID).send(f"{role_mapping[MENTION_MAPPING[div['div']]]}", embed=embed) DB.add_epic(div.get("id")) sleep_seconds = r.get("last_updated") + 60 - self.timestamp @@ -210,17 +335,17 @@ bot = commands.Bot(command_prefix="!") @bot.event async def on_ready(): - print("Bot loaded") + logger.debug("Bot loaded") # print(bot.user.name) # print(bot.user.id) - print("------") + logger.debug("------") @bot.command() -async def kill(ctx): +async def exit(ctx): if ctx.author.id == ADMIN_ID: await ctx.send(f"{ctx.author.mention} Bye!") - sys.exit(1) + sys.exit(0) else: await ctx.send(f"Labs mฤ“ฤฃinฤjums! Mani nogalinฤt var tikai <@{ADMIN_ID}>") diff --git a/map_events.py b/map_events.py new file mode 100644 index 0000000..d0fde95 --- /dev/null +++ b/map_events.py @@ -0,0 +1,225 @@ +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/requirements.txt b/requirements.txt index 9f8c44a..f3ee45f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ discord.py==1.7.3 requests==2.26.0 python-dotenv==0.19.0 -sqlite_utils==3.13 +sqlite_utils==3.14 +feedparser==6.0.8 +pytz==2021.1 +erepublik