diff --git a/.github/workflows/link_checker.yml b/.github/workflows/link_checker.yml new file mode 100644 index 0000000..b6f0039 --- /dev/null +++ b/.github/workflows/link_checker.yml @@ -0,0 +1,32 @@ +name: Discord 404 checker (cron + manual test) + +on: + schedule: + # Runs every 15 minutes (UTC). Change as needed. + - cron: "*/5 * * * *" + workflow_dispatch: {} + +jobs: + check-links: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install deps (none for this script) + run: python -m pip install --upgrade pip + + - name: Run script + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + USER_ID: ${{ secrets.USER_ID }} + run: | + python -u scripts/link_checker.py diff --git a/README.md b/README.md index 0114494..b7b3a63 100644 --- a/README.md +++ b/README.md @@ -162,11 +162,13 @@ You'll need your own throwaway Discord server and bot to develop against: 1. In the [Discord Developer Portal](https://discord.com/developers/applications), create an application, then under **Bot** click **Reset Token** and copy the token. No privileged intents are required — `/ask` uses default intents. -2. Under **OAuth2 → URL Generator**, select the `bot` and `applications.commands` +2. ADD HERE (rough for now) Scroll down and check the permissions the bot needs: **Send Messages**, + **View Channel** and **Read Message History**. +3. Under **OAuth2 → URL Generator**, select the `bot` and `applications.commands` scopes, open the generated URL, and invite the bot to a server you own. -3. Enable **Settings → Advanced → Developer Mode**, then right-click your server +4. Enable **Settings → Advanced → Developer Mode**, then right-click your server icon → **Copy Server ID**. -4. Add both values to your `.env`: +5. Add both values to your `.env`: ``` DISCORD_BOT_TOKEN=your_token_here diff --git a/scripts/link_checker.py b/scripts/link_checker.py new file mode 100644 index 0000000..70b5ea6 --- /dev/null +++ b/scripts/link_checker.py @@ -0,0 +1,57 @@ +import json +import os +import urllib.error +import urllib.request +from pathlib import Path + +from src.config.logger import get_logger + +log = get_logger(__name__) + + +# sends relevant info to webhook +def send_discord(webhook_url: str, content: list[str]): + payload = {"content": "\n".join(content)} + req = urllib.request.Request( + webhook_url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json", "User-Agent": "link-checker/1.0"}, + method="POST", + ) + try: + with urllib.request.urlopen(req) as resp: + log.info("Discord status:", status=resp.status) + log.info(resp.read().decode("utf-8", errors="ignore")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="ignore") + log.error("Discord HTTPError:", error=e.code, response_body=body) + raise + + +# webhook for the discord channel +webhook = os.environ["DISCORD_WEBHOOK_URL"] +# user id of the user we want to ping +user_id = os.environ["USER_ID"] + +# works only if this script is in /scripts +REPO_ROOT = Path(__file__).resolve().parents[1] +# replace with any list +URL_LIST_PATH = REPO_ROOT / "data" / "webpages" / "test_list.json" + +failed_urls = [] + +with open(URL_LIST_PATH) as f: + urls = json.load(f) + +for url in urls: + url_status = urllib.request.urlopen(url).getcode() + if url_status != 200: + failed_urls.append(f"FAILED URL: {url}") + # uncomment this if you want to try the functionality with any other statuscode (or none) + # failed_urls.append("FAILED URL: " + url) + +payload = failed_urls +payload.insert(0, f"<@{user_id}>, these links are returning a 404! Check these links.") + +if failed_urls: + send_discord(webhook, payload) diff --git a/src/apps/discord_bot.py b/src/apps/discord_bot.py index a412ea3..480dafc 100644 --- a/src/apps/discord_bot.py +++ b/src/apps/discord_bot.py @@ -22,6 +22,38 @@ async def on_ready(self) -> None: await self.tree.sync(guild=guild) log.info("discord_ready", self_user=self.user, guild=settings.discord_guild_id) + async def on_message(self, message) -> None: + # runs only in servers + if message.guild is None: + return + + # ignores other bots and itself + if message.author.bot: + return + + bot_standard_mention = f"<@{self.user.id}>" + bot_nickname_mention = f"<@!{self.user.id}>" + + # triggering on explicit mention + if bot_standard_mention in message.content or bot_nickname_mention in message.content: + question = await question_assembler(message) + + # if question is just empty/non-text + if question == "": + return + + async with message.channel.typing(): + answer = await ask(question=question) + + content = await render_answer_content(answer) + + try: + await message.reply(content) + except Exception as e: + await message.reply("❌ I couldn't process this request, please try again later.") + log.error("discord_ask_failed", error=str(e)) + return + client = CsAssistantBot() @@ -41,12 +73,62 @@ async def ask_command(interaction: discord.Interaction, question: str) -> None: log.error("discord_ask_failed", interaction_id=interaction.id, error=str(e)) return + content = await render_answer_content(answer) + await interaction.followup.send(content) + log.info("discord_ask_completed", interaction_id=interaction.id) + + +async def question_assembler(message) -> str: + # if message is reply to a question + referenced_text = "" + if message.reference and message.reference.message_id: + try: + referenced_message = await message.channel.fetch_message(message.reference.message_id) + referenced_text = referenced_message.content + except discord.NotFound: + log.info( + "No referenced text. Not Found.", + channel_id=message.channel.id, + channel_name=message.channel.name, + guild_id=message.guild.id, + target_message_id=message.reference.message_id, + ) + except discord.Forbidden: + log.warning( + "No referenced text. Forbidden.", + channel_id=message.channel.id, + channel_name=message.channel.name, + guild_id=message.guild.id, + target_message_id=message.reference.message_id, + ) + + return question_assembler_helper( + content=message.content, + bot_id=message.guild.me.id, + referenced_text=referenced_text, + ) + + +def question_assembler_helper(*, content: str, bot_id: int, referenced_text: str = "") -> str: + mention_text = strip_bot_mention(content, bot_id) + + if referenced_text: + return referenced_text + "\n\n" + mention_text + return mention_text + + +def strip_bot_mention(content: str, bot_id: int) -> str: + mention_text = content + for x in {f"<@{bot_id}>", f"<@!{bot_id}>"}: + mention_text = mention_text.replace(x, "") + return mention_text.strip() + + +async def render_answer_content(answer) -> str: content = answer.text if answer.sources: content += "\n\n**Sources:**\n" + "\n".join(f"• {source.url}" for source in answer.sources) - - await interaction.followup.send(content) - log.info("discord_ask_completed", interaction_id=interaction.id) + return content if __name__ == "__main__": diff --git a/tests/apps/test_discord_bot.py b/tests/apps/test_discord_bot.py new file mode 100644 index 0000000..9258e2f --- /dev/null +++ b/tests/apps/test_discord_bot.py @@ -0,0 +1,27 @@ +from src.apps.discord_bot import question_assembler_helper, strip_bot_mention + +BOT_ID = 123456789 + + +def test_plain_mention(): + message = f"<@{BOT_ID}> test question" + assert (strip_bot_mention(message, BOT_ID)) == "test question" + + +def test_both_mentions(): + message = f"<@{BOT_ID}> <@!{BOT_ID}> test question" + assert (strip_bot_mention(message, BOT_ID)) == "test question" + + +def test_concatenate_reference_and_original(): + message = f"<@{BOT_ID}> test question" + referenced_text = "Original text" + assert ( + question_assembler_helper(content=message, bot_id=BOT_ID, referenced_text=referenced_text) + == "Original text\n\ntest question" + ) + + +def test_empty_response(): + message = f"<@{BOT_ID}>" + assert (question_assembler_helper(content=message, bot_id=BOT_ID)) == ""