Skip to content

Commit

Permalink
Implemented Discord notifications using discord.py
Browse files Browse the repository at this point in the history
  • Loading branch information
lars-devs committed Mar 31, 2024
1 parent e673af1 commit 4fd6069
Show file tree
Hide file tree
Showing 10 changed files with 894 additions and 112 deletions.
9 changes: 9 additions & 0 deletions config.sample.ini
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,12 @@ URL =
Enabled = false
Command =
; Cron =

[DISCORD]
## Register an application and associated bot user for use with TGTG scanner at https://discord.com/developers/applications
## See wiki for more information
Enabled = false
Prefix = !
Token =
Body =
; Cron =
673 changes: 567 additions & 106 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ version = "1.20.3"
apprise = "^1.4.0"
colorlog = "^6.7.0"
cron-descriptor = "^1.4.0"
discord = "^2.3.2"
googlemaps = "^4.10.0"
humanize = "^4.7.0"
packaging = "^23.1"
Expand Down
19 changes: 14 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
aiohttp==3.9.3 ; python_version >= "3.9" and python_version < "3.13"
aiosignal==1.3.1 ; python_version >= "3.9" and python_version < "3.13"
anyio==4.3.0 ; python_version >= "3.9" and python_version < "3.13"
apprise==1.7.3 ; python_version >= "3.9" and python_version < "3.13"
apprise==1.7.4 ; python_version >= "3.9" and python_version < "3.13"
async-timeout==4.0.3 ; python_version >= "3.9" and python_version < "3.11"
attrs==23.2.0 ; python_version >= "3.9" and python_version < "3.13"
cachetools==5.3.3 ; python_version >= "3.9" and python_version < "3.13"
certifi==2024.2.2 ; python_version >= "3.9" and python_version < "3.13"
charset-normalizer==3.3.2 ; python_version >= "3.9" and python_version < "3.13"
click==8.1.7 ; python_version >= "3.9" and python_version < "3.13"
colorama==0.4.6 ; python_version >= "3.9" and python_version < "3.13" and (sys_platform == "win32" or platform_system == "Windows")
colorlog==6.8.2 ; python_version >= "3.9" and python_version < "3.13"
cron-descriptor==1.4.3 ; python_version >= "3.9" and python_version < "3.13"
discord-py==2.3.2 ; python_version >= "3.9" and python_version < "3.13"
discord==2.3.2 ; python_version >= "3.9" and python_version < "3.13"
exceptiongroup==1.2.0 ; python_version >= "3.9" and python_version < "3.11"
frozenlist==1.4.1 ; python_version >= "3.9" and python_version < "3.13"
googlemaps==4.10.0 ; python_version >= "3.9" and python_version < "3.13"
h11==0.14.0 ; python_version >= "3.9" and python_version < "3.13"
httpcore==1.0.4 ; python_version >= "3.9" and python_version < "3.13"
httpx==0.27.0 ; python_version >= "3.9" and python_version < "3.13"
humanize==4.9.0 ; python_version >= "3.9" and python_version < "3.13"
idna==3.6 ; python_version >= "3.9" and python_version < "3.13"
importlib-metadata==7.0.2 ; python_version >= "3.9" and python_version < "3.10"
markdown==3.5.2 ; python_version >= "3.9" and python_version < "3.13"
importlib-metadata==7.1.0 ; python_version >= "3.9" and python_version < "3.10"
markdown==3.6 ; python_version >= "3.9" and python_version < "3.13"
multidict==6.0.5 ; python_version >= "3.9" and python_version < "3.13"
oauthlib==3.2.2 ; python_version >= "3.9" and python_version < "3.13"
packaging==23.2 ; python_version >= "3.9" and python_version < "3.13"
progress==1.6 ; python_version >= "3.9" and python_version < "3.13"
Expand All @@ -24,9 +32,10 @@ pycron==3.0.0 ; python_version >= "3.9" and python_version < "3.13"
python-pushsafer==1.1 ; python_version >= "3.9" and python_version < "3.13"
python-telegram-bot[callback-data]==21.0.1 ; python_version >= "3.9" and python_version < "3.13"
pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "3.13"
requests-oauthlib==1.3.1 ; python_version >= "3.9" and python_version < "3.13"
requests-oauthlib==2.0.0 ; python_version >= "3.9" and python_version < "3.13"
requests==2.31.0 ; python_version >= "3.9" and python_version < "3.13"
sniffio==1.3.1 ; python_version >= "3.9" and python_version < "3.13"
typing-extensions==4.10.0 ; python_version >= "3.9" and python_version < "3.11"
urllib3==2.2.1 ; python_version >= "3.9" and python_version < "3.13"
zipp==3.17.0 ; python_version >= "3.9" and python_version < "3.10"
yarl==1.9.4 ; python_version >= "3.9" and python_version < "3.13"
zipp==3.18.1 ; python_version >= "3.9" and python_version < "3.10"
16 changes: 16 additions & 0 deletions tests/test_notifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from tgtg_scanner.models import Config, Cron, Favorites, Item, Reservations
from tgtg_scanner.notifiers.apprise import Apprise
from tgtg_scanner.notifiers.console import Console
from tgtg_scanner.notifiers.discord import Discord
from tgtg_scanner.notifiers.ifttt import IFTTT
from tgtg_scanner.notifiers.ntfy import Ntfy
from tgtg_scanner.notifiers.script import Script
Expand Down Expand Up @@ -323,3 +324,18 @@ def test_telegram(test_item: Item, reservations: Reservations, favorites: Favori
assert telegram.thread.is_alive()
telegram.stop()
assert not telegram.thread.is_alive()


def test_discord(test_item: Item, reservations: Reservations, favorites: Favorites):
config = Config()
config.discord.enabled = True
config.discord.channel = 123456789012345678
config.discord.token = "ABCDEFGHIJKLMNOPQRSTUVWXYZ.123456.ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKL"

discord = Discord(config, reservations, favorites)
discord.start()
discord.send(test_item)
sleep(5)
discord.bot.dispatch("close")
discord.stop()
assert discord.bot_id and discord.channel_id and discord.server_id
6 changes: 6 additions & 0 deletions tgtg_scanner/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ class LocationConfigurationError(ConfigurationError):
def __init__(self, message="Invalid Location configuration"):
self.message = message
super().__init__(self.message)


class DiscordConfigurationError(ConfigurationError):
def __init__(self, message="Invalid Discord configuration"):
self.message = message
super().__init__(self.message)
35 changes: 35 additions & 0 deletions tgtg_scanner/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,38 @@ def _read_env(self):
self._env_get("SCRIPT_COMMAND", "command")


@dataclass
class DiscordConfig(NotifierConfig):
"""Discord configuration"""

enabled: bool = False
prefix: Union[str, None] = "!"
token: Union[str, None] = None
channel: int = 0
body: str = (
"*${{display_name}}*\n*Available*: ${{items_available}}\n*Price*: ${{price}} ${{currency}}\n*Pickup*: ${{pickupdate}}"
)
disable_commands: bool = False

def _read_ini(self, parser: configparser.ConfigParser):
self._ini_get_boolean(parser, "DISCORD", "Enabled", "enabled")
self._ini_get(parser, "DISCORD", "Prefix", "prefix")
self._ini_get(parser, "DISCORD", "Token", "token")
self._ini_get_int(parser, "DISCORD", "Channel", "channel")
self._ini_get(parser, "DISCORD", "Body", "body")
self._ini_get_boolean(parser, "DISCORD", "DisableCommands", "disable_commands")
self._ini_get_cron(parser, "DISCORD", "Cron", "cron")

def _read_env(self):
self._env_get_boolean("DISCORD", "enabled")
self._env_get("DISCORD_PREFIX", "prefix")
self._env_get("DISCORD_TOKEN", "token")
self._env_get_int("DISCORD_CHANNEL", "channel")
self._env_get("DISCORD_BODY", "body")
self._env_get_boolean("DISCORD_DISABLE_COMMANDS", "disable_commands")
self._env_get_cron("DISCORD_CRON", "cron")


@dataclass
class TgtgConfig(BaseConfig):
"""Tgtg configuration"""
Expand Down Expand Up @@ -525,6 +557,7 @@ class Config(BaseConfig):
ntfy: NtfyConfig = field(default_factory=NtfyConfig)
webhook: WebhookConfig = field(default_factory=WebhookConfig)
script: ScriptConfig = field(default_factory=ScriptConfig)
discord: DiscordConfig = field(default_factory=DiscordConfig)

def __post_init__(self):
if self.file:
Expand All @@ -546,6 +579,7 @@ def __post_init__(self):
self.ntfy._read_ini(parser)
self.webhook._read_ini(parser)
self.script._read_ini(parser)
self.discord._read_ini(parser)

log.info("Loaded config from %s", config_file.absolute())
else:
Expand All @@ -561,6 +595,7 @@ def __post_init__(self):
self.ntfy._read_env()
self.webhook._read_env()
self.script._read_env()
self.discord._read_env()

log.info("Loaded config from environment variables")

Expand Down
226 changes: 226 additions & 0 deletions tgtg_scanner/notifiers/discord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import asyncio
import datetime
import logging
from queue import Empty
from typing import Union

import discord
from discord.ext import commands, tasks

from tgtg_scanner.errors import DiscordConfigurationError, MaskConfigurationError
from tgtg_scanner.models import Config, Favorites, Item, Reservations
from tgtg_scanner.models.reservations import Reservation
from tgtg_scanner.notifiers.base import Notifier

log = logging.getLogger("tgtg")


class Discord(Notifier):
"""Notifier for Discord"""

def __init__(self, config: Config, reservations: Reservations, favorites: Favorites):
super().__init__(config, reservations, favorites)
self.enabled = config.discord.enabled
self.prefix = config.discord.prefix
self.token = config.discord.token
self.channel = config.discord.channel
self.body = config.discord.body
self.disable_commands = config.discord.disable_commands
self.cron = config.discord.cron
self.mute: Union[datetime.datetime, None] = None
self.bot_id = None
self.channel_id = None
self.server_id = None

if self.enabled:
if self.token is None or self.channel == 0:
raise DiscordConfigurationError()
try:
Item.check_mask(self.body)
except MaskConfigurationError as exc:
raise DiscordConfigurationError(exc.message) from exc

try:
# Setting event loop explicitly for python 3.9 compatibility
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self.bot = commands.Bot(command_prefix=self.prefix, intents=discord.Intents.all())
except MaskConfigurationError as exc:
raise DiscordConfigurationError(exc.message) from exc

async def _send(self, item: Union[Item, Reservation]) -> None: # type: ignore[override]
"""Sends item information using Discord bot"""
if self.mute and self.mute > datetime.datetime.now():
return
if self.mute:
log.info("Reactivated Discord Notifications")
self.mute = None
if isinstance(item, Item):
message = item.unmask(self.body)
self.bot.dispatch("send_notification", message)

@tasks.loop(seconds=1)
async def _listen_for_items(self):
"""Method for polling notifications every second"""
try:
item = self.queue.get(block=False)
if item is None:
self.bot.dispatch("close")
return
log.debug("Sending %s Notification", self.name)
await self._send(item)
except Empty:
pass
except Exception as exc:
log.error("Failed sending %s: %s", self.name, exc)

def _run(self):
async def _start_bot() -> None:
await self.bot.start(self.token)

# Events include methods for post-init, shutting down, and notification sending
self._setup_events()
if not self.disable_commands:
# Commands are handled separately, in case commands are not enabled
self._setup_commands()

# Setting event loop explicitly for python 3.9 compatibility
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self.config.set_locale()
asyncio.run(_start_bot())

def _setup_events(self):
@self.bot.event
async def on_ready():
"""Callback after successful login (only explicitly used in test_notifiers.py)"""
self.bot_id = self.bot.user.id
self.channel_id = self.channel
self.server_id = self.bot.guilds[0].id if len(self.bot.guilds) > 0 else 0
self._listen_for_items.start()

@self.bot.event
async def on_send_notification(message):
"""Callback for item notification"""
channel = self.bot.get_channel(self.channel) or await self.bot.fetch_channel(self.channel)
if channel:
await channel.send(message)

@self.bot.event
async def on_close():
"""Logout from Discord (only explicitly used in test_notifiers.py)"""
await self.bot.close()

def _setup_commands(self):
@self.bot.command(name="mute")
async def _mute(ctx, *args):
"""Deactivates Discord Notifications for x days"""
days = int(args[0]) if len(args) > 0 and args[0].isnumeric() else 1
self.mute = datetime.datetime.now() + datetime.timedelta(days=days)
log.info("Deactivated Discord Notifications for %s day(s)", days)
log.info("Reactivation at %s", self.mute)
await ctx.send(
f"Deactivated Discord notifications for {days} days.\nReactivating at {self.mute} or use `{self.prefix}unmute`."
)

@self.bot.command(name="unmute")
async def _unmute(ctx):
"""Reactivate Discord notifications"""
self.mute = None
log.info("Reactivated Discord notifications")
await ctx.send("Reactivated Discord notifications")

@self.bot.command(name="listfavorites")
async def _list_favorites(ctx):
"""List favorites using display name"""
favorites = self.favorites.get_favorites()
if not favorites:
await ctx.send("You currently don't have any favorites.")
else:
await ctx.send("\n".join([f"• {item.item_id} - {item.display_name}" for item in favorites]))

@self.bot.command(name="listfavoriteids")
async def _list_favorite_ids(ctx):
"""List favorites using id"""
favorites = self.favorites.get_favorites()
if not favorites:
await ctx.send("You currently don't have any favorites.")
else:
await ctx.send(" ".join([item.item_id for item in favorites]))

@self.bot.command(name="addfavorites")
async def _add_favorites(ctx, *args):
"""Add favorite(s)"""
item_ids = list(
filter(
lambda x: x.isdigit() and int(x) != 0,
map(
str.strip,
[split_args for arg in args for split_args in arg.split(",")],
),
)
)
if not item_ids:
await ctx.channel.send(
"Please supply item ids in one of the following ways: "
f"'{self.prefix}addfavorites 12345 23456 34567' or "
f"'{self.prefix}addfavorites 12345,23456,34567'"
)
return

self.favorites.add_favorites(item_ids)
await ctx.send(f"Added the following item ids to favorites: {' '.join(item_ids)}")
log.debug('Added the following item ids to favorites: "%s"', item_ids)

@self.bot.command(name="removefavorites")
async def _remove_favorites(ctx, *args):
"""Remove favorite(s)"""
item_ids = list(
filter(
lambda x: x.isdigit() and int(x) != 0,
map(
str.strip,
[split_args for arg in args for split_args in arg.split(",")],
),
)
)
if not item_ids:
await ctx.channel.send(
"Please supply item ids in one of the following ways: "
f"'{self.prefix}removefavorites 12345 23456 34567' or "
f"'{self.prefix}removefavorites 12345,23456,34567'"
)
return

self.favorites.remove_favorite(item_ids)
await ctx.send(f"Removed the following item ids from favorites: {' '.join(item_ids)}")
log.debug('Removed the following item ids from favorites: "%s"', item_ids)

@self.bot.command(name="gettoken")
async def _get_token(ctx):
"""Display token used to login (without needing to manually check in config.ini)"""
await ctx.send(f"Token in use: {self.token}")

@self.bot.command(name="getinfo")
async def _get_info(ctx):
"""Display basic info about connection"""
bot_id = ctx.me.id
bot_name = ctx.me.display_name
bot_mention = ctx.me.mention
joined_at = ctx.me.joined_at
channel_id = ctx.channel.id
channel_name = ctx.channel.name
guild_id = ctx.guild.id
guild_name = ctx.guild.name

response = (
f"Hi! I'm {bot_mention}, the TGTG Bot on this server. I joined at {joined_at}\n"
f"```Bot (ID): {bot_name} ({bot_id})\n"
f"Channel (ID): {channel_name} ({channel_id})\n"
f"Server (ID): {guild_name} ({guild_id})```"
)

await ctx.send(response)

def __repr__(self) -> str:
return f"Discord: Channel ID {self.channel}"
Loading

0 comments on commit 4fd6069

Please sign in to comment.