346 lines
12 KiB
Python
346 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
DuckDuckGo search plugin (ddgs library). Results are shown inside collapsible details boxes.
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
from html import escape
|
||
|
||
import simplematrixbotlib as botlib
|
||
from ddgs import DDGS
|
||
|
||
logger = logging.getLogger("ddg")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Async search wrapper
|
||
# ---------------------------------------------------------------------------
|
||
async def _async_search(func, *args, **kwargs):
|
||
loop = asyncio.get_running_loop()
|
||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Command handler
|
||
# ---------------------------------------------------------------------------
|
||
async def handle_command(room, message, bot, prefix, config):
|
||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")):
|
||
return
|
||
|
||
args = match.args()
|
||
if not args:
|
||
await send_help(room, bot)
|
||
return
|
||
|
||
subcommand = args[0].lower()
|
||
|
||
# ---- Instant answer (default) ----
|
||
if subcommand in ("instant", "i"):
|
||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not query:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
||
return
|
||
await instant_answer(room, bot, query)
|
||
|
||
# ---- Web search ----
|
||
elif subcommand == "search":
|
||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not query:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
||
return
|
||
await web_search(room, bot, query)
|
||
|
||
# ---- Image search ----
|
||
elif subcommand == "image":
|
||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not query:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
||
return
|
||
await image_search(room, bot, query)
|
||
|
||
# ---- News search ----
|
||
elif subcommand == "news":
|
||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not query:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
||
return
|
||
await news_search(room, bot, query)
|
||
|
||
# ---- Video search ----
|
||
elif subcommand == "video":
|
||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not query:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
||
return
|
||
await video_search(room, bot, query)
|
||
|
||
# ---- Bang search ----
|
||
elif subcommand == "bang":
|
||
bang_query = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not bang_query:
|
||
await bang_help(room, bot)
|
||
return
|
||
await bang_search(room, bot, bang_query)
|
||
|
||
# ---- Definitions ----
|
||
elif subcommand == "define":
|
||
word = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not word:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
||
return
|
||
await definition(room, bot, word)
|
||
|
||
# ---- Calculator ----
|
||
elif subcommand == "calc":
|
||
expr = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not expr:
|
||
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
||
return
|
||
await calculator(room, bot, expr)
|
||
|
||
# ---- Weather ----
|
||
elif subcommand == "weather":
|
||
location = " ".join(args[1:]) if len(args) > 1 else ""
|
||
if not location:
|
||
location = "current location"
|
||
await weather(room, bot, location)
|
||
|
||
# ---- Help ----
|
||
elif subcommand == "help":
|
||
await send_help(room, bot)
|
||
|
||
# ---- Default: treat as instant answer ----
|
||
else:
|
||
query = " ".join(args)
|
||
await instant_answer(room, bot, query)
|
||
|
||
|
||
# ==============================
|
||
# Result functions (all wrapped in <details>)
|
||
# ==============================
|
||
|
||
async def instant_answer(room, bot, query):
|
||
"""Top web result wrapped in a collapsible box."""
|
||
try:
|
||
with DDGS() as ddgs:
|
||
results = await _async_search(ddgs.text, query, max_results=1)
|
||
except Exception as e:
|
||
logger.error(f"DDG instant answer error: {e}")
|
||
await bot.api.send_markdown_message(
|
||
room.room_id,
|
||
f"🦆 <strong>DuckDuckGo: {escape(query)}</strong><br><br>Error fetching results. Try again later."
|
||
)
|
||
return
|
||
|
||
content = ""
|
||
if results:
|
||
r = results[0]
|
||
title = escape(r.get("title", "Result"))
|
||
body = escape(r.get("body", ""))
|
||
content = f"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
|
||
else:
|
||
search_url = f"https://duckduckgo.com/?q={escape(query)}"
|
||
content = f"No results found.<br>🔍 <a href='{search_url}'>Search on DuckDuckGo</a>"
|
||
|
||
msg = f"""<details>
|
||
<summary>🦆 DuckDuckGo: {escape(query)}</summary>
|
||
{content}
|
||
</details>"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def web_search(room, bot, query):
|
||
try:
|
||
with DDGS() as ddgs:
|
||
results = await _async_search(ddgs.text, query, max_results=5)
|
||
except Exception as e:
|
||
logger.error(f"DDG web search error: {e}")
|
||
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||
return
|
||
|
||
if not results:
|
||
await bot.api.send_text_message(room.room_id, f"No results for '{query}'.")
|
||
return
|
||
|
||
items = ""
|
||
for r in results:
|
||
title = escape(r.get("title", "Result"))
|
||
body = escape(r.get("body", ""))
|
||
items += f"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||
|
||
msg = f"""<details>
|
||
<summary>🔍 Search: {escape(query)}</summary>
|
||
{items}
|
||
</details>"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def image_search(room, bot, query):
|
||
try:
|
||
with DDGS() as ddgs:
|
||
results = await _async_search(ddgs.images, query, max_results=3)
|
||
except Exception as e:
|
||
logger.error(f"DDG image error: {e}")
|
||
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||
return
|
||
|
||
if not results:
|
||
await bot.api.send_text_message(room.room_id, f"No images for '{query}'.")
|
||
return
|
||
|
||
items = ""
|
||
for img in results:
|
||
title = escape(img.get("title", "Image"))
|
||
items += f"• <a href='{img['image']}'>{title}</a>"
|
||
if img.get("width") and img.get("height"):
|
||
items += f" ({img['width']}×{img['height']})"
|
||
items += "<br>"
|
||
|
||
search_url = f"https://duckduckgo.com/?q={escape(query)}&iax=images&ia=images"
|
||
items += f"<br>🔍 <a href='{search_url}'>View all images</a>"
|
||
|
||
msg = f"""<details>
|
||
<summary>🖼️ Images: {escape(query)}</summary>
|
||
{items}
|
||
</details>"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def news_search(room, bot, query):
|
||
try:
|
||
with DDGS() as ddgs:
|
||
results = await _async_search(ddgs.news, query, max_results=3)
|
||
except Exception as e:
|
||
logger.error(f"DDG news error: {e}")
|
||
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||
return
|
||
|
||
if not results:
|
||
await bot.api.send_text_message(room.room_id, f"No news for '{query}'.")
|
||
return
|
||
|
||
items = ""
|
||
for n in results:
|
||
title = escape(n.get("title", "Article"))
|
||
body = escape(n.get("body", ""))
|
||
items += f"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||
|
||
msg = f"""<details>
|
||
<summary>📰 News: {escape(query)}</summary>
|
||
{items}
|
||
</details>"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def video_search(room, bot, query):
|
||
try:
|
||
with DDGS() as ddgs:
|
||
results = await _async_search(ddgs.videos, query, max_results=3)
|
||
except Exception as e:
|
||
logger.error(f"DDG video error: {e}")
|
||
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||
return
|
||
|
||
if not results:
|
||
await bot.api.send_text_message(room.room_id, f"No videos for '{query}'.")
|
||
return
|
||
|
||
items = ""
|
||
for v in results:
|
||
title = escape(v.get("title", "Video"))
|
||
items += f"• <a href='{v['content']}'>{title}</a><br>"
|
||
|
||
search_url = f"https://duckduckgo.com/?q={escape(query)}&iar=videos"
|
||
items += f"<br>🔍 <a href='{search_url}'>View all videos</a>"
|
||
|
||
msg = f"""<details>
|
||
<summary>🎬 Videos: {escape(query)}</summary>
|
||
{items}
|
||
</details>"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def bang_search(room, bot, bang_query):
|
||
search_url = f"https://duckduckgo.com/?q={escape(bang_query)}"
|
||
content = f"🔗 <a href='{search_url}'>Search with {escape(bang_query)} on DuckDuckGo</a>"
|
||
msg = f"""<details>
|
||
<summary>🎯 Bang: {escape(bang_query)}</summary>
|
||
{content}
|
||
</details>"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def definition(room, bot, word):
|
||
await instant_answer(room, bot, f"define {word}")
|
||
|
||
|
||
async def calculator(room, bot, expr):
|
||
await instant_answer(room, bot, expr)
|
||
|
||
|
||
async def weather(room, bot, location):
|
||
await instant_answer(room, bot, f"weather {location}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Help messages (no details wrapper – kept readable)
|
||
# ---------------------------------------------------------------------------
|
||
async def bang_help(room, bot):
|
||
msg = """
|
||
<strong>🎯 DuckDuckGo Bangs</strong><br>
|
||
Usage: <code>!ddg bang !bang query</code><br><br>
|
||
<strong>Popular bangs:</strong><br>
|
||
• <code>!w</code> – Wikipedia
|
||
• <code>!g</code> – Google
|
||
• <code>!yt</code> – YouTube
|
||
• <code>!aw</code> – ArchWiki
|
||
• <code>!gh</code> – GitHub
|
||
• <code>!so</code> – Stack Overflow
|
||
• <code>!reddit</code> – Reddit
|
||
<br>
|
||
<a href="https://duckduckgo.com/bangs">Full list here</a>
|
||
"""
|
||
await bot.api.send_markdown_message(room.room_id, msg)
|
||
|
||
|
||
async def send_help(room, bot):
|
||
help_msg = """
|
||
<strong>🦆 DuckDuckGo Commands</strong><br>
|
||
<code>!ddg <query></code> – Top result (collapsible)<br>
|
||
<code>!ddg search <query></code> – 5 web results<br>
|
||
<code>!ddg image <query></code> – 3 images<br>
|
||
<code>!ddg news <query></code> – 3 news articles<br>
|
||
<code>!ddg video <query></code> – 3 videos<br>
|
||
<code>!ddg bang <!bang query></code> – Bang redirect<br>
|
||
<code>!ddg define <word></code> – Definition<br>
|
||
<code>!ddg calc <expr></code> – Calculator<br>
|
||
<code>!ddg weather [city]</code> – Weather<br>
|
||
<code>!ddg help</code> – This help
|
||
"""
|
||
await bot.api.send_markdown_message(room.room_id, help_msg)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Plugin metadata
|
||
# ---------------------------------------------------------------------------
|
||
__version__ = "2.1.0"
|
||
__author__ = "Funguy Bot"
|
||
__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
|
||
__help__ = """
|
||
<details>
|
||
<summary><strong>!ddg</strong> – DuckDuckGo search (web, images, news, etc.)</summary>
|
||
<ul>
|
||
<li><code>!ddg <query></code> – Top web result snippet (collapsible)</li>
|
||
<li><code>!ddg search <query></code> – 5 web results</li>
|
||
<li><code>!ddg image <query></code> – 3 images</li>
|
||
<li><code>!ddg news <query></code> – 3 news articles</li>
|
||
<li><code>!ddg video <query></code> – 3 videos</li>
|
||
<li><code>!ddg bang <!bang query></code> – Bang redirect</li>
|
||
<li><code>!ddg define <word></code> – Definition</li>
|
||
<li><code>!ddg calc <expression></code> – Calculator</li>
|
||
<li><code>!ddg weather [location]</code> – Weather</li>
|
||
</ul>
|
||
<p>Uses <code>ddgs</code> library. No API key required.</p>
|
||
</details>
|
||
"""
|