diff --git a/bangalore_bot/bot_commands.py b/bangalore_bot/bot_commands.py index e6742af..456bbd2 100644 --- a/bangalore_bot/bot_commands.py +++ b/bangalore_bot/bot_commands.py @@ -1,8 +1,13 @@ from nio import AsyncClient, MatrixRoom, RoomMessageText -from bangalore_bot.chat_functions import react_to_event, send_text_to_room +from bangalore_bot.chat_functions import react_to_event, send_text_to_room, find_admins_and_reply, make_pill from bangalore_bot.config import Config from bangalore_bot.storage import Storage +from datetime import datetime +import random +import aiohttp +import base64 +from urllib.parse import urlencode class Command: @@ -37,15 +42,204 @@ def __init__( self.room = room self.event = event self.args = self.command.split()[1:] + self.day = "" + self.month = "" + self.year = "" async def process(self): """Process the command""" - await self._unknown_command() + if self.command.startswith("help"): + await self._show_help() + elif self.command.startswith("birthday"): + await self._birthday_func() + elif self.command.startswith("rules"): + await self._rules_func() + elif self.command.startswith("admin"): + await self._tag_admins() + elif self.command.startswith("8ball"): + await self._8ball() + elif self.command.startswith("spotify"): + await self._search_spotify() + else: + await self._unknown_command() + + async def _get_access_token(self, session): + """Get an access token for Spotify API authentication.""" + auth_str = f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}" + b64_auth_str = base64.b64encode(auth_str.encode()).decode() + + headers = { + 'Authorization': f'Basic {b64_auth_str}', + 'Content-Type': 'application/x-www-form-urlencoded' + } + + data = {'grant_type': 'client_credentials'} + + async with session.post("https://accounts.spotify.com/api/token", headers=headers, data=data) as response: + response_data = await response.json() + return response_data.get("access_token") + + async def _search_spotify(self, type='track'): + """Search Spotify for a given query and return Spotify URLs.""" + query = " ".join(self.args) + async with aiohttp.ClientSession() as session: + access_token = await self._get_access_token(session) + + headers = { + 'Authorization': f'Bearer {access_token}' + } + + search_params = { + 'q': query, + 'type': type, + 'limit': 1 # Adjust this to get more results + } + + search_url = f"https://api.spotify.com/v1/search?{urlencode(search_params)}" + + async with session.get(search_url, headers=headers) as response: + search_results = await response.json() + try: + uri = search_results['tracks']['items'][0]['uri'] + spotify_id = uri.split(":")[2] + response = f"https://open.spotify.com/track/{spotify_id}" + except: + response = "No song found for this search 🥹" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + + + + async def _tag_admins(self) -> None: + """Send a message responding to this one, tagging admins""" + text = "Tagging all admins! " + all_users = self.room.power_levels.users + admins = [user for user, level in all_users.items() if level >= 50 and 'whatsappbot' not in user] + text += ", ".join([make_pill(admin) for admin in admins]) + await find_admins_and_reply(self.client, self.room.room_id, self.event.event_id, text, admins) + + async def _8ball(self): + responses = [ + "It is certain.", + "It is decidedly so.", + "Without a doubt.", + "Yes - definitely.", + "You may rely on it.", + "As I see it, yes.", + "Most likely.", + "Outlook good.", + "Yes.", + "Signs point to yes.", + "Reply hazy, try again.", + "Ask again later.", + "Better not tell you now.", + "Cannot predict now.", + "Concentrate and ask again.", + "Don't count on it.", + "My reply is no.", + "My sources say no.", + "Outlook not so good.", + "Very doubtful." + ] + response = random.choice(responses) + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) async def _echo(self): """Echo back the command's arguments""" response = " ".join(self.args) - await send_text_to_room(self.client, self.room.room_id, response) + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + + async def _birthday_func(self): + """Birthday provider aggregator""" + args = " ".join(self.args) + print("Sender:", self.event.sender) + sender_name = "" + print("Args:", self.args) + response = "WIP function" + #await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + #return + valid_date = await self.is_valid_date_any_format(args) + + if self.args == []: + response = "Please use !birthday list to list birthdays" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + + if self.args[0] == "list": + if len(self.args) != 1: + self.store._execute(f"select sender, birth_day from birthdays where birth_month={self.args[1]} order by birth_day asc") + res = self.store.cursor.fetchall() + await self._display_names(res, self.args[1]) + else: + response = "Please use a month to specify which month you want results for" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + + if valid_date: + res = self.store._execute("INSERT INTO birthdays (sender, sender_name, birth_month, birth_day, birth_year) VALUES (?, ?, ?, ?, ?)", (self.event.sender, sender_name, self.month, self.day, self.year)) + response = f"Stored the birthday!" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + + def _ordinal(self, n): + return str(n)+("th" if 4<=n%100<=20 else {1:"st",2:"nd",3:"rd"}.get(n%10, "th")) + + async def _display_names(self, birthdays, birth_month): + counter = 1 + formatted_message = f"Birthdays for the month of {birth_month}" + if len(birthdays) == 0: + formatted_message = "I don't know anyone's birthday for this month 😢" + else: + for row in birthdays: + formatted_message += f"

{make_pill(row[0])}'s birthday is on the {self._ordinal(row[1])}!

" + await send_text_to_room(self.client, self.room.room_id, formatted_message, reply_to_event_id=self.event.event_id) + + async def is_valid_date_any_format(self, date_string): + date_formats = [ + "%Y-%m-%d", # 2023-10-15 + "%m/%d/%Y", # 10/15/2023 + "%d-%m-%Y", # 15-10-2023 + "%d/%m/%Y", # 15/10/2023 + "%Y/%m/%d", # 2023/10/15 + "%b %d, %Y", # Oct 15, 2023 + "%B %d, %Y", # October 15, 2023 + "%d %b %Y", # 15 Oct 2023 + "%d %B %Y", # 15 October 2023 + ] + + for date_format in date_formats: + try: + # Try to parse the date string with the current format + date_obj = datetime.strptime(date_string, date_format) + today = datetime.now() + eighteen = today.replace(year=today.year-18) + ninety = today.replace(year=today.year-90) + if date_obj > eighteen and date_obj < today: + response = "Underage b&. Mooooods!!!" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + return False + if date_obj < ninety: + response = "Wow, how are you even alive? Need help using this app?" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + return False + if date_obj > today: + response = "Hey, Time traveller! Mind telling us some juicy facts about the future?" + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) + return False + + self.day = date_obj.day + self.month = date_obj.month + self.year = date_obj.year + return True + except ValueError: + # If the format doesn't match, continue trying with the next format + continue + return False + + async def _rules_func(self): + response = (f"The rules of this chat:\n\n" + f"- This group is a *safe space*. Add and invite people. Please don’t let the GC die.\n\n" +f"- We’ll plan hangouta every weekend or do something fun. Let’s kill loneliness away.\n\n" +f"- Pls post an intro once you’re in :)\n\n" +f"- Please keep conversations in English or provide translations for other languages in view of the larger group." + ) + await send_text_to_room(self.client, self.room.room_id, response, reply_to_event_id=self.event.event_id) async def _react(self): """Make the bot react to the command message""" @@ -65,20 +259,33 @@ async def _show_help(self): """Show the help text""" if not self.args: text = ( - "Hello, I am a bot made with matrix-nio! Use `help commands` to view " - "available commands." + f"Hello, I am a bot made by {make_pill('@tlh:intothematrix.in')}, using `matrix-nio`!\n\n" + f"I run on the messaging protocol matrix, so expect problems if my maker didn't maintain me properly.\n\n" + f"Use `!help commands` to view available commands." ) - await send_text_to_room(self.client, self.room.room_id, text) + await send_text_to_room(self.client, self.room.room_id, text, reply_to_event_id=self.event.event_id) return topic = self.args[0] if topic == "rules": - text = "These are the rules!" + text = "These are the rules: Don't ask me for commands!" elif topic == "commands": - text = "Available commands: ..." + text = "Available commands: admins, birthday, rules, 8ball, spotify" + elif topic == "admins": + text = "Using !admin or !admins while writing to a message will notify the admins" + elif topic == "birthday": + text = """!birthday DD-MM-YYYY - Add or update your birthday\n +!birthday list (1-12) - List of upcoming birthdays in this month""" + elif topic == "birthdays": + text = """!birthday DD-MM-YYY - Add or update your birthday\n + !birthday list (1-12) - List of upcoming birthdays in this month""" + elif topic == "spotify": + text = "!spotify - Get Spotify song links in the chat" + elif topic == "8ball": + text = "!8ball - Ask the magic 8 ball!" else: text = "Unknown help topic!" - await send_text_to_room(self.client, self.room.room_id, text) + await send_text_to_room(self.client, self.room.room_id, text, reply_to_event_id=self.event.event_id) async def _unknown_command(self): await send_text_to_room( diff --git a/bangalore_bot/callbacks.py b/bangalore_bot/callbacks.py index 5414311..af1ae18 100644 --- a/bangalore_bot/callbacks.py +++ b/bangalore_bot/callbacks.py @@ -81,6 +81,9 @@ async def message(self, room: MatrixRoom, event: RoomMessageText) -> None: async def user_invited(self, room: MatrixRoom, event: RoomMemberEvent) -> None: """ Callback for when user is invited in room""" + if room.room_id != os.getenv("MAIN_ROOM"): + print("Not posting welcome message in non-main room:", room.room_id) + return membership = event.membership # only care about joins sender = event.state_key diff --git a/bangalore_bot/chat_functions.py b/bangalore_bot/chat_functions.py index 066c837..cdb1380 100644 --- a/bangalore_bot/chat_functions.py +++ b/bangalore_bot/chat_functions.py @@ -1,5 +1,7 @@ import logging from typing import Optional, Union +import time +import random from markdown import markdown from nio import ( @@ -60,12 +62,21 @@ async def send_text_to_room( content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}} try: - return await client.room_send( + await client.room_typing( + room_id, + typing_state=True + ) + time.sleep(random.randint(1,5)) + await client.room_send( room_id, "m.room.message", content, ignore_unverified_devices=True, ) + return await client.room_typing( + room_id, + typing_state=False + ) except SendRetryError: logger.exception(f"Unable to send message response to {room_id}") @@ -101,12 +112,21 @@ async def send_text_with_mention( }, } try: - return await client.room_send( + await client.room_typing( + room_id, + typing_state=True + ) + time.sleep(random.randint(1,5)) + await client.room_send( room_id, "m.room.message", content, ignore_unverified_devices=True, ) + return await client.room_typing( + room_id, + typing_state=False + ) except SendRetryError: logger.exception(f"Unable to send message response to {room_id}") @@ -179,7 +199,9 @@ async def find_admins_and_reply( ) -> Union[Response, ErrorResponse]: # find the admins somehow content = { + "formatted_body": reply_text, "body": reply_text, + "format": "org.matrix.custom.html", "m.mentions": { "user_ids": admins }, @@ -194,7 +216,7 @@ async def find_admins_and_reply( room_id, "m.room.message", content, - ignore_unverified_devices=True, + ignore_unverified_devices=False, ) diff --git a/bangalore_bot/config.py b/bangalore_bot/config.py index 883df9c..1977588 100644 --- a/bangalore_bot/config.py +++ b/bangalore_bot/config.py @@ -103,7 +103,7 @@ def _parse_config_values(self): ) self.homeserver_url = self._get_cfg(["matrix", "homeserver_url"], required=True) - self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " " + self.command_prefix = self._get_cfg(["command_prefix"], default="!c") def _get_cfg( self, diff --git a/bangalore_bot/main.py b/bangalore_bot/main.py index f6265d0..da26ec7 100644 --- a/bangalore_bot/main.py +++ b/bangalore_bot/main.py @@ -3,6 +3,7 @@ import logging import sys from time import sleep +from datetime import datetime, timedelta from aiohttp import ClientConnectionError, ServerDisconnectedError from nio import ( @@ -20,9 +21,43 @@ from bangalore_bot.callbacks import Callbacks from bangalore_bot.config import Config from bangalore_bot.storage import Storage +from bangalore_bot.chat_functions import make_pill, send_text_to_room logger = logging.getLogger(__name__) +async def daily_task(client, store): + """The function to run at 12 a.m. each day.""" + logger.info("Running daily task at midnight") + current_date = datetime.now() + room_id = "" + + # Extract the day and month + day = current_date.day + month = current_date.month + store._execute(f"select sender from birthdays where birth_month={month} and birth_day={day}") + res = store.cursor.fetchall() + if len(res) == 0: + logger.info("Nobody to wish today") + else: + for row in res: + formatted_message = f"{make_pill(row[0])}'s birthday is today🎉" + await send_text_to_room(client, room_id, formatted_message) + +async def schedule_daily_task(client, store): + """Calculate the time until next 12 a.m. and sleep until then, repeating every day.""" + while True: + now = datetime.now() + # Calculate the time until the next 12 a.m. + next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + seconds_until_midnight = (next_midnight - now).total_seconds() + print("seconds left:", seconds_until_midnight) + + # Sleep until 12 a.m. + await asyncio.sleep(seconds_until_midnight) + + # Run the daily task + await daily_task(client, store) + async def main(): """The first function that is run when starting the bot""" @@ -72,6 +107,8 @@ async def main(): client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,)) client.add_event_callback(callbacks.unknown, (UnknownEvent,)) + asyncio.create_task(schedule_daily_task(client, store)) + # Keep trying to reconnect on failure (with some time in-between) while True: try: diff --git a/bangalore_bot/message_responses.py b/bangalore_bot/message_responses.py index e9583b1..008d341 100644 --- a/bangalore_bot/message_responses.py +++ b/bangalore_bot/message_responses.py @@ -45,22 +45,15 @@ async def process(self) -> None: """Process and possibly respond to the message""" if self.message_content.lower() == "hello world": await self._hello_world() - if self.message_content.lower() == "@admin": - await self._tag_admins() - if self.message_content.lower() == "@admins": - await self._tag_admins() async def _hello_world(self) -> None: """Say hello""" text = "Hello, world!" await send_text_to_room(self.client, self.room.room_id, text) - async def _tag_admins(self) -> None: + async def tag_admins(self) -> None: """Send a message responding to this one, tagging admins""" text = "Tagging all admins" - # what are power levels? - # https://matrix.org/docs/chat_basics/private-group-chat/#keeping-the-group-safe - # mautrix-whatsapp generally uses 50 for WhatsApp admins and 100 for the bridge bot all_users = self.room.power_levels.users admins = [user for user, level in all_users.items() if level >= 50 and 'whatsappbot' not in user] await find_admins_and_reply(self.client, self.room.room_id, self.event.event_id, text, admins) diff --git a/bangalore_bot/storage.py b/bangalore_bot/storage.py index 7eb0221..850ec83 100644 --- a/bangalore_bot/storage.py +++ b/bangalore_bot/storage.py @@ -99,6 +99,16 @@ def _initial_setup(self) -> None: ) """ ) + self._execute( + """ + CREATE TABLE birthdays ( + sender VARCHAR PRIMARY KEY + birth_month INTEGER + birth_day INTEGER + birth_year INTEGER + ) + """ + ) # Set up any other necessary database tables here