commit e58ba3a74527ea38839e0c73315c3e6a8cc28368 Author: Marc Ahlgrim Date: Wed Jul 20 20:30:33 2022 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4db8fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# ---> Python + +# Config file with bot secret + +secret.py + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d2a78d --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# R.A.L.F. is a discord-bot + +R.A.L.F. (short for Responsive Artificial Lifeform ;-) ) is the "housekeeping" bot on the oneMarcFifty Discord Server + +It can do the following things (release version 0.2): + +- reply with "pong" to a "ping" (WHOA!!!) +- Send out configurable random messages when the server is idle (like "Did you know...") +- automatically create Events (we have Sunday video sessions at 9 AM and 6 PM) +- remind subscribers of upcoming events + +Planned (release 0.3) + +- Help the user create a support thread with a modal view +- let the user subscribe / unsubscribe to notification messages with a modal view + +## How to use + +You need the discord.py wrapper from Rapptz : + + git clone https://github.com/Rapptz/discord.py + cd discord.py/ + python3 -m pip install -U .[voice] + +Next, adapt the `secret.py` file to reflect your token etc. +Also, customize all settings in `config.py` and the text files in the +`bot_messages`directory + +Now you can cd into the bot's directory and launch it with + + python3 main.py diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..815328e --- /dev/null +++ b/bot.py @@ -0,0 +1,301 @@ +import discord +from discord import app_commands +from dis_events import DiscordEvents +import random +from glob import glob +import numpy as np +import config +from secret import BOT_TOKEN, CLIENT_ID, GUILD_ID +import utils +from discord.ext import tasks +import datetime +from dateutil import tz + +# ####################################### +# The OMFClient class +# ####################################### + + +class OMFClient(discord.Client): + + channel_idle_timer: int + asked_question = False + last_question: discord.Message = None + guild_Events = None + lastNotifyTimeStamp = None + + # ####################################### + # init constructor + # ####################################### + + def __init__(self) -> None: + + print('Init') + + # Try to set all intents + + intents = discord.Intents.all() + super().__init__(intents=intents) + + # We need a `discord.app_commands.CommandTree` instance + # to register application commands (slash commands in this case) + + self.tree = app_commands.CommandTree(self) + + self.channel_idle_timer = 0 + self.idle_channel = self.get_channel(config.IDLE_MESSAGE_CHANNEL_ID) + + # ######################### + # setup_hook waits for the + # command tree to sync + # ######################### + + async def setup_hook(self) -> None: + # Sync the application command with Discord. + await self.tree.sync() + + # ###################################################### + # send_random_message is called when the server is idle + # and posts a random message to the server + # ###################################################### + + async def send_random_message(self): + print("Sending random message") + if self.idle_channel == None: + self.idle_channel = self.get_channel(config.IDLE_MESSAGE_CHANNEL_ID) + await self.idle_channel.send(f"{random.choice(self.idle_messages)}") + + # ###################################################### + # on_ready is called once the client is initialized + # it then reads in the files in the config.IDLE_MESSAGE_DIR + # directory and posts them randomly every + # config.CHANNEL_IDLE_INTERVAL seconds into the + # config.IDLE_MESSAGE_CHANNEL_ID channel + # ###################################################### + + async def on_ready(self): + print('Logged on as', self.user) + + # read in the random message files + # the idle_messages array holds one element per message + # every file is read in as a whole into one element of the array + + self.idle_messages = [] + + for filename in glob(config.IDLE_MESSAGE_DIR + '/*.txt'): + print ("read {}",filename) + with open(filename) as f: + self.idle_messages.append(f.read()) + + self.idle_messages = np.array(self.idle_messages) + + # start the schedulers + + self.task_scheduler.start() + self.daily_tasks.start() + + + # ###################################################### + # handle_ping is called when a user sends ping + # it just replies with pong + # ###################################################### + + async def handle_ping (self,message : discord.Message): + await message.channel.send('pong', reference=message) + + # ###################################################### + # create_events will create the Sunday Funday events + # for the next sunday + # ###################################################### + + async def create_events (self): + + print("Create Events") + + newEvent = DiscordEvents(BOT_TOKEN,f'https://discord.com/api/oauth2/authorize?client_id={CLIENT_ID}&permissions=8&scope=bot') + + for theEvent in config.AUTO_EVENTS: + + # calculate the date of the future event + theDate:datetime.datetime = utils.onDay(datetime.date.today(),theEvent['day_of_week']) + # we need the offset from local time to UTC for the date of the event, + # not the date when we create the event. The event is on a Sunday and might be the + # first daylight saving day + utcOffset=tz.tzlocal().utcoffset(datetime.datetime.strptime(f"{theDate}","%Y-%m-%d")) + # the times are stored in local time in the dictionary but will + # be UTC in the discord API + utcStartTime=format(datetime.datetime.strptime(theEvent['start_time'],"%H:%M:%S")-utcOffset,"%H:%M:%S") + utcEndTime=format(datetime.datetime.strptime(theEvent['end_time'],"%H:%M:%S")-utcOffset,"%H:%M:%S") + # Now we just add the date portion and the time portion + # of course this will not work for events where the UTCOffset is bigger + # than the start time - for Germany this is OK as long as the start time is + # after 2 AM + strStart=theDate.strftime(f"%Y-%m-%dT{utcStartTime}") + strEnd=theDate.strftime(f"%Y-%m-%dT{utcEndTime}") + await newEvent.create_guild_event( + GUILD_ID, + theEvent['title'], + theEvent['description'], + f"{strStart}", + f"{strEnd}", + {},2,theEvent['channel']) + + # once we have created the event, we let everyone know + channel = self.get_channel(config.IDLE_MESSAGE_CHANNEL_ID) + await channel.send(f'Hi - I have created the scheduled Event {theEvent["title"]}') + + + # ###################################################### + # get_event_list gives a list of scheduled events + # ###################################################### + + async def get_events_list (self): + newEvent = DiscordEvents(BOT_TOKEN,f'https://discord.com/api/oauth2/authorize?client_id={CLIENT_ID}&permissions=8&scope=bot') + eventList = await newEvent.list_guild_events(GUILD_ID) + self.guild_Events = eventList + return eventList + + + + # ###################################################### + # on_message scans for message contents and takes + # corresponding actions. + # User sends ping - bot replies with pong + # User asks a question - bot checks if question has been + # answered + # ###################################################### + + async def on_message(self, message : discord.Message ): + print("{} has just sent {}".format(message.author, message.content)) + + # don't respond to ourselves + if message.author == self.user: + return + # reset the idle timer if a message has been sent or received + self.channel_idle_timer = 0 + + # reply to ping + if message.content == 'ping': + await self.handle_ping(message) + + # check if there is a question + + if "?" in message.content: + self.asked_question = True + self.last_question = message + else: + self.asked_question = False + self.last_question = None + + + # ###################################################### + # on_typing detects if a user types. + # We might use this one day to have users agree to policies etc. + # before they are allowed to speak + # ###################################################### + + + async def on_typing(self, channel, user, _): + # we do not want the bot to reply to itself + if user.id == self.user.id: + return + print(f"{user} is typing in {channel}") + self.channel_idle_timer = 0 + #await channel.trigger_typing() + + # ###################################################### + # daily_tasks runs once a day and checks the following: + # - does an event need to be created ? + # ###################################################### + + @tasks.loop(hours=24) + async def daily_tasks(self): + print("DAILY TASKS") + + # Every Monday we want to create the scheduled events + # for the next Sunday + + if datetime.date.today().weekday() == 0: + print("create events") + try: + await self.create_events() + except Exception as e: + print(f"Daily Task create Events failed: {e}") + + + # ###################################################### + # task_scheduler is the main supervisor task + # it runs every 10 minutes and checks the following: + # - has a question been asked that has not been answered ? + # - do reminders need to be sent out ? + # - does a random message need to be sent out ? + # ###################################################### + + + @tasks.loop(minutes=10) + async def task_scheduler(self): + + self.channel_idle_timer += 1 + print("SCHEDULE") + + # ##################################### + # See if there are unanswered questions + # ##################################### + try: + if(self.asked_question): + print("scheduler: Question") + print(self.last_question.created_at) + # TODO - we need to send out a message to the @here role + # asking users to help if the question had not been answered + # Also - if the message is a reply then we should not post into the channel + if self.channel_idle_timer > config.QUESTION_SLEEPING_TIME: + print("QUESTION WITHOUT REPLY") + self.asked_question = False + except Exception as e: + print(f"Scheduler question failed: {e}") + + # ##################################### + # see if we need to send a random message + # if the counter is greater than CHANNEL_IDLE_INTERVAL + # then send a random message into the idle_channel + # ##################################### + try: + if self.channel_idle_timer >= config.CHANNEL_IDLE_INTERVAL: + self.channel_idle_timer = 0 + await self.send_random_message() + except Exception as e: + print(f"Scheduler random_message failed: {e}") + + # see if we need to send out notifications for events + # The Event details are stored in config. + eventList=None + for theEvent in config.AUTO_EVENTS: + # first let's convert the String dates to datetime: + theDate=utils.onDay(datetime.date.today(),theEvent['day_of_week']) + startTime=theEvent['start_time'] + eventScheduledTimeDate=datetime.datetime.fromisoformat (theDate.strftime(f"%Y-%m-%dT{startTime}")) + # now let's figure out the time deltas: + timeUntilEvent=eventScheduledTimeDate - datetime.datetime.today() + earliestPing=eventScheduledTimeDate - datetime.timedelta(minutes=theEvent["notify_minutes"]) - datetime.timedelta(minutes=10) + latestPing=eventScheduledTimeDate - datetime.timedelta(minutes=theEvent["notify_minutes"]) + #print("found scheduled event") + #print(f"The event is on {theDate} at {startTime} - that is in {timeUntilEvent}") + #print (f"we ping between {earliestPing} and {latestPing}") + # If we are in the interval then let's initiate the reminder + if (earliestPing < datetime.datetime.today() <= latestPing): + # let's first check if the event is still on + # it may have been deleted or modified on the server + # we don't want to alert for non-existing events + # we'll just use the title to compare for the time being. + print("Let me check if the event is still on") + try: + if eventList == None: + newEvent = DiscordEvents(BOT_TOKEN,f'https://discord.com/api/oauth2/authorize?client_id={CLIENT_ID}&permissions=8&scope=bot') + eventList = await newEvent.list_guild_events(GUILD_ID) + for theScheduledEvent in eventList: + if theScheduledEvent["name"] == theEvent["title"]: + channel = self.get_channel(config.IDLE_MESSAGE_CHANNEL_ID) + theMessageText=f'Hi <@&{theEvent["subscription_role_num"]}>, the event *** {theEvent["title"]} *** will start in roughly {theEvent["notify_minutes"]} minutes in the <#{theEvent["channel"]}> channel. {theEvent["description"]}' + await channel.send(f"{theMessageText}") + except Exception as e: + print(f"Scheduler event_reminder failed: {e}") diff --git a/bot_messages/SundayFunday.txt b/bot_messages/SundayFunday.txt new file mode 100644 index 0000000..47c4ba0 --- /dev/null +++ b/bot_messages/SundayFunday.txt @@ -0,0 +1,11 @@ +Hi, I am R.A.L.F., your assistant bot. + +Did you know that there is a **video/voice session** that is **open to everyone** on this server? +***Every Sunday at 9 AM and 6 PM Berlin/Paris time*** you can join in the <#758271650688008202> channel. + +Chat with Marc and the folks here on the server ! +Share your screen if you want to walk through a problem. +Talk about tech stuff with the other members +or just listen in... + +****Everyone is invited!**** diff --git a/bot_messages/check_support.txt b/bot_messages/check_support.txt new file mode 100644 index 0000000..0f91d43 --- /dev/null +++ b/bot_messages/check_support.txt @@ -0,0 +1,5 @@ +Hi, this is R.A.L.F., your assistant bot. + +Please check in every now and then to the <#866779182293057566> channel. +This is where folks here on the server ask for help. +Maybe **** YOU **** can help someone there ? \ No newline at end of file diff --git a/bot_messages/marcs_videos.txt b/bot_messages/marcs_videos.txt new file mode 100644 index 0000000..85c68ab --- /dev/null +++ b/bot_messages/marcs_videos.txt @@ -0,0 +1,3 @@ +Hi, I am R.A.L.F., your assistant bot. + +Did you know that you can see ***all of Marc's videos*** in the <#792662116456726538> channel? \ No newline at end of file diff --git a/bot_messages/sponsor.txt b/bot_messages/sponsor.txt new file mode 100644 index 0000000..0d66e83 --- /dev/null +++ b/bot_messages/sponsor.txt @@ -0,0 +1,9 @@ +Hi, I am R.A.L.F., your assistant bot. + +**We are always looking for Sponsors!** + +If you have Nitro or if you want to spend some $$$ + +*** maybe you want to boost the server ? *** + +This will give everyone better video and voice quality in the Sunday Funday calls or for tech support calls. diff --git a/bot_messages/support threads.txt b/bot_messages/support threads.txt new file mode 100644 index 0000000..c6c06d5 --- /dev/null +++ b/bot_messages/support threads.txt @@ -0,0 +1,9 @@ +Hi, I am R.A.L.F., your assistant bot! + +Did you know that we have a ***support channel*** here ? + +Please see the <#954732247909552149> channel on how to get hep on a tech issue. +Alternatively, you can type /support and I will help you to create one. + +Please do also check in every now and then to the <#866779182293057566> channel. +Maybe ****YOU**** can help someone there ? \ No newline at end of file diff --git a/bot_messages/video_voice.txt b/bot_messages/video_voice.txt new file mode 100644 index 0000000..cee483d --- /dev/null +++ b/bot_messages/video_voice.txt @@ -0,0 +1,7 @@ +Hi, I am R.A.L.F., the Responsive Artificial Life Form ;-) + +Did you know that everyone can use the + +*** <#957636889718968380> *** + +channel for video/voice calls at any time ? \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..95574d7 --- /dev/null +++ b/config.py @@ -0,0 +1,57 @@ +# the number of scheduler cycles that the channel needs to be idle +# before the bot posts a generic "did you know" +# message + +CHANNEL_IDLE_INTERVAL: int = 4 + +# the name of the directory where the text files are +# located which contain the messages +# which the bot will randomly send +# (1 file = 1 message) + +IDLE_MESSAGE_DIR: str = "bot_messages" + +# the channel where the bot will post messages to + +IDLE_MESSAGE_CHANNEL_ID = 758271650226765848 + +# Variable that indicates when the bot answers after a question has been asked +# (in scheduler cycles) + +QUESTION_SLEEPING_TIME = 2 + +# The Auto Events. +# this is used in three contexts: +# 1. Automatic creation of the event +# 2. Automatic reminder of subscribed users +# 3. in the /subscribe command + +# ALL TIMES LOCAL TIMEZONE OF THE BOT !!!! + +AUTO_EVENTS = [ + { + 'title':"Sunday Funday session (AM)", + 'description':"Chat with Marc and the folks here on the server ! Share your screen if you want to walk through a problem. Talk about tech stuff with the other members or just listen in...", + 'channel':758271650688008202, + 'notify_hint':'get notified when the AM session starts', + 'subscription_role':'notify_am', + 'subscription_role_num':764893618066161695, + 'notify_minutes':30, + 'day_of_week':6, + 'start_time':"09:00:00", + 'end_time':"10:00:00" + }, + { + 'title':"Sunday Funday session (PM)", + 'description':"Chat with Marc and the folks here on the server ! Share your screen if you want to walk through a problem. Talk about tech stuff with the other members or just listen in...", + 'channel':758271650688008202, + 'notify_hint':'get notified when the PM session starts', + 'subscription_role':'notify_pm', + 'subscription_role_num':769829891419537448, + 'notify_minutes':30, + 'day_of_week':6, + 'start_time':"18:00:00", + 'end_time':"19:00:00" + } +] + diff --git a/dis_events.py b/dis_events.py new file mode 100644 index 0000000..f2cb94d --- /dev/null +++ b/dis_events.py @@ -0,0 +1,76 @@ +import json +import aiohttp + +# ######################################################### +# The class for Discord scheduled events +# as of 2022-07, discord.py does not support +# scheduled Events. Therefore we need to use the Rest API +# ######################################################### + + +class DiscordEvents: + '''Class to create and list Discord events utilizing their API''' + def __init__(self, discord_token: str, bot_url: str) -> None: + self.base_api_url = 'https://discord.com/api/v10' + self.auth_headers = { + 'Authorization':f'Bot {discord_token}', + 'User-Agent':f'DiscordBot ({bot_url}) Python/3.9 aiohttp/3.8.1', + 'Content-Type':'application/json' + } + + async def list_guild_events(self, guild_id: str) -> list: + + print("list_guild_events") + + '''Returns a list of upcoming events for the supplied guild ID + Format of return is a list of one dictionary per event containing information.''' + event_retrieve_url = f'{self.base_api_url}/guilds/{guild_id}/scheduled-events' + async with aiohttp.ClientSession(headers=self.auth_headers) as session: + try: + async with session.get(event_retrieve_url) as response: + response.raise_for_status() + assert response.status == 200 + response_list = json.loads(await response.read()) + except Exception as e: + print(f'EXCEPTION: {e}') + finally: + await session.close() + return response_list + + async def create_guild_event( + self, + guild_id: str, + event_name: str, + event_description: str, + event_start_time: str, + event_end_time: str, + event_metadata: dict, + event_privacy_level=2, + channel_id=None + ) -> None: + '''Creates a guild event using the supplied arguments + The expected event_metadata format is event_metadata={'location': 'YOUR_LOCATION_NAME'} + The required time format is %Y-%m-%dT%H:%M:%S''' + + print("create_guild_event") + + event_create_url = f'{self.base_api_url}/guilds/{guild_id}/scheduled-events' + event_data = json.dumps({ + 'name': event_name, + 'privacy_level': event_privacy_level, + 'scheduled_start_time': event_start_time, + 'scheduled_end_time': event_end_time, + 'description': event_description, + 'channel_id': channel_id, + 'entity_metadata': event_metadata, + 'entity_type': 2 + }) + async with aiohttp.ClientSession(headers=self.auth_headers) as session: + try: + async with session.post(event_create_url, data=event_data) as response: + response.raise_for_status() + assert response.status == 200 + except Exception as e: + print(f'EXCEPTION: {e}') + finally: + await session.close() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..5b07409 --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +import bot +import secret + +client = bot.OMFClient() +client.run(secret.BOT_TOKEN) diff --git a/secret.py.example b/secret.py.example new file mode 100644 index 0000000..a38b337 --- /dev/null +++ b/secret.py.example @@ -0,0 +1,16 @@ +# You need to create a file called secret.py that contains your +# discord token. This is just an example. Replace the tokens with your own ! + +# the BOT_TOKEN is the Oauth2 token for your bot + +BOT_TOKEN: str = "DFHEZRERZQRJTSUPERSECRETTTOKENUTZZH" + +# The GUILD_ID is the ID of your Server - in the discord client, +# right click on your server and select " copy ID" to get it + +GUILD_ID: str = "0236540000563456" + +# The client ID can be copied from your App settings page and is needed +# to authenticate with the Discord Restful API for Event creation + +CLIENT_ID: str = "9990236500564536" \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..a2284f2 --- /dev/null +++ b/utils.py @@ -0,0 +1,24 @@ +import datetime + +# ################################################ +# Returns the date of the next given weekday after +# the given date. For example, the date of next Monday. +# +# NB: if it IS the day we're looking for, this returns 0. +# consider then doing onDay(foo, day + 1). +# +# Monday=0, Tuesday=1 .... Sunday=6 +# ################################################ + + +def onDay(date, day): + """ + Returns the date of the next given weekday after + the given date. For example, the date of next Monday. + + NB: if it IS the day we're looking for, this returns 0. + consider then doing onDay(foo, day + 1). + """ + days = (day - date.weekday() + 7) % 7 + return date + datetime.timedelta(days=days) +