diff --git a/README.md b/README.md index b6c788d..eb8b928 100644 --- a/README.md +++ b/README.md @@ -548,6 +548,88 @@ Generates text using the Infermatic AI API with multiple model support: **📰 !xkcd** Fetches and displays a random XKCD comic. +### Academic Paper Search + +**📚 !arxiv [query]** +Search academic papers on arXiv.org with categories including AI, ML, Security, Physics, Math, and more. +No API key required - completely free to use. + +**Features:** +- Search across 20+ categories including computer science, mathematics, physics, economics +- Fetch paper details, abstracts, authors, and publication info +- Get papers by arXiv ID or search with custom queries +- Browse recent papers by category or get random papers + +**Commands:** +- `!arxiv ` - Search for papers with given query terms +- `!arxiv list ` - List papers without showing abstracts +- `!arxiv category ` - Browse recent papers in a specific category +- `!arxiv recent ` - Most recent papers in a category +- `!arxiv random` - Get a random paper from arXiv +- `!arxiv ` - Get paper by arXiv ID (e.g., 2101.00101) + +**Categories:** ai, ml, security, crypto, cv, nlp, math, physics, quantum, bio, software, economics + +**Examples:** +```bash +!arxiv machine learning +!arxiv list quantum computing +!arxiv category ai +!arxiv recent ml +!arxiv random +!arxiv 2101.00101 +``` + +### News Aggregator + +**📰 !news [category]** +Fetch latest headlines from various news categories using GNews API. +*Requires GNEWS_API_KEY environment variable* + +**Categories:** +- `!news` - Top headlines (default) +- `!news world` - World news +- `!news tech` - Technology news +- `!news business` - Business news +- `!news science` - Science news +- `!news health` - Health news +- `!news crypto` - Cryptocurrency news +- `!news search ` - Search for specific news + +**Examples:** +```bash +!news +!news tech +!news search artificial intelligence +``` + +### Hacker News Reader + +**🔥 !hn [command]** +Fetch top stories from Hacker News using Firebase API. +No API key required - completely free to use. + +**Commands:** +- `!hn` - Show top 5 stories (default) +- `!hn top` - Top stories +- `!hn new` - Newest stories +- `!hn best` - Best stories +- `!hn ask` - Ask HN threads +- `!hn show` - Show HN posts +- `!hn job` - Job postings +- `!hn story ` - Get details of a specific story +- `!hn comments ` - Show comments for a story +- `!hn search ` - Search stories (via Algolia) + +**Examples:** +```bash +!hn +!hn new +!hn story 1234567 +!hn comments 1234567 +!hn search artificial intelligence +``` + ### Administration Commands *Admin only - requires admin_user configuration* diff --git a/funguy.py b/funguy.py index 99427da..fe493f2 100755 --- a/funguy.py +++ b/funguy.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Funguy Bot Class +Funguy Bot Class - A modular Matrix bot with plugin support """ # Importing necessary libraries and modules @@ -17,6 +17,7 @@ import toml # Library for parsing TOML configuration files # Importing FunguyConfig class from plugins.config module from plugins.config import FunguyConfig + class FunguyBot: """ A bot class for managing plugins and handling commands in a Matrix chat environment. @@ -71,24 +72,40 @@ class FunguyBot: Method to configure logging settings. """ # Basic configuration for logging messages to console - logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.INFO) - logging.getLogger().setLevel(logging.INFO) + log_level = os.getenv("LOG_LEVEL", "INFO").upper() + + # Configure logging format + logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=getattr(logging, log_level, logging.INFO) + ) + + # Set specific loggers to appropriate levels + logging.getLogger().setLevel(getattr(logging, log_level, logging.INFO)) + logging.getLogger("simplematrixbotlib").setLevel(logging.WARNING) + logging.getLogger("nio").setLevel(logging.WARNING) + + logging.info(f"Logging configured with level: {log_level}") def load_plugins(self): """ Method to load plugins from the specified directory. """ # Iterating through files in the plugins directory + plugin_count = 0 for plugin_file in os.listdir(self.PLUGINS_DIR): - if plugin_file.endswith(".py"): # Checking if file is a Python file + if plugin_file.endswith(".py") and plugin_file != "__init__.py": plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name try: # Importing plugin module dynamically module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}") self.PLUGINS[plugin_name] = module # Storing loaded plugin module - logging.info(f"Loaded plugin: {plugin_name}") # Logging successful plugin loading + logging.info(f"Loaded plugin: {plugin_name}") + plugin_count += 1 except Exception as e: - logging.error(f"Error loading plugin {plugin_name}: {e}") # Logging error if plugin loading fails + logging.error(f"Error loading plugin {plugin_name}: {e}", exc_info=True) + + logging.info(f"Total plugins loaded: {plugin_count}") def setup_plugins(self): """ @@ -98,33 +115,51 @@ class FunguyBot: that plugins which register custom event listeners (e.g. on_custom_event for RoomMemberEvent) receive a valid bot instance. """ + setup_count = 0 for plugin_name, plugin_module in self.PLUGINS.items(): if hasattr(plugin_module, "setup") and callable(plugin_module.setup): try: plugin_module.setup(self.bot) logging.info(f"Setup called for plugin: {plugin_name}") + setup_count += 1 except Exception as e: - logging.error(f"Error during setup of plugin {plugin_name}: {e}") + logging.error(f"Error during setup of plugin {plugin_name}: {e}", exc_info=True) + + logging.info(f"Setup completed for {setup_count} plugins") def reload_plugins(self): """ Method to reload all plugins. """ - self.PLUGINS = {} # Clearing loaded plugins dictionary + logging.info("Reloading plugins...") + + # Clear loaded plugins dictionary + self.PLUGINS.clear() + # Unloading modules from sys.modules - for plugin_name in list(sys.modules.keys()): - if plugin_name.startswith(self.PLUGINS_DIR + "."): - del sys.modules[plugin_name] # Deleting plugin module from system modules - self.load_plugins() # Reloading plugins + modules_to_remove = [] + for module_name in sys.modules.keys(): + if module_name.startswith(self.PLUGINS_DIR + "."): + modules_to_remove.append(module_name) + + for module_name in modules_to_remove: + del sys.modules[module_name] + + # Reload plugins + self.load_plugins() + # Re-run setup for any plugin that needs it (bot already exists at this point) if self.bot is not None: self.setup_plugins() + logging.info("Plugins reloaded successfully") + def load_config(self): """ Method to load configuration settings. """ self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration + logging.info("Configuration loaded") def load_disabled_plugins(self): """ @@ -132,11 +167,18 @@ class FunguyBot: """ # Checking if configuration file exists if os.path.exists('funguy.conf'): - # Loading configuration data from TOML file - with open('funguy.conf', 'r') as f: - config_data = toml.load(f) - # Extracting disabled plugins from configuration data - self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {}) + try: + # Loading configuration data from TOML file + with open('funguy.conf', 'r') as f: + config_data = toml.load(f) + # Extracting disabled plugins from configuration data + self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {}) + logging.info(f"Loaded disabled plugins configuration for {len(self.disabled_plugins)} rooms") + except Exception as e: + logging.error(f"Error loading disabled plugins configuration: {e}") + self.disabled_plugins = {} + else: + logging.info("No funguy.conf found, starting with empty disabled plugins list") def save_disabled_plugins(self): """ @@ -145,83 +187,139 @@ class FunguyBot: existing_config = {} # Checking if configuration file exists if os.path.exists('funguy.conf'): - # Loading existing configuration data - with open('funguy.conf', 'r') as f: - existing_config = toml.load(f) + try: + # Loading existing configuration data + with open('funguy.conf', 'r') as f: + existing_config = toml.load(f) + except Exception as e: + logging.error(f"Error reading funguy.conf: {e}") + # Updating configuration data with disabled plugins - existing_config['plugins'] = {'disabled': self.disabled_plugins} + if 'plugins' not in existing_config: + existing_config['plugins'] = {} + existing_config['plugins']['disabled'] = self.disabled_plugins + # Writing updated configuration data back to file - with open('funguy.conf', 'w') as f: - toml.dump(existing_config, f) + try: + with open('funguy.conf', 'w') as f: + toml.dump(existing_config, f) + logging.info("Saved disabled plugins configuration") + except Exception as e: + logging.error(f"Error saving disabled plugins configuration: {e}") async def handle_commands(self, room, message): """ Method to handle incoming commands and dispatch them to appropriate plugins. """ match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) # Matching message against bot's prefix + # Reloading plugins command if match.is_not_from_this_bot() and match.prefix() and match.command("reload"): if str(message.sender) == self.config.admin_user: # Checking if sender is admin user self.reload_plugins() # Reloading plugins - await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully") # Sending success message + await self.bot.api.send_text_message(room.room_id, "✅ Plugins reloaded successfully") else: - await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.") # Sending unauthorized message + await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to reload plugins.") + return # Disable plugin command if match.is_not_from_this_bot() and match.prefix() and match.command("disable"): if str(message.sender) == self.config.admin_user: # Checking if sender is admin user args = match.args() # Getting command arguments if len(args) != 2: # Checking if correct number of arguments provided - await self.bot.api.send_text_message(room.room_id, "Usage: !disable ") # Sending usage message + await self.bot.api.send_text_message(room.room_id, "Usage: !disable ") else: plugin_name, room_id = args # Extracting plugin name and room ID await self.disable_plugin(room_id, plugin_name) # Disabling plugin - await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'") # Sending success message + await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' disabled for room '{room_id}'") else: - await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.") # Sending unauthorized message + await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to disable plugins.") + return # Enable plugin command if match.is_not_from_this_bot() and match.prefix() and match.command("enable"): if str(message.sender) == self.config.admin_user: # Checking if sender is admin user args = match.args() # Getting command arguments if len(args) != 2: # Checking if correct number of arguments provided - await self.bot.api.send_text_message(room.room_id, "Usage: !enable ") # Sending usage message + await self.bot.api.send_text_message(room.room_id, "Usage: !enable ") else: plugin_name, room_id = args # Extracting plugin name and room ID await self.enable_plugin(room_id, plugin_name) # Enabling plugin - await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'") # Sending success message + await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' enabled for room '{room_id}'") else: - await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.") # Sending unauthorized message - - # Dispatching commands to plugins - for plugin_name, plugin_module in self.PLUGINS.items(): - if plugin_name not in self.disabled_plugins.get(room.room_id, []): - await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config) + await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to enable plugins.") + 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 self.rehash_config() # Rehashing configuration - await self.bot.api.send_text_message(room.room_id, "Config rehashed") # Sending success message + await self.bot.api.send_text_message(room.room_id, "✅ Configuration rehashed successfully") else: - await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.") # Sending unauthorized message + await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to rehash configuration.") + return + + # List plugins command + if match.is_not_from_this_bot() and match.prefix() and match.command("plugins"): + if self.PLUGINS: + plugin_list = "\n".join([f" • {name}" for name in sorted(self.PLUGINS.keys())]) + await self.bot.api.send_markdown_message( + room.room_id, + f"**📦 Loaded Plugins ({len(self.PLUGINS)})**\n\n{plugin_list}" + ) + else: + await self.bot.api.send_text_message(room.room_id, "No plugins are currently loaded.") + return + + # Dispatching commands to plugins (only if plugin is not disabled for this room) + for plugin_name, plugin_module in self.PLUGINS.items(): + # Check if plugin is disabled for this room + if plugin_name in self.disabled_plugins.get(room.room_id, []): + continue + + # Check if plugin has handle_command function + if hasattr(plugin_module, "handle_command") and callable(plugin_module.handle_command): + try: + await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config) + except Exception as e: + logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True) + # Don't break the loop, continue with other plugins def rehash_config(self): """ Method to rehash the configuration settings. """ - del self.config # Deleting current configuration object - self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration + logging.info("Rehashing configuration...") + try: + # Reload environment variables + load_dotenv() + + # Reload configuration + del self.config # Deleting current configuration object + self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration + + # Reload disabled plugins + self.load_disabled_plugins() + + logging.info("Configuration rehashed successfully") + except Exception as e: + logging.error(f"Error rehashing configuration: {e}") async def disable_plugin(self, room_id, plugin_name): """ Method to disable a plugin for a specific room. """ + if plugin_name not in self.PLUGINS: + logging.warning(f"Attempted to disable non-existent plugin: {plugin_name}") + return + if room_id not in self.disabled_plugins: self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist + if plugin_name not in self.disabled_plugins[room_id]: self.disabled_plugins[room_id].append(plugin_name) # Adding plugin to list of disabled plugins for the room self.save_disabled_plugins() # Saving disabled plugins to configuration file + logging.info(f"Plugin '{plugin_name}' disabled for room {room_id}") async def enable_plugin(self, room_id, plugin_name): """ @@ -229,31 +327,60 @@ class FunguyBot: """ if room_id in self.disabled_plugins and plugin_name in self.disabled_plugins[room_id]: self.disabled_plugins[room_id].remove(plugin_name) # Removing plugin from list of disabled plugins for the room + + # Clean up empty room entries + if not self.disabled_plugins[room_id]: + del self.disabled_plugins[room_id] + self.save_disabled_plugins() # Saving disabled plugins to configuration file + logging.info(f"Plugin '{plugin_name}' enabled for room {room_id}") def run(self): """ Method to initialize and run the bot. """ - # Retrieving Matrix credentials from environment variables - MATRIX_URL = os.getenv("MATRIX_URL") - MATRIX_USER = os.getenv("MATRIX_USER") - MATRIX_PASS = os.getenv("MATRIX_PASS") - creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object - self.bot = botlib.Bot(creds, self.config) # Creating bot instance + try: + # Retrieving Matrix credentials from environment variables + MATRIX_URL = os.getenv("MATRIX_URL") + MATRIX_USER = os.getenv("MATRIX_USER") + MATRIX_PASS = os.getenv("MATRIX_PASS") - # Call setup() on any plugin that defines it, now that self.bot exists. - # This is what registers custom event listeners such as the join-event - # listener in the welcome plugin. - self.setup_plugins() + # Validate credentials + if not all([MATRIX_URL, MATRIX_USER, MATRIX_PASS]): + logging.error("Missing Matrix credentials in .env file. Please check MATRIX_URL, MATRIX_USER, MATRIX_PASS") + return - # Defining listener for message events - @self.bot.listener.on_message_event - async def wrapper_handle_commands(room, message): - await self.handle_commands(room, message) # Calling handle_commands method for incoming messages + creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object + self.bot = botlib.Bot(creds, self.config) # Creating bot instance + + logging.info(f"Bot starting with user: {MATRIX_USER}") + logging.info(f"Connected to homeserver: {MATRIX_URL}") + + # Call setup() on any plugin that defines it, now that self.bot exists. + # This is what registers custom event listeners such as the join-event + # listener in the welcome plugin. + self.setup_plugins() + + # Defining listener for message events + @self.bot.listener.on_message_event + async def wrapper_handle_commands(room, message): + await self.handle_commands(room, message) # Calling handle_commands method for incoming messages + + logging.info("Bot is ready and listening for commands...") + self.bot.run() # Running the bot + + except Exception as e: + logging.error(f"Fatal error running bot: {e}", exc_info=True) + sys.exit(1) - self.bot.run() # Running the bot if __name__ == "__main__": - bot = FunguyBot() # Creating instance of FunguyBot - bot.run() # Running the bot + try: + bot = FunguyBot() # Creating instance of FunguyBot + bot.run() # Running the bot + except KeyboardInterrupt: + logging.info("Bot stopped by user") + sys.exit(0) + except Exception as e: + logging.error(f"Unhandled exception: {e}", exc_info=True) + sys.exit(1) diff --git a/plugins/arxiv.py b/plugins/arxiv.py new file mode 100644 index 0000000..859247d --- /dev/null +++ b/plugins/arxiv.py @@ -0,0 +1,322 @@ +""" +arXiv Paper Search Plugin for Funguy Bot + +Searches academic papers in physics, mathematics, computer science, and more. +Uses arXiv API - completely free, no API key required. + +Commands: + !arxiv - Search for papers (shows abstract) + !arxiv list - List papers without abstracts + !arxiv category - Browse recent papers by category + !arxiv recent [category] - Recent papers (last 7 days) + !arxiv random - Random paper + !arxiv - Get paper by arXiv ID +""" + +import logging +import aiohttp +import xml.etree.ElementTree as ET +import random +from typing import Optional, Dict, List +from datetime import datetime, timedelta + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_RESULTS = 3 +MAX_RESULTS = 10 + +CATEGORIES = { + "ai": "cs.AI", + "ml": "cs.LG", + "security": "cs.CR", + "crypto": "cs.CR", + "cv": "cs.CV", + "nlp": "cs.CL", + "math": "math", + "physics": "physics", + "quantum": "quant-ph", + "bio": "q-bio", + "economics": "econ", + "software": "cs.SE" +} + + +# --------------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------------- + +def _format_collapsible(title: str, content: str, expanded: bool = False) -> str: + """Format content in a collapsible details/summary block.""" + open_attr = ' open' if expanded else '' + return f"\n📚 {title}\n\n{content}\n\n" + + +def _oxford_comma(items): + if not items: + return "" + if len(items) == 1: + return items[0] + if len(items) == 2: + return f"{items[0]} and {items[1]}" + return f"{', '.join(items[:-1])}, and {items[-1]}" + + +def _format_paper(paper: Dict, index: int, include_abstract: bool = True) -> str: + """Format a paper as an HTML list item.""" + result = f"
  • \n{index}. {paper['title']}
    \n" + + # Authors + result += f"👥 Authors: {_oxford_comma(paper['authors'][:3])}" + if len(paper['authors']) > 3: + result += f" and {len(paper['authors']) - 3} others" + result += "
    \n" + + # Metadata + result += f"📅 Published: {paper['published']}
    \n" + result += f"🏷️ Categories: {', '.join(paper['categories'][:3])}" + if len(paper['categories']) > 3: + result += f" +{len(paper['categories']) - 3}" + result += "
    \n" + + # Links + result += f"🔗 arXiv ID: {paper['id']}
    \n" + result += f"📄 PDF: {paper['pdf_url']}
    \n" + + # Abstract + if include_abstract and paper['summary'] != "No abstract": + abstract = paper['summary'] + if len(abstract) > 500: + abstract = abstract[:497] + "..." + result += f"📝 Abstract:
    {abstract}\n" + + result += "
  • " + return result + + +async def _search_arxiv(query: str, max_results: int = DEFAULT_RESULTS, id_list: List[str] = None) -> Optional[List[Dict]]: + base_url = "http://export.arxiv.org/api/query" + + if id_list: + id_query = "+OR+".join([f"id:{pid}" for pid in id_list]) + params = {"search_query": id_query, "max_results": max_results} + else: + params = { + "search_query": query, + "max_results": max_results, + "sortBy": "relevance", + "sortOrder": "descending" + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(base_url, params=params) as response: + if response.status == 200: + text = await response.text() + return _parse_arxiv_response(text) + return None + except Exception as e: + logging.error(f"Error searching arXiv: {e}") + return None + + +async def _get_category_papers(category: str, limit: int = DEFAULT_RESULTS) -> Optional[List[Dict]]: + return await _search_arxiv(f"cat:{category}", limit) + + +async def _get_recent_papers(category: str = None, days: int = 7) -> Optional[List[Dict]]: + date = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d") + if category: + query = f"cat:{category} AND submittedDate:[{date}000000 TO {datetime.now().strftime('%Y%m%d')}235959]" + else: + query = f"submittedDate:[{date}000000 TO {datetime.now().strftime('%Y%m%d')}235959]" + return await _search_arxiv(query, DEFAULT_RESULTS) + + +async def _get_random_paper() -> Optional[Dict]: + terms = ["machine learning", "quantum", "neural network", "optimization", "algorithm", "security"] + query = random.choice(terms) + results = await _search_arxiv(query, max_results=MAX_RESULTS) + return random.choice(results) if results else None + + +def _parse_arxiv_response(xml_text: str) -> List[Dict]: + namespaces = {'atom': 'http://www.w3.org/2005/Atom', 'arxiv': 'http://arxiv.org/schemas/atom'} + root = ET.fromstring(xml_text) + entries = root.findall('atom:entry', namespaces) + + papers = [] + for entry in entries: + title = entry.find('atom:title', namespaces) + title_text = ' '.join(title.text.strip().split()) if title is not None else "No title" + + summary = entry.find('atom:summary', namespaces) + summary_text = ' '.join(summary.text.strip().split()) if summary is not None else "No abstract" + + authors = [] + for author in entry.findall('atom:author', namespaces): + name = author.find('atom:name', namespaces) + if name is not None and name.text: + authors.append(name.text) + + id_elem = entry.find('atom:id', namespaces) + paper_id = id_elem.text.split('/')[-1] if id_elem is not None else "Unknown" + + pdf_link = None + for link in entry.findall('atom:link', namespaces): + if link.get('title') == 'pdf': + pdf_link = link.get('href') + break + + categories = [] + for category in entry.findall('atom:category', namespaces): + term = category.get('term') + if term: + categories.append(term) + + published = entry.find('atom:published', namespaces) + pub_date = published.text.split('T')[0] if published is not None else "Unknown" + + papers.append({ + 'id': paper_id, + 'title': title_text, + 'summary': summary_text, + 'authors': authors, + 'pdf_url': pdf_link or f"http://arxiv.org/pdf/{paper_id}.pdf", + 'arxiv_url': f"http://arxiv.org/abs/{paper_id}", + 'categories': categories, + 'published': pub_date + }) + + return papers + + +# --------------------------------------------------------------------------- +# Command Handler +# --------------------------------------------------------------------------- + +async def handle_command(room, message, bot, prefix, config): + import simplematrixbotlib as botlib + + match = botlib.MessageMatch(room, message, bot, prefix) + + if not (match.is_not_from_this_bot() and match.prefix() and match.command("arxiv")): + return + + args = match.args() + + if not args: + help_content = ( + "Commands:

    " + "• !arxiv <query> - Search papers
    " + "• !arxiv list <query> - List without abstracts
    " + "• !arxiv category <cat> - Browse category
    " + "• !arxiv recent [cat] - Recent papers
    " + "• !arxiv random - Random paper
    " + "• !arxiv <id> - Get by ID

    " + "Categories: ai, ml, security, crypto, cv, nlp, math, physics, quantum, bio, software" + ) + response = _format_collapsible("arXiv Help", help_content, expanded=True) + await bot.api.send_markdown_message(room.room_id, response) + return + + cmd = args[0].lower() + limit = DEFAULT_RESULTS + include_abstract = True + + if args and args[0].isdigit(): + limit = min(int(args[0]), MAX_RESULTS) + args = args[1:] + cmd = args[0].lower() if args else None + elif args and args[-1].isdigit(): + limit = min(int(args[-1]), MAX_RESULTS) + args = args[:-1] + cmd = args[0].lower() if args else None + + if cmd == "list": + include_abstract = False + if len(args) >= 2: + query = " ".join(args[1:]) + else: + await bot.api.send_text_message(room.room_id, "Usage: !arxiv list ") + return + + elif cmd == "category" and len(args) >= 2: + cat_key = args[1].lower() + if cat_key in CATEGORIES: + category = CATEGORIES[cat_key] + await bot.api.send_text_message(room.room_id, f"📚 Fetching {cat_key.upper()} papers...") + papers = await _get_category_papers(category, limit) + title = f"Recent Papers in {cat_key.upper()}" + else: + await bot.api.send_text_message(room.room_id, f"Unknown category. Available: {', '.join(CATEGORIES.keys())}") + return + + elif cmd == "recent": + category = None + if len(args) >= 2 and args[1].lower() in CATEGORIES: + category = CATEGORIES[args[1].lower()] + await bot.api.send_text_message(room.room_id, f"📚 Fetching recent {args[1].upper()} papers...") + title = f"Recent Papers in {args[1].upper()} (7 Days)" + else: + await bot.api.send_text_message(room.room_id, "📚 Fetching recent papers...") + title = "Recent Papers (Last 7 Days)" + papers = await _get_recent_papers(category, limit) + + elif cmd == "random": + await bot.api.send_text_message(room.room_id, "🎲 Fetching random paper...") + paper = await _get_random_paper() + if paper: + content = f"
      \n{_format_paper(paper, 1, True)}\n
    " + response = _format_collapsible("Random Paper", content, True) + await bot.api.send_markdown_message(room.room_id, response) + else: + await bot.api.send_text_message(room.room_id, "❌ Failed to fetch random paper.") + return + + elif cmd and (cmd[0].isdigit() or ('.' in cmd and len(cmd.split('.')) == 2)): + paper_ids = [cmd] + [arg for arg in args[1:] if arg[0].isdigit() or ('.' in arg and len(arg.split('.')) == 2)] + if paper_ids: + await bot.api.send_text_message(room.room_id, f"📚 Fetching paper(s)...") + papers = await _search_arxiv("", max_results=len(paper_ids), id_list=paper_ids) + title = "Paper Details" + else: + await bot.api.send_text_message(room.room_id, "❌ Invalid arXiv ID.") + return + + else: + query = " ".join(args) + await bot.api.send_text_message(room.room_id, f"🔍 Searching: *{query[:50]}*...") + papers = await _search_arxiv(query, limit) + title = f"Search: '{query[:50]}'" + + if not papers: + await bot.api.send_text_message(room.room_id, "❌ No papers found.") + return + + content = "
      \n" + for i, paper in enumerate(papers, 1): + content += _format_paper(paper, i, include_abstract) + "\n" + content += f"
    \n\nFound {len(papers)} papers" + + response = _format_collapsible(title, content, False) + await bot.api.send_markdown_message(room.room_id, response) + logging.info(f"Sent arXiv search results") + + +# --------------------------------------------------------------------------- +# Plugin Setup +# --------------------------------------------------------------------------- + +def setup(bot): + logging.info("arXiv plugin loaded") + + +# --------------------------------------------------------------------------- +# Plugin Metadata +# --------------------------------------------------------------------------- + +__version__ = "1.0.0" +__author__ = "Funguy Bot" +__description__ = "arXiv academic paper search" diff --git a/plugins/hackernews.py b/plugins/hackernews.py new file mode 100644 index 0000000..338b0a6 --- /dev/null +++ b/plugins/hackernews.py @@ -0,0 +1,390 @@ +""" +Hacker News Plugin for Funguy Bot + +Fetches top stories from Hacker News using the Firebase API. +No API key required - completely free. + +Commands: + !hn - Show top 5 stories (default) + !hn top - Top stories + !hn new - Newest stories + !hn best - Best stories + !hn ask - Ask HN threads + !hn show - Show HN posts + !hn job - Job postings + !hn story - Get details of a specific story + !hn comments - Show comments for a story + !hn search - Search stories (via Algolia) +""" + +import logging +import aiohttp +import re +import asyncio +from typing import Optional, Dict, Any, List +from datetime import datetime, timezone + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_STORIES = 5 +MAX_STORIES = 15 + +# Story types +STORY_TYPES = { + "top": "topstories", + "new": "newstories", + "best": "beststories", + "ask": "askstories", + "show": "showstories", + "job": "jobstories" +} + + +# --------------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------------- + +def _format_collapsible(title: str, content: str, expanded: bool = False) -> str: + """Format content in a collapsible details/summary block.""" + open_attr = ' open' if expanded else '' + return f"\n📰 {title}\n\n{content}\n\n" + + +def _format_time(timestamp: int) -> str: + """Format Unix timestamp to relative time string.""" + if not timestamp: + return "unknown time" + + try: + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + now = datetime.now(timezone.utc) + diff = now - dt + + if diff.days > 0: + return f"{diff.days}d ago" + elif diff.seconds > 3600: + return f"{diff.seconds // 3600}h ago" + else: + return f"{diff.seconds // 60}m ago" + except: + return "unknown time" + + +def _format_story(story: Dict, index: int, show_points: bool = True) -> str: + """Format a story for display using HTML list elements.""" + title = story.get("title", "No title") + url = story.get("url", "#") + score = story.get("score", 0) + by = story.get("by", "unknown") + descendants = story.get("descendants", 0) + time_str = _format_time(story.get("time", 0)) + story_id = story.get("id", 0) + + # Build story HTML + story_html = f"
  • {index}. {title}
    " + + if url and url != "#": + story_html += f"🔗 {url}
    " + else: + hn_url = f"https://news.ycombinator.com/item?id={story_id}" + story_html += f"💬 Discussion on Hacker News
    " + + story_html += f"👤 {by} | 🕐 {time_str}" + + if show_points: + story_html += f" | ⭐ {score} points | 💬 {descendants} comments" + + story_html += "
  • " + + return story_html + + +def _format_comment(comment: Dict, depth: int = 0) -> str: + """Format a comment for display.""" + if comment.get("deleted") or comment.get("dead"): + return None + + text = comment.get("text", "") + if not text: + return None + + text = re.sub(r'<[^>]+>', '', text) + + if len(text) > 300: + text = text[:297] + "..." + + by = comment.get("by", "unknown") + indent = " " * depth + + return f"{indent}
  • {by}: {text}
  • " + + +def _format_search_result(hit: Dict, index: int) -> str: + """Format a search result using HTML list elements.""" + title = hit.get("title", "No title") + url = hit.get("url", "#") + points = hit.get("points", 0) + author = hit.get("author", "unknown") + comment_count = hit.get("num_comments", 0) + story_id = hit.get("objectID") + + result_html = f"
  • {index}. {title}
    " + + if url and url != "#": + result_html += f"🔗 {url}
    " + else: + hn_url = f"https://news.ycombinator.com/item?id={story_id}" + result_html += f"💬 Discussion on Hacker News
    " + + result_html += f"👤 {author} | ⭐ {points} points | 💬 {comment_count} comments" + result_html += "
  • " + + return result_html + + +async def _fetch_story_ids(story_type: str) -> Optional[List[int]]: + """Fetch story IDs from Hacker News API.""" + if story_type not in STORY_TYPES.values(): + return None + + url = f"https://hacker-news.firebaseio.com/v0/{story_type}.json" + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status == 200: + data = await response.json() + if data and isinstance(data, list): + logging.info(f"Fetched {len(data)} story IDs for {story_type}") + return data + return None + return None + except Exception as e: + logging.error(f"Error fetching story IDs: {e}") + return None + + +async def _fetch_item(item_id: int) -> Optional[Dict]: + """Fetch a single item by ID.""" + url = f"https://hacker-news.firebaseio.com/v0/item/{item_id}.json" + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status == 200: + return await response.json() + return None + except Exception as e: + logging.error(f"Error fetching item {item_id}: {e}") + return None + + +async def _search_hackernews(query: str, limit: int = DEFAULT_STORIES) -> Optional[List[Dict]]: + """Search Hacker News using Algolia API.""" + url = "https://hn.algolia.com/api/v1/search" + params = { + "query": query, + "tags": "story", + "hitsPerPage": limit + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status == 200: + data = await response.json() + return data.get("hits", []) + return None + except Exception as e: + logging.error(f"Error searching HN: {e}") + return None + + +# --------------------------------------------------------------------------- +# Command Handler +# --------------------------------------------------------------------------- + +async def handle_command(room, message, bot, prefix, config): + """Handle !hn commands.""" + import simplematrixbotlib as botlib + + match = botlib.MessageMatch(room, message, bot, prefix) + + if not (match.is_not_from_this_bot() and match.prefix() and match.command("hn")): + return + + args = match.args() + limit = DEFAULT_STORIES + + if args and args[-1].isdigit(): + limit = min(int(args[-1]), MAX_STORIES) + args = args[:-1] + + # No arguments - show top stories + if not args: + story_type = "top" + type_name = "Top" + await bot.api.send_text_message(room.room_id, "📰 Fetching top Hacker News stories...") + + # Handle story ID + elif args[0].lower() == "story" and len(args) >= 2: + try: + story_id = int(args[1]) + await bot.api.send_text_message(room.room_id, f"📖 Fetching story {story_id}...") + story = await _fetch_item(story_id) + + if not story or story.get("deleted"): + await bot.api.send_text_message(room.room_id, f"❌ Story {story_id} not found.") + return + + content = f"
      \n{_format_story(story, 1, show_points=True)}\n
    " + + if len(args) >= 3 and args[2].lower() == "comments": + content += "\nTop Comments:\n
      \n" + if story.get("kids"): + comment_ids = story["kids"][:5] + for comment_id in comment_ids: + comment = await _fetch_item(comment_id) + if comment: + formatted = _format_comment(comment) + if formatted: + content += f"{formatted}\n" + content += "
    " + + response = _format_collapsible(f"Story: {story.get('title', 'Unknown')[:50]}", content, expanded=True) + await bot.api.send_markdown_message(room.room_id, response) + except ValueError: + await bot.api.send_text_message(room.room_id, "❌ Invalid story ID.") + return + + # Handle comments + elif args[0].lower() == "comments" and len(args) >= 2: + try: + story_id = int(args[1]) + await bot.api.send_text_message(room.room_id, f"💬 Fetching comments...") + story = await _fetch_item(story_id) + + if not story: + await bot.api.send_text_message(room.room_id, f"❌ Story {story_id} not found.") + return + + content = f"{story.get('title', 'Unknown')}\n
      \n" + + if story.get("kids"): + comment_ids = story["kids"][:limit] + count = 0 + for comment_id in comment_ids: + comment = await _fetch_item(comment_id) + if comment and not comment.get("deleted"): + formatted = _format_comment(comment) + if formatted: + content += f"{formatted}\n" + count += 1 + if count >= limit: + break + + if count == 0: + content += "
    • No comments found.
    • " + else: + content += f"\n
    \nShowing {count} comments" + else: + content += "
  • This story has no comments yet.
  • \n" + + response = _format_collapsible(f"Comments for Story {story_id}", content, expanded=True) + await bot.api.send_markdown_message(room.room_id, response) + except ValueError: + await bot.api.send_text_message(room.room_id, "❌ Invalid story ID.") + return + + # Handle search + elif args[0].lower() == "search" and len(args) >= 2: + query = " ".join(args[1:]) + await bot.api.send_text_message(room.room_id, f"🔍 Searching for: *{query}*...") + + results = await _search_hackernews(query, limit) + + if not results: + await bot.api.send_text_message(room.room_id, "❌ No results found.") + return + + content = "
      \n" + for i, hit in enumerate(results[:limit], 1): + content += _format_search_result(hit, i) + "\n" + content += f"
    \n\nFound {len(results[:limit])} results" + + response = _format_collapsible(f"Search: '{query}'", content, expanded=False) + await bot.api.send_markdown_message(room.room_id, response) + return + + # Handle story type (top, new, best, etc.) + else: + cmd = args[0].lower() + if cmd in STORY_TYPES: + story_type = STORY_TYPES[cmd] + type_name = cmd.capitalize() + await bot.api.send_text_message(room.room_id, f"📰 Fetching {type_name} stories...") + else: + # Assume it's a story ID + try: + story_id = int(cmd) + story = await _fetch_item(story_id) + if story: + content = f"
      \n{_format_story(story, 1, show_points=True)}\n
    " + response = _format_collapsible(f"Story {story_id}", content, expanded=True) + await bot.api.send_markdown_message(room.room_id, response) + else: + await bot.api.send_text_message(room.room_id, f"❌ Story {story_id} not found.") + return + except ValueError: + await bot.api.send_text_message(room.room_id, f"❌ Unknown command. Use !hn for help.") + return + + # Fetch and display stories + story_ids = await _fetch_story_ids(story_type) + + if not story_ids: + await bot.api.send_text_message(room.room_id, f"❌ Failed to fetch {type_name} stories.") + return + + story_ids = story_ids[:limit] + stories = [] + + for story_id in story_ids: + story = await _fetch_item(story_id) + if story and not story.get("deleted"): + stories.append(story) + await asyncio.sleep(0.1) + + if not stories: + await bot.api.send_text_message(room.room_id, f"❌ Could not fetch any {type_name} stories.") + return + + content = "
      \n" + for i, story in enumerate(stories, 1): + content += _format_story(story, i, show_points=True) + "\n" + content += f"
    \n\nFetched {len(stories)} stories" + + response = _format_collapsible(f"Hacker News - {type_name} Stories", content, expanded=False) + await bot.api.send_markdown_message(room.room_id, response) + + logging.info(f"Sent Hacker News to {room.room_id}: type={story_type}") + + +# --------------------------------------------------------------------------- +# Plugin Setup +# --------------------------------------------------------------------------- + +def setup(bot): + """Initialize plugin with bot instance.""" + logging.info("Hacker News plugin loaded") + + +# --------------------------------------------------------------------------- +# Plugin Metadata +# --------------------------------------------------------------------------- + +__version__ = "1.0.0" +__author__ = "Funguy Bot" +__description__ = "Hacker News integration" diff --git a/plugins/help.py b/plugins/help.py index 633a27d..f99e20b 100644 --- a/plugins/help.py +++ b/plugins/help.py @@ -501,6 +501,53 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in

    +
    📚 !arxiv [query] +

    Search academic papers on arXiv.org. Categories include AI, ML, Security, Physics, Math, and more. No API key required.

    +

    Commands:

    +
      +
    • !arxiv - Search for papers (shows abstracts)
    • +
    • !arxiv list - List papers without abstracts
    • +
    • !arxiv category - Browse recent papers by category
    • +
    • !arxiv recent - Most recent papers in category
    • +
    • !arxiv random - Get a random paper
    • +
    • !arxiv - Get paper by arXiv ID (e.g., 2101.00101)
    • +
    +

    Categories: ai, ml, security, crypto, cv, nlp, math, physics, quantum, bio, software

    +
    + +
    📰 !news [category/query] +

    Fetch latest headlines from various news categories using GNews API. Requires GNEWS_API_KEY environment variable.

    +

    Commands:

    +
      +
    • !news - Get top headlines (default)
    • +
    • !news top - Top headlines
    • +
    • !news world - World news
    • +
    • !news tech - Technology news
    • +
    • !news business - Business news
    • +
    • !news science - Science news
    • +
    • !news health - Health news
    • +
    • !news crypto - Cryptocurrency news
    • +
    • !news search - Search for specific news
    • +
    +
    + +
    🔥 !hn [command] +

    Fetch top stories from Hacker News using Firebase API. No API key required.

    +

    Commands:

    +
      +
    • !hn - Show top 5 stories (default)
    • +
    • !hn top - Top stories
    • +
    • !hn new - Newest stories
    • +
    • !hn best - Best stories
    • +
    • !hn ask - Ask HN threads
    • +
    • !hn show - Show HN posts
    • +
    • !hn job - Job postings
    • +
    • !hn story - Get details of a specific story
    • +
    • !hn comments - Show comments for a story
    • +
    • !hn search - Search stories (via Algolia)
    • +
    +
    +
    ⏱️ !cron [add|remove] [room_id] [cron_entry] [command]

    Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.

    diff --git a/plugins/news.py b/plugins/news.py new file mode 100644 index 0000000..a5cc614 --- /dev/null +++ b/plugins/news.py @@ -0,0 +1,234 @@ +""" +News Aggregator Plugin for Funguy Bot + +Fetches latest headlines from various news categories using GNews API. +Free tier: 100 requests/day + +Commands: + !news - Get top headlines (default) + !news top - Top headlines + !news world - World news + !news tech - Technology news + !news business - Business news + !news science - Science news + !news health - Health news + !news crypto - Cryptocurrency news + !news search - Search for specific news +""" + +import logging +import aiohttp +import os +from typing import Optional, Dict, Any, List +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +# Get API key from environment variable +GNEWS_API_KEY = os.getenv("GNEWS_API_KEY") + +# Number of articles to return per command +DEFAULT_ARTICLES = 5 +MAX_ARTICLES = 10 + +# Category mapping +CATEGORIES = { + "top": "general", + "world": "world", + "tech": "technology", + "business": "business", + "entertainment": "entertainment", + "science": "science", + "health": "health", + "sports": "sports", + "crypto": "cryptocurrency" +} + + +# --------------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------------- + +def _format_collapsible(title: str, content: str, expanded: bool = False) -> str: + """Format content in a collapsible details/summary block.""" + open_attr = ' open' if expanded else '' + return f"\n📰 {title}\n\n{content}\n\n" + + +def _format_news_article(article: Dict, index: int) -> str: + """Format a single news article as an HTML list item.""" + title = article.get("title", "No title") + source = article.get("source", {}).get("name", "Unknown source") + url = article.get("url", "#") + description = article.get("description", "No description available") + published = article.get("publishedAt", "") + + # Truncate description if too long + if len(description) > 300: + description = description[:297] + "..." + + # Format date if available + date_str = "" + if published: + try: + from datetime import datetime + dt = datetime.fromisoformat(published.replace('Z', '+00:00')) + date_str = f" | 📅 {dt.strftime('%Y-%m-%d %H:%M')}" + except: + pass + + return ( + f"
  • \n" + f"{index}. {title}
    \n" + f"📰 Source: {source}{date_str}
    \n" + f"📝 Summary: {description}
    \n" + f"🔗 {url}\n" + f"
  • " + ) + + +async def _fetch_news(category: str = "general", query: str = None, limit: int = DEFAULT_ARTICLES) -> Optional[List[Dict]]: + """Fetch news articles from GNews API.""" + if not GNEWS_API_KEY: + logging.error("GNews API key not configured. Set GNEWS_API_KEY in .env file") + return None + + base_url = "https://gnews.io/api/v4" + + if query: + # Search endpoint + url = f"{base_url}/search" + params = { + "q": query, + "apikey": GNEWS_API_KEY, + "lang": "en", + "max": limit, + "country": "us" + } + else: + # Top headlines endpoint + url = f"{base_url}/top-headlines" + params = { + "apikey": GNEWS_API_KEY, + "lang": "en", + "country": "us", + "max": limit + } + if category and category != "general": + params["category"] = category + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params) as response: + if response.status == 200: + data = await response.json() + return data.get("articles", []) + else: + logging.error(f"GNews API error: {response.status}") + return None + except Exception as e: + logging.error(f"Error fetching news: {e}") + return None + + +# --------------------------------------------------------------------------- +# Plugin Setup +# --------------------------------------------------------------------------- + +def setup(bot): + """Initialize plugin with bot instance.""" + global GNEWS_API_KEY + GNEWS_API_KEY = os.getenv("GNEWS_API_KEY") + + if GNEWS_API_KEY: + logging.info("News plugin loaded with API key") + else: + logging.warning("News plugin loaded but GNEWS_API_KEY not set in .env file") + + +# --------------------------------------------------------------------------- +# Command Handler +# --------------------------------------------------------------------------- + +async def handle_command(room, message, bot, prefix, config): + """Handle !news commands.""" + import simplematrixbotlib as botlib + + match = botlib.MessageMatch(room, message, bot, prefix) + + if not (match.is_not_from_this_bot() and match.prefix() and match.command("news")): + return + + global GNEWS_API_KEY + if not GNEWS_API_KEY: + GNEWS_API_KEY = os.getenv("GNEWS_API_KEY") + if not GNEWS_API_KEY: + await bot.api.send_text_message( + room.room_id, + "⚠️ News plugin is not configured. Please set GNEWS_API_KEY in .env file and restart the bot." + ) + return + + args = match.args() + + # Parse command arguments + category = "top" + query = None + limit = DEFAULT_ARTICLES + + if args: + command = args[0].lower() + + if len(args) >= 2 and args[-1].isdigit(): + limit = min(int(args[-1]), MAX_ARTICLES) + args = args[:-1] + if args: + command = args[0].lower() + + if command == "search" and len(args) >= 2: + query = " ".join(args[1:]) + category = None + elif command in CATEGORIES: + category = CATEGORIES[command] + else: + query = " ".join(args) + category = None + + # Fetch news + if query: + await bot.api.send_text_message(room.room_id, f"🔍 Searching for: *{query}*...") + articles = await _fetch_news(query=query, limit=limit) + title = f"Search Results: '{query}'" + else: + articles = await _fetch_news(category=category, limit=limit) + category_name = next((k for k, v in CATEGORIES.items() if v == category), category) + title = f"Top {category_name.title()} News" + + if not articles: + await bot.api.send_text_message(room.room_id, "❌ Failed to fetch news or no results found.") + return + + # Build content as HTML list + content = "
      \n" + for i, article in enumerate(articles[:limit], 1): + content += _format_news_article(article, i) + "\n" + content += f"
    \n\nFetched {len(articles[:limit])} articles" + + # Format as collapsible and send + response = _format_collapsible(title, content, expanded=False) + await bot.api.send_markdown_message(room.room_id, response) + logging.info(f"Sent news to {room.room_id}: category={category}, query={query}") + + +# --------------------------------------------------------------------------- +# Plugin Metadata +# --------------------------------------------------------------------------- + +__version__ = "1.0.0" +__author__ = "Funguy Bot" +__description__ = "News aggregator using GNews API"