Add academic paper search, news aggregator, and Hacker News plugins with collapsible output
- Added arxiv.py plugin for searching academic papers on arXiv.org - Added news.py plugin for fetching news from GNews API - Added hackernews.py plugin for fetching stories from Hacker News - All plugins now output results in collapsible <details> tags for better UX - Enhanced funguy.py with improved error handling, logging, and plugin management - Updated help.py and README.md with documentation for new plugins - Added !plugins command to list loaded plugins - Improved configuration loading and plugin disable/enable functionality
This commit is contained in:
@@ -548,6 +548,88 @@ Generates text using the Infermatic AI API with multiple model support:
|
|||||||
**📰 !xkcd**
|
**📰 !xkcd**
|
||||||
Fetches and displays a random XKCD comic.
|
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 <query>` - Search for papers with given query terms
|
||||||
|
- `!arxiv list <query>` - List papers without showing abstracts
|
||||||
|
- `!arxiv category <category>` - Browse recent papers in a specific category
|
||||||
|
- `!arxiv recent <category>` - Most recent papers in a category
|
||||||
|
- `!arxiv random` - Get a random paper from arXiv
|
||||||
|
- `!arxiv <id>` - 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 <query>` - 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 <id>` - Get details of a specific story
|
||||||
|
- `!hn comments <id>` - Show comments for a story
|
||||||
|
- `!hn search <query>` - Search stories (via Algolia)
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
!hn
|
||||||
|
!hn new
|
||||||
|
!hn story 1234567
|
||||||
|
!hn comments 1234567
|
||||||
|
!hn search artificial intelligence
|
||||||
|
```
|
||||||
|
|
||||||
### Administration Commands
|
### Administration Commands
|
||||||
*Admin only - requires admin_user configuration*
|
*Admin only - requires admin_user configuration*
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Funguy Bot Class
|
Funguy Bot Class - A modular Matrix bot with plugin support
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Importing necessary libraries and modules
|
# Importing necessary libraries and modules
|
||||||
@@ -17,6 +17,7 @@ import toml # Library for parsing TOML configuration files
|
|||||||
# Importing FunguyConfig class from plugins.config module
|
# Importing FunguyConfig class from plugins.config module
|
||||||
from plugins.config import FunguyConfig
|
from plugins.config import FunguyConfig
|
||||||
|
|
||||||
|
|
||||||
class FunguyBot:
|
class FunguyBot:
|
||||||
"""
|
"""
|
||||||
A bot class for managing plugins and handling commands in a Matrix chat environment.
|
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.
|
Method to configure logging settings.
|
||||||
"""
|
"""
|
||||||
# Basic configuration for logging messages to console
|
# Basic configuration for logging messages to console
|
||||||
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.INFO)
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
logging.getLogger().setLevel(logging.INFO)
|
|
||||||
|
# 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):
|
def load_plugins(self):
|
||||||
"""
|
"""
|
||||||
Method to load plugins from the specified directory.
|
Method to load plugins from the specified directory.
|
||||||
"""
|
"""
|
||||||
# Iterating through files in the plugins directory
|
# Iterating through files in the plugins directory
|
||||||
|
plugin_count = 0
|
||||||
for plugin_file in os.listdir(self.PLUGINS_DIR):
|
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
|
plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name
|
||||||
try:
|
try:
|
||||||
# Importing plugin module dynamically
|
# Importing plugin module dynamically
|
||||||
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
||||||
self.PLUGINS[plugin_name] = module # Storing loaded plugin module
|
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:
|
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):
|
def setup_plugins(self):
|
||||||
"""
|
"""
|
||||||
@@ -98,33 +115,51 @@ class FunguyBot:
|
|||||||
that plugins which register custom event listeners (e.g. on_custom_event
|
that plugins which register custom event listeners (e.g. on_custom_event
|
||||||
for RoomMemberEvent) receive a valid bot instance.
|
for RoomMemberEvent) receive a valid bot instance.
|
||||||
"""
|
"""
|
||||||
|
setup_count = 0
|
||||||
for plugin_name, plugin_module in self.PLUGINS.items():
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
||||||
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
||||||
try:
|
try:
|
||||||
plugin_module.setup(self.bot)
|
plugin_module.setup(self.bot)
|
||||||
logging.info(f"Setup called for plugin: {plugin_name}")
|
logging.info(f"Setup called for plugin: {plugin_name}")
|
||||||
|
setup_count += 1
|
||||||
except Exception as e:
|
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):
|
def reload_plugins(self):
|
||||||
"""
|
"""
|
||||||
Method to reload all plugins.
|
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
|
# Unloading modules from sys.modules
|
||||||
for plugin_name in list(sys.modules.keys()):
|
modules_to_remove = []
|
||||||
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
for module_name in sys.modules.keys():
|
||||||
del sys.modules[plugin_name] # Deleting plugin module from system modules
|
if module_name.startswith(self.PLUGINS_DIR + "."):
|
||||||
self.load_plugins() # Reloading plugins
|
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)
|
# Re-run setup for any plugin that needs it (bot already exists at this point)
|
||||||
if self.bot is not None:
|
if self.bot is not None:
|
||||||
self.setup_plugins()
|
self.setup_plugins()
|
||||||
|
|
||||||
|
logging.info("Plugins reloaded successfully")
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
"""
|
"""
|
||||||
Method to load configuration settings.
|
Method to load configuration settings.
|
||||||
"""
|
"""
|
||||||
self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration
|
self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration
|
||||||
|
logging.info("Configuration loaded")
|
||||||
|
|
||||||
def load_disabled_plugins(self):
|
def load_disabled_plugins(self):
|
||||||
"""
|
"""
|
||||||
@@ -132,11 +167,18 @@ class FunguyBot:
|
|||||||
"""
|
"""
|
||||||
# Checking if configuration file exists
|
# Checking if configuration file exists
|
||||||
if os.path.exists('funguy.conf'):
|
if os.path.exists('funguy.conf'):
|
||||||
# Loading configuration data from TOML file
|
try:
|
||||||
with open('funguy.conf', 'r') as f:
|
# Loading configuration data from TOML file
|
||||||
config_data = toml.load(f)
|
with open('funguy.conf', 'r') as f:
|
||||||
# Extracting disabled plugins from configuration data
|
config_data = toml.load(f)
|
||||||
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
|
# 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):
|
def save_disabled_plugins(self):
|
||||||
"""
|
"""
|
||||||
@@ -145,83 +187,139 @@ class FunguyBot:
|
|||||||
existing_config = {}
|
existing_config = {}
|
||||||
# Checking if configuration file exists
|
# Checking if configuration file exists
|
||||||
if os.path.exists('funguy.conf'):
|
if os.path.exists('funguy.conf'):
|
||||||
# Loading existing configuration data
|
try:
|
||||||
with open('funguy.conf', 'r') as f:
|
# Loading existing configuration data
|
||||||
existing_config = toml.load(f)
|
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
|
# 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
|
# Writing updated configuration data back to file
|
||||||
with open('funguy.conf', 'w') as f:
|
try:
|
||||||
toml.dump(existing_config, f)
|
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):
|
async def handle_commands(self, room, message):
|
||||||
"""
|
"""
|
||||||
Method to handle incoming commands and dispatch them to appropriate plugins.
|
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
|
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) # Matching message against bot's prefix
|
||||||
|
|
||||||
# Reloading plugins command
|
# Reloading plugins command
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
|
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
|
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
||||||
self.reload_plugins() # Reloading plugins
|
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:
|
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
|
# Disable plugin command
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
|
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
|
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
||||||
args = match.args() # Getting command arguments
|
args = match.args() # Getting command arguments
|
||||||
if len(args) != 2: # Checking if correct number of arguments provided
|
if len(args) != 2: # Checking if correct number of arguments provided
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>") # Sending usage message
|
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>")
|
||||||
else:
|
else:
|
||||||
plugin_name, room_id = args # Extracting plugin name and room ID
|
plugin_name, room_id = args # Extracting plugin name and room ID
|
||||||
await self.disable_plugin(room_id, plugin_name) # Disabling plugin
|
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:
|
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
|
# Enable plugin command
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
|
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
|
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
||||||
args = match.args() # Getting command arguments
|
args = match.args() # Getting command arguments
|
||||||
if len(args) != 2: # Checking if correct number of arguments provided
|
if len(args) != 2: # Checking if correct number of arguments provided
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>") # Sending usage message
|
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>")
|
||||||
else:
|
else:
|
||||||
plugin_name, room_id = args # Extracting plugin name and room ID
|
plugin_name, room_id = args # Extracting plugin name and room ID
|
||||||
await self.enable_plugin(room_id, plugin_name) # Enabling plugin
|
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:
|
else:
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.") # Sending unauthorized message
|
await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to enable plugins.")
|
||||||
|
return
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Rehash config command
|
# Rehash config command
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
|
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
|
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
||||||
self.rehash_config() # Rehashing configuration
|
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:
|
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):
|
def rehash_config(self):
|
||||||
"""
|
"""
|
||||||
Method to rehash the configuration settings.
|
Method to rehash the configuration settings.
|
||||||
"""
|
"""
|
||||||
del self.config # Deleting current configuration object
|
logging.info("Rehashing configuration...")
|
||||||
self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated 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):
|
async def disable_plugin(self, room_id, plugin_name):
|
||||||
"""
|
"""
|
||||||
Method to disable a plugin for a specific room.
|
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:
|
if room_id not in self.disabled_plugins:
|
||||||
self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist
|
self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist
|
||||||
|
|
||||||
if plugin_name not in self.disabled_plugins[room_id]:
|
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.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
|
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):
|
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]:
|
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
|
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
|
self.save_disabled_plugins() # Saving disabled plugins to configuration file
|
||||||
|
logging.info(f"Plugin '{plugin_name}' enabled for room {room_id}")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
"""
|
||||||
Method to initialize and run the bot.
|
Method to initialize and run the bot.
|
||||||
"""
|
"""
|
||||||
# Retrieving Matrix credentials from environment variables
|
try:
|
||||||
MATRIX_URL = os.getenv("MATRIX_URL")
|
# Retrieving Matrix credentials from environment variables
|
||||||
MATRIX_USER = os.getenv("MATRIX_USER")
|
MATRIX_URL = os.getenv("MATRIX_URL")
|
||||||
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
MATRIX_USER = os.getenv("MATRIX_USER")
|
||||||
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object
|
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
||||||
self.bot = botlib.Bot(creds, self.config) # Creating bot instance
|
|
||||||
|
|
||||||
# Call setup() on any plugin that defines it, now that self.bot exists.
|
# Validate credentials
|
||||||
# This is what registers custom event listeners such as the join-event
|
if not all([MATRIX_URL, MATRIX_USER, MATRIX_PASS]):
|
||||||
# listener in the welcome plugin.
|
logging.error("Missing Matrix credentials in .env file. Please check MATRIX_URL, MATRIX_USER, MATRIX_PASS")
|
||||||
self.setup_plugins()
|
return
|
||||||
|
|
||||||
# Defining listener for message events
|
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object
|
||||||
@self.bot.listener.on_message_event
|
self.bot = botlib.Bot(creds, self.config) # Creating bot instance
|
||||||
async def wrapper_handle_commands(room, message):
|
|
||||||
await self.handle_commands(room, message) # Calling handle_commands method for incoming messages
|
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__":
|
if __name__ == "__main__":
|
||||||
bot = FunguyBot() # Creating instance of FunguyBot
|
try:
|
||||||
bot.run() # Running the bot
|
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)
|
||||||
|
|||||||
@@ -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 <query> - Search for papers (shows abstract)
|
||||||
|
!arxiv list <query> - List papers without abstracts
|
||||||
|
!arxiv category <category> - Browse recent papers by category
|
||||||
|
!arxiv recent [category] - Recent papers (last 7 days)
|
||||||
|
!arxiv random - Random paper
|
||||||
|
!arxiv <id> - 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"<details{open_attr}>\n<summary>📚 {title}</summary>\n\n{content}\n\n</details>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<li>\n<strong>{index}. {paper['title']}</strong><br/>\n"
|
||||||
|
|
||||||
|
# Authors
|
||||||
|
result += f"👥 <strong>Authors:</strong> {_oxford_comma(paper['authors'][:3])}"
|
||||||
|
if len(paper['authors']) > 3:
|
||||||
|
result += f" and {len(paper['authors']) - 3} others"
|
||||||
|
result += "<br/>\n"
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
result += f"📅 <strong>Published:</strong> {paper['published']}<br/>\n"
|
||||||
|
result += f"🏷️ <strong>Categories:</strong> {', '.join(paper['categories'][:3])}"
|
||||||
|
if len(paper['categories']) > 3:
|
||||||
|
result += f" +{len(paper['categories']) - 3}"
|
||||||
|
result += "<br/>\n"
|
||||||
|
|
||||||
|
# Links
|
||||||
|
result += f"🔗 <strong>arXiv ID:</strong> {paper['id']}<br/>\n"
|
||||||
|
result += f"📄 <strong>PDF:</strong> <a href='{paper['pdf_url']}'>{paper['pdf_url']}</a><br/>\n"
|
||||||
|
|
||||||
|
# Abstract
|
||||||
|
if include_abstract and paper['summary'] != "No abstract":
|
||||||
|
abstract = paper['summary']
|
||||||
|
if len(abstract) > 500:
|
||||||
|
abstract = abstract[:497] + "..."
|
||||||
|
result += f"📝 <strong>Abstract:</strong><br/>{abstract}\n"
|
||||||
|
|
||||||
|
result += "</li>"
|
||||||
|
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 = (
|
||||||
|
"<strong>Commands:</strong><br/><br/>"
|
||||||
|
"• <code>!arxiv <query></code> - Search papers<br/>"
|
||||||
|
"• <code>!arxiv list <query></code> - List without abstracts<br/>"
|
||||||
|
"• <code>!arxiv category <cat></code> - Browse category<br/>"
|
||||||
|
"• <code>!arxiv recent [cat]</code> - Recent papers<br/>"
|
||||||
|
"• <code>!arxiv random</code> - Random paper<br/>"
|
||||||
|
"• <code>!arxiv <id></code> - Get by ID<br/><br/>"
|
||||||
|
"<strong>Categories:</strong> 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 <query>")
|
||||||
|
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"<ul>\n{_format_paper(paper, 1, True)}\n</ul>"
|
||||||
|
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 = "<ul>\n"
|
||||||
|
for i, paper in enumerate(papers, 1):
|
||||||
|
content += _format_paper(paper, i, include_abstract) + "\n"
|
||||||
|
content += f"</ul>\n\n<em>Found {len(papers)} papers</em>"
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -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 <id> - Get details of a specific story
|
||||||
|
!hn comments <id> - Show comments for a story
|
||||||
|
!hn search <query> - 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"<details{open_attr}>\n<summary>📰 {title}</summary>\n\n{content}\n\n</details>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<li><strong>{index}. {title}</strong><br/>"
|
||||||
|
|
||||||
|
if url and url != "#":
|
||||||
|
story_html += f"🔗 <a href='{url}'>{url}</a><br/>"
|
||||||
|
else:
|
||||||
|
hn_url = f"https://news.ycombinator.com/item?id={story_id}"
|
||||||
|
story_html += f"💬 <a href='{hn_url}'>Discussion on Hacker News</a><br/>"
|
||||||
|
|
||||||
|
story_html += f"👤 {by} | 🕐 {time_str}"
|
||||||
|
|
||||||
|
if show_points:
|
||||||
|
story_html += f" | ⭐ {score} points | 💬 {descendants} comments"
|
||||||
|
|
||||||
|
story_html += "</li>"
|
||||||
|
|
||||||
|
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}<li><strong>{by}:</strong> {text}</li>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<li><strong>{index}. {title}</strong><br/>"
|
||||||
|
|
||||||
|
if url and url != "#":
|
||||||
|
result_html += f"🔗 <a href='{url}'>{url}</a><br/>"
|
||||||
|
else:
|
||||||
|
hn_url = f"https://news.ycombinator.com/item?id={story_id}"
|
||||||
|
result_html += f"💬 <a href='{hn_url}'>Discussion on Hacker News</a><br/>"
|
||||||
|
|
||||||
|
result_html += f"👤 {author} | ⭐ {points} points | 💬 {comment_count} comments"
|
||||||
|
result_html += "</li>"
|
||||||
|
|
||||||
|
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"<ul>\n{_format_story(story, 1, show_points=True)}\n</ul>"
|
||||||
|
|
||||||
|
if len(args) >= 3 and args[2].lower() == "comments":
|
||||||
|
content += "\n<strong>Top Comments:</strong>\n<ul>\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 += "</ul>"
|
||||||
|
|
||||||
|
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"<strong>{story.get('title', 'Unknown')}</strong>\n<ul>\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 += "<li>No comments found.</li>"
|
||||||
|
else:
|
||||||
|
content += f"\n</ul>\n<em>Showing {count} comments</em>"
|
||||||
|
else:
|
||||||
|
content += "<li>This story has no comments yet.</li>\n</ul>"
|
||||||
|
|
||||||
|
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 = "<ul>\n"
|
||||||
|
for i, hit in enumerate(results[:limit], 1):
|
||||||
|
content += _format_search_result(hit, i) + "\n"
|
||||||
|
content += f"</ul>\n\n<em>Found {len(results[:limit])} results</em>"
|
||||||
|
|
||||||
|
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"<ul>\n{_format_story(story, 1, show_points=True)}\n</ul>"
|
||||||
|
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 = "<ul>\n"
|
||||||
|
for i, story in enumerate(stories, 1):
|
||||||
|
content += _format_story(story, i, show_points=True) + "\n"
|
||||||
|
content += f"</ul>\n\n<em>Fetched {len(stories)} stories</em>"
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -501,6 +501,53 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details><summary>📚 <strong>!arxiv [query]</strong></summary>
|
||||||
|
<p>Search academic papers on arXiv.org. Categories include AI, ML, Security, Physics, Math, and more. No API key required.</p>
|
||||||
|
<p><strong>Commands:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>!arxiv <query></code> - Search for papers (shows abstracts)</li>
|
||||||
|
<li><code>!arxiv list <query></code> - List papers without abstracts</li>
|
||||||
|
<li><code>!arxiv category <category></code> - Browse recent papers by category</li>
|
||||||
|
<li><code>!arxiv recent <category></code> - Most recent papers in category</li>
|
||||||
|
<li><code>!arxiv random</code> - Get a random paper</li>
|
||||||
|
<li><code>!arxiv <id></code> - Get paper by arXiv ID (e.g., 2101.00101)</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Categories:</strong> ai, ml, security, crypto, cv, nlp, math, physics, quantum, bio, software</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>📰 <strong>!news [category/query]</strong></summary>
|
||||||
|
<p>Fetch latest headlines from various news categories using GNews API. Requires GNEWS_API_KEY environment variable.</p>
|
||||||
|
<p><strong>Commands:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>!news</code> - Get top headlines (default)</li>
|
||||||
|
<li><code>!news top</code> - Top headlines</li>
|
||||||
|
<li><code>!news world</code> - World news</li>
|
||||||
|
<li><code>!news tech</code> - Technology news</li>
|
||||||
|
<li><code>!news business</code> - Business news</li>
|
||||||
|
<li><code>!news science</code> - Science news</li>
|
||||||
|
<li><code>!news health</code> - Health news</li>
|
||||||
|
<li><code>!news crypto</code> - Cryptocurrency news</li>
|
||||||
|
<li><code>!news search <query></code> - Search for specific news</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>🔥 <strong>!hn [command]</strong></summary>
|
||||||
|
<p>Fetch top stories from Hacker News using Firebase API. No API key required.</p>
|
||||||
|
<p><strong>Commands:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>!hn</code> - Show top 5 stories (default)</li>
|
||||||
|
<li><code>!hn top</code> - Top stories</li>
|
||||||
|
<li><code>!hn new</code> - Newest stories</li>
|
||||||
|
<li><code>!hn best</code> - Best stories</li>
|
||||||
|
<li><code>!hn ask</code> - Ask HN threads</li>
|
||||||
|
<li><code>!hn show</code> - Show HN posts</li>
|
||||||
|
<li><code>!hn job</code> - Job postings</li>
|
||||||
|
<li><code>!hn story <id></code> - Get details of a specific story</li>
|
||||||
|
<li><code>!hn comments <id></code> - Show comments for a story</li>
|
||||||
|
<li><code>!hn search <query></code> - Search stories (via Algolia)</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details><summary>⏱️ <strong>!cron [add|remove] [room_id] [cron_entry] [command]</strong></summary>
|
<details><summary>⏱️ <strong>!cron [add|remove] [room_id] [cron_entry] [command]</strong></summary>
|
||||||
<p>Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.</p>
|
<p>Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.</p>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+234
@@ -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 <query> - 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"<details{open_attr}>\n<summary>📰 {title}</summary>\n\n{content}\n\n</details>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<li>\n"
|
||||||
|
f"<strong>{index}. {title}</strong><br/>\n"
|
||||||
|
f"📰 <strong>Source:</strong> {source}{date_str}<br/>\n"
|
||||||
|
f"📝 <strong>Summary:</strong> {description}<br/>\n"
|
||||||
|
f"🔗 <a href='{url}'>{url}</a>\n"
|
||||||
|
f"</li>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = "<ul>\n"
|
||||||
|
for i, article in enumerate(articles[:limit], 1):
|
||||||
|
content += _format_news_article(article, i) + "\n"
|
||||||
|
content += f"</ul>\n\n<em>Fetched {len(articles[:limit])} articles</em>"
|
||||||
|
|
||||||
|
# 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"
|
||||||
Reference in New Issue
Block a user