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"