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:
2026-05-04 04:36:35 -05:00
parent f6db805b47
commit c72ea72bae
6 changed files with 1259 additions and 57 deletions
+322
View File
@@ -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 &lt;query&gt;</code> - Search papers<br/>"
"• <code>!arxiv list &lt;query&gt;</code> - List without abstracts<br/>"
"• <code>!arxiv category &lt;cat&gt;</code> - Browse category<br/>"
"• <code>!arxiv recent [cat]</code> - Recent papers<br/>"
"• <code>!arxiv random</code> - Random paper<br/>"
"• <code>!arxiv &lt;id&gt;</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"
+390
View File
@@ -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"
+47
View File
@@ -501,6 +501,53 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
</p>
</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>
<p>Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.</p>
</details>
+234
View File
@@ -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"