From a4f3725354a7ab3518226eda465d9be2dd658fe4 Mon Sep 17 00:00:00 2001 From: Hash Borgir Date: Thu, 7 May 2026 22:43:21 -0500 Subject: [PATCH] quote plugin added. Fixed funguy.py and plugins.py for plugin list. --- funguy.py | 40 --------- plugins/plugins.py | 64 ++++++++------- plugins/quote.py | 199 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 +- 4 files changed, 236 insertions(+), 72 deletions(-) mode change 100755 => 100644 funguy.py create mode 100644 plugins/quote.py diff --git a/funguy.py b/funguy.py old mode 100755 new mode 100644 index 7b44368..ea9ec2e --- a/funguy.py +++ b/funguy.py @@ -213,46 +213,6 @@ class FunguyBot: await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.") # Sending unauthorized message return - # List plugins command with descriptions (bold plugin names) - if match.is_not_from_this_bot() and match.prefix() and match.command("plugins"): - if self.PLUGINS: - # Build a list with plugin names and their descriptions - plugin_list = [] - for plugin_name in sorted(self.PLUGINS.keys()): - plugin_module = self.PLUGINS[plugin_name] - # Try to get description from plugin - description = getattr(plugin_module, "__description__", None) - if not description: - # Try to get from docstring - docstring = getattr(plugin_module, "__doc__", None) - if docstring: - # Get first line of docstring - description = docstring.strip().split('\n')[0] - else: - description = "No description available" - - # Make plugin name bold using HTML tag - plugin_list.append(f"[{plugin_name}.py]: {description}") - - # Send as HTML message (bold will render) - response = "\n".join(plugin_list) - # Split into multiple messages if too long - if len(response) > 4000: - chunk = "" - for line in plugin_list: - if len(chunk) + len(line) + 1 > 4000: - await self.bot.api.send_markdown_message(room.room_id, chunk) - chunk = line + "\n" - else: - chunk += line + "\n" - if chunk: - await self.bot.api.send_markdown_message(room.room_id, chunk) - else: - await self.bot.api.send_markdown_message(room.room_id, response) - else: - await self.bot.api.send_text_message(room.room_id, "No plugins are currently loaded.") - return - # Rehash config command if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"): if str(message.sender) == self.config.admin_user: # Checking if sender is admin user diff --git a/plugins/plugins.py b/plugins/plugins.py index 1b2bd17..069d80b 100644 --- a/plugins/plugins.py +++ b/plugins/plugins.py @@ -1,11 +1,8 @@ """ -This plugin provides a command to list all loaded plugins along with their descriptions. +This plugin lists all loaded plugins (from bot.plugins) inside a collapsible block, +and sends a separate always‑visible line showing the total active count. """ -# plugins/plugins.py - -import os -import sys import logging import simplematrixbotlib as botlib @@ -13,46 +10,51 @@ async def handle_command(room, message, bot, prefix, config): match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("plugins"): logging.info("Received !plugins command") - plugin_descriptions = get_plugin_descriptions() - plugin_descriptions.insert(0, "
🔌Plugins List🔌
⤵︎Click Here to Expand⤵︎
") - plugins_message = "
".join(plugin_descriptions) - plugins_message += "
" + # Fetch the real, currently loaded plugins from the bot instance + plugins_dict = getattr(bot, "plugins", {}) + if not plugins_dict: + await bot.api.send_markdown_message(room.room_id, "📊 **Total Active Plugins:** 0") + return - await bot.api.send_markdown_message(room.room_id, plugins_message) - logging.info("Sent plugin list to the room") + # Build sorted list of descriptions + plugin_items = [] + for plugin_name, module in sorted(plugins_dict.items()): + desc = getattr(module, "__description__", None) + if not desc and module.__doc__: + desc = module.__doc__.strip().split("\n")[0] + desc = desc or "No description available" + plugin_items.append(f"[{plugin_name}.py]: {desc}") + active_count = len(plugin_items) -def get_plugin_descriptions(): - plugin_descriptions = [] - for module_name, module in sys.modules.items(): - if module_name.startswith("plugins.") and hasattr(module, "__file__"): - plugin_path = module.__file__ - plugin_name = os.path.basename(plugin_path).split(".")[0] + # 1) Always‑visible count + count_msg = f"📊 **Total Active Plugins:** {active_count}" + await bot.api.send_markdown_message(room.room_id, count_msg) - if hasattr(module, "__description__"): - description = module.__description__ - elif module.__doc__: - description = module.__doc__.strip().split("\n")[0] - else: - description = "No description available" + # 2) Collapsible list + list_html = "
".join(plugin_items) + detail_msg = ( + f"
" + f"🔌 Plugin List 🔌
⤵︎ Click to expand
" + f"
{list_html}" + f"
" + ) + await bot.api.send_markdown_message(room.room_id, detail_msg) - plugin_descriptions.append(f"[{plugin_name}.py]: {description}") - - plugin_descriptions.sort() - return plugin_descriptions + logging.info(f"Sent plugin list ({active_count} active) to room {room.room_id}") # --------------------------------------------------------------------------- # Plugin Metadata # --------------------------------------------------------------------------- -__version__ = "1.0.0" +__version__ = "1.0.4" __author__ = "Funguy Bot" -__description__ = "List all loaded plugins" +__description__ = "List all loaded plugins with count, collapsible" __help__ = """
-!plugins – List loaded plugins -

Displays all currently loaded plugins and their descriptions.

+!plugins – List active plugins +

Shows the total number of loaded plugins and a collapsible list with descriptions.

""" diff --git a/plugins/quote.py b/plugins/quote.py new file mode 100644 index 0000000..242ba28 --- /dev/null +++ b/plugins/quote.py @@ -0,0 +1,199 @@ +""" +Goodreads Quote Scraper – Playwright (headless Chromium) +No external APIs, no keys; scrapes directly from goodreads.com +""" + +import logging +import random +import re +import asyncio +import simplematrixbotlib as botlib +from bs4 import BeautifulSoup +from urllib.parse import urlencode + +logger = logging.getLogger("quote") + +GR_POPULAR = "https://www.goodreads.com/quotes" +GR_SEARCH = "https://www.goodreads.com/quotes/search" +QUOTES_PER_PAGE = 30 +MAX_SEARCH_PAGES = 3 + +# --------------------------------------------------------------------------- +# Playwright browser (shared, launched once) +# --------------------------------------------------------------------------- +_browser = None +_playwright = None + +async def _get_browser(): + global _browser, _playwright + if _browser is None: + from playwright.async_api import async_playwright + _playwright = await async_playwright().start() + _browser = await _playwright.chromium.launch(headless=True) + logger.info("Playwright browser started") + return _browser + +async def _close_browser(): + global _browser, _playwright + if _browser: + await _browser.close() + _browser = None + if _playwright: + await _playwright.stop() + _playwright = None + +# --------------------------------------------------------------------------- +# HTML parsing (Goodreads specific) +# --------------------------------------------------------------------------- +def _extract_quotes(html: str) -> list[dict]: + """Parse Goodreads HTML and return a list of {content, author} dicts.""" + soup = BeautifulSoup(html, "lxml") + quotes = [] + + for div in soup.find_all("div", class_="quoteText"): + full_text = div.get_text(" ", strip=True) + # Try curly quotes + m = re.search(r"“(.+?)”", full_text) + if not m: + m = re.search(r"(.+?)\s*―", full_text) + if not m: + continue + content = m.group(1).strip() + + author_span = div.find("span", class_="authorOrTitle") + author = author_span.get_text(strip=True).rstrip(",") if author_span else "Unknown" + quotes.append({"content": content, "author": author}) + + # Alternative layout (if first method yielded nothing) + for div in soup.find_all("div", class_="quoteDetails"): + text_elem = div.find("div", class_="quoteText") + author_elem = div.find("span", class_="authorOrTitle") + if text_elem: + content = text_elem.get_text(strip=True).strip("“”") + else: + continue + author = author_elem.get_text(strip=True).rstrip(",") if author_elem else "Unknown" + quotes.append({"content": content, "author": author}) + + return quotes + +# --------------------------------------------------------------------------- +# Page fetching +# --------------------------------------------------------------------------- +async def _scrape(url: str, params: dict = None) -> str: + browser = await _get_browser() + context = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" + ) + page = await context.new_page() + try: + if params: + full_url = f"{url}?{urlencode(params)}" + else: + full_url = url + await page.goto(full_url, wait_until="networkidle", timeout=15000) + html = await page.content() + return html + except Exception as e: + logger.error(f"Failed to load {full_url}: {e}") + return "" + finally: + await page.close() + await context.close() + +async def get_random_popular() -> list[dict]: + html = await _scrape(GR_POPULAR) + return _extract_quotes(html) + +async def get_author_quotes(author: str) -> list[dict]: + all_quotes = [] + for page in range(1, MAX_SEARCH_PAGES + 1): + html = await _scrape(GR_SEARCH, {"q": author, "commit": "Search", "page": page}) + page_quotes = _extract_quotes(html) + all_quotes.extend(page_quotes) + if len(page_quotes) < QUOTES_PER_PAGE: + break + return all_quotes + +# --------------------------------------------------------------------------- +# Formatting +# --------------------------------------------------------------------------- +def format_quote(q: dict) -> str: + return f'"{q["content"]}"\n\n— {q["author"]}' + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- +async def handle_command(room, message, bot, prefix, config): + match = botlib.MessageMatch(room, message, bot, prefix) + if not (match.is_not_from_this_bot() and match.prefix() and match.command("quote")): + return + + args = match.args() + + # Help + if args and args[0].lower() in ("help", "-h", "--help"): + help_html = ( + "
📖 !quote help" + "
    " + "
  • !quote – random popular quote from Goodreads
  • " + "
  • !quote <author> – random quote by that author
  • " + "
  • !quote help – this
  • " + "
" + "

Examples:
!quote
" + "!quote Terence McKenna
" + "!quote Oscar Wilde

" + "

Scraped with Playwright (headless browser).

" + "
" + ) + await bot.api.send_markdown_message(room.room_id, help_html) + return + + try: + if args: + author = " ".join(args).strip() + await bot.api.send_text_message( + room.room_id, f"🔍 Searching Goodreads for quotes by **{author}**…" + ) + quotes = await get_author_quotes(author) + if not quotes: + await bot.api.send_text_message( + room.room_id, + f"❌ No quotes found for '**{author}**'. Try a different spelling." + ) + return + chosen = random.choice(quotes) + else: + await bot.api.send_text_message(room.room_id, "✨ Fetching a random popular quote…") + quotes = await get_random_popular() + if not quotes: + await bot.api.send_text_message(room.room_id, "❌ Could not fetch any quotes.") + return + chosen = random.choice(quotes) + + await bot.api.send_markdown_message(room.room_id, format_quote(chosen)) + logger.info(f"Quote sent: {chosen['author']}") + + except Exception as e: + logger.exception("Unexpected error in quote plugin") + await bot.api.send_text_message( + room.room_id, f"❌ Scraping error: {e}" + ) + +# --------------------------------------------------------------------------- +# Plugin metadata +# --------------------------------------------------------------------------- +__version__ = "1.0.1" +__author__ = "Funguy Bot" +__description__ = "Goodreads quotes via Playwright (headless browser)" +__help__ = """ +
+!quote – Quotes from Goodreads (scraped with Playwright) +
    +
  • !quote – random popular quote
  • +
  • !quote <author> – random quote by that author
  • +
  • !quote help
  • +
+

No API keys, no JSON files – just a real browser fetching from Goodreads.

+
+""" diff --git a/requirements.txt b/requirements.txt index 4ce88bd..3ab2d59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,7 @@ pillow omdbapi apscheduler pytz -ddgs \ No newline at end of file +ddgs +playwright +lxml +beautifulsoup4 \ No newline at end of file