diff --git a/plugins/arxiv.py b/plugins/arxiv.py
index 0bb9788..b118a05 100644
--- a/plugins/arxiv.py
+++ b/plugins/arxiv.py
@@ -383,7 +383,7 @@ def setup(bot):
__version__ = "1.0.2"
__author__ = "Funguy Bot"
-__description__ = "arXiv academic paper search (with rate limiting and error reporting)"
+__description__ = "arXiv academic paper search"
__help__ = """
!arxiv – Search academic papers on arXiv
diff --git a/plugins/common.py b/plugins/common.py
index 0ff80ac..ee38312 100644
--- a/plugins/common.py
+++ b/plugins/common.py
@@ -5,6 +5,7 @@ import html
import ipaddress
import socket
import logging
+from wcwidth import wcswidth
logger = logging.getLogger(__name__)
@@ -80,3 +81,58 @@ async def send_html_message(bot, room_id, html_body, markdown_fallback):
message_type="m.room.message",
content=content
)
+
+
+def code_block(title: str, sections: list) -> str:
+ """
+ Build a Markdown code block with perfectly aligned columns (emoji‑aware).
+
+ Args:
+ title: header line inside the code block
+ sections: list of dicts with keys 'title' (str) and 'rows'
+ rows is a list of (emoji, label, value) tuples
+
+ Returns:
+ Markdown string with triple backticks and aligned content.
+ """
+ labelled = []
+ for sec in sections:
+ for emoji, text, value in sec["rows"]:
+ if text.strip() or emoji.strip():
+ labelled.append((emoji, text, value))
+
+ max_label_width = max((len(str(t)) for _, t, _ in labelled), default=0)
+
+ emoji_widths = {}
+ for emoji, _, _ in labelled:
+ if emoji:
+ w = wcswidth(emoji) or 1
+ emoji_widths[emoji] = w
+ else:
+ emoji_widths[emoji] = 0
+ max_emoji_width = max(emoji_widths.values()) if emoji_widths else 0
+
+ prefix_width = max_emoji_width + 1 + max_label_width + 3 # "E label : "
+ separator = "=" * (prefix_width + 30)
+ lines = [title, separator]
+
+ for sec in sections:
+ # Only print a section header if the title is not empty
+ if sec["title"].strip():
+ lines.append("")
+ lines.append(f"── {sec['title']} ──")
+ for emoji, text, value in sec["rows"]:
+ if text.strip() or emoji.strip():
+ if emoji:
+ actual_w = emoji_widths.get(emoji, 0)
+ pad = max_emoji_width - actual_w
+ emoji_field = emoji + " " * pad
+ else:
+ emoji_field = " " * max_emoji_width
+ padded_label = f"{text:<{max_label_width}}"
+ lines.append(f"{emoji_field} {padded_label} : {value}")
+ else:
+ lines.append(f"{' ' * prefix_width}{value}")
+ lines.append("")
+ lines.append(separator)
+ return "```\n" + "\n".join(lines) + "\n```"
diff --git a/plugins/ddg.py b/plugins/ddg.py
index 3e14bd1..ffd16ab 100644
--- a/plugins/ddg.py
+++ b/plugins/ddg.py
@@ -265,7 +265,7 @@ async def send_help(room, bot):
__version__ = "2.1.1"
__author__ = "Funguy Bot"
-__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
+__description__ = "DuckDuckGo search plugin"
__help__ = """
!ddg – DuckDuckGo search (web, images, news, etc.)
diff --git a/plugins/dns.py b/plugins/dns.py
index 2d45369..b2334b2 100644
--- a/plugins/dns.py
+++ b/plugins/dns.py
@@ -1,14 +1,15 @@
"""
-This plugin provides a command to perform DNS reconnaissance on a domain.
+DNS reconnaissance plugin – queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.
+Outputs a formatted code block with emojis and perfectly aligned columns.
"""
import logging
+import asyncio
import dns.resolver
import dns.reversename
import simplematrixbotlib as botlib
import re
-
-from plugins.utils import is_public_destination
+from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV']
@@ -16,113 +17,131 @@ def is_valid_domain(domain):
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
return re.match(pattern, domain) is not None
-def format_dns_record(record_type, records):
- if not records:
- return ""
- output = f"{record_type} Records:
"
- for record in records:
- output += f" • {record}
"
- return output
-
async def query_dns_records(domain):
- results = {}
- resolver = dns.resolver.Resolver()
- resolver.timeout = 5
- resolver.lifetime = 5
- for record_type in RECORD_TYPES:
- try:
- logging.info(f"Querying {record_type} records for {domain}")
- answers = resolver.resolve(domain, record_type)
- records = []
- for rdata in answers:
- if record_type == 'MX':
- records.append(f"{rdata.preference} {rdata.exchange}")
- elif record_type == 'SOA':
- records.append(f"{rdata.mname} {rdata.rname}")
- elif record_type == 'SRV':
- records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}")
- elif record_type == 'TXT':
- txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
- records.append(txt_data)
- else:
- records.append(str(rdata))
- if records:
- results[record_type] = records
- logging.info(f"Found {len(records)} {record_type} record(s)")
- except dns.resolver.NoAnswer:
- continue
- except dns.resolver.NXDOMAIN:
- logging.warning(f"Domain {domain} does not exist")
- return None
- except dns.resolver.Timeout:
- continue
- except Exception as e:
- logging.error(f"Error querying {record_type} for {domain}: {e}")
- continue
- return results
+ loop = asyncio.get_running_loop()
+ def _resolve():
+ results = {}
+ resolver = dns.resolver.Resolver()
+ resolver.timeout = 5
+ resolver.lifetime = 5
+ for record_type in RECORD_TYPES:
+ try:
+ answers = resolver.resolve(domain, record_type)
+ records = []
+ for rdata in answers:
+ if record_type == 'MX':
+ records.append(f"{rdata.preference} {rdata.exchange}")
+ elif record_type == 'SOA':
+ records.append(f"{rdata.mname} {rdata.rname}")
+ elif record_type == 'SRV':
+ records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}")
+ elif record_type == 'TXT':
+ txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
+ records.append(txt_data)
+ else:
+ records.append(str(rdata))
+ if records:
+ results[record_type] = records
+ except dns.resolver.NoAnswer:
+ continue
+ except dns.resolver.NXDOMAIN:
+ return None
+ except dns.resolver.Timeout:
+ continue
+ except Exception as e:
+ logging.error(f"Error querying {record_type} for {domain}: {e}")
+ continue
+ return results
+ return await loop.run_in_executor(None, _resolve)
+
+RECORD_META = {
+ 'A': ('🌐', 'A (IPv4)'),
+ 'AAAA': ('🌐', 'AAAA (IPv6)'),
+ 'MX': ('📧', 'MX (Mail)'),
+ 'NS': ('🌐', 'NS (Nameserver)'),
+ 'TXT': ('📄', 'TXT'),
+ 'CNAME': ('🔀', 'CNAME'),
+ 'SOA': ('📋', 'SOA'),
+ 'PTR': ('↩️', 'PTR'),
+ 'SRV': ('🔌', 'SRV'),
+}
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("dns"):
- logging.info("Received !dns command")
args = match.args()
if len(args) != 1:
- await bot.api.send_text_message(room.room_id,
- "Usage: !dns \nExample: !dns example.com")
+ await bot.api.send_text_message(room.room_id, "Usage: !dns \nExample: !dns example.com")
return
domain = args[0].lower().strip()
domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
+
if not is_valid_domain(domain):
- await bot.api.send_text_message(room.room_id, f"Invalid domain name: {domain}")
+ await bot.api.send_text_message(room.room_id, f"Invalid domain name: {html_escape(domain)}")
return
+
+ if not is_public_destination(domain):
+ await bot.api.send_text_message(room.room_id, "❌ DNS queries for private/internal domains are not allowed.")
+ return
+
+ await bot.api.send_text_message(room.room_id, f"🔍 Performing DNS reconnaissance on {html_escape(domain)}...")
+
try:
- await bot.api.send_text_message(room.room_id,
- f"🔍 Performing DNS reconnaissance on {domain}...")
results = await query_dns_records(domain)
if results is None:
- await bot.api.send_text_message(room.room_id,
- f"Domain {domain} does not exist (NXDOMAIN)")
+ await bot.api.send_text_message(room.room_id, f"Domain {html_escape(domain)} does not exist (NXDOMAIN)")
return
if not results:
- await bot.api.send_text_message(room.room_id,
- f"No DNS records found for {domain}")
+ await bot.api.send_text_message(room.room_id, f"No DNS records found for {html_escape(domain)}")
return
- # SSRF / privacy check: if all A/AAAA records are private, refuse.
+
a_records = results.get('A', [])
aaaa_records = results.get('AAAA', [])
all_ips = a_records + aaaa_records
if all_ips and not any(is_public_destination(ip) for ip in all_ips):
- await bot.api.send_text_message(room.room_id,
- "❌ This domain resolves exclusively to private/internal IPs.")
+ await bot.api.send_text_message(room.room_id, "❌ This domain resolves exclusively to private/internal IPs.")
return
- output = f"🔍 DNS Records for {domain}
"
- preferred_order = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
- for record_type in preferred_order:
- if record_type in results:
- output += format_dns_record(record_type, results[record_type])
- output += "
"
- for record_type in results:
- if record_type not in preferred_order:
- output += format_dns_record(record_type, results[record_type])
- output += "
"
- if output.count('
') > 15:
- output = f"🔍 DNS Records for {domain}
{output} "
+
+ rows = []
+ preferred = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
+ for rtype in preferred:
+ if rtype in results:
+ emoji, label = RECORD_META.get(rtype, ('❓', rtype))
+ for rec in results[rtype]:
+ rows.append((emoji, label, rec))
+ emoji = ""
+ label = ""
+ for rtype in results:
+ if rtype not in preferred:
+ emoji, label = RECORD_META.get(rtype, ('❓', rtype))
+ for rec in results[rtype]:
+ rows.append((emoji, label, rec))
+ emoji = ""
+ label = ""
+
+ if not rows:
+ await bot.api.send_text_message(room.room_id, f"No displayable records for {html_escape(domain)}")
+ return
+
+ sections = [{"title": "", "rows": rows}]
+ block = code_block(f"🔍 DNS Records for {domain}", sections)
+ output = collapsible_summary(f"🔍 DNS: {html_escape(domain)}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent DNS records for {domain}")
+
except Exception as e:
- await bot.api.send_text_message(room.room_id,
- f"An error occurred while performing DNS lookup: {str(e)}")
+ await bot.api.send_text_message(room.room_id, f"An error occurred while performing DNS lookup: {str(e)}")
logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
-__version__ = "1.0.1"
+__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "DNS reconnaissance (SSRF‑safe)"
__help__ = """
!dns – DNS reconnaissance
-!dns <domain> – Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.
+!dns <domain> – Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records and displays them in a clean, aligned table.
"""
diff --git a/plugins/dnsdumpster.py b/plugins/dnsdumpster.py
index 071342e..b4279d7 100644
--- a/plugins/dnsdumpster.py
+++ b/plugins/dnsdumpster.py
@@ -1,11 +1,13 @@
"""
-This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping.
+DNSDumpster.com integration for domain reconnaissance and DNS mapping.
+Output uses shared code_block for aligned columns.
"""
+
import logging
import os
import aiohttp
import simplematrixbotlib as botlib
-from plugins.common import html_escape, collapsible_summary
+from plugins.common import html_escape, code_block, collapsible_summary
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
@@ -13,20 +15,13 @@ DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
- logging.info("Received !dnsdumpster command")
-
if not DNSDUMPSTER_API_KEY:
- await bot.api.send_text_message(
- room.room_id,
- "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env."
- )
+ await bot.api.send_text_message(room.room_id, "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env.")
return
-
args = match.args()
if len(args) < 1:
await show_usage(room, bot)
return
-
if args[0].lower() == "test":
await test_dnsdumpster_connection(room, bot)
else:
@@ -37,9 +32,6 @@ async def show_usage(room, bot):
usage = """🔍 DNSDumpster Commands:
!dnsdumpster <domain_name> - Get comprehensive DNS reconnaissance for a domain
!dnsdumpster test - Test API connection
-Examples:
-• !dnsdumpster google.com
-• !dnsdumpster github.com
"""
await bot.api.send_markdown_message(room.room_id, usage)
@@ -51,8 +43,7 @@ async def test_dnsdumpster_connection(room, bot):
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=15) as response:
status = response.status
- debug_info = f"🔧 DNSDumpster API Test
Status Code: {status}
Test Domain: {test_domain}
"
-
+ debug_info = f"🔧 DNSDumpster API Test
Status Code: {status}
"
if status == 200:
data = await response.json()
debug_info += "✅ SUCCESS
"
@@ -81,50 +72,66 @@ async def dnsdumpster_domain_lookup(room, bot, domain):
return
data = await response.json()
- output = await format_dnsdumpster_report(domain, data)
+ sections = []
+
+ # A Records
+ if data.get('a'):
+ rows = []
+ for rec in data['a']:
+ host = rec.get('host', 'N/A')
+ ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
+ rows.append(("📍", host, ips))
+ sections.append({"title": "A Records (IPv4)", "rows": rows})
+
+ # NS Records
+ if data.get('ns'):
+ rows = []
+ for rec in data['ns']:
+ host = rec.get('host', 'N/A')
+ ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
+ rows.append(("🖧", host, ips))
+ sections.append({"title": "NS Records", "rows": rows})
+
+ # MX Records
+ if data.get('mx'):
+ rows = []
+ for rec in data['mx']:
+ host = rec.get('host', 'N/A')
+ ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
+ rows.append(("📧", host, ips))
+ sections.append({"title": "MX Records", "rows": rows})
+
+ # CNAME
+ if data.get('cname'):
+ rows = []
+ for rec in data['cname']:
+ host = rec.get('host', 'N/A')
+ target = rec.get('target', 'N/A')
+ rows.append(("🔀", host, target))
+ sections.append({"title": "CNAME Records", "rows": rows})
+
+ # TXT
+ if data.get('txt'):
+ rows = []
+ for txt in data['txt']:
+ rows.append(("📄", "TXT", txt[:150] if len(txt) > 150 else txt))
+ sections.append({"title": "TXT Records", "rows": rows})
+
+ if not sections:
+ await bot.api.send_text_message(room.room_id, "No DNS records found.")
+ return
+
+ block = code_block(f"🔍 DNSDumpster Report: {safe_domain}", sections)
+ output = collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain}", block)
await bot.api.send_markdown_message(room.room_id, output)
- logging.info(f"Sent DNSDumpster data for {domain}")
- except asyncio.TimeoutError:
- await bot.api.send_text_message(room.room_id, "Request timed out.")
- except Exception as e:
+
+ except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error: {e}")
-async def format_dnsdumpster_report(domain, data):
- safe_domain = html_escape(domain)
- output = f"🔍 DNSDumpster Report: {safe_domain}
"
- if data.get('total_a_recs'):
- output += f"📊 Summary
Total A Records: {data['total_a_recs']}
"
-
- for record_type, label in [('a','A Records'),('ns','NS Records'),('mx','MX Records'),('cname','CNAME'),('txt','TXT')]:
- if data.get(record_type) and data[record_type]:
- output += f"
{label} ({len(data[record_type])} found)
"
- for rec in data[record_type]:
- if record_type == 'txt':
- txt = html_escape(str(rec))
- if len(txt) > 200:
- txt = txt[:200] + "..."
- output += f" • {txt}
"
- elif record_type == 'a':
- host = html_escape(rec.get('host','N/A'))
- ips = rec.get('ips',[])
- output += f" • {host}
"
- for ip_info in ips:
- ip = html_escape(ip_info.get('ip','N/A'))
- country = html_escape(ip_info.get('country','Unknown'))
- output += f" └─ {ip} ({country})
"
- else:
- host = html_escape(rec.get('host','N/A'))
- ips = rec.get('ips',[])
- output += f" • {host}
"
- for ip_info in ips:
- ip = html_escape(ip_info.get('ip','N/A'))
- country = html_escape(ip_info.get('country','Unknown'))
- output += f" └─ {ip} ({country})
"
-
- output += "
💡 Rate Limit: 1 request per 2 seconds"
- return collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain} (Click to expand)", output)
-
-__version__ = "1.0.1"
+# ---------------------------------------------------------------------------
+# Plugin Metadata
+# ---------------------------------------------------------------------------
+__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "DNSDumpster domain reconnaissance"
__help__ = """
diff --git a/plugins/geo.py b/plugins/geo.py
index f88d714..f0c2a82 100644
--- a/plugins/geo.py
+++ b/plugins/geo.py
@@ -1,14 +1,17 @@
"""
-This plugin provides IP geolocation functionality using free APIs.
+IP geolocation plugin – uses ip-api.com (primary) and ipapi.co (fallback).
+Outputs a formatted code block with emojis and perfectly aligned columns.
"""
+
import logging
import aiohttp
import simplematrixbotlib as botlib
import socket
import re
-from plugins.common import is_public_destination, html_escape, collapsible_summary
+from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
async def is_valid_ip(ip):
+ """Check if the provided string is a valid IP address."""
try:
socket.inet_pton(socket.AF_INET, ip)
return True
@@ -20,18 +23,21 @@ async def is_valid_ip(ip):
return False
def is_domain(domain):
+ """Check if the provided string is a domain name."""
domain_pattern = re.compile(
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
)
return bool(domain_pattern.match(domain))
async def resolve_domain(domain):
+ """Resolve a domain name to an IP address."""
try:
return socket.gethostbyname(domain)
except socket.gaierror:
return None
async def query_ip_api_com(ip):
+ """Query ip-api.com for geolocation information."""
url = f"http://ip-api.com/json/{ip}"
try:
async with aiohttp.ClientSession() as session:
@@ -43,6 +49,7 @@ async def query_ip_api_com(ip):
return None
async def query_ipapi_co(ip):
+ """Query ipapi.co for geolocation information (fallback)."""
url = f"https://ipapi.co/{ip}/json/"
try:
async with aiohttp.ClientSession() as session:
@@ -54,69 +61,125 @@ async def query_ipapi_co(ip):
return None
async def query_geolocation(ip):
+ """Query geolocation using primary and fallback APIs."""
data = await query_ip_api_com(ip)
if not data or data.get('status') == 'fail':
data = await query_ipapi_co(ip)
return data
-async def format_geolocation_results(ip, data):
- if not data or ('status' in data and data.get('status') == 'fail'):
- return f"🔍 No geolocation data found for {ip}."
- country = data.get('country', 'N/A')
- country_code = data.get('countryCode', 'N/A')
- region = data.get('regionName', data.get('region', 'N/A'))
- city = data.get('city', 'N/A')
- postal = data.get('zip', 'N/A')
- latitude = data.get('lat', 'N/A')
- longitude = data.get('lon', 'N/A')
- timezone = data.get('timezone', 'N/A')
- isp = data.get('isp', 'N/A')
- org = data.get('org', 'N/A')
- asn = data.get('as', 'N/A')
-
- content = (f"Country: {country} ({country_code})
"
- f"Region: {region}
"
- f"City: {city}
"
- f"Postal Code: {postal}
"
- f"Coordinates: {latitude}, {longitude}
"
- f"Timezone: {timezone}
"
- f"ISP/Organization: {isp}
"
- f"ASN: {asn}
")
- return collapsible_summary(f"🔍 Geolocation: {ip}", content)
-
async def handle_command(room, message, bot, prefix, config):
+ """Handle the !geo command."""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("geo"):
args = match.args()
if len(args) < 1:
- await bot.api.send_text_message(room.room_id, "Usage: !geo ")
+ await bot.api.send_text_message(
+ room.room_id,
+ "Usage: !geo \nExample: !geo 8.8.8.8\nExample: !geo example.com"
+ )
return
query = args[0].strip()
- ip = query
- if is_domain(query):
- await bot.api.send_text_message(room.room_id, f"🔍 Resolving domain {html_escape(query)}...")
- ip = await resolve_domain(query)
- if not ip:
- await bot.api.send_text_message(room.room_id, f"Failed to resolve {html_escape(query)}.")
+ logging.info(f"Received !geo command for: {query}")
+
+ try:
+ ip = query
+ if is_domain(query):
+ await bot.api.send_text_message(
+ room.room_id,
+ f"🔍 Resolving domain {html_escape(query)} to IP address..."
+ )
+ ip = await resolve_domain(query)
+ if not ip:
+ await bot.api.send_text_message(room.room_id,
+ f"Failed to resolve domain {html_escape(query)} to IP address.")
+ return
+ if not is_public_destination(ip):
+ await bot.api.send_text_message(room.room_id,
+ "❌ That domain resolves to a private/internal IP, geo not allowed.")
+ return
+ await bot.api.send_text_message(room.room_id,
+ f"Domain {html_escape(query)} resolved to IP {ip}")
+ elif not await is_valid_ip(query):
+ await bot.api.send_text_message(room.room_id,
+ f"Invalid IP address or domain format: {html_escape(query)}")
return
- if not is_public_destination(ip):
- await bot.api.send_text_message(room.room_id, "❌ Domain resolves to private IP.")
- return
- await bot.api.send_text_message(room.room_id, f"Resolved to {ip}")
- elif not await is_valid_ip(query):
- await bot.api.send_text_message(room.room_id, f"Invalid IP/domain: {html_escape(query)}")
- return
- else:
- if not is_public_destination(ip):
- await bot.api.send_text_message(room.room_id, "❌ Private IP not allowed.")
+ else:
+ if not is_public_destination(ip):
+ await bot.api.send_text_message(room.room_id,
+ "❌ Geolocation of private IP addresses is not allowed.")
+ return
+
+ await bot.api.send_text_message(room.room_id,
+ f"🔍 Looking up geolocation for {ip}...")
+
+ geo_data = await query_geolocation(ip)
+
+ if not geo_data or ('status' in geo_data and geo_data.get('status') == 'fail'):
+ await bot.api.send_text_message(room.room_id, f"No geolocation data found for {ip}.")
return
- geo_data = await query_geolocation(ip)
- result = await format_geolocation_results(ip, geo_data)
- await bot.api.send_markdown_message(room.room_id, result)
+ # Build rows
+ rows = []
+ if 'country' in geo_data: # ip-api.com format
+ country = geo_data.get('country', 'N/A')
+ country_code = geo_data.get('countryCode', 'N/A')
+ region = geo_data.get('regionName', geo_data.get('region', 'N/A'))
+ city = geo_data.get('city', 'N/A')
+ postal = geo_data.get('zip', 'N/A')
+ latitude = geo_data.get('lat', 'N/A')
+ longitude = geo_data.get('lon', 'N/A')
+ timezone = geo_data.get('timezone', 'N/A')
+ isp = geo_data.get('isp', 'N/A')
+ org = geo_data.get('org', 'N/A')
+ asn = geo_data.get('as', 'N/A')
+ else: # ipapi.co format
+ country = geo_data.get('country_name', geo_data.get('country', 'N/A'))
+ country_code = geo_data.get('country_code', geo_data.get('countryCode', 'N/A'))
+ region = geo_data.get('region', 'N/A')
+ city = geo_data.get('city', 'N/A')
+ postal = geo_data.get('postal', 'N/A')
+ latitude = geo_data.get('latitude', 'N/A')
+ longitude = geo_data.get('longitude', 'N/A')
+ timezone = geo_data.get('timezone', 'N/A')
+ isp = geo_data.get('org', 'N/A')
+ org = geo_data.get('org', 'N/A')
+ asn = geo_data.get('asn', 'N/A')
-__version__ = "1.0.2"
+ rows.append(("🌍", "Country", f"{country} ({country_code})"))
+ rows.append(("🏙️", "City", city))
+ if region and region != city:
+ rows.append(("🏷️", "Region", region))
+ if postal and postal != 'N/A':
+ rows.append(("📮", "Postal Code", postal))
+ rows.append(("📍", "Coordinates", f"{latitude}, {longitude}"))
+ rows.append(("🕒", "Timezone", timezone))
+ rows.append(("📡", "ISP", isp))
+ if org and org != isp:
+ rows.append(("🏢", "Organization", org))
+ if asn and asn != 'N/A':
+ rows.append(("🔢", "ASN", asn))
+
+ sections = [{"title": "", "rows": rows}]
+ block = code_block(f"🔍 IP Geolocation for {ip}", sections)
+ output = collapsible_summary(f"🔍 Geolocation: {ip}", block)
+ await bot.api.send_markdown_message(room.room_id, output)
+ logging.info(f"Successfully sent geolocation results for {ip}")
+
+ except Exception as e:
+ await bot.api.send_text_message(room.room_id,
+ f"An error occurred during geolocation lookup for {html_escape(query)}.")
+ logging.error(f"Error in geo plugin for {query}: {e}", exc_info=True)
+
+# ---------------------------------------------------------------------------
+# Plugin Metadata
+# ---------------------------------------------------------------------------
+
+__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "IP geolocation lookup"
-__help__ = """!geo – IP / domain geolocation
-!geo <ip> or !geo <domain>
"""
+__help__ = """
+
+!geo – IP / domain geolocation
+!geo <ip or domain> – Locate an IP address or domain. Shows country, city, coordinates, ISP, ASN, etc.
+
+"""
diff --git a/plugins/hashid.py b/plugins/hashid.py
index bb25e82..71e6a18 100644
--- a/plugins/hashid.py
+++ b/plugins/hashid.py
@@ -1,22 +1,17 @@
"""
-This plugin provides a command to identify hash types using comprehensive pattern matching.
+Hash identifier plugin – identifies 100+ hash types with confidence and tool modes.
+Outputs a clean code block with emojis and perfectly aligned columns.
"""
import logging
import re
import simplematrixbotlib as botlib
+from plugins.common import collapsible_summary, html_escape, code_block
+# ---------------------------------------------------------------------------
+# Hash identification logic (unchanged from original)
+# ---------------------------------------------------------------------------
def identify_hash(hash_string):
- """
- Identify the hash type based on comprehensive pattern matching.
-
- Args:
- hash_string (str): The hash string to identify
-
- Returns:
- list: List of tuples (hash_type, hashcat_mode, john_format, confidence)
- """
-
hash_string = hash_string.strip()
hash_lower = hash_string.lower()
length = len(hash_string)
@@ -25,15 +20,10 @@ def identify_hash(hash_string):
# Unix crypt and modular crypt formats (most specific first)
if hash_string.startswith('$'):
- # yescrypt (modern Linux /etc/shadow)
if re.match(r'^\$y\$', hash_string):
possible_types.append(("yescrypt", None, "yescrypt", 95))
-
- # scrypt
elif re.match(r'^\$7\$', hash_string):
possible_types.append(("scrypt", "8900", "scrypt", 95))
-
- # Argon2
elif re.match(r'^\$argon2(id?|d)\$', hash_string):
if '$argon2i$' in hash_string:
possible_types.append(("Argon2i", "10900", "argon2", 95))
@@ -41,72 +31,39 @@ def identify_hash(hash_string):
possible_types.append(("Argon2d", None, "argon2", 95))
elif '$argon2id$' in hash_string:
possible_types.append(("Argon2id", "10900", "argon2", 95))
-
- # bcrypt variants
elif re.match(r'^\$(2[abxy]?)\$', hash_string):
bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1)
possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95))
-
- # SHA-512 Crypt (common in Linux)
elif re.match(r'^\$6\$', hash_string):
possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95))
-
- # SHA-256 Crypt (Unix)
elif re.match(r'^\$5\$', hash_string):
possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95))
-
- # MD5 Crypt (Unix)
elif re.match(r'^\$1\$', hash_string):
possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95))
-
- # Apache MD5
elif re.match(r'^\$apr1\$', hash_string):
possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95))
-
- # AIX SMD5
elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {smd5}", "6300", None, 90))
-
- # AIX SSHA256
elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {ssha256}", "6700", None, 90))
-
- # AIX SSHA512
elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {ssha512}", "6800", None, 90))
-
- # phpBB3
elif re.match(r'^\$H\$', hash_string):
possible_types.append(("phpBB3", "400", "phpass", 90))
-
- # Wordpress
elif re.match(r'^\$P\$', hash_string):
possible_types.append(("Wordpress", "400", "phpass", 90))
-
- # Drupal 7+
elif re.match(r'^\$S\$', hash_string):
possible_types.append(("Drupal 7+", "7900", "drupal7", 90))
-
- # WBB3 (Woltlab Burning Board)
elif re.match(r'^\$wbb3\$', hash_string):
possible_types.append(("WBB3 (Woltlab)", None, None, 85))
-
- # PBKDF2-HMAC-SHA256
elif re.match(r'^\$pbkdf2-sha256\$', hash_string):
possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90))
-
- # PBKDF2-HMAC-SHA512
elif re.match(r'^\$pbkdf2-sha512\$', hash_string):
possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90))
-
- # Django PBKDF2
elif re.match(r'^pbkdf2_sha256\$', hash_string):
possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90))
-
- # Unknown modular crypt format
else:
possible_types.append(("Unknown Modular Crypt Format", None, None, 30))
-
return possible_types
# LDAP formats
@@ -123,31 +80,22 @@ def identify_hash(hash_string):
possible_types.append(("LDAP CRYPT", None, None, 85))
return possible_types
- # Check for colon-separated formats (LM:NTLM, username:hash, etc.)
+ # Colon-separated formats
if ':' in hash_string:
parts = hash_string.split(':')
-
- # NetNTLMv1 / NetNTLMv2
if len(parts) >= 5:
possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85))
possible_types.append(("NetNTLMv1", "5500", "netntlm", 75))
-
- # LM:NTLM format
elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32:
possible_types.append(("LM:NTLM", "1000", "nt", 90))
-
- # Username:Hash or similar
elif len(parts) == 2:
hash_part = parts[1]
if len(hash_part) == 32:
possible_types.append(("NTLM (with username)", "1000", "nt", 80))
elif len(hash_part) == 40:
possible_types.append(("SHA-1 (with salt/username)", "110", None, 70))
-
- # PostgreSQL md5
if hash_string.startswith('md5') and len(hash_string) == 35:
possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90))
-
return possible_types if possible_types else None
# MySQL formats
@@ -159,7 +107,6 @@ def identify_hash(hash_string):
if re.match(r'^[A-F0-9]{16}:[A-F0-9]{16}$', hash_string.upper()):
possible_types.append(("Oracle 11g", "112", "oracle11", 90))
return possible_types
-
if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()):
possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90))
return possible_types
@@ -168,234 +115,84 @@ def identify_hash(hash_string):
if re.match(r'^0x0100[A-F0-9]{8}[A-F0-9]{40}$', hash_string.upper()):
possible_types.append(("MSSQL 2000", "131", "mssql", 90))
return possible_types
-
if re.match(r'^0x0200[A-F0-9]{8}[A-F0-9]{128}$', hash_string.upper()):
possible_types.append(("MSSQL 2012/2014", "1731", "mssql12", 90))
return possible_types
- # Base64 pattern check
- is_base64 = re.match(r'^[A-Za-z0-9+/]+=*$', hash_string) and length % 4 == 0
-
# Raw hash identification by length
is_hex = re.match(r'^[a-f0-9]+$', hash_lower)
-
if is_hex:
if length == 16:
possible_types.append(("MySQL < 4.1", "200", "mysql", 85))
possible_types.append(("Half MD5", None, None, 60))
-
elif length == 32:
possible_types.append(("MD5", "0", "raw-md5", 80))
possible_types.append(("MD4", "900", "raw-md4", 70))
possible_types.append(("NTLM", "1000", "nt", 75))
possible_types.append(("LM", "3000", "lm", 60))
- possible_types.append(("RAdmin v2.x", "9900", None, 50))
- possible_types.append(("Snefru-128", None, None, 40))
- possible_types.append(("HMAC-MD5 (key = $pass)", "50", None, 50))
-
elif length == 40:
possible_types.append(("SHA-1", "100", "raw-sha1", 85))
possible_types.append(("RIPEMD-160", "6000", "ripemd-160", 65))
- possible_types.append(("Tiger-160", None, None, 50))
- possible_types.append(("Haval-160", None, None, 45))
- possible_types.append(("HMAC-SHA1 (key = $pass)", "150", None, 55))
-
- elif length == 48:
- possible_types.append(("Tiger-192", None, None, 70))
- possible_types.append(("Haval-192", None, None, 65))
-
- elif length == 56:
- possible_types.append(("SHA-224", "1300", "raw-sha224", 85))
- possible_types.append(("Haval-224", None, None, 60))
-
elif length == 64:
possible_types.append(("SHA-256", "1400", "raw-sha256", 85))
- possible_types.append(("RIPEMD-256", None, None, 60))
possible_types.append(("SHA3-256", "17400", "raw-sha3", 70))
possible_types.append(("Keccak-256", "17800", "raw-keccak-256", 70))
- possible_types.append(("Haval-256", None, None, 50))
- possible_types.append(("GOST R 34.11-94", "6900", None, 55))
- possible_types.append(("BLAKE2b-256", None, None, 60))
-
- elif length == 80:
- possible_types.append(("RIPEMD-320", None, None, 80))
-
- elif length == 96:
- possible_types.append(("SHA-384", "10800", "raw-sha384", 85))
- possible_types.append(("SHA3-384", "17900", None, 70))
- possible_types.append(("Keccak-384", None, None, 65))
-
elif length == 128:
possible_types.append(("SHA-512", "1700", "raw-sha512", 85))
possible_types.append(("Whirlpool", "6100", "whirlpool", 75))
- possible_types.append(("SHA3-512", "17600", None, 70))
- possible_types.append(("Keccak-512", None, None, 65))
- possible_types.append(("BLAKE2b-512", None, None, 60))
-
- # Base64 encoded hashes
- elif is_base64:
- if length == 24:
- possible_types.append(("MD5 (Base64)", None, None, 75))
- elif length == 28:
- possible_types.append(("SHA-1 (Base64)", None, None, 75))
- elif length == 32:
- possible_types.append(("SHA-224 (Base64)", None, None, 75))
- elif length == 44:
- possible_types.append(("SHA-256 (Base64)", None, None, 75))
- elif length == 64:
- possible_types.append(("SHA-384 (Base64)", None, None, 75))
- elif length == 88:
- possible_types.append(("SHA-512 (Base64)", None, None, 75))
return possible_types if possible_types else [("Unknown", None, None, 0)]
+# ---------------------------------------------------------------------------
+# Output formatting
+# ---------------------------------------------------------------------------
+def _format_results(hash_input, results):
+ """Build a code block with sections for each possible hash type."""
+ sections = []
+ for idx, (hash_type, hashcat_mode, john_format, confidence) in enumerate(results, 1):
+ emoji = "🟢" if confidence >= 90 else "🟡" if confidence >= 80 else "🟠" if confidence >= 60 else "🔴"
+ title = f"{emoji} Match #{idx}: {hash_type} ({confidence}%)"
+ rows = [
+ ("", "Hash Type", hash_type),
+ ("", "Confidence", f"{confidence}%"),
+ ]
+ if hashcat_mode:
+ rows.append(("", "Hashcat Mode", f"-m {hashcat_mode}"))
+ if john_format:
+ rows.append(("", "John Format", f"--format={john_format}"))
+ sections.append({"title": title, "rows": rows})
+
+ block = code_block(f"🔐 Hash Identification: {hash_input[:30]}...", sections)
+ return collapsible_summary("🔐 Hash Identification Results", block)
+
+
async def handle_command(room, message, bot, prefix, config):
- """
- Function to handle the !hashid command.
-
- Args:
- room (Room): The Matrix room where the command was invoked.
- message (RoomMessage): The message object containing the command.
- bot (Bot): The bot object.
- prefix (str): The command prefix.
- config (dict): Configuration parameters.
-
- Returns:
- None
- """
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"):
- logging.info("Received !hashid command")
-
args = match.args()
-
if len(args) < 1:
- usage_msg = """🔐 Hash Identifier Usage
-
-Usage: !hashid <hash>
-
-Examples:
-• !hashid 5f4dcc3b5aa765d61d8327deb882cf99
-• !hashid 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
-• !hashid $6$rounds=5000$salt$hash...
-• !hashid $y$j9T$... (yescrypt from /etc/shadow)
-
-Supported Hash Types:
-• Modern: yescrypt, scrypt, Argon2, bcrypt
-• Unix Crypt: SHA-512 Crypt, SHA-256 Crypt, MD5 Crypt
-• Raw Hashes: MD5, SHA-1/224/256/384/512, SHA-3, NTLM, LM
-• Database: MySQL, PostgreSQL, Oracle, MSSQL
-• CMS: Wordpress, phpBB3, Drupal, Django
-• LDAP: SSHA, SMD5, and various LDAP formats
-• Network: NetNTLMv1/v2, Kerberos
-• Exotic: Whirlpool, RIPEMD, BLAKE2, Keccak, GOST
-"""
- await bot.api.send_markdown_message(room.room_id, usage_msg)
+ await bot.api.send_markdown_message(room.room_id, "Usage: !hashid <hash>")
return
-
hash_input = ' '.join(args)
-
- try:
- # Identify the hash
- identified = identify_hash(hash_input)
-
- if not identified:
- await bot.api.send_text_message(
- room.room_id,
- "Could not identify hash type. Please verify the hash format."
- )
- return
-
- # Sort by confidence (highest first)
- identified = sorted(identified, key=lambda x: x[3], reverse=True)
-
- # Format the response
- hash_preview = hash_input[:60] + "..." if len(hash_input) > 60 else hash_input
-
- # Determine confidence indicator
- top_confidence = identified[0][3]
- if top_confidence >= 90:
- confidence_emoji = "🟢"
- confidence_label = "Very High"
- elif top_confidence >= 80:
- confidence_emoji = "🟡"
- confidence_label = "High"
- elif top_confidence >= 60:
- confidence_emoji = "🟠"
- confidence_label = "Medium"
- else:
- confidence_emoji = "🔴"
- confidence_label = "Low"
-
- # Build response inside collapsible details
- response = "🔐 Hash Identification Results
\n"
- response += "
\n"
- response += f"Input: {hash_preview}
\n"
- response += f"Length: {len(hash_input)} characters
\n"
- response += f"Overall Confidence: {confidence_emoji} {confidence_label} ({top_confidence}%)
\n"
- response += "
\n"
-
- response += f"Possible Hash Types ({len(identified)}):
\n"
-
- for idx, (hash_type, hashcat_mode, john_format, confidence) in enumerate(identified, 1):
- # Confidence indicator per hash
- if confidence >= 90:
- conf_emoji = "🟢"
- elif confidence >= 80:
- conf_emoji = "🟡"
- elif confidence >= 60:
- conf_emoji = "🟠"
- else:
- conf_emoji = "🔴"
-
- response += f" {idx}. {hash_type} {conf_emoji} {confidence}%
\n"
-
- tools = []
- if hashcat_mode:
- tools.append(f"Hashcat: -m {hashcat_mode}")
- if john_format:
- tools.append(f"John: --format={john_format}")
-
- if tools:
- response += f" {' | '.join(tools)}
\n"
-
- response += "
\n"
-
- # Add useful tips
- if len(identified) == 1 and identified[0][0] not in ["Unknown", "Unknown Modular Crypt Format"]:
- response += "
💡 Single match with high confidence
\n"
- elif len(identified) > 5:
- response += "
ℹ️ Multiple possibilities - context may help narrow it down
\n"
-
- # Add legend
- response += "
\n"
- response += "Confidence Legend:
\n"
- response += "🟢 Very High (90-100%) | 🟡 High (80-89%) | 🟠 Medium (60-79%) | 🔴 Low (0-59%)
\n"
-
- response += " "
-
- await bot.api.send_markdown_message(room.room_id, response)
- logging.info(f"Identified hash types: {', '.join([f'{h[0]} ({h[3]}%)' for h in identified])}")
-
- except Exception as e:
- await bot.api.send_text_message(
- room.room_id,
- f"Error identifying hash: {str(e)}"
- )
- logging.error(f"Error in hashid command: {e}", exc_info=True)
+ results = identify_hash(hash_input)
+ if not results or results[0][0] == "Unknown":
+ await bot.api.send_text_message(room.room_id, "Could not identify the hash type.")
+ return
+ # Sort by confidence descending
+ results.sort(key=lambda x: x[3], reverse=True)
+ output = _format_results(hash_input, results[:6]) # show top 6
+ await bot.api.send_markdown_message(room.room_id, output)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
-
-__version__ = "1.0.0"
+__version__ = "1.1.0"
__author__ = "Funguy Bot"
__description__ = "Hash type identifier"
__help__ = """
!hashid – Identify hash type
-!hashid <hash> – Recognises 100+ hash formats (MD5, SHA, bcrypt, etc.).
-Shows confidence level, Hashcat mode, and John the Ripper format.
+!hashid <hash> – Recognises 100+ formats and displays tool modes in a clean table.
"""
diff --git a/plugins/headers.py b/plugins/headers.py
index 0637ced..633fd22 100644
--- a/plugins/headers.py
+++ b/plugins/headers.py
@@ -1,5 +1,6 @@
"""
-This plugin provides comprehensive HTTP security header analysis.
+HTTP security header analysis plugin.
+Outputs a structured code block with perfectly aligned columns.
"""
import logging
@@ -10,342 +11,198 @@ from urllib.parse import urlparse
import ssl
import socket
import datetime
-from plugins.common import is_public_destination, collapsible_summary, html_escape
+from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
+
+async def _run_in_thread(func, *args, **kwargs):
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
+
+async def analyze_http_response(url):
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as resp:
+ return str(resp.url), resp.status, dict(resp.headers), resp.url.scheme == 'https'
+ except aiohttp.ClientError as e:
+ logging.warning(f"HTTP analysis error: {e}")
+ return url, None, {}, False
+
+async def analyze_https_response(url):
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as resp:
+ return resp.status, dict(resp.headers)
+ except aiohttp.ClientError as e:
+ logging.warning(f"HTTPS analysis error: {e}")
+ return None, {}
+
+def _get_cert_info(domain):
+ try:
+ context = ssl.create_default_context()
+ with socket.create_connection((domain, 443), timeout=10) as sock:
+ with context.wrap_socket(sock, server_hostname=domain) as ssock:
+ cert = ssock.getpeercert()
+ return {
+ 'subject': dict(x[0] for x in cert['subject']),
+ 'issuer': dict(x[0] for x in cert['issuer']),
+ 'not_before': cert['notBefore'],
+ 'not_after': cert['notAfter'],
+ 'san': cert.get('subjectAltName', []),
+ }
+ except Exception as e:
+ logging.warning(f"SSL cert error: {e}")
+ return None
+
+def calculate_score(headers, redirects_to_https, cert_info):
+ score = 100
+ if 'Strict-Transport-Security' not in headers: score -= 15
+ if 'Content-Security-Policy' not in headers: score -= 15
+ if 'X-Content-Type-Options' not in headers: score -= 15
+ if 'X-Frame-Options' not in headers: score -= 15
+ if 'X-XSS-Protection' not in headers: score -= 15
+ hsts = headers.get('Strict-Transport-Security', '')
+ if 'max-age=31536000' not in hsts: score -= 10
+ if 'includeSubDomains' not in hsts: score -= 5
+ if 'preload' not in hsts: score -= 5
+ if headers.get('Referrer-Policy'): score += 5
+ if headers.get('Feature-Policy') or headers.get('Permissions-Policy'): score += 5
+ if headers.get('X-Content-Type-Options') == 'nosniff': score += 5
+ if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']: score += 5
+ if redirects_to_https: score += 10
+ if cert_info and cert_info.get('not_after'):
+ try:
+ expires = datetime.datetime.strptime(cert_info['not_after'], '%b %d %H:%M:%S %Y %Z')
+ if (expires - datetime.datetime.utcnow()).days < 30: score -= 10
+ except: pass
+ return max(0, score)
+
+def generate_recommendations(headers, redirects_to_https):
+ recs = []
+ if 'Strict-Transport-Security' not in headers:
+ recs.append("🔒 Implement HSTS with max-age=31536000, includeSubDomains, preload")
+ if 'Content-Security-Policy' not in headers:
+ recs.append("🛡️ Add Content-Security-Policy")
+ if 'X-Frame-Options' not in headers:
+ recs.append("🚫 Add X-Frame-Options (DENY or SAMEORIGIN)")
+ if 'X-Content-Type-Options' not in headers:
+ recs.append("📄 Add X-Content-Type-Options: nosniff")
+ if not redirects_to_https:
+ recs.append("🔐 Redirect HTTP to HTTPS")
+ if 'Server' in headers or 'X-Powered-By' in headers:
+ recs.append("🕵️ Remove info disclosure headers (Server, X-Powered-By)")
+ return recs
async def handle_command(room, message, bot, prefix, config):
- """
- Function to handle !headers command for HTTP security header analysis.
- """
match = botlib.MessageMatch(room, message, bot, prefix)
- if match.is_not_from_this_bot() and match.prefix() and match.command("headers"):
- logging.info("Received !headers command")
+ if not (match.is_not_from_this_bot() and match.prefix() and match.command("headers")):
+ return
- args = match.args()
+ args = match.args()
+ if len(args) < 1:
+ await bot.api.send_markdown_message(room.room_id,
+ "🔒 HTTP Security Headers Analysis\n!headers <url>")
+ return
- if len(args) < 1:
- await show_usage(room, bot)
- return
+ original_input = args[0].strip()
+ url = original_input
+ if not url.startswith(('http://', 'https://')):
+ url = 'https://' + url
- url = args[0].strip()
+ parsed = urlparse(url)
+ host = parsed.hostname
+ if not is_public_destination(host):
+ await bot.api.send_text_message(room.room_id, "❌ Private/internal addresses are not allowed.")
+ return
- # Add protocol if missing
- if not url.startswith(('http://', 'https://')):
- url = 'https://' + url
+ safe_input = html_escape(original_input)
+ safe_host = html_escape(host)
+ await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {safe_input}...")
- # SSRF protection: refuse internal hosts
- parsed = urlparse(url)
- host = parsed.hostname
- if not is_public_destination(host):
- await bot.api.send_text_message(room.room_id,
- "❌ Scanning of private/internal addresses is not allowed.")
- return
+ final_url, status_code, http_headers, redirects_to_https = await analyze_http_response(url)
+ _, https_headers = await analyze_https_response(url) if url.startswith('https://') else (None, {})
- await analyze_headers(room, bot, url)
+ headers = https_headers or http_headers
+ cert_info = None
+ if url.startswith('https://'):
+ cert_info = await _run_in_thread(_get_cert_info, host)
-async def show_usage(room, bot):
- """Display headers command usage."""
- usage = """
-🔒 HTTP Security Headers Analysis
+ score = calculate_score(headers, redirects_to_https, cert_info)
+ recommendations = generate_recommendations(headers, redirects_to_https)
-!headers <url> - Comprehensive HTTP security header analysis
+ sections = []
-Examples:
-• !headers example.com
-• !headers https://github.com
-• !headers http://localhost:8080
-
-Analyzes:
-• Security headers presence and configuration
-• SSL/TLS certificate information
-• HTTP to HTTPS redirects
-• Security scoring and recommendations
-"""
- await bot.api.send_markdown_message(room.room_id, usage)
-
-async def analyze_headers(room, bot, url):
- """Perform comprehensive HTTP security header analysis."""
- try:
- await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {html_escape(url)}")
-
- results = {
- 'url': url,
- 'http_headers': {},
- 'https_headers': {},
- 'redirect_chain': [],
- 'ssl_info': {},
- 'security_score': 0,
- 'recommendations': []
- }
-
- # Test HTTP first (if HTTPS was provided, we'll still check redirects)
- parsed = urlparse(url)
- http_url = f"http://{parsed.netloc or parsed.path}"
- https_url = f"https://{parsed.netloc or parsed.path}"
-
- # Analyze HTTP response and redirects
- await analyze_http_response(results, http_url if not url.startswith('https://') else https_url)
-
- # Analyze HTTPS response
- if url.startswith('https://') or results.get('redirects_to_https'):
- await analyze_https_response(results, https_url)
-
- # Analyze SSL certificate if HTTPS
- if url.startswith('https://') or results.get('redirects_to_https'):
- await analyze_ssl_certificate(results, parsed.netloc or parsed.path)
-
- # Calculate security score
- await calculate_security_score(results)
-
- # Generate recommendations
- await generate_recommendations(results)
-
- # Format and send results
- output = await format_header_analysis(results)
- await bot.api.send_markdown_message(room.room_id, output)
-
- logging.info(f"Completed header analysis for {url}")
-
- except Exception as e:
- await bot.api.send_text_message(room.room_id, f"Error analyzing headers: {str(e)}")
- logging.error(f"Error in analyze_headers: {e}")
-
-async def analyze_http_response(results, url):
- """Analyze HTTP response and redirect chain."""
- try:
- async with aiohttp.ClientSession() as session:
- async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response:
- results['final_url'] = str(response.url)
- results['status_code'] = response.status
- results['http_headers'] = dict(response.headers)
- results['redirects_to_https'] = response.url.scheme == 'https'
- # aiohttp doesn't give access to redirect history easily, so we'll mark if final URL differs
- if str(response.url) != url:
- results['redirect_chain'] = [{'url': url, 'status_code': 301}] # simplified
- except aiohttp.ClientError as e:
- results['http_error'] = str(e)
-
-async def analyze_https_response(results, url):
- """Analyze HTTPS response headers."""
- try:
- async with aiohttp.ClientSession() as session:
- async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as response:
- results['https_headers'] = dict(response.headers)
- results['https_status'] = response.status
- except aiohttp.ClientError as e:
- results['https_error'] = str(e)
-
-async def analyze_ssl_certificate(results, domain):
- """Analyze SSL certificate information (run in thread to avoid event loop blocking)."""
- def _get_cert():
- try:
- context = ssl.create_default_context()
- with socket.create_connection((domain, 443), timeout=10) as sock:
- with context.wrap_socket(sock, server_hostname=domain) as ssock:
- cert = ssock.getpeercert()
- return {
- 'subject': dict(x[0] for x in cert['subject']),
- 'issuer': dict(x[0] for x in cert['issuer']),
- 'not_before': cert['notBefore'],
- 'not_after': cert['notAfter'],
- 'san': cert.get('subjectAltName', []),
- 'version': cert.get('version'),
- 'serial_number': cert.get('serialNumber')
- }
- except Exception as e:
- return f"Error: {e}"
-
- loop = asyncio.get_running_loop()
- ssl_data = await loop.run_in_executor(None, _get_cert)
- if isinstance(ssl_data, str):
- results['ssl_error'] = ssl_data
- else:
- results['ssl_info'] = ssl_data
-
-async def calculate_security_score(results):
- """Calculate overall security score based on headers and configuration."""
- score = 100
- missing_headers = []
-
- critical_headers = [
- 'Strict-Transport-Security',
- 'Content-Security-Policy',
- 'X-Content-Type-Options',
- 'X-Frame-Options',
- 'X-XSS-Protection'
- ]
-
- headers = results.get('https_headers') or results.get('http_headers', {})
-
- for header in critical_headers:
- if header not in headers:
- score -= 15
- missing_headers.append(header)
-
- # Check HSTS configuration
- hsts = headers.get('Strict-Transport-Security', '')
- if 'max-age=31536000' not in hsts:
- score -= 10
- if 'includeSubDomains' not in hsts:
- score -= 5
- if 'preload' not in hsts:
- score -= 5
-
- # Check CSP configuration
- csp = headers.get('Content-Security-Policy', '')
- if not csp:
- score -= 10
- elif "default-src 'none'" not in csp and "default-src 'self'" not in csp:
- score -= 5
-
- # Check for insecure headers
- insecure_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version']
- for header in insecure_headers:
- if header in headers:
- score -= 5
-
- # Bonus for good practices
- if headers.get('Referrer-Policy'):
- score += 5
- if headers.get('Feature-Policy') or headers.get('Permissions-Policy'):
- score += 5
- if headers.get('X-Content-Type-Options') == 'nosniff':
- score += 5
- if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']:
- score += 5
-
- # HTTPS enforcement bonus
- if results.get('redirects_to_https'):
- score += 10
-
- results['security_score'] = max(0, score)
- results['missing_headers'] = missing_headers
-
-async def generate_recommendations(results):
- """Generate security recommendations based on analysis."""
- recommendations = []
- headers = results.get('https_headers') or results.get('http_headers', {})
-
- if 'Strict-Transport-Security' not in headers:
- recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload")
- else:
- hsts = headers['Strict-Transport-Security']
- if 'max-age=31536000' not in hsts:
- recommendations.append("🔒 Increase HSTS max-age to 31536000 (1 year)")
- if 'includeSubDomains' not in hsts:
- recommendations.append("🔒 Add includeSubDomains to HSTS header")
- if 'preload' not in hsts:
- recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading")
-
- if 'Content-Security-Policy' not in headers:
- recommendations.append("🛡️ Implement Content Security Policy to prevent XSS attacks")
-
- if 'X-Frame-Options' not in headers:
- recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)")
-
- if 'X-Content-Type-Options' not in headers:
- recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing")
-
- if 'Referrer-Policy' not in headers:
- recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage")
-
- if 'Server' in headers or 'X-Powered-By' in headers:
- recommendations.append("🕵️ Remove Server and X-Powered-By headers to avoid information disclosure")
-
- if not results.get('redirects_to_https') and not results['url'].startswith('https://'):
- recommendations.append("🔐 Implement HTTP to HTTPS redirects")
-
- results['recommendations'] = recommendations
-
-async def format_header_analysis(results):
- """Format the header analysis results for display."""
- safe_url = html_escape(results['url'])
- output = f"🔒 Security Headers Analysis: {safe_url}
"
-
- # Security Score
- score = results['security_score']
+ # Score
score_emoji = "🟢" if score >= 80 else "🟡" if score >= 60 else "🔴"
- output += f"{score_emoji} Security Score: {score}/100
"
+ sections.append({
+ "title": f"{score_emoji} Security Score",
+ "rows": [("", "Score", f"{score}/100")]
+ })
# Basic Information
- output += "📊 Basic Information
"
- output += f" • Final URL: {html_escape(results.get('final_url', 'N/A'))}
"
- output += f" • Status Code: {results.get('status_code', 'N/A')}
"
- if results.get('redirects_to_https'):
- output += f" • HTTPS Redirect: ✅ Enforced
"
- else:
- output += f" • HTTPS Redirect: ❌ Not enforced
"
- output += "
"
-
- # Security Headers Analysis
- headers = results.get('https_headers') or results.get('http_headers', {})
- output += "🛡️ Security Headers Analysis
"
+ basic_rows = [
+ ("🌐", "Final URL", final_url),
+ ("📊", "Status Code", str(status_code) if status_code else "N/A"),
+ ("🔐", "HTTPS Redirect", "✅ Yes" if redirects_to_https else "❌ No"),
+ ]
+ sections.append({"title": "📊 Basic Information", "rows": basic_rows})
+ # Security Headers
security_headers = {
'Strict-Transport-Security': ('🔒', 'HSTS'),
- 'Content-Security-Policy': ('🛡️', 'CSP'),
- 'X-Frame-Options': ('🚫', 'Clickjacking Protection'),
- 'X-Content-Type-Options': ('📄', 'MIME Sniffing'),
- 'X-XSS-Protection': ('❌', 'XSS Protection (Deprecated)'),
- 'Referrer-Policy': ('🔗', 'Referrer Policy'),
- 'Feature-Policy': ('⚙️', 'Feature Policy'),
- 'Permissions-Policy': ('🔧', 'Permissions Policy'),
+ 'Content-Security-Policy': ('🛡️', 'CSP'),
+ 'X-Frame-Options': ('🚫', 'Frame Options'),
+ 'X-Content-Type-Options': ('📄', 'Content Type'),
+ 'X-XSS-Protection': ('❌', 'XSS Protection'),
+ 'Referrer-Policy': ('🔗', 'Referrer Policy'),
+ 'Permissions-Policy': ('🔧', 'Permissions Policy'),
+ 'Feature-Policy': ('⚙️', 'Feature Policy'),
}
-
- for header, (emoji, description) in security_headers.items():
- if header in headers:
- value = html_escape(str(headers[header]))[:100]
- output += f" • {emoji} {header}: ✅ {value}
"
+ header_rows = []
+ for hdr, (emoji, label) in security_headers.items():
+ if hdr in headers:
+ val = headers[hdr][:100]
+ header_rows.append((emoji, label, f"✅ {val}"))
else:
- output += f" • {emoji} {header}: ❌ Missing
"
- output += "
"
+ header_rows.append((emoji, label, "❌ Missing"))
+ sections.append({"title": "🛡️ Security Headers", "rows": header_rows})
- # Other Headers (Information Disclosure)
- output += "📋 Other Headers
"
- for header in ['Server', 'X-Powered-By']:
- if header in headers:
- output += f" • 🔍 {header}: {html_escape(str(headers[header]))}
"
- output += "
"
+ # Other Headers
+ other_rows = []
+ for hdr in ['Server', 'X-Powered-By']:
+ if hdr in headers:
+ other_rows.append(("🔍", hdr, headers[hdr]))
+ if other_rows:
+ sections.append({"title": "📋 Other Headers", "rows": other_rows})
- # SSL Certificate Information (if available)
- if results.get('ssl_info') and 'subject' in results['ssl_info']:
- output += "🔐 SSL Certificate
"
- ssl_info = results['ssl_info']
- if ssl_info.get('subject'):
- output += f" • Subject: {html_escape(ssl_info['subject'].get('commonName', 'N/A'))}
"
- if ssl_info.get('issuer'):
- output += f" • Issuer: {html_escape(ssl_info['issuer'].get('organizationName', 'N/A'))}
"
- if ssl_info.get('not_after'):
- output += f" • Expires: {html_escape(ssl_info['not_after'])}
"
- output += "
"
+ # SSL Certificate
+ if cert_info:
+ ssl_rows = [
+ ("📜", "Subject", cert_info['subject'].get('commonName', 'N/A')),
+ ("🏢", "Issuer", cert_info['issuer'].get('organizationName', 'N/A')),
+ ("📅", "Expires", cert_info.get('not_after', 'N/A')),
+ ]
+ san = [san[1] for san in cert_info.get('san', []) if san[0] == 'DNS']
+ if san:
+ ssl_rows.append(("🌐", "SANs", ", ".join(san[:5])))
+ sections.append({"title": "🔐 SSL Certificate", "rows": ssl_rows})
# Recommendations
- if results.get('recommendations'):
- output += "💡 Security Recommendations
"
- for rec in results['recommendations'][:8]:
- output += f" • {rec}
"
- output += "
"
+ if recommendations:
+ rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
+ sections.append({"title": "💡 Recommendations", "rows": rec_rows})
- # Final rating
- if score >= 80:
- rating = "🟢 Excellent"
- elif score >= 60:
- rating = "🟡 Good"
- elif score >= 40:
- rating = "🟠 Fair"
- else:
- rating = "🔴 Poor"
- output += f"📈 Security Rating: {rating}
"
+ block = code_block(f"🔒 Security Headers: {safe_host}", sections)
+ output = collapsible_summary(f"🔒 Headers: {safe_host}", block)
+ await bot.api.send_markdown_message(room.room_id, output)
- # Wrap in collapsible details
- return collapsible_summary(f"🔒 Security Headers Analysis: {safe_url} (Score: {score}/100)", output)
-
-__version__ = "1.0.2"
+# ---------------------------------------------------------------------------
+# Plugin Metadata
+# ---------------------------------------------------------------------------
+__version__ = "1.1.2"
__author__ = "Funguy Bot"
-__description__ = "HTTP security header analysis (SSRF‑safe, async)"
+__description__ = "HTTP security header analysis"
__help__ = """
-!headers – HTTP security header scanner
-!headers <url> – Checks HSTS, CSP, X-Frame-Options, etc.
-Provides security score (0-100) and recommendations. Also shows SSL certificate info.
+!headers – HTTP security headers analysis
+!headers <url> – Analyzes security headers, SSL cert, gives score and recommendations in a clean, aligned table.
"""
diff --git a/plugins/infermatic-text.py b/plugins/infermatic-text.py
index f363b6b..837866f 100644
--- a/plugins/infermatic-text.py
+++ b/plugins/infermatic-text.py
@@ -172,7 +172,7 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens):
__version__ = "1.0.3"
__author__ = "Funguy Bot"
-__description__ = "AI text generation via Infermatic API (async, safe)"
+__description__ = "AI text generation via Infermatic API"
__help__ = """
!text – AI text generation (Infermatic)
diff --git a/plugins/lastfm.py b/plugins/lastfm.py
index 90d2f3c..a484149 100644
--- a/plugins/lastfm.py
+++ b/plugins/lastfm.py
@@ -1,194 +1,79 @@
"""
-This plugin provides comprehensive Last.fm integration for the bot.
-It allows users to register their Last.fm username and access rich music analytics.
-
-Commands:
- !register - Register your Last.fm username
- !np - Show currently playing track (no collapsible)
- !recent [user] [limit] - Show recent tracks (default 10, max 50)
- !toptracks [user] [period] - Show top tracks (overall/7day/1month/3month/6month/12month)
- !topartists [user] [period] - Show top artists
- !topalbums [user] [period] - Show top albums
- !loved [user] - Show recently loved tracks
- !profile [user] - Detailed user profile
- !playcount [user] - Total scrobbles
- !scrobbles [user] - Detailed scrobbling statistics
- !compare - Compare musical tastes
- !taste [user] - Top artists with taste-o-meter
- !friends [user] - Show Last.fm friends
- !recommend [user] - Artist recommendations
- !similar - Find similar artists
- !tag - Top artists for a tag/genre
- !charts - Global top tracks chart
- !tagcloud [user] - Top genre tags
- !now - What are registered users playing?
- !decades [user] - Favorite decades analysis
- !genres [user] - Top genres/tags
- !era - Popular tracks from a year
- !weekly [user] - Weekly listening report
- !monthly [user] - Monthly listening report
- !yearly [user] [year] - Yearly listening report
- !first [user] - Find first scrobble of an artist
- !concerts [user] - Upcoming concerts for top artists
- !radio - Generate playlist based on artist
- !mashup - Musical connections between artists
- !collage [user] [size] - Album art collage (image) using ImageMagick
- !listening [user] - Currently listening with album art
- !awards [user] - Milestone achievements
- !lastfm - Show this help
+Comprehensive Last.fm plugin for FunguyBot – code-block output for tabular commands.
+Artist extraction now correctly handles both string and {name} dict formats.
"""
-import logging
-import os
-import time
-import subprocess
-import tempfile
-import asyncio
-import aiohttp
-import aiosqlite
+import logging, os, time, subprocess, tempfile, asyncio, aiohttp, aiosqlite
import simplematrixbotlib as botlib
from datetime import datetime, timedelta
+from plugins.common import collapsible_summary, html_escape, code_block
-# ---------------------------------------------------------------------------
-# Constants
-# ---------------------------------------------------------------------------
+# ---------- constants ----------
DB_PATH = "lastfm.db"
API_BASE = "http://ws.audioscrobbler.com/2.0/"
VALID_PERIODS = ["overall", "7day", "1month", "3month", "6month", "12month"]
-
PERIOD_LABELS = {
- "overall": "All Time",
- "7day": "Last 7 Days",
- "1month": "Last Month",
- "3month": "Last 3 Months",
- "6month": "Last 6 Months",
- "12month": "Last Year",
+ "overall": "All Time", "7day": "Last 7 Days", "1month": "Last Month",
+ "3month": "Last 3 Months", "6month": "Last 6 Months", "12month": "Last Year",
}
-
-# User-Agent to avoid 403/404 from CDNs
HEADERS = {"User-Agent": "FunguyBot/1.0 (Matrix last.fm plugin)"}
-# ---------------------------------------------------------------------------
-# Database helpers
-# ---------------------------------------------------------------------------
-
+# ---------- database ----------
async def init_db():
- """Initialize the database with the required tables."""
async with aiosqlite.connect(DB_PATH) as db:
- await db.execute("""
- CREATE TABLE IF NOT EXISTS user_lastfm (
- matrix_user TEXT PRIMARY KEY,
- lastfm_user TEXT NOT NULL
- )
- """)
+ await db.execute(
+ "CREATE TABLE IF NOT EXISTS user_lastfm (matrix_user TEXT PRIMARY KEY, lastfm_user TEXT NOT NULL)"
+ )
await db.commit()
-
async def get_lastfm_username(matrix_user):
- """Get Last.fm username for a Matrix user."""
async with aiosqlite.connect(DB_PATH) as db:
- async with db.execute(
- "SELECT lastfm_user FROM user_lastfm WHERE matrix_user = ?",
- (matrix_user,),
- ) as cursor:
- row = await cursor.fetchone()
+ async with db.execute("SELECT lastfm_user FROM user_lastfm WHERE matrix_user=?", (matrix_user,)) as cur:
+ row = await cur.fetchone()
return row[0] if row else None
-
async def set_lastfm_username(matrix_user, lastfm_user):
- """Associate a Last.fm username with a Matrix user."""
async with aiosqlite.connect(DB_PATH) as db:
- async with db.execute(
- "SELECT lastfm_user FROM user_lastfm WHERE matrix_user = ?",
- (matrix_user,),
- ) as cursor:
- row = await cursor.fetchone()
+ cur = await db.execute("SELECT lastfm_user FROM user_lastfm WHERE matrix_user=?", (matrix_user,))
+ row = await cur.fetchone()
if row:
- await db.execute(
- "UPDATE user_lastfm SET lastfm_user = ? WHERE matrix_user = ?",
- (lastfm_user, matrix_user),
- )
+ await db.execute("UPDATE user_lastfm SET lastfm_user=? WHERE matrix_user=?", (lastfm_user, matrix_user))
else:
- await db.execute(
- "INSERT INTO user_lastfm (matrix_user, lastfm_user) VALUES (?, ?)",
- (matrix_user, lastfm_user),
- )
+ await db.execute("INSERT INTO user_lastfm (matrix_user, lastfm_user) VALUES (?,?)", (matrix_user, lastfm_user))
await db.commit()
-
async def get_all_registered_users():
- """Get all registered Matrix user -> Last.fm user mappings."""
async with aiosqlite.connect(DB_PATH) as db:
- async with db.execute("SELECT matrix_user, lastfm_user FROM user_lastfm") as cursor:
- rows = await cursor.fetchall()
- return {row[0]: row[1] for row in rows}
-
-
-# ---------------------------------------------------------------------------
-# Resolve username: registered user or explicit argument
-# ---------------------------------------------------------------------------
+ async with db.execute("SELECT matrix_user, lastfm_user FROM user_lastfm") as cur:
+ return {row[0]: row[1] for row in await cur.fetchall()}
async def resolve_username(matrix_user, args, bot, room):
- """
- Resolve the Last.fm username from args or registration.
- Returns (lastfm_user, display_name) or (None, None) if not resolved.
- Sends error message to room if not resolved and bot+room provided.
- """
if args:
- lastfm_user = args[0].strip()
- display_name = lastfm_user
- return lastfm_user, display_name
-
- lastfm_user = await get_lastfm_username(matrix_user)
- if not lastfm_user:
+ return args[0].strip(), args[0].strip()
+ user = await get_lastfm_username(matrix_user)
+ if not user:
if bot and room:
- await bot.api.send_text_message(
- room.room_id,
- "Please register your Last.fm username first with !register \n"
- "Or specify a username: !command ",
- )
+ await bot.api.send_text_message(room.room_id,
+ "Please register your Last.fm username first with !register \nOr specify a username: !command ")
return None, None
- return lastfm_user, matrix_user
-
-
-# ---------------------------------------------------------------------------
-# API helper
-# ---------------------------------------------------------------------------
+ return user, matrix_user
+# ---------- API helper ----------
def get_api_key():
- """Get Last.fm API key from environment."""
- api_key = os.getenv("LASTFM_API_KEY")
- if not api_key:
- logging.error("LASTFM_API_KEY not found in environment variables")
- return api_key
-
+ return os.getenv("LASTFM_API_KEY")
async def call_lastfm_api(method, params, bot=None, room=None):
- """
- Call the Last.fm API with the given method and params.
- Returns JSON data or None on error.
- Optionally sends error messages to a room.
- """
api_key = get_api_key()
if not api_key:
if bot and room:
- await bot.api.send_text_message(
- room.room_id, "❌ Last.fm API key not configured. Set LASTFM_API_KEY."
- )
+ await bot.api.send_text_message(room.room_id, "❌ Last.fm API key not configured. Set LASTFM_API_KEY.")
return None
-
- full_params = {
- "method": method,
- "api_key": api_key,
- "format": "json",
- **params,
- }
-
+ full_params = {"method": method, "api_key": api_key, "format": "json", **params}
try:
async with aiohttp.ClientSession(headers=HEADERS) as session:
- async with session.get(API_BASE, params=full_params, timeout=15) as response:
- if response.status == 200:
- data = await response.json()
+ async with session.get(API_BASE, params=full_params, timeout=15) as resp:
+ if resp.status == 200:
+ data = await resp.json()
if "error" in data:
msg = data.get("message", "Unknown error")
logging.error(f"Last.fm API error ({method}): {msg}")
@@ -196,13 +81,10 @@ async def call_lastfm_api(method, params, bot=None, room=None):
await bot.api.send_text_message(room.room_id, f"❌ Last.fm error: {msg}")
return None
return data
- else:
- logging.error(f"Last.fm API returned status {response.status} for {method}")
- if bot and room:
- await bot.api.send_text_message(
- room.room_id, f"❌ Last.fm API error: HTTP {response.status}"
- )
- return None
+ logging.error(f"Last.fm API HTTP {resp.status} for {method}")
+ if bot and room:
+ await bot.api.send_text_message(room.room_id, f"❌ Last.fm API error: HTTP {resp.status}")
+ return None
except aiohttp.ClientError as e:
logging.error(f"HTTP error calling Last.fm API ({method}): {e}")
if bot and room:
@@ -214,44 +96,28 @@ async def call_lastfm_api(method, params, bot=None, room=None):
await bot.api.send_text_message(room.room_id, f"❌ Error: {e}")
return None
-
async def get_youtube_link(artist, track_name):
- """Search for a YouTube link for the given artist and track."""
- youtube_api_key = os.getenv("YOUTUBE_API_KEY")
- if not youtube_api_key:
- return None
-
- search_query = f"{artist} {track_name}"
- url = "https://www.googleapis.com/youtube/v3/search"
- params = {
- "part": "snippet",
- "q": search_query,
- "type": "video",
- "key": youtube_api_key,
- "maxResults": "1",
- }
-
+ yt_key = os.getenv("YOUTUBE_API_KEY")
+ if not yt_key: return None
try:
async with aiohttp.ClientSession() as session:
- async with session.get(url, params=params) as response:
- if response.status == 200:
- data = await response.json()
+ async with session.get("https://www.googleapis.com/youtube/v3/search", params={
+ "part": "snippet", "q": f"{artist} {track_name}", "type": "video",
+ "key": yt_key, "maxResults": "1"
+ }) as resp:
+ if resp.status == 200:
+ data = await resp.json()
items = data.get("items", [])
if items:
- video_id = items[0].get("id", {}).get("videoId")
- if video_id:
- return f"https://www.youtube.com/watch?v={video_id}"
+ vid = items[0].get("id", {}).get("videoId")
+ if vid:
+ return f"https://www.youtube.com/watch?v={vid}"
except Exception as e:
- logging.error(f"Error searching YouTube: {e}")
+ logging.error(f"YouTube search error: {e}")
return None
-
-# ---------------------------------------------------------------------------
-# Safe extraction helpers
-# ---------------------------------------------------------------------------
-
+# ---------- safe extraction ----------
def safe_text(obj, key, default="Unknown"):
- """Safely extract #text from a nested dict."""
if isinstance(obj, dict):
val = obj.get(key, {})
if isinstance(val, dict):
@@ -260,160 +126,106 @@ def safe_text(obj, key, default="Unknown"):
return val
return default
-
def safe_int(obj, key, default=0):
- """Safely extract an integer value."""
try:
val = safe_text(obj, key, str(default))
return int(val)
except (ValueError, TypeError):
return default
-
-# ---------------------------------------------------------------------------
-# Collapsible wrapper
-# ---------------------------------------------------------------------------
-
-def wrap_collapsible(summary, body):
- """Wrap content in a collapsible HTML details block."""
- return f"{summary}
{body} "
-
-
-# ---------------------------------------------------------------------------
-# COLLAGE HELPER – download image and save to a temp file, return path or None
-# ---------------------------------------------------------------------------
-
-# ------------------------------------------------------------
-# Helper: safely get artist name from album or track object
-# ------------------------------------------------------------
-def album_artist_name(album):
- """Extract artist name from an album object (handles both string and dict)."""
- artist = album.get("artist", {})
+def _artist_name(track_or_artist_obj):
+ """Extract artist name from a track object (or a direct artist object).
+ Handles both string artist and {name}/{#text} dict formats.
+ """
+ artist = track_or_artist_obj.get("artist") if isinstance(track_or_artist_obj, dict) else track_or_artist_obj
if isinstance(artist, str):
return artist
if isinstance(artist, dict):
- # Album API returns 'name', track API returns '#text'
+ # try 'name' first (for lists), then '#text' (for recent tracks), then fallback
+ return artist.get("name") or artist.get("#text") or "Unknown"
+ return "Unknown"
+
+# ---------- code-block output helper ----------
+def _output(title, rows):
+ sections = [{"title": "", "rows": rows}]
+ block = code_block(title, sections)
+ return collapsible_summary(title, block)
+
+# ---------- collage helpers (unchanged) ----------
+def album_artist_name(album):
+ artist = album.get("artist", {})
+ if isinstance(artist, str): return artist
+ if isinstance(artist, dict):
return artist.get("name", artist.get("#text", "Unknown"))
return "Unknown"
-
-# ------------------------------------------------------------
-# Download an image to temp file – tries direct URL first,
-# falls back to album.getInfo + download.
-# ------------------------------------------------------------
async def download_album_art_to_file(session, album_data):
- """
- Download album art.
- album_data is the raw album dict from user.getTopAlbums.
- Returns (artist, album_name, filepath) or (artist, album_name, None).
- """
album_name = safe_text(album_data, "name", "Unknown Album")
artist = album_artist_name(album_data)
-
- # 1. Try direct image from the album object (extralarge or any)
- direct_url = None
+ # direct url
+ direct = None
for img in album_data.get("image", []):
- if img.get("size") == "extralarge":
- direct_url = img.get("#text")
- break
- if not direct_url:
+ if img.get("size") == "extralarge": direct = img.get("#text"); break
+ if not direct:
for img in album_data.get("image", []):
- url = img.get("#text")
- if url:
- direct_url = url
- break
-
- if direct_url:
+ direct = img.get("#text")
+ if direct: break
+ if direct:
try:
- async with session.get(direct_url, timeout=15) as resp:
+ async with session.get(direct, timeout=15) as resp:
if resp.status == 200:
content = await resp.read()
if len(content) >= 500:
- ext = "jpg" if not direct_url.endswith(".png") else "png"
- fd, tmp_path = tempfile.mkstemp(suffix=f".{ext}")
+ fd, tmp = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
- with open(tmp_path, "wb") as f:
- f.write(content)
- logging.info(f"Downloaded '{album_name}' from direct URL")
- return (artist, album_name, tmp_path)
- except Exception as e:
- logging.warning(f"Direct URL failed for '{album_name}': {e}")
-
- # 2. Fallback: album.getInfo
+ with open(tmp, "wb") as f: f.write(content)
+ return (artist, album_name, tmp)
+ except Exception: pass
+ # fallback album.getInfo
if artist != "Unknown":
try:
- params = {
- "method": "album.getInfo",
- "artist": artist,
- "album": album_name,
- "autocorrect": "1",
- "api_key": get_api_key(),
- "format": "json",
- }
+ params = {"method": "album.getInfo", "artist": artist, "album": album_name,
+ "autocorrect": "1", "api_key": get_api_key(), "format": "json"}
async with session.get(API_BASE, params=params, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
album_info = data.get("album", {})
if album_info:
- # Get best image from album info
- image_url = None
+ img_url = None
for img in album_info.get("image", []):
- if img.get("size") == "extralarge":
- image_url = img.get("#text")
- break
- if not image_url:
+ if img.get("size") == "extralarge": img_url = img.get("#text"); break
+ if not img_url:
for img in album_info.get("image", []):
- url = img.get("#text")
- if url:
- image_url = url
- break
- if image_url:
- async with session.get(image_url, timeout=15) as img_resp:
- if img_resp.status == 200:
- content = await img_resp.read()
+ img_url = img.get("#text")
+ if img_url: break
+ if img_url:
+ async with session.get(img_url, timeout=15) as ir:
+ if ir.status == 200:
+ content = await ir.read()
if len(content) >= 500:
- ext = "jpg" if not image_url.endswith(".png") else "png"
- fd, tmp_path = tempfile.mkstemp(suffix=f".{ext}")
+ fd, tmp = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
- with open(tmp_path, "wb") as f:
- f.write(content)
- logging.info(f"Downloaded '{album_name}' via album.getInfo")
- return (artist, album_name, tmp_path)
- except Exception as e:
- logging.warning(f"album.getInfo fallback failed for '{album_name}': {e}")
-
+ with open(tmp, "wb") as f: f.write(content)
+ return (artist, album_name, tmp)
+ except Exception: pass
return (artist, album_name, None)
-
-
# ===================================================================
-# COMMAND HANDLERS
+# COMMAND HANDLERS (all now use _artist_name)
# ===================================================================
-# ---- !register ---------------------------------------------------
-
async def cmd_register(room, message, bot, args):
- """Handle !register """
if len(args) < 1:
await bot.api.send_text_message(room.room_id, "Usage: !register ")
return
-
lastfm_user = args[0].strip()
matrix_user = str(message.sender)
-
await set_lastfm_username(matrix_user, lastfm_user)
- await bot.api.send_text_message(
- room.room_id, f"✅ Registered Last.fm user **{lastfm_user}** for {matrix_user}"
- )
- logging.info(f"Registered Last.fm user {lastfm_user} for {matrix_user}")
-
-
-# ---- !np ---------------------------------------------------------
+ await bot.api.send_text_message(room.room_id, f"✅ Registered Last.fm user **{lastfm_user}** for {matrix_user}")
+# ---- !np ----
async def cmd_np(room, message, bot, args):
- """Handle !np - Show now playing track. No collapsible."""
matrix_user = str(message.sender)
-
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user:
return
@@ -430,7 +242,7 @@ async def cmd_np(room, message, bot, args):
track = tracks[0] if isinstance(tracks, list) else tracks
now_playing = track.get("@attr", {}).get("nowplaying", "false") == "true"
- artist = safe_text(track, "artist")
+ artist = _artist_name(track)
name = safe_text(track, "name")
album = safe_text(track, "album", "")
@@ -448,28 +260,28 @@ async def cmd_np(room, message, bot, args):
youtube_link = await get_youtube_link(artist, name)
if youtube_link:
- message_text += f" | [YouTube]({youtube_link})"
+ message_text += f" | [▶️ YouTube]({youtube_link})"
- # ---- New: fetch track genres ----
- # --- Fetch genres: try track-level first, fall back to artist ---
+ # ---- Genre tags: track-level first, fall back to artist ----
genre_tags = []
# 1) Try track top tags
- track_tag_data = await call_lastfm_api("track.getTopTags", {"artist": artist, "track": name, "autocorrect": "1"})
+ track_tag_data = await call_lastfm_api("track.getTopTags",
+ {"artist": artist, "track": name, "autocorrect": "1"})
if track_tag_data:
tags = track_tag_data.get("toptags", {}).get("tag", [])
genre_tags = [safe_text(t, "name") for t in tags if safe_text(t, "name")]
# 2) If empty, fall back to artist top tags
if not genre_tags:
- artist_tag_data = await call_lastfm_api("artist.getTopTags", {"artist": artist, "autocorrect": "1"})
+ artist_tag_data = await call_lastfm_api("artist.getTopTags",
+ {"artist": artist, "autocorrect": "1"})
if artist_tag_data:
tags = artist_tag_data.get("toptags", {}).get("tag", [])
genre_tags = [safe_text(t, "name") for t in tags if safe_text(t, "name")]
- # 3) Append to message if we got anything
if genre_tags:
genre_str = " | 🏷️ " + ", ".join(genre_tags[:3])
message_text += genre_str
- # ---- Fetch track duration (new) ----
+ # ---- Track duration ----
track_info = await call_lastfm_api("track.getInfo", {
"artist": artist,
"track": name,
@@ -486,936 +298,561 @@ async def cmd_np(room, message, bot, args):
await bot.api.send_markdown_message(room.room_id, message_text)
logging.info(f"Sent now playing for {lastfm_user}")
-
-# ---- !recent -----------------------------------------------------
-
+# ---- !recent ----
async def cmd_recent(room, message, bot, args):
- """Handle !recent [user] [limit]"""
matrix_user = str(message.sender)
-
limit = 10
user_arg = list(args)
- # Parse --limit if present
cleaned = []
i = 0
while i < len(args):
- if args[i] == "--limit" and i + 1 < len(args):
- limit = min(int(args[i + 1]), 50)
- i += 2
+ if args[i] == "--limit" and i+1 < len(args):
+ limit = min(int(args[i+1]), 50); i += 2
else:
- cleaned.append(args[i])
- i += 1
+ cleaned.append(args[i]); i += 1
user_arg = cleaned
- # Allow limit as last argument if numeric
if user_arg and user_arg[-1].isdigit():
- limit = min(int(user_arg[-1]), 50)
- user_arg.pop()
-
+ limit = min(int(user_arg[-1]), 50); user_arg.pop()
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getRecentTracks", {"user": lastfm_user, "limit": str(limit)}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": str(limit)}, bot, room)
+ if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No recent tracks for {lastfm_user}.")
return
-
total = int(data.get("recenttracks", {}).get("@attr", {}).get("total", "0"))
- summary = f"🎵 {display_name} — Recent Tracks ({min(limit, len(tracks))} of {total})"
- lines = []
- for i, track in enumerate(tracks[:limit], 1):
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- album = safe_text(track, "album", "")
- now = "🔊" if track.get("@attr", {}).get("nowplaying") == "true" else ""
+ rows = []
+ for i, t in enumerate(tracks[:limit], 1):
+ artist = _artist_name(t)
+ name = safe_text(t, "name")
+ album = safe_text(t, "album", "")
+ now = "🔊 " if t.get("@attr", {}).get("nowplaying") == "true" else ""
date_str = ""
- if "date" in track and "#text" in track["date"]:
- date_str = f" — {track['date']['#text']}"
- album_str = f" | *{album}*" if album else ""
- lines.append(f" {i}. {now}**{name}** by {artist}{album_str}{date_str}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !toptracks --------------------------------------------------
+ if "date" in t and "#text" in t["date"]:
+ date_str = t["date"]["#text"]
+ rows.append(("🎵", f"{now}{name}", f"{artist}{' | '+album if album else ''} {date_str}"))
+ output = _output(f"🎵 {display_name} — Recent Tracks ({min(limit, len(tracks))} of {total})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !toptracks ----
async def cmd_toptracks(room, message, bot, args):
- """Handle !toptracks [user] [period]"""
matrix_user = str(message.sender)
period = "overall"
user_arg = list(args)
-
if user_arg and user_arg[-1] in VALID_PERIODS:
period = user_arg.pop()
else:
- cleaned = []
- i = 0
+ cleaned = []; i = 0
while i < len(args):
- if args[i] in VALID_PERIODS:
- period = args[i]
- i += 1
- else:
- cleaned.append(args[i])
- i += 1
+ if args[i] in VALID_PERIODS: period = args[i]; i += 1
+ else: cleaned.append(args[i]); i += 1
user_arg = cleaned
-
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getTopTracks",
- {"user": lastfm_user, "period": period, "limit": "10"},
- bot, room,
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopTracks", {"user": lastfm_user, "period": period, "limit": "10"}, bot, room)
+ if not data: return
tracks = data.get("toptracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No top tracks for {lastfm_user}.")
return
-
period_label = PERIOD_LABELS.get(period, period)
- summary = f"🏆 {display_name} — Top Tracks ({period_label})"
- lines = []
- for i, track in enumerate(tracks[:10], 1):
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- playcount = safe_int(track, "playcount")
- lines.append(f" {i}. **{name}** by {artist} — *{playcount} plays*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !topartists -------------------------------------------------
+ rows = []
+ for i, t in enumerate(tracks[:10], 1):
+ artist = _artist_name(t)
+ name = safe_text(t, "name")
+ plays = safe_int(t, "playcount")
+ rows.append(("🎶", name, f"{artist} — {plays} plays"))
+ output = _output(f"🏆 {display_name} — Top Tracks ({period_label})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !topartists ----
async def cmd_topartists(room, message, bot, args):
- """Handle !topartists [user] [period]"""
matrix_user = str(message.sender)
period = "overall"
user_arg = list(args)
-
- if user_arg and user_arg[-1] in VALID_PERIODS:
- period = user_arg.pop()
+ if user_arg and user_arg[-1] in VALID_PERIODS: period = user_arg.pop()
else:
- cleaned = []
- i = 0
+ cleaned = []; i = 0
while i < len(args):
- if args[i] in VALID_PERIODS:
- period = args[i]
- i += 1
- else:
- cleaned.append(args[i])
- i += 1
+ if args[i] in VALID_PERIODS: period = args[i]; i += 1
+ else: cleaned.append(args[i]); i += 1
user_arg = cleaned
-
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getTopArtists",
- {"user": lastfm_user, "period": period, "limit": "10"},
- bot, room,
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": period, "limit": "10"}, bot, room)
+ if not data: return
artists = data.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No top artists for {lastfm_user}.")
return
-
period_label = PERIOD_LABELS.get(period, period)
- summary = f"🎤 {display_name} — Top Artists ({period_label})"
- lines = []
- for i, artist in enumerate(artists[:10], 1):
- name = safe_text(artist, "name")
- playcount = safe_int(artist, "playcount")
- lines.append(f" {i}. **{name}** — *{playcount} plays*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !topalbums --------------------------------------------------
+ rows = []
+ for i, a in enumerate(artists[:10], 1):
+ # artist object directly
+ name = a.get("name", _artist_name(a))
+ plays = safe_int(a, "playcount")
+ rows.append(("🎤", name, f"{plays} plays"))
+ output = _output(f"🎤 {display_name} — Top Artists ({period_label})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !topalbums ----
async def cmd_topalbums(room, message, bot, args):
- """Handle !topalbums [user] [period]"""
matrix_user = str(message.sender)
period = "overall"
user_arg = list(args)
-
- if user_arg and user_arg[-1] in VALID_PERIODS:
- period = user_arg.pop()
+ if user_arg and user_arg[-1] in VALID_PERIODS: period = user_arg.pop()
else:
- cleaned = []
- i = 0
+ cleaned = []; i = 0
while i < len(args):
- if args[i] in VALID_PERIODS:
- period = args[i]
- i += 1
- else:
- cleaned.append(args[i])
- i += 1
+ if args[i] in VALID_PERIODS: period = args[i]; i += 1
+ else: cleaned.append(args[i]); i += 1
user_arg = cleaned
-
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getTopAlbums",
- {"user": lastfm_user, "period": period, "limit": "10"},
- bot, room,
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopAlbums", {"user": lastfm_user, "period": period, "limit": "10"}, bot, room)
+ if not data: return
albums = data.get("topalbums", {}).get("album", [])
if not albums:
await bot.api.send_text_message(room.room_id, f"🔍 No top albums for {lastfm_user}.")
return
-
period_label = PERIOD_LABELS.get(period, period)
- summary = f"💿 {display_name} — Top Albums ({period_label})"
- lines = []
- for i, album in enumerate(albums[:10], 1):
- artist = safe_text(album, "artist")
- name = safe_text(album, "name")
- playcount = safe_int(album, "playcount")
- lines.append(f" {i}. **{name}** by {artist} — *{playcount} plays*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !loved ------------------------------------------------------
+ rows = []
+ for i, alb in enumerate(albums[:10], 1):
+ artist = album_artist_name(alb)
+ name = safe_text(alb, "name")
+ plays = safe_int(alb, "playcount")
+ rows.append(("💿", name, f"{artist} — {plays} plays"))
+ output = _output(f"💿 {display_name} — Top Albums ({period_label})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !loved ----
async def cmd_loved(room, message, bot, args):
- """Handle !loved [user]"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getLovedTracks", {"user": lastfm_user, "limit": "10"}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getLovedTracks", {"user": lastfm_user, "limit": "10"}, bot, room)
+ if not data: return
tracks = data.get("lovedtracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"💔 No loved tracks for {lastfm_user}.")
return
-
total = int(data.get("lovedtracks", {}).get("@attr", {}).get("total", "0"))
- summary = f"❤️ {display_name} — Loved Tracks ({len(tracks)} of {total})"
- lines = []
- for i, track in enumerate(tracks[:10], 1):
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
+ rows = []
+ for t in tracks[:10]:
+ artist = _artist_name(t)
+ name = safe_text(t, "name")
date_str = ""
- if "date" in track and "#text" in track["date"]:
- date_str = f" — {track['date']['#text']}"
- lines.append(f" {i}. **{name}** by {artist}{date_str}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !profile ----------------------------------------------------
+ if "date" in t and "#text" in t["date"]:
+ date_str = f" — {t['date']['#text']}"
+ rows.append(("❤️", f"{name}", f"{artist}{date_str}"))
+ output = _output(f"❤️ {display_name} — Loved Tracks ({len(tracks)} of {total})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !profile ----
async def cmd_profile(room, message, bot, args):
- """Handle !profile [user]"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
+ if not lastfm_user: return
data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
- if not data:
- return
-
- user_info = data.get("user", {})
- if not user_info:
+ if not data: return
+ ui = data.get("user", {})
+ if not ui:
await bot.api.send_text_message(room.room_id, f"🔍 User {lastfm_user} not found.")
return
-
- real_name = user_info.get("realname", "")
- country = user_info.get("country", "Unknown")
- playcount = safe_int(user_info, "playcount")
- playlists = safe_int(user_info, "playlists")
- registered = user_info.get("registered", {}).get("#text", "Unknown")
- url = user_info.get("url", "")
- subscriber = "✅" if user_info.get("subscriber", "0") == "1" else "❌"
-
- summary = f"👤 Profile: {display_name} ({lastfm_user})"
- lines = [
- f" • **Last.fm:** [{lastfm_user}]({url})" if url else f" • **Last.fm:** {lastfm_user}",
- f" • **Real Name:** {real_name}" if real_name else "",
- f" • **Country:** {country}",
- f" • **Registered:** {registered}",
- f" • **Total Plays:** {playcount:,}",
- f" • **Playlists:** {playlists}",
- f" • **Subscriber:** {subscriber}",
+ rows = [
+ ("👤", "Last.fm", lastfm_user),
+ ("📃", "Real Name", ui.get("realname", "")),
+ ("🌍", "Country", ui.get("country", "Unknown")),
+ ("📅", "Registered", ui.get("registered", {}).get("#text", "Unknown")),
+ ("🎵", "Total Plays", f"{safe_int(ui, 'playcount'):,}"),
+ ("📋", "Playlists", str(safe_int(ui, "playlists"))),
+ ("⭐", "Subscriber", "✅" if ui.get("subscriber", "0") == "1" else "❌"),
]
- lines = [l for l in lines if l]
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !playcount --------------------------------------------------
+ rows = [r for r in rows if r[1]]
+ output = _output(f"👤 Profile: {display_name}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !playcount (unchanged) ----
async def cmd_playcount(room, message, bot, args):
- """Handle !playcount [user] - short output, no collapsible needed."""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
+ if not lastfm_user: return
data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
- if not data:
- return
-
- playcount = safe_int(data.get("user", {}), "playcount")
- await bot.api.send_markdown_message(
- room.room_id, f"🔢 **{display_name}** has scrobbled **{playcount:,}** tracks total."
- )
-
-
-# ---- !scrobbles --------------------------------------------------
+ if not data: return
+ pc = safe_int(data.get("user", {}), "playcount")
+ await bot.api.send_markdown_message(room.room_id, f"🔢 **{display_name}** has scrobbled **{pc:,}** tracks total.")
+# ---- !scrobbles ----
async def cmd_scrobbles(room, message, bot, args):
- """Handle !scrobbles [user] - detailed scrobbling stats"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- info_data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
- if not info_data:
- return
-
- user_info = info_data.get("user", {})
- playcount = safe_int(user_info, "playcount")
- registered = user_info.get("registered", {}).get("#text", "Unknown")
- artist_count = safe_int(user_info, "artist_count", 0)
-
- recent_data = await call_lastfm_api(
- "user.getRecentTracks", {"user": lastfm_user, "limit": "200"}, bot, room
- )
- today_count = 0
- if recent_data:
- tracks = recent_data.get("recenttracks", {}).get("track", [])
- today = datetime.utcnow().strftime("%d %b %Y")
- for track in tracks:
- if "date" in track and "#text" in track["date"]:
- if today in track["date"]["#text"]:
- today_count += 1
-
+ if not lastfm_user: return
+ info = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
+ if not info: return
+ ui = info.get("user", {})
+ playcount = safe_int(ui, "playcount")
+ registered = ui.get("registered", {}).get("#text", "Unknown")
+ artist_count = safe_int(ui, "artist_count", 0)
+ recent = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": "200"}, bot, room)
+ today = 0
+ if recent:
+ tracks = recent.get("recenttracks", {}).get("track", [])
+ today_str = datetime.utcnow().strftime("%d %b %Y")
+ for t in tracks:
+ if "date" in t and "#text" in t["date"] and today_str in t["date"]["#text"]:
+ today += 1
try:
reg_date = datetime.strptime(registered, "%d %b %Y")
days_since = max((datetime.utcnow() - reg_date).days, 1)
- avg_per_day = round(playcount / days_since, 1)
- except (ValueError, TypeError):
- avg_per_day = "?"
-
- summary = f"📊 {display_name} — Scrobbling Stats"
- lines = [
- f" • **Total Scrobbles:** {playcount:,}",
- f" • **Unique Artists:** {artist_count:,}" if artist_count else "",
- f" • **Registered:** {registered}",
- f" • **Avg Scrobbles/Day:** {avg_per_day}",
- f" • **Today's Scrobbles:** {today_count}",
+ avg = round(playcount / days_since, 1)
+ except: avg = "?"
+ rows = [
+ ("🎵", "Total Scrobbles", f"{playcount:,}"),
+ ("🎤", "Unique Artists", f"{artist_count:,}") if artist_count else None,
+ ("📅", "Registered", registered),
+ ("📊", "Avg Scrobbles/Day", str(avg)),
+ ("📅", "Today's Scrobbles", str(today)),
]
- lines = [l for l in lines if l]
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !compare ----------------------------------------------------
+ rows = [r for r in rows if r]
+ output = _output(f"📊 {display_name} — Scrobbling Stats", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !compare ----
async def cmd_compare(room, message, bot, args):
- """Handle !compare """
if len(args) < 2:
- await bot.api.send_text_message(
- room.room_id, "Usage: !compare \nExample: !compare alice bob"
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !compare ")
return
-
- user1, user2 = args[0].strip(), args[1].strip()
-
- data1 = await call_lastfm_api(
- "user.getTopArtists", {"user": user1, "period": "overall", "limit": "50"}, bot, room
- )
- data2 = await call_lastfm_api(
- "user.getTopArtists", {"user": user2, "period": "overall", "limit": "50"}, bot, room
- )
-
- if not data1 or not data2:
- return
-
- artists1 = {safe_text(a, "name").lower(): safe_int(a, "playcount")
- for a in data1.get("topartists", {}).get("artist", [])}
- artists2 = {safe_text(a, "name").lower(): safe_int(a, "playcount")
- for a in data2.get("topartists", {}).get("artist", [])}
-
- set1, set2 = set(artists1.keys()), set(artists2.keys())
- common = set1 & set2
- only1 = set1 - set2
- only2 = set2 - set1
-
- similarity = round(len(common) / max(len(set1 | set2), 1) * 100, 1) if (set1 | set2) else 0
-
- summary = f"🔄 Musical Taste Comparison: {user1} vs {user2}"
- lines = [
- f" • **Taste Similarity:** {similarity}%",
- f" • **Common Artists:** {len(common)}",
- f" • **Unique to {user1}:** {len(only1)}",
- f" • **Unique to {user2}:** {len(only2)}",
+ u1, u2 = args[0].strip(), args[1].strip()
+ d1 = await call_lastfm_api("user.getTopArtists", {"user": u1, "period": "overall", "limit": "50"}, bot, room)
+ d2 = await call_lastfm_api("user.getTopArtists", {"user": u2, "period": "overall", "limit": "50"}, bot, room)
+ if not d1 or not d2: return
+ a1 = {a.get("name", _artist_name(a)).lower(): safe_int(a, "playcount") for a in d1.get("topartists", {}).get("artist", [])}
+ a2 = {a.get("name", _artist_name(a)).lower(): safe_int(a, "playcount") for a in d2.get("topartists", {}).get("artist", [])}
+ s1, s2 = set(a1.keys()), set(a2.keys())
+ common = s1 & s2
+ similarity = round(len(common) / max(len(s1|s2),1)*100, 1) if (s1|s2) else 0
+ rows = [
+ ("🔄", "Taste Similarity", f"{similarity}%"),
+ ("🎶", "Common Artists", str(len(common))),
+ ("👤", f"Only {u1}", str(len(s1 - s2))),
+ ("👤", f"Only {u2}", str(len(s2 - s1))),
]
if common:
- top_common = sorted(common, key=lambda a: artists1[a] + artists2.get(a, 0), reverse=True)[:5]
- lines.append(f" • **Top Shared:** {', '.join(top_common)}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !taste ------------------------------------------------------
+ top_shared = sorted(common, key=lambda x: a1[x]+a2.get(x,0), reverse=True)[:5]
+ rows.append(("🏆", "Top Shared", ", ".join(top_shared)))
+ output = _output(f"🔄 Musical Taste Comparison: {u1} vs {u2}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !taste ----
async def cmd_taste(room, message, bot, args):
- """Handle !taste [user] - top artists with taste-o-meter"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "15"}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "15"}, bot, room)
+ if not data: return
artists = data.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No artists found for {lastfm_user}.")
return
+ total = sum(safe_int(a, "playcount") for a in artists) or 1
+ rows = []
+ for a in artists[:15]:
+ name = a.get("name", _artist_name(a))
+ pc = safe_int(a, "playcount")
+ pct = round(pc/total*100, 1)
+ bar = "█"*min(int(pct*2), 20)
+ rows.append(("🎯", name, f"{bar} {pct}%"))
+ output = _output(f"🎯 {display_name} — Taste-o-Meter", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- total_plays = sum(safe_int(a, "playcount") for a in artists)
- if total_plays == 0:
- total_plays = 1
-
- summary = f"🎯 {display_name} — Taste-o-Meter"
- lines = []
- for i, artist in enumerate(artists[:15], 1):
- name = safe_text(artist, "name")
- pc = safe_int(artist, "playcount")
- pct = round(pc / total_plays * 100, 1) if total_plays else 0
- bar = "█" * min(int(pct * 2), 20)
- lines.append(f" {i:2}. **{name}** {bar} {pct}%")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !friends ----------------------------------------------------
-
+# ---- !friends ----
async def cmd_friends(room, message, bot, args):
- """Handle !friends [user]"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getFriends", {"user": lastfm_user, "recenttracks": "1", "limit": "20"}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getFriends", {"user": lastfm_user, "recenttracks": "1", "limit": "20"}, bot, room)
+ if not data: return
friends = data.get("friends", {}).get("user", [])
if not friends:
await bot.api.send_text_message(room.room_id, f"👥 No friends found for {lastfm_user}.")
return
-
total = int(data.get("friends", {}).get("@attr", {}).get("total", "0"))
- summary = f"👥 {display_name} — Friends ({len(friends)} of {total})"
- lines = []
+ rows = []
for f in friends[:15]:
fname = safe_text(f, "name")
- realname = f.get("realname", "")
+ rname = f.get("realname", "")
now = ""
if "recenttrack" in f:
rt = f["recenttrack"]
- now = f" — 🎵 {safe_text(rt, 'artist')} - {safe_text(rt, 'name')}"
- display = f"{fname} ({realname})" if realname else fname
- lines.append(f" • **{display}**{now}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !recommend --------------------------------------------------
+ now = f" — 🎵 {_artist_name(rt)} - {safe_text(rt, 'name')}"
+ rows.append(("👥", fname + (f" ({rname})" if rname else ""), now))
+ output = _output(f"👥 {display_name} — Friends ({len(friends)} of {total})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !recommend ----
async def cmd_recommend(room, message, bot, args):
- """Handle !recommend [user] - artist recommendations"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- top_data = await call_lastfm_api(
- "user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "5"}, bot, room
- )
- if not top_data:
- return
-
- top_artists = [safe_text(a, "name") for a in top_data.get("topartists", {}).get("artist", [])]
+ if not lastfm_user: return
+ top = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "5"}, bot, room)
+ if not top: return
+ top_artists = [a.get("name", _artist_name(a)) for a in top.get("topartists", {}).get("artist", [])]
if not top_artists:
await bot.api.send_text_message(room.room_id, f"🔍 Not enough data for {lastfm_user}.")
return
-
seen = set(a.lower() for a in top_artists)
- recommendations = []
-
- for artist_name in top_artists[:3]:
- sim_data = await call_lastfm_api(
- "artist.getSimilar", {"artist": artist_name, "limit": "5", "autocorrect": "1"}, bot
- )
- if sim_data:
- for a in sim_data.get("similarartists", {}).get("artist", []):
- name = safe_text(a, "name")
+ recs = []
+ for aname in top_artists[:3]:
+ sim = await call_lastfm_api("artist.getSimilar", {"artist": aname, "limit": "5", "autocorrect": "1"}, bot)
+ if sim:
+ for a in sim.get("similarartists", {}).get("artist", []):
+ name = a.get("name", _artist_name(a))
match = float(a.get("match", "0"))
if name.lower() not in seen:
seen.add(name.lower())
- recommendations.append((name, match, artist_name))
-
- recommendations.sort(key=lambda x: x[1], reverse=True)
- recommendations = recommendations[:15]
-
- if not recommendations:
+ recs.append((name, match, aname))
+ recs.sort(key=lambda x: x[1], reverse=True)
+ if not recs:
await bot.api.send_text_message(room.room_id, "No recommendations found.")
return
+ rows = [(name, f"{round(match*100)}% match via {src}") for name, match, src in recs[:15]]
+ rows = [("💡", a, b) for a,b in rows]
+ output = _output(f"💡 Recommendations for {display_name}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"💡 Recommendations for {display_name} (based on top artists)"
- lines = []
- for i, (name, match, source) in enumerate(recommendations, 1):
- pct = round(match * 100)
- lines.append(f" {i}. **{name}** — {pct}% match (via {source})")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !similar ----------------------------------------------------
-
+# ---- !similar ----
async def cmd_similar(room, message, bot, args):
- """Handle !similar """
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !similar ")
return
-
- artist_name = " ".join(args)
- data = await call_lastfm_api(
- "artist.getSimilar", {"artist": artist_name, "limit": "15", "autocorrect": "1"}, bot, room
- )
- if not data:
- return
-
+ aname = " ".join(args)
+ data = await call_lastfm_api("artist.getSimilar", {"artist": aname, "limit": "15", "autocorrect": "1"}, bot, room)
+ if not data: return
artists = data.get("similarartists", {}).get("artist", [])
if not artists:
- await bot.api.send_text_message(room.room_id, f"🔍 No similar artists found for **{artist_name}**.")
+ await bot.api.send_text_message(room.room_id, f"🔍 No similar artists found for **{aname}**.")
return
+ rows = [(a.get("name", _artist_name(a)), f"{round(float(a.get('match','0'))*100)}% match") for a in artists[:15]]
+ rows = [("🔗", a, b) for a,b in rows]
+ output = _output(f"🔗 Similar to {aname}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"🔗 Similar to {artist_name}"
- lines = []
- for i, a in enumerate(artists[:15], 1):
- name = safe_text(a, "name")
- match_pct = round(float(a.get("match", "0")) * 100)
- lines.append(f" {i}. **{name}** — {match_pct}% match")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !tag --------------------------------------------------------
-
+# ---- !tag ----
async def cmd_tag(room, message, bot, args):
- """Handle !tag """
if not args:
- await bot.api.send_text_message(room.room_id, "Usage: !tag \nExample: !tag metal")
+ await bot.api.send_text_message(room.room_id, "Usage: !tag ")
return
-
tag = " ".join(args)
- data = await call_lastfm_api(
- "tag.getTopArtists", {"tag": tag, "limit": "15"}, bot, room
- )
- if not data:
- return
-
+ data = await call_lastfm_api("tag.getTopArtists", {"tag": tag, "limit": "15"}, bot, room)
+ if not data: return
artists = data.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"🔍 No artists found for tag **{tag}**.")
return
+ rows = [(a.get("name", _artist_name(a)), f"{safe_int(a, 'count')} taggings") for a in artists[:15]]
+ rows = [("🏷️", a, b) for a,b in rows]
+ output = _output(f"🏷️ Top Artists tagged '{tag}'", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"🏷️ Top Artists tagged '{tag}'"
- lines = []
- for i, a in enumerate(artists[:15], 1):
- name = safe_text(a, "name")
- count = safe_int(a, "count")
- lines.append(f" {i}. **{name}** — *{count} taggings*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !charts -----------------------------------------------------
-
+# ---- !charts ----
async def cmd_charts(room, message, bot, args):
- """Handle !charts - global top tracks"""
data = await call_lastfm_api("chart.getTopTracks", {"limit": "10"}, bot, room)
- if not data:
- return
-
+ if not data: return
tracks = data.get("tracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, "No chart data available.")
return
+ rows = []
+ for t in tracks[:10]:
+ artist = _artist_name(t) # t.artist is an object with name
+ name = safe_text(t, "name")
+ listeners = safe_int(t, "listeners")
+ rows.append(("🌍", name, f"{artist} — {listeners:,} listeners"))
+ output = _output("🌍 Global Top Tracks", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = "🌍 Global Top Tracks"
- lines = []
- for i, track in enumerate(tracks[:10], 1):
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- listeners = safe_int(track, "listeners")
- lines.append(f" {i}. **{name}** by {artist} — *{listeners:,} listeners*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !tagcloud ---------------------------------------------------
-
+# ---- !tagcloud ----
async def cmd_tagcloud(room, message, bot, args):
- """Handle !tagcloud [user]"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getTopTags", {"user": lastfm_user, "limit": "30"}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopTags", {"user": lastfm_user, "limit": "30"}, bot, room)
+ if not data: return
tags = data.get("toptags", {}).get("tag", [])
if not tags:
await bot.api.send_text_message(room.room_id, f"🔍 No tags found for {lastfm_user}.")
return
+ rows = [(safe_text(t, "name"), str(safe_int(t, "count"))) for t in tags[:20]]
+ rows = [("☁️", a, b) for a,b in rows]
+ output = _output(f"☁️ {display_name} — Tag Cloud", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"☁️ {display_name} — Tag Cloud"
- tag_strs = []
- for tag in tags:
- name = safe_text(tag, "name")
- count = safe_int(tag, "count")
- tag_strs.append(f"{name}({count})")
-
- lines = [" " + " • ".join(tag_strs[:30])]
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !now --------------------------------------------------------
-
+# ---- !now ----
async def cmd_now(room, message, bot, args):
- """Handle !now - what are all registered users playing?"""
all_users = await get_all_registered_users()
if not all_users:
await bot.api.send_text_message(room.room_id, "No users registered yet.")
return
-
- summary = "🎵 Now Playing Across Registered Users"
- lines = []
+ rows = []
found = False
-
for mx_user, lfm_user in all_users.items():
- data = await call_lastfm_api(
- "user.getRecentTracks", {"user": lfm_user, "limit": "1"}, bot
- )
- if not data:
- continue
+ data = await call_lastfm_api("user.getRecentTracks", {"user": lfm_user, "limit": "1"}, bot)
+ if not data: continue
tracks = data.get("recenttracks", {}).get("track", [])
- if not tracks:
- continue
- track = tracks[0] if isinstance(tracks, list) else tracks
- if track.get("@attr", {}).get("nowplaying") == "true":
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- lines.append(f" • **{lfm_user}**: {name} by {artist}")
+ if not tracks: continue
+ t = tracks[0] if isinstance(tracks, list) else tracks
+ if t.get("@attr", {}).get("nowplaying") == "true":
+ artist = _artist_name(t)
+ name = safe_text(t, "name")
+ rows.append(("🎵", lfm_user, f"{name} by {artist}"))
found = True
-
if not found:
- lines.append(" • Nobody is currently scrobbling.")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !decades ----------------------------------------------------
+ rows.append(("🎵", "Nobody", "is currently scrobbling"))
+ output = _output("🎵 Now Playing Across Registered Users", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !decades ----
async def cmd_decades(room, message, bot, args):
- """Handle !decades [user] - favorite decades"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- top_data = await call_lastfm_api(
- "user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "20"}, bot, room
- )
- if not top_data:
- return
-
- artists = top_data.get("topartists", {}).get("artist", [])
+ if not lastfm_user: return
+ top = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "overall", "limit": "20"}, bot, room)
+ if not top: return
+ artists = top.get("topartists", {}).get("artist", [])
if not artists:
await bot.api.send_text_message(room.room_id, f"Not enough data for {lastfm_user}.")
return
-
decade_counts = {}
- for artist_obj in artists[:10]:
- artist_name = safe_text(artist_obj, "name")
- playcount = safe_int(artist_obj, "playcount")
- tag_data = await call_lastfm_api(
- "artist.getTopTags", {"artist": artist_name, "autocorrect": "1"}, bot
- )
+ for a in artists[:10]:
+ aname = a.get("name", _artist_name(a))
+ pc = safe_int(a, "playcount")
+ tag_data = await call_lastfm_api("artist.getTopTags", {"artist": aname, "autocorrect": "1"}, bot)
if tag_data:
for tag in tag_data.get("toptags", {}).get("tag", []):
- tag_name = safe_text(tag, "name").lower()
- if tag_name.endswith("s") and len(tag_name) == 3 and tag_name[:2].isdigit():
- decade = tag_name
- decade_counts[decade] = decade_counts.get(decade, 0) + playcount
- elif tag_name.endswith("s") and len(tag_name) == 5 and tag_name[:4].isdigit():
- decade = tag_name
- decade_counts[decade] = decade_counts.get(decade, 0) + playcount
-
+ tn = safe_text(tag, "name").lower()
+ if (len(tn)==3 and tn[:2].isdigit() and tn.endswith("s")) or (len(tn)==5 and tn[:4].isdigit() and tn.endswith("s")):
+ decade_counts[tn] = decade_counts.get(tn,0) + pc
if not decade_counts:
- await bot.api.send_text_message(
- room.room_id, f"Could not determine decade preferences for {lastfm_user}."
- )
+ await bot.api.send_text_message(room.room_id, f"Could not determine decade preferences for {lastfm_user}.")
return
-
- sorted_decades = sorted(decade_counts.items(), key=lambda x: x[1], reverse=True)
+ sorted_d = sorted(decade_counts.items(), key=lambda x: x[1], reverse=True)
total = sum(decade_counts.values())
- summary = f"📅 {display_name} — Favorite Decades"
- lines = []
- for decade, count in sorted_decades[:8]:
- pct = round(count / total * 100, 1) if total else 0
- bar = "█" * min(int(pct * 2), 20)
- lines.append(f" • **{decade}** {bar} {pct}%")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !genres -----------------------------------------------------
+ rows = []
+ for dec, cnt in sorted_d[:8]:
+ pct = round(cnt/total*100,1) if total else 0
+ bar = "█"*min(int(pct*2),20)
+ rows.append(("📅", dec, f"{bar} {pct}%"))
+ output = _output(f"📅 {display_name} — Favorite Decades", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !genres ----
async def cmd_genres(room, message, bot, args):
- """Handle !genres [user] - top genres/tags"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getTopTags", {"user": lastfm_user, "limit": "15"}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopTags", {"user": lastfm_user, "limit": "15"}, bot, room)
+ if not data: return
tags = data.get("toptags", {}).get("tag", [])
if not tags:
await bot.api.send_text_message(room.room_id, f"🔍 No genre tags for {lastfm_user}.")
return
+ rows = [(safe_text(t, "name"), f"{safe_int(t, 'count')}×") for t in tags[:15]]
+ rows = [("🎶", a, b) for a,b in rows]
+ output = _output(f"🎶 {display_name} — Top Genres", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"🎶 {display_name} — Top Genres"
- lines = []
- for i, tag in enumerate(tags[:15], 1):
- name = safe_text(tag, "name")
- count = safe_int(tag, "count")
- lines.append(f" {i}. **{name}** — *{count}×*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !era --------------------------------------------------------
-
+# ---- !era ----
async def cmd_era(room, message, bot, args):
- """Handle !era """
if not args:
- await bot.api.send_text_message(room.room_id, "Usage: !era \nExample: !era 1994")
+ await bot.api.send_text_message(room.room_id, "Usage: !era ")
return
-
year = args[0].strip()
- if not year.isdigit() or len(year) != 4:
+ if not year.isdigit() or len(year)!=4:
await bot.api.send_text_message(room.room_id, "Please specify a valid 4-digit year.")
return
-
- tag = f"{year}s" if year.endswith("0") else year
- data = await call_lastfm_api(
- "tag.getTopTracks", {"tag": tag, "limit": "10"}, bot, room
- )
- if not data:
- data = await call_lastfm_api(
- "tag.getTopTracks", {"tag": year, "limit": "10"}, bot, room
- )
- if not data:
- return
-
+ tag = year+"s" if year.endswith("0") else year
+ data = await call_lastfm_api("tag.getTopTracks", {"tag": tag, "limit": "10"}, bot, room)
+ if not data: data = await call_lastfm_api("tag.getTopTracks", {"tag": year, "limit": "10"}, bot, room)
+ if not data: return
tracks = data.get("tracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"🔍 No tracks found for era **{year}**.")
return
+ rows = [(safe_text(t, "name"), _artist_name(t)) for t in tracks[:10]]
+ rows = [("🕰️", a, b) for a,b in rows]
+ output = _output(f"🕰️ Popular Tracks — {year}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"🕰️ Popular Tracks — {year}"
- lines = []
- for i, track in enumerate(tracks[:10], 1):
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- lines.append(f" {i}. **{name}** by {artist}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !weekly -----------------------------------------------------
-
+# ---- !weekly ----
async def cmd_weekly(room, message, bot, args):
- """Handle !weekly [user]"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getWeeklyTrackChart", {"user": lastfm_user}, bot, room
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getWeeklyTrackChart", {"user": lastfm_user}, bot, room)
+ if not data: return
tracks = data.get("weeklytrackchart", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"📊 No weekly chart for {lastfm_user}.")
return
+ total_plays = sum(safe_int(t, "playcount") for t in tracks)
+ rows = [("📊", "Unique Tracks", str(len(tracks))), ("🎵", "Total Plays", str(total_plays))]
+ for t in tracks[:10]:
+ artist = _artist_name(t)
+ name = safe_text(t, "name")
+ plays = safe_int(t, "playcount")
+ rows.append(("📅", name, f"{artist} — {plays} plays"))
+ output = _output(f"📅 {display_name} — Weekly Report", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"📅 {display_name} — Weekly Report"
- lines = []
- total_plays = 0
- for i, track in enumerate(tracks[:10], 1):
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- playcount = safe_int(track, "playcount")
- total_plays += playcount
- lines.append(f" {i}. **{name}** by {artist} — *{playcount} plays*")
-
- header = f" • **Total unique tracks:** {len(tracks)} | **Total plays this week:** {total_plays}
"
- body = header + "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !monthly ----------------------------------------------------
-
+# ---- !monthly ----
async def cmd_monthly(room, message, bot, args):
- """Handle !monthly [user] - last 30 days"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
+ if not lastfm_user: return
to_ts = int(time.time())
from_ts = int((datetime.utcnow() - timedelta(days=30)).timestamp())
-
- data = await call_lastfm_api(
- "user.getRecentTracks",
- {"user": lastfm_user, "from": str(from_ts), "to": str(to_ts), "limit": "200"},
- bot, room,
- )
- if not data:
- return
-
+ data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "from": str(from_ts), "to": str(to_ts), "limit": "200"}, bot, room)
+ if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"📊 No tracks in the last 30 days for {lastfm_user}.")
return
-
- track_counts = {}
- artist_counts = {}
- for track in tracks:
- name = safe_text(track, "name")
- artist = safe_text(track, "artist")
+ track_counts, artist_counts = {}, {}
+ for t in tracks:
+ name = safe_text(t, "name")
+ artist = _artist_name(t)
key = f"{name}|||{artist}"
- track_counts[key] = track_counts.get(key, 0) + 1
- artist_counts[artist] = artist_counts.get(artist, 0) + 1
-
- total = len(tracks)
- unique_tracks = len(track_counts)
- unique_artists = len(artist_counts)
-
- top_tracks = sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10]
- top_artists = sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5]
-
- summary = f"📆 {display_name} — Monthly Report (Last 30 Days)"
- lines = [
- f" • **Total Scrobbles:** {total} | **Unique Tracks:** {unique_tracks} | **Unique Artists:** {unique_artists}",
- "
Top Tracks:",
+ track_counts[key] = track_counts.get(key,0)+1
+ artist_counts[artist] = artist_counts.get(artist,0)+1
+ rows = [
+ ("🎵", "Total Scrobbles", str(len(tracks))),
+ ("🔀", "Unique Tracks", str(len(track_counts))),
+ ("🎤", "Unique Artists", str(len(artist_counts))),
]
- for i, (key, count) in enumerate(top_tracks, 1):
- name, artist = key.split("|||", 1)
- lines.append(f" {i}. **{name}** by {artist} — *{count} plays*")
-
- lines.append("
Top Artists:")
- for i, (artist, count) in enumerate(top_artists, 1):
- lines.append(f" {i}. **{artist}** — *{count} plays*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !yearly -----------------------------------------------------
+ rows.append(("🎶", "Top Tracks", ""))
+ for i, (key, cnt) in enumerate(sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10], 1):
+ n, a = key.split("|||", 1)
+ rows.append(("", n, f"{a} — {cnt} plays"))
+ rows.append(("🎤", "Top Artists", ""))
+ for i, (a, cnt) in enumerate(sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5], 1):
+ rows.append(("", a, f"{cnt} plays"))
+ output = _output(f"📆 {display_name} — Monthly Report (Last 30 Days)", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !yearly ----
async def cmd_yearly(room, message, bot, args):
- """Handle !yearly [user] [year]"""
matrix_user = str(message.sender)
year = None
user_arg = list(args)
-
- if user_arg:
- last = user_arg[-1]
- if last.isdigit() and len(last) == 4:
- year = int(last)
- user_arg.pop()
-
+ if user_arg and user_arg[-1].isdigit() and len(user_arg[-1])==4:
+ year = int(user_arg.pop())
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
+ if not lastfm_user: return
if year:
try:
- from_ts = int(datetime(year, 1, 1).timestamp())
- to_ts = int(datetime(year, 12, 31, 23, 59, 59).timestamp())
+ from_ts = int(datetime(year,1,1).timestamp())
+ to_ts = int(datetime(year,12,31,23,59,59).timestamp())
except ValueError:
await bot.api.send_text_message(room.room_id, "Invalid year.")
return
@@ -1423,532 +860,328 @@ async def cmd_yearly(room, message, bot, args):
to_ts = int(time.time())
from_ts = int((datetime.utcnow() - timedelta(days=365)).timestamp())
year = datetime.utcnow().year
-
- data = await call_lastfm_api(
- "user.getRecentTracks",
- {"user": lastfm_user, "from": str(from_ts), "to": str(to_ts), "limit": "200"},
- bot, room,
- )
- if not data:
- return
-
+ data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "from": str(from_ts), "to": str(to_ts), "limit": "200"}, bot, room)
+ if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"📊 No tracks in {year} for {lastfm_user}.")
return
-
- track_counts = {}
- artist_counts = {}
- for track in tracks:
- name = safe_text(track, "name")
- artist = safe_text(track, "artist")
+ track_counts, artist_counts = {}, {}
+ for t in tracks:
+ name = safe_text(t, "name")
+ artist = _artist_name(t)
key = f"{name}|||{artist}"
- track_counts[key] = track_counts.get(key, 0) + 1
- artist_counts[artist] = artist_counts.get(artist, 0) + 1
-
- top_tracks = sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10]
- top_artists = sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5]
-
- summary = f"📆 {display_name} — Yearly Report ({year})"
- lines = [
- f" • **Total Scrobbles:** {len(tracks)} | **Unique Tracks:** {len(track_counts)} | **Unique Artists:** {len(artist_counts)}",
- "
Top Tracks:",
+ track_counts[key] = track_counts.get(key,0)+1
+ artist_counts[artist] = artist_counts.get(artist,0)+1
+ rows = [
+ ("🎵", "Total Scrobbles", str(len(tracks))),
+ ("🔀", "Unique Tracks", str(len(track_counts))),
+ ("🎤", "Unique Artists", str(len(artist_counts))),
]
- for i, (key, count) in enumerate(top_tracks, 1):
- name, artist = key.split("|||", 1)
- lines.append(f" {i}. **{name}** by {artist} — *{count} plays*")
-
- lines.append("
Top Artists:")
- for i, (artist, count) in enumerate(top_artists, 1):
- lines.append(f" {i}. **{artist}** — *{count} plays*")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !first ------------------------------------------------------
+ rows.append(("🎶", "Top Tracks", ""))
+ for i, (key, cnt) in enumerate(sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:10], 1):
+ n, a = key.split("|||", 1)
+ rows.append(("", n, f"{a} — {cnt} plays"))
+ rows.append(("🎤", "Top Artists", ""))
+ for i, (a, cnt) in enumerate(sorted(artist_counts.items(), key=lambda x: x[1], reverse=True)[:5], 1):
+ rows.append(("", a, f"{cnt} plays"))
+ output = _output(f"📆 {display_name} — Yearly Report ({year})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !first (unchanged) ----
async def cmd_first(room, message, bot, args):
- """Handle !first [user]"""
matrix_user = str(message.sender)
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !first [username]")
return
-
artist_parts = list(args)
potential_user = artist_parts[-1]
user_arg = []
-
- if len(artist_parts) >= 2:
- if " " not in potential_user:
- user_arg = [potential_user]
- artist_parts.pop()
-
+ if len(artist_parts) >= 2 and " " not in potential_user:
+ user_arg = [potential_user]; artist_parts.pop()
artist_name = " ".join(artist_parts)
-
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
- data = await call_lastfm_api(
- "user.getRecentTracks",
- {"user": lastfm_user, "limit": "200", "from": "0"},
- bot, room,
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getRecentTracks", {"user": lastfm_user, "limit": "200", "from": "0"}, bot, room)
+ if not data: return
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"No scrobbles found for {lastfm_user}.")
return
-
matches = []
- for track in tracks:
- track_artist = safe_text(track, "artist")
+ for t in tracks:
+ track_artist = _artist_name(t)
if artist_name.lower() in track_artist.lower():
date_str = ""
- if "date" in track and "#text" in track["date"]:
- date_str = track["date"]["#text"]
- matches.append((track, date_str))
-
+ if "date" in t and "#text" in t["date"]:
+ date_str = t["date"]["#text"]
+ matches.append((t, date_str))
if not matches:
- await bot.api.send_text_message(
- room.room_id,
- f"🔍 No scrobbles of **{artist_name}** found for {display_name} (within recent history).",
- )
+ await bot.api.send_text_message(room.room_id, f"🔍 No scrobbles of **{artist_name}** found for {display_name}.")
return
-
oldest_track, oldest_date = matches[-1]
name = safe_text(oldest_track, "name")
- track_artist = safe_text(oldest_track, "artist")
-
- await bot.api.send_markdown_message(
- room.room_id,
- f"🔍 **{display_name}** first scrobbled **{artist_name}** with:\n"
- f" • **{name}** by {track_artist}\n"
- f" • 📅 {oldest_date if oldest_date else 'Unknown date'}",
- )
-
-
-# ---- !concerts ---------------------------------------------------
+ track_artist = _artist_name(oldest_track)
+ await bot.api.send_markdown_message(room.room_id,
+ f"🔍 **{display_name}** first scrobbled **{artist_name}** with:\n • **{name}** by {track_artist}\n • 📅 {oldest_date if oldest_date else 'Unknown date'}")
+# ---- !concerts ----
async def cmd_concerts(room, message, bot, args):
- """Handle !concerts [user] - upcoming concerts for top artists"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- top_data = await call_lastfm_api(
- "user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "10"}, bot, room
- )
- if not top_data:
- return
-
- artists = [safe_text(a, "name") for a in top_data.get("topartists", {}).get("artist", [])]
- if not artists:
- return
-
+ if not lastfm_user: return
+ top = await call_lastfm_api("user.getTopArtists", {"user": lastfm_user, "period": "3month", "limit": "10"}, bot, room)
+ if not top: return
+ artists = [a.get("name", _artist_name(a)) for a in top.get("topartists", {}).get("artist", [])]
+ if not artists: return
await bot.api.send_text_message(room.room_id, "🔍 Searching for upcoming concerts...")
-
all_events = []
- for artist_name in artists[:5]:
- ev_data = await call_lastfm_api(
- "artist.getEvents", {"artist": artist_name, "limit": "3", "autocorrect": "1"}, bot
- )
- if ev_data:
- for ev in ev_data.get("events", {}).get("event", [])[:3]:
- title = safe_text(ev, "title")
- venue_name = safe_text(ev.get("venue", {}), "name", "Unknown Venue")
- city = safe_text(ev.get("venue", {}).get("location", {}), "city", "")
- country = safe_text(ev.get("venue", {}).get("location", {}), "country", "")
- start_date = safe_text(ev, "startDate", "TBD")
- location = f"{city}, {country}" if city else country
- all_events.append((title, artist_name, venue_name, location, start_date))
-
+ for aname in artists[:5]:
+ ev = await call_lastfm_api("artist.getEvents", {"artist": aname, "limit": "3", "autocorrect": "1"}, bot)
+ if ev:
+ for e in ev.get("events", {}).get("event", [])[:3]:
+ title = safe_text(e, "title")
+ venue = safe_text(e.get("venue", {}), "name", "Unknown Venue")
+ city = safe_text(e.get("venue", {}).get("location", {}), "city", "")
+ country = safe_text(e.get("venue", {}).get("location", {}), "country", "")
+ start = safe_text(e, "startDate", "TBD")
+ loc = f"{city}, {country}" if city else country
+ all_events.append((title, aname, venue, loc, start))
if not all_events:
- await bot.api.send_text_message(
- room.room_id, f"🎫 No upcoming concerts found for {display_name}'s top artists."
- )
+ await bot.api.send_text_message(room.room_id, f"🎫 No upcoming concerts found for {display_name}'s top artists.")
return
+ rows = []
+ for title, artist, venue, loc, date in all_events[:15]:
+ rows.append(("🎫", f"{artist} — {title}", f"📍 {venue}, {loc} | 📅 {date}"))
+ output = _output(f"🎫 Upcoming Concerts for {display_name}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- summary = f"🎫 Upcoming Concerts for {display_name}'s Top Artists ({len(all_events)} found)"
- lines = []
- for title, artist, venue, location, date in all_events[:15]:
- lines.append(f" • **{artist}** — {title}")
- lines.append(f" 📍 {venue}, {location} | 📅 {date}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !radio ------------------------------------------------------
-
+# ---- !radio ----
async def cmd_radio(room, message, bot, args):
- """Handle !radio - generate playlist from similar artists"""
if not args:
await bot.api.send_text_message(room.room_id, "Usage: !radio ")
return
-
- artist_name = " ".join(args)
- sim_data = await call_lastfm_api(
- "artist.getSimilar", {"artist": artist_name, "limit": "10", "autocorrect": "1"}, bot, room
- )
- if not sim_data:
- return
-
- similar = sim_data.get("similarartists", {}).get("artist", [])
+ aname = " ".join(args)
+ sim = await call_lastfm_api("artist.getSimilar", {"artist": aname, "limit": "10", "autocorrect": "1"}, bot, room)
+ if not sim: return
+ similar = sim.get("similarartists", {}).get("artist", [])
if not similar:
- await bot.api.send_text_message(room.room_id, f"No similar artists for **{artist_name}**.")
+ await bot.api.send_text_message(room.room_id, f"No similar artists for **{aname}**.")
return
-
playlist = []
- for sim in similar[:8]:
- sim_name = safe_text(sim, "name")
- top_data = await call_lastfm_api(
- "artist.getTopTracks", {"artist": sim_name, "limit": "1", "autocorrect": "1"}, bot
- )
- if top_data:
- tracks = top_data.get("toptracks", {}).get("track", [])
+ for s in similar[:8]:
+ sname = s.get("name", _artist_name(s))
+ top_tracks = await call_lastfm_api("artist.getTopTracks", {"artist": sname, "limit": "1", "autocorrect": "1"}, bot)
+ if top_tracks:
+ tracks = top_tracks.get("toptracks", {}).get("track", [])
if tracks:
- track = tracks[0] if isinstance(tracks, list) else tracks
- tname = safe_text(track, "name")
- playlist.append((sim_name, tname))
-
+ t = tracks[0] if isinstance(tracks, list) else tracks
+ playlist.append((sname, safe_text(t, "name")))
if not playlist:
await bot.api.send_text_message(room.room_id, "Could not generate playlist.")
return
-
- summary = f"📻 Radio: {artist_name} — Similar Artists Playlist ({len(playlist)} tracks)"
- lines = []
- for i, (art, track) in enumerate(playlist, 1):
+ rows = []
+ for art, track in playlist:
yt = await get_youtube_link(art, track)
- yt_str = f" | [▶️ YouTube]({yt})" if yt else ""
- lines.append(f" {i}. **{track}** by {art}{yt_str}")
-
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !mashup -----------------------------------------------------
+ rows.append(("📻", track, f"{art}" + (f" | ▶️ {yt}" if yt else "")))
+ output = _output(f"📻 Radio: {aname} — Similar Artists Playlist", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- !mashup ----
async def cmd_mashup(room, message, bot, args):
- """Handle !mashup - find musical connections"""
if len(args) < 2:
- await bot.api.send_text_message(
- room.room_id, "Usage: !mashup "
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !mashup ")
return
-
full = " ".join(args)
if "," in full:
parts = full.split(",", 1)
- artist1, artist2 = parts[0].strip(), parts[1].strip()
+ a1, a2 = parts[0].strip(), parts[1].strip()
else:
- mid = len(args) // 2
- artist1 = " ".join(args[:mid])
- artist2 = " ".join(args[mid:])
-
- data1 = await call_lastfm_api(
- "artist.getSimilar", {"artist": artist1, "limit": "20", "autocorrect": "1"}, bot, room
- )
- data2 = await call_lastfm_api(
- "artist.getSimilar", {"artist": artist2, "limit": "20", "autocorrect": "1"}, bot, room
- )
-
- if not data1 or not data2:
- return
-
- sim1 = {safe_text(a, "name").lower(): float(a.get("match", 0))
- for a in data1.get("similarartists", {}).get("artist", [])}
- sim2 = {safe_text(a, "name").lower(): float(a.get("match", 0))
- for a in data2.get("similarartists", {}).get("artist", [])}
-
+ mid = len(args)//2
+ a1, a2 = " ".join(args[:mid]), " ".join(args[mid:])
+ d1 = await call_lastfm_api("artist.getSimilar", {"artist": a1, "limit": "20", "autocorrect": "1"}, bot, room)
+ d2 = await call_lastfm_api("artist.getSimilar", {"artist": a2, "limit": "20", "autocorrect": "1"}, bot, room)
+ if not d1 or not d2: return
+ sim1 = {a.get("name", _artist_name(a)).lower(): float(a.get("match",0)) for a in d1.get("similarartists", {}).get("artist", [])}
+ sim2 = {a.get("name", _artist_name(a)).lower(): float(a.get("match",0)) for a in d2.get("similarartists", {}).get("artist", [])}
common = set(sim1.keys()) & set(sim2.keys())
-
- summary = f"🔀 Mashup: {artist1} ↔ {artist2}"
- lines = []
-
+ rows = []
if common:
- shared = sorted(common, key=lambda a: sim1[a] + sim2[a], reverse=True)[:10]
- lines.append(f" • **Shared similar artists:** {len(common)}")
- lines.append(" • **Top connections:**")
+ shared = sorted(common, key=lambda a: sim1[a]+sim2[a], reverse=True)[:10]
+ rows.append(("🔀", "Shared similar artists", str(len(common))))
for a in shared:
- avg = round((sim1[a] + sim2[a]) / 2 * 100)
- lines.append(f" - **{a}** ({avg}% avg match)")
+ avg = round((sim1[a]+sim2[a])/2*100)
+ rows.append(("", a, f"{avg}% avg match"))
else:
- lines.append(" • No direct musical connections found between these artists.")
-
- tags1_data = await call_lastfm_api(
- "artist.getTopTags", {"artist": artist1, "autocorrect": "1"}, bot
- )
- tags2_data = await call_lastfm_api(
- "artist.getTopTags", {"artist": artist2, "autocorrect": "1"}, bot
- )
-
- tags1 = set()
- tags2 = set()
- if tags1_data:
- tags1 = {safe_text(t, "name").lower() for t in tags1_data.get("toptags", {}).get("tag", [])}
- if tags2_data:
- tags2 = {safe_text(t, "name").lower() for t in tags2_data.get("toptags", {}).get("tag", [])}
-
- common_tags = tags1 & tags2
+ rows.append(("🔀", "Connections", "No direct connections found"))
+ tags1 = await call_lastfm_api("artist.getTopTags", {"artist": a1, "autocorrect": "1"}, bot)
+ tags2 = await call_lastfm_api("artist.getTopTags", {"artist": a2, "autocorrect": "1"}, bot)
+ t1 = set(safe_text(t,"name").lower() for t in (tags1.get("toptags",{}).get("tag",[]) if tags1 else []))
+ t2 = set(safe_text(t,"name").lower() for t in (tags2.get("toptags",{}).get("tag",[]) if tags2 else []))
+ common_tags = t1 & t2
if common_tags:
- lines.append(f" • **Shared genres:** {', '.join(sorted(common_tags)[:8])}")
+ rows.append(("🏷️", "Shared genres", ", ".join(sorted(common_tags)[:8])))
+ output = _output(f"🔀 Mashup: {a1} ↔ {a2}", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ===================================================================
-# !collage – new ImageMagick-based implementation
-# ===================================================================
-# ------------------------------------------------------------
-# !collage command (using ImageMagick)
-# ------------------------------------------------------------
+# ---- !collage ----
async def cmd_collage(room, message, bot, args):
- """Handle !collage [user] [size] – create album art collage via ImageMagick."""
matrix_user = str(message.sender)
size = 3
-
user_arg = list(args)
if user_arg and user_arg[-1].isdigit():
size = max(2, min(5, int(user_arg[-1])))
user_arg.pop()
-
lastfm_user, display_name = await resolve_username(matrix_user, user_arg, bot, room)
- if not lastfm_user:
- return
-
- # 1. get top albums
- data = await call_lastfm_api(
- "user.getTopAlbums",
- {"user": lastfm_user, "period": "overall", "limit": str(size * size)},
- bot, room,
- )
- if not data:
- return
-
+ if not lastfm_user: return
+ data = await call_lastfm_api("user.getTopAlbums", {"user": lastfm_user, "period": "overall", "limit": str(size*size)}, bot, room)
+ if not data: return
albums = data.get("topalbums", {}).get("album", [])
if not albums:
await bot.api.send_text_message(room.room_id, f"No albums for {lastfm_user}.")
return
-
- # 2. download all covers concurrently
- timeout = aiohttp.ClientTimeout(total=60)
- async with aiohttp.ClientSession(timeout=timeout, headers=HEADERS) as session:
- tasks = [download_album_art_to_file(session, alb) for alb in albums[:size * size]]
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), headers=HEADERS) as session:
+ tasks = [download_album_art_to_file(session, alb) for alb in albums[:size*size]]
results = await asyncio.gather(*tasks)
-
- # results = list of (artist, album_name, filepath)
downloaded = [r for r in results if r[2] is not None]
if not downloaded:
await bot.api.send_text_message(room.room_id, "Could not download any album art.")
return
-
- # 3. create a white placeholder tile for missing images
- placeholder_path = os.path.join(tempfile.gettempdir(), "lastfm_placeholder.png")
- subprocess.run(["convert", "-size", "200x200", "xc:white", placeholder_path], check=True)
-
- # Build ordered list of files (placeholder where missing)
+ placeholder = os.path.join(tempfile.gettempdir(), "lastfm_placeholder.png")
+ subprocess.run(["convert", "-size", "200x200", "xc:white", placeholder], check=True)
file_list = []
for _, _, path in results:
- file_list.append(path if path else placeholder_path)
- while len(file_list) < size * size:
- file_list.append(placeholder_path)
-
- # 4. Use ImageMagick montage to stitch the grid
+ file_list.append(path if path else placeholder)
+ while len(file_list) < size*size:
+ file_list.append(placeholder)
collage_path = os.path.join(tempfile.gettempdir(), f"lastfm_collage_{lastfm_user}_{int(time.time())}.png")
cmd = ["montage", "-geometry", "200x200+2+2", "-tile", f"{size}x{size}"] + file_list + [collage_path]
try:
subprocess.run(cmd, check=True, timeout=30)
- except subprocess.CalledProcessError as e:
- logging.error(f"montage failed: {e}")
- # fallback: send the first downloaded image
+ except subprocess.CalledProcessError:
if downloaded:
collage_path = downloaded[0][2]
else:
await bot.api.send_text_message(room.room_id, "Failed to create collage.")
return
-
- # 5. send the image
await bot.api.send_image_message(room_id=room.room_id, image_filepath=collage_path)
-
- # 6. collapsible text details
- summary = f"🖼️ {display_name} — Album Collage ({size}×{size})"
- lines = [f"Top {size*size} albums for {display_name}"]
- for i, album in enumerate(albums[:size * size], 1):
- artist = album_artist_name(album)
- name = safe_text(album, "name")
- playcount = safe_int(album, "playcount")
- lines.append(f" {i}. **{name}** by {artist} — *{playcount} plays*")
- body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
- # Cleanup temp files
+ rows = []
+ for alb in albums[:size*size]:
+ artist = album_artist_name(alb)
+ name = safe_text(alb, "name")
+ plays = safe_int(alb, "playcount")
+ rows.append(("🖼️", name, f"{artist} — {plays} plays"))
+ output = _output(f"🖼️ {display_name} — Album Collage ({size}×{size})", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
for _, _, path in downloaded:
- if path and os.path.exists(path):
- os.remove(path)
- if os.path.exists(placeholder_path):
- os.remove(placeholder_path)
- if os.path.exists(collage_path):
- os.remove(collage_path)
-
-
-# ---- !listening --------------------------------------------------
+ if path and os.path.exists(path): os.remove(path)
+ if os.path.exists(placeholder): os.remove(placeholder)
+ if os.path.exists(collage_path): os.remove(collage_path)
+# ---- !listening ----
async def cmd_listening(room, message, bot, args):
- """Handle !listening [user] - what's playing with album art"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
if not lastfm_user:
return
-
data = await call_lastfm_api(
"user.getRecentTracks", {"user": lastfm_user, "limit": "1"}, bot, room
)
if not data:
return
-
tracks = data.get("recenttracks", {}).get("track", [])
if not tracks:
await bot.api.send_text_message(room.room_id, f"No recent tracks for {lastfm_user}.")
return
-
- track = tracks[0] if isinstance(tracks, list) else tracks
- now_playing = track.get("@attr", {}).get("nowplaying", "false") == "true"
- artist = safe_text(track, "artist")
- name = safe_text(track, "name")
- album = safe_text(track, "album", "")
-
- image_url = ""
- for img in track.get("image", []):
- if img.get("size") == "extralarge":
- image_url = img.get("#text", "")
+ t = tracks[0] if isinstance(tracks, list) else tracks
+ now_playing = t.get("@attr", {}).get("nowplaying", "false") == "true"
+ artist = _artist_name(t)
+ name = safe_text(t, "name")
+ album = safe_text(t, "album", "")
+ # find best image
+ img = ""
+ for im in t.get("image", []):
+ if im.get("size") == "extralarge":
+ img = im.get("#text", "")
break
- if not image_url:
- for img in track.get("image", []):
- image_url = img.get("#text", "")
- if image_url:
+ if not img:
+ for im in t.get("image", []):
+ img = im.get("#text", "")
+ if img:
break
-
action = "is listening to" if now_playing else "last listened to"
summary = f"🎧 {display_name} {action}: {name} by {artist}"
lines = []
- if image_url:
- lines.append(f" ")
- lines.append(f" **{name}** by **{artist}**")
+ if img:
+ lines.append(f"")
+ lines.append(f"**{name}** by **{artist}**")
if album:
- lines.append(f" Album: *{album}*")
-
+ lines.append(f"Album: *{album}*")
yt = await get_youtube_link(artist, name)
if yt:
- lines.append(f" [▶️ YouTube]({yt})")
-
+ lines.append(f"[▶️ YouTube]({yt})")
body = "
".join(lines)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !awards -----------------------------------------------------
+ await bot.api.send_markdown_message(room.room_id, collapsible_summary(summary, body))
+# ---- !awards ----
async def cmd_awards(room, message, bot, args):
- """Handle !awards [user] - milestone achievements"""
matrix_user = str(message.sender)
lastfm_user, display_name = await resolve_username(matrix_user, args, bot, room)
- if not lastfm_user:
- return
-
- info_data = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
- if not info_data:
- return
-
- user_info = info_data.get("user", {})
- playcount = safe_int(user_info, "playcount")
- artist_count = safe_int(user_info, "artist_count", 0)
- registered = user_info.get("registered", {}).get("#text", "Unknown")
-
+ if not lastfm_user: return
+ info = await call_lastfm_api("user.getInfo", {"user": lastfm_user}, bot, room)
+ if not info: return
+ ui = info.get("user", {})
+ pc = safe_int(ui, "playcount")
+ ac = safe_int(ui, "artist_count", 0)
+ reg = ui.get("registered", {}).get("#text", "Unknown")
achievements = []
-
- milestones = [
- (100, "🎧 Newcomer"),
- (1000, "🎶 Listener"),
- (5000, "🎵 Collector"),
- (10000, "💿 Music Fanatic"),
- (25000, "🎸 Audiophile"),
- (50000, "🎹 Music Scholar"),
- (100000, "🏆 Scrobble Master"),
- (250000, "👑 Scrobble King/Queen"),
- (500000, "🌟 Scrobble Legend"),
- (1000000, "🌌 Scrobble Galaxy"),
- ]
- for threshold, title in milestones:
- if playcount >= threshold:
- achievements.append(f" • {title} — {threshold:,}+ scrobbles")
-
- if artist_count >= 10:
- achievements.append(f" • 🌿 Explorer — 10+ artists")
- if artist_count >= 50:
- achievements.append(f" • 🌳 Curator — 50+ artists")
- if artist_count >= 100:
- achievements.append(f" • 🌍 Globetrotter — 100+ artists")
- if artist_count >= 500:
- achievements.append(f" • 🌌 Universe Explorer — 500+ artists")
- if artist_count >= 1000:
- achievements.append(f" • 🚀 Cosmopolitan — 1,000+ artists")
-
+ for threshold, title in [(100,"🎧 Newcomer"),(1000,"🎶 Listener"),(5000,"🎵 Collector"),(10000,"💿 Music Fanatic"),
+ (25000,"🎸 Audiophile"),(50000,"🎹 Music Scholar"),(100000,"🏆 Scrobble Master"),
+ (250000,"👑 Scrobble King/Queen"),(500000,"🌟 Scrobble Legend"),(1000000,"🌌 Scrobble Galaxy")]:
+ if pc >= threshold:
+ achievements.append(f"{title} — {threshold:,}+ scrobbles")
+ if ac >= 10: achievements.append("🌿 Explorer — 10+ artists")
+ if ac >= 50: achievements.append("🌳 Curator — 50+ artists")
+ if ac >= 100: achievements.append("🌍 Globetrotter — 100+ artists")
+ if ac >= 500: achievements.append("🌌 Universe Explorer — 500+ artists")
+ if ac >= 1000: achievements.append("🚀 Cosmopolitan — 1,000+ artists")
try:
- reg_date = datetime.strptime(registered, "%d %b %Y")
+ reg_date = datetime.strptime(reg, "%d %b %Y")
years = (datetime.utcnow() - reg_date).days // 365
- if years >= 1:
- achievements.append(f" • 📅 Veteran — {years} year{'s' if years > 1 else ''} on Last.fm")
- if years >= 5:
- achievements.append(f" • 🏅 Loyalist — 5+ years on Last.fm")
- if years >= 10:
- achievements.append(f" • 🎖️ Decade Club — 10+ years on Last.fm")
- except (ValueError, TypeError):
- pass
-
- if user_info.get("subscriber", "0") == "1":
- achievements.append(f" • ⭐ Subscriber — Supporting Last.fm")
-
- if not achievements:
- achievements.append(" • 🆕 Keep scrobbling to earn achievements!")
-
- summary = f"🏆 {display_name} — Achievements"
- header = f" • Total Scrobbles: **{playcount:,}** | Artists: **{artist_count:,}**
"
- body = header + "
".join(achievements)
- await bot.api.send_markdown_message(room.room_id, wrap_collapsible(summary, body))
-
-
-# ---- !lastfm -----------------------------------------------------
+ if years >= 1: achievements.append(f"📅 Veteran — {years} year{'s' if years!=1 else ''} on Last.fm")
+ if years >= 5: achievements.append("🏅 Loyalist — 5+ years on Last.fm")
+ if years >= 10: achievements.append("🎖️ Decade Club — 10+ years on Last.fm")
+ except: pass
+ if ui.get("subscriber","0") == "1": achievements.append("⭐ Subscriber")
+ if not achievements: achievements.append("🆕 Keep scrobbling!")
+ rows = [("🏆", ach, "") for ach in achievements]
+ output = _output(f"🏆 {display_name} — Achievements", rows)
+ await bot.api.send_markdown_message(room.room_id, output)
+# ---- help & dispatch ----
async def cmd_lastfm_help(room, message, bot, args):
- """Handle !lastfm - show help for all Last.fm plugin commands."""
- help_text = """
-🎵 Last.fm Plugin Commands
+ help_text = """🎵 Last.fm Plugin Commands
Registration & Now Playing
• !register <username> - Register your Last.fm username
-• !np [user] - Show currently playing track (no collapsible)
+• !np [user] - Show currently playing track
Recent & Loved
• !recent [user] [limit] - Recent tracks (default 10, max 50)
• !loved [user] - Recently loved tracks
-Top Lists (period: overall/7day/1month/3month/6month/12month)
+Top Lists
• !toptracks [user] [period] - Top tracks
• !topartists [user] [period] - Top artists
• !topalbums [user] [period] - Top albums
Profile & Stats
• !profile [user] - Detailed profile
-• !playcount [user] - Total scrobbles (short output)
+• !playcount [user] - Total scrobbles
• !scrobbles [user] - Detailed scrobbling statistics
Social & Comparison
-• !compare <user1> <user2> - Compare two users' musical tastes
+• !compare <user1> <user2> - Compare musical tastes
• !taste [user] - Top artists with taste-o-meter
• !friends [user] - Last.fm friends
@@ -1956,8 +1189,8 @@ async def cmd_lastfm_help(room, message, bot, args):
• !recommend [user] - Artist recommendations
• !similar <artist> - Find similar artists
• !tag <tag> - Top artists for a tag/genre
-• !radio <artist> - Generate a playlist from similar artists
-• !mashup <artist1> <artist2> - Find musical connections
+• !radio <artist> - Generate a playlist
+• !mashup <artist1> <artist2> - Find connections
Charts & Tags
• !charts - Global top tracks
@@ -1980,103 +1213,38 @@ async def cmd_lastfm_help(room, message, bot, args):
Room‑wide
• !now - Show what registered users are playing
-
-
-"""
+
"""
await bot.api.send_markdown_message(room.room_id, help_text)
-
-# ===================================================================
-# MAIN DISPATCH
-# ===================================================================
-
async def handle_command(room, message, bot, prefix, config):
- """
- Main command dispatcher for the Last.fm plugin.
- Preserves all existing functionality and adds comprehensive new commands.
- """
match = botlib.MessageMatch(room, message, bot, prefix)
-
- # Initialize database on first run
await init_db()
-
- if not (match.is_not_from_this_bot() and match.prefix()):
- return
-
- command = match.command()
+ if not (match.is_not_from_this_bot() and match.prefix()): return
+ cmd = match.command()
args = match.args()
-
- # Command routing table
- command_map = {
- "register": cmd_register,
- "np": cmd_np,
- "recent": cmd_recent,
- "toptracks": cmd_toptracks,
- "topartists": cmd_topartists,
- "topalbums": cmd_topalbums,
- "loved": cmd_loved,
- "profile": cmd_profile,
- "playcount": cmd_playcount,
- "scrobbles": cmd_scrobbles,
- "compare": cmd_compare,
- "taste": cmd_taste,
- "friends": cmd_friends,
- "recommend": cmd_recommend,
- "similar": cmd_similar,
- "tag": cmd_tag,
- "charts": cmd_charts,
- "tagcloud": cmd_tagcloud,
- "now": cmd_now,
- "decades": cmd_decades,
- "genres": cmd_genres,
- "era": cmd_era,
- "weekly": cmd_weekly,
- "monthly": cmd_monthly,
- "yearly": cmd_yearly,
- "first": cmd_first,
- "concerts": cmd_concerts,
- "radio": cmd_radio,
- "mashup": cmd_mashup,
- "collage": cmd_collage,
- "listening": cmd_listening,
- "awards": cmd_awards,
- "lastfm": cmd_lastfm_help,
+ handlers = {
+ "register": cmd_register, "np": cmd_np, "recent": cmd_recent,
+ "toptracks": cmd_toptracks, "topartists": cmd_topartists, "topalbums": cmd_topalbums,
+ "loved": cmd_loved, "profile": cmd_profile, "playcount": cmd_playcount,
+ "scrobbles": cmd_scrobbles, "compare": cmd_compare, "taste": cmd_taste,
+ "friends": cmd_friends, "recommend": cmd_recommend, "similar": cmd_similar,
+ "tag": cmd_tag, "charts": cmd_charts, "tagcloud": cmd_tagcloud,
+ "now": cmd_now, "decades": cmd_decades, "genres": cmd_genres,
+ "era": cmd_era, "weekly": cmd_weekly, "monthly": cmd_monthly,
+ "yearly": cmd_yearly, "first": cmd_first, "concerts": cmd_concerts,
+ "radio": cmd_radio, "mashup": cmd_mashup, "collage": cmd_collage,
+ "listening": cmd_listening, "awards": cmd_awards, "lastfm": cmd_lastfm_help,
}
-
- handler = command_map.get(command)
+ handler = handlers.get(cmd)
if handler:
try:
await handler(room, message, bot, args)
except Exception as e:
- logging.error(f"Error in Last.fm command '{command}': {e}")
- await bot.api.send_text_message(
- room.room_id, f"❌ Error processing !{command}: {str(e)}"
- )
+ logging.error(f"Error in Last.fm command '{cmd}': {e}")
+ await bot.api.send_text_message(room.room_id, f"❌ Error processing !{cmd}: {str(e)}")
-
-# ---------------------------------------------------------------------------
-# Plugin Metadata
-# ---------------------------------------------------------------------------
-
-__version__ = "1.0.0"
+__version__ = "1.1.1"
__author__ = "Funguy Bot"
-__description__ = "Last.fm integration"
-__help__ = """
-
-!lastfm – Last.fm music stats (30+ commands)
-
-!register <username> – Connect account
-!np [user] – Now playing
-!recent [user] [limit] – Recent tracks
-!toptracks, !topartists, !topalbums
-!loved, !profile, !playcount, !scrobbles
-!compare <user1> <user2> – Taste comparison
-!recommend, !similar <artist>, !tag <genre>
-!charts, !now, !decades, !genres, !tagcloud
-!era <year>, !weekly, !monthly, !yearly
-!first <artist>, !concerts, !radio <artist>
-!collage [user] [size], !listening, !awards
-
-For full details: !lastfm
Requires LASTFM_API_KEY env var.
-
-"""
+__description__ = "Last.fm music stats with aligned code block output"
+__help__ = """!lastfm – Last.fm music stats
+Use !lastfm for full command list. Requires LASTFM_API_KEY env var.
"""
diff --git a/plugins/plugins.py b/plugins/plugins.py
index 069d80b..2a1734c 100644
--- a/plugins/plugins.py
+++ b/plugins/plugins.py
@@ -51,7 +51,7 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.4"
__author__ = "Funguy Bot"
-__description__ = "List all loaded plugins with count, collapsible"
+__description__ = "List all loaded plugins with count"
__help__ = """
!plugins – List active plugins
diff --git a/plugins/proxy.py b/plugins/proxy.py
index 19ec94d..f40f26a 100644
--- a/plugins/proxy.py
+++ b/plugins/proxy.py
@@ -138,7 +138,7 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.2"
__author__ = "Funguy Bot"
-__description__ = "Working SOCKS5 proxy finder (SSRF‑safe, async)"
+__description__ = "Working SOCKS5 proxy finder"
__help__ = """
!proxy – Random working SOCKS5 proxy
diff --git a/plugins/quote.py b/plugins/quote.py
index d39b083..17dd76c 100644
--- a/plugins/quote.py
+++ b/plugins/quote.py
@@ -120,6 +120,6 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.2"
__author__ = "Funguy Bot"
-__description__ = "Goodreads quotes via Playwright (headless)"
+__description__ = "Fetch Goodreads quotes"
__help__ = """!quote – Quotes from Goodreads
!quote random, !quote <author>.
"""
diff --git a/plugins/roomstats.py b/plugins/roomstats.py
index 302cdd0..bebdc7f 100644
--- a/plugins/roomstats.py
+++ b/plugins/roomstats.py
@@ -2,79 +2,56 @@
"""
plugins/roomstats.py — per‑user room statistics (Limnoria‑style).
Commands: !roomstats, !rank, !stats
+Output is a clean code block with emojis and aligned columns.
"""
import time
import re
import sqlite3
import logging
-
import nio
import simplematrixbotlib as botlib
+from plugins.common import collapsible_summary, code_block
logger = logging.getLogger("roomstats")
-
DB_PATH = "roomstats.db"
-# ------------------------------------------------------------------
-# Emoji / smiley regex (Unicode blocks)
-# ------------------------------------------------------------------
+# Emoji regex (unchanged)
EMOJI_RE = re.compile(
"["
- "\U0001F600-\U0001F64F" # Emoticons
- "\U0001F300-\U0001F5FF" # Symbols & pictographs
- "\U0001F680-\U0001F6FF" # Transport & map
- "\U0001F1E0-\U0001F1FF" # Flags
- "\U00002702-\U000027B0" # Dingbats
- "\U000024C2-\U0001F251" # Misc
- "]+", re.UNICODE)
+ "\U0001F600-\U0001F64F"
+ "\U0001F300-\U0001F5FF"
+ "\U0001F680-\U0001F6FF"
+ "\U0001F1E0-\U0001F1FF"
+ "\U00002702-\U000027B0"
+ "\U000024C2-\U0001F251"
+ "]+", re.UNICODE
+)
def count_smileys(text):
- """Return number of emoji occurrences."""
return len(EMOJI_RE.findall(text))
-# ------------------------------------------------------------------
-# Database init
-# ------------------------------------------------------------------
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS user_room_stats (
- room_id TEXT,
- user_id TEXT,
- msgs INTEGER DEFAULT 0,
- chars INTEGER DEFAULT 0,
- words INTEGER DEFAULT 0,
- smileys INTEGER DEFAULT 0,
- actions INTEGER DEFAULT 0,
- joins INTEGER DEFAULT 0,
- parts INTEGER DEFAULT 0,
- kicks_given INTEGER DEFAULT 0,
- kicked_received INTEGER DEFAULT 0,
- topics_set INTEGER DEFAULT 0,
- last_updated INTEGER,
+ room_id TEXT, user_id TEXT,
+ msgs INTEGER DEFAULT 0, chars INTEGER DEFAULT 0, words INTEGER DEFAULT 0,
+ smileys INTEGER DEFAULT 0, actions INTEGER DEFAULT 0,
+ joins INTEGER DEFAULT 0, parts INTEGER DEFAULT 0,
+ kicks_given INTEGER DEFAULT 0, kicked_received INTEGER DEFAULT 0,
+ topics_set INTEGER DEFAULT 0, last_updated INTEGER,
PRIMARY KEY (room_id, user_id)
)
""")
conn.commit()
conn.close()
-# ------------------------------------------------------------------
-# Multi‑word user resolution helper
-# ------------------------------------------------------------------
async def resolve_user_from_tokens(bot, room_id, tokens):
- """
- Given a list of word tokens, find a matching display name.
- Returns (mxid, display_name) or raises ValueError.
- """
- # Build cache of (lowered display name → user_id) from joined members
resp = await bot.async_client.joined_members(room_id)
if resp.members is None:
raise ValueError("Could not fetch member list.")
-
- # Create a dict: lower_display → (mxid, display_name)
- # If duplicate display name, store None to signal ambiguity.
cache = {}
for member in resp.members:
display = (member.display_name or "").strip()
@@ -85,68 +62,31 @@ async def resolve_user_from_tokens(bot, room_id, tokens):
cache[key] = None
else:
cache[key] = (member.user_id, display)
-
- # Try progressively longer prefixes of the tokens
for end in range(len(tokens), 0, -1):
candidate = " ".join(tokens[:end]).strip().lower()
if candidate in cache:
entry = cache[candidate]
if entry is not None:
- return entry # (mxid, display_name)
- else:
- # Ambiguous – we need to fetch and check exactly
- matches = []
- for member in resp.members:
- if (member.display_name or "").strip().lower() == candidate:
- matches.append((member.user_id, member.display_name or member.user_id))
- if len(matches) == 1:
- return matches[0]
- elif len(matches) > 1:
- raise ValueError(
- f"Multiple users have display name '{candidate}'. Use an MXID instead."
- )
- # if none, continue
+ return entry
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
-async def resolve_user(bot, room_id, name_or_tokens):
- """
- Accept either a single string (MXID or single-token display name)
- or a list of tokens. Returns (mxid, display_name).
- """
- if isinstance(name_or_tokens, str):
- if name_or_tokens.startswith("@"):
- return name_or_tokens, None
- # Single token – try direct cache match or fallback to multi‑word
- tokens = [name_or_tokens]
- else:
- tokens = name_or_tokens
-
- return await resolve_user_from_tokens(bot, room_id, tokens)
-
-# ------------------------------------------------------------------
-# Setup: register custom event listeners for membership & topics
-# ------------------------------------------------------------------
def setup(bot):
init_db()
-
@bot.listener.on_custom_event(nio.RoomMemberEvent)
async def member_event(room, event):
room_id = room.room_id
membership = event.content.get("membership")
state_key = event.state_key
sender = event.sender
-
- # Ignore the bot's own membership changes
if state_key == bot.async_client.user_id:
return
-
if membership == "join":
_incr(room_id, state_key, "joins")
elif membership == "leave":
- if sender != state_key: # kick
+ if sender != state_key:
_incr(room_id, sender, "kicks_given")
_incr(room_id, state_key, "kicked_received")
- else: # part
+ else:
_incr(room_id, state_key, "parts")
@bot.listener.on_custom_event(nio.RoomTopicEvent)
@@ -156,53 +96,34 @@ def setup(bot):
_incr(room_id, sender, "topics_set")
def _incr(room_id, user_id, column):
- """Increment a stat column by 1, creating row if needed."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
- c.execute(
- "INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)",
- (room_id, user_id)
- )
- c.execute(
- f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
- (int(time.time()), room_id, user_id)
- )
+ c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, user_id))
+ c.execute(f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
+ (int(time.time()), room_id, user_id))
conn.commit()
conn.close()
-# ------------------------------------------------------------------
-# Message handler – silently records stats, and handles commands
-# ------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
room_id = room.room_id
sender = message.sender
- # ----- silently record stats for any non‑bot message -----
- if sender != bot.async_client.user_id: # <-- FIXED
+ # silently record stats
+ if sender != bot.async_client.user_id:
body = message.body or ""
words = len(body.split())
chars = len(body)
smileys = count_smileys(body)
is_action = getattr(message, "msgtype", None) == "m.emote"
-
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
- c.execute(
- """UPDATE user_room_stats
- SET msgs = msgs + 1,
- chars = chars + ?,
- words = words + ?,
- smileys = smileys + ?,
- actions = actions + ?,
- last_updated = ?
- WHERE room_id = ? AND user_id = ?""",
- (chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender)
- )
+ c.execute("""UPDATE user_room_stats SET msgs=msgs+1, chars=chars+?, words=words+?, smileys=smileys+?, actions=actions+?, last_updated=?
+ WHERE room_id=? AND user_id=?""",
+ (chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender))
conn.commit()
conn.close()
- # ----- command matching -----
match = botlib.MessageMatch(room, message, bot, prefix)
if not match.is_not_from_this_bot() or not match.prefix():
return
@@ -210,33 +131,16 @@ async def handle_command(room, message, bot, prefix, config):
cmd = match.command()
args = match.args()
- # ===============================
- # !roomstats
- # ===============================
if cmd == "roomstats":
await _handle_roomstats(bot, room_id)
-
- # ===============================
- # !rank
- # ===============================
elif cmd == "rank":
if not args:
- await bot.api.send_text_message(
- room_id,
- "Usage: !rank \n"
- "Stats: msgs, chars, words, smileys, actions, joins, parts, "
- "kicks_given, kicked_received, topics_set"
- )
+ await bot.api.send_text_message(room_id, "Usage: !rank ")
return
col = args[0].lower()
await _handle_rank(bot, room_id, col)
-
- # ===============================
- # !stats []
- # ===============================
elif cmd == "stats":
if args:
- # Use all tokens as the display name (multi‑word)
try:
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
except ValueError as e:
@@ -244,44 +148,27 @@ async def handle_command(room, message, bot, prefix, config):
return
else:
target_mxid = sender
- await _handle_user_stats(bot, room_id, target_mxid, sender)
+ await _handle_user_stats(bot, room_id, target_mxid)
-# ------------------------------------------------------------------
-# Command implementations
-# ------------------------------------------------------------------
VALID_STATS = {
- "msgs": "Messages",
- "chars": "Characters",
- "words": "Words",
- "smileys": "Smileys",
- "actions": "Actions",
- "joins": "Joins",
- "parts": "Parts",
- "kicks_given": "Kicks given",
- "kicked_received": "Times kicked",
- "topics_set": "Topics set",
+ "msgs": "Messages", "chars": "Characters", "words": "Words", "smileys": "Smileys",
+ "actions": "Actions", "joins": "Joins", "parts": "Parts", "kicks_given": "Kicks given",
+ "kicked_received": "Times kicked", "topics_set": "Topics set",
}
async def _get_aggregate(room_id):
- """Return dict of aggregate stats for a room."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
- c.execute("""SELECT
- COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0),
- COALESCE(SUM(words),0), COALESCE(SUM(smileys),0),
- COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
- COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0),
- COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0)
+ c.execute("""SELECT COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), COALESCE(SUM(words),0),
+ COALESCE(SUM(smileys),0), COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
+ COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0), COALESCE(SUM(kicked_received),0),
+ COALESCE(SUM(topics_set),0)
FROM user_room_stats WHERE room_id=?""", (room_id,))
row = c.fetchone()
conn.close()
if not row or all(v == 0 for v in row):
return None
- return {
- "msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3],
- "actions": row[4], "joins": row[5], "parts": row[6],
- "kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9]
- }
+ return dict(zip(VALID_STATS.keys(), row))
async def _handle_roomstats(bot, room_id):
agg = await _get_aggregate(room_id)
@@ -289,17 +176,14 @@ async def _handle_roomstats(bot, room_id):
await bot.api.send_text_message(room_id, "No stats collected yet.")
return
- # Get top 10 by msgs
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
- c.execute("""SELECT user_id, msgs FROM user_room_stats
- WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
+ c.execute("SELECT user_id, msgs FROM user_room_stats WHERE room_id=? ORDER BY msgs DESC LIMIT 10", (room_id,))
top = c.fetchall()
conn.close()
- # Resolve display names for top users
- top_lines = []
resp = await bot.async_client.joined_members(room_id)
+ top_rows = []
for uid, cnt in top:
disp = uid
if resp.members:
@@ -307,78 +191,63 @@ async def _handle_roomstats(bot, room_id):
if m.user_id == uid:
disp = m.display_name or uid
break
- top_lines.append(f"{disp} — {cnt} msgs")
+ top_rows.append(("📈", disp, f"{cnt} msgs"))
- msg = f"""
-Room Statistics
-
-- 📩 Messages: {agg['msgs']}
-- 🔤 Characters: {agg['chars']}
-- 📝 Words: {agg['words']}
-- 😀 Smileys: {agg['smileys']}
-- 🎭 Actions: {agg['actions']}
-- 🚪 Joins: {agg['joins']}
-- 👋 Parts: {agg['parts']}
-- 👢 Kicks given: {agg['kicks_given']}
-- 🥾 Times kicked: {agg['kicked_received']}
-- 📌 Topics set: {agg['topics_set']}
-
-Top 10 by messages:
-
-{''.join(top_lines)}
-
- """
- await bot.api.send_markdown_message(room_id, msg)
+ sections = [
+ {"title": "Room Statistics", "rows": [
+ ("📩", "Messages", agg["msgs"]),
+ ("🔤", "Characters", agg["chars"]),
+ ("📝", "Words", agg["words"]),
+ ("😀", "Smileys", agg["smileys"]),
+ ("🎭", "Actions", agg["actions"]),
+ ("🚪", "Joins", agg["joins"]),
+ ("👋", "Parts", agg["parts"]),
+ ("👢", "Kicks given", agg["kicks_given"]),
+ ("🥾", "Times kicked", agg["kicked_received"]),
+ ("📌", "Topics set", agg["topics_set"]),
+ ]},
+ {"title": "Top 10 by messages", "rows": top_rows},
+ ]
+ block = code_block("📊 Room Statistics", sections)
+ output = collapsible_summary("📊 Room Statistics", block)
+ await bot.api.send_markdown_message(room_id, output)
async def _handle_rank(bot, room_id, col):
- # Validate column
if col not in VALID_STATS:
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
return
-
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
- # Safe to use f-string because col is validated against a hardcoded set
- c.execute(f"""SELECT user_id, {col} FROM user_room_stats
- WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,))
+ c.execute(f"SELECT user_id, {col} FROM user_room_stats WHERE room_id=? AND {col}>0 ORDER BY {col} DESC LIMIT 10", (room_id,))
rows = c.fetchall()
conn.close()
-
if not rows:
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
return
resp = await bot.async_client.joined_members(room_id)
- items = []
- for i, (uid, val) in enumerate(rows, 1):
+ rank_rows = []
+ for uid, val in rows:
disp = uid
if resp.members:
for m in resp.members:
if m.user_id == uid:
disp = m.display_name or uid
break
- items.append(f"{i}. {disp} — {val}")
+ rank_rows.append(("🏅", disp, str(val)))
+ sections = [{"title": f"Ranking by {VALID_STATS[col]}", "rows": rank_rows}]
+ block = code_block(f"🏆 Top {VALID_STATS[col]}", sections)
+ output = collapsible_summary(f"🏆 {VALID_STATS[col]} Ranking", block)
+ await bot.api.send_markdown_message(room_id, output)
- msg = f"""
-Ranking by {VALID_STATS[col]}
-
-{''.join(items)}
-
- """
- await bot.api.send_markdown_message(room_id, msg)
-
-async def _handle_user_stats(bot, room_id, user_id, sender):
- # Fetch stats
+async def _handle_user_stats(bot, room_id, user_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
- c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts,
- kicks_given, kicked_received, topics_set
+ c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
row = c.fetchone()
conn.close()
-
if not row or all(v == 0 for v in row):
- # No stats, maybe just joined – get display name for the message
disp = user_id
resp = await bot.async_client.joined_members(room_id)
if resp.members:
@@ -389,46 +258,44 @@ async def _handle_user_stats(bot, room_id, user_id, sender):
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
return
- # Get display name
- disp = user_id
resp = await bot.async_client.joined_members(room_id)
+ disp = user_id
if resp.members:
for m in resp.members:
if m.user_id == user_id:
disp = m.display_name or user_id
break
- msg = f"""
-Stats for {disp}
-
-- 📩 Messages: {row[0]}
-- 🔤 Characters: {row[1]}
-- 📝 Words: {row[2]}
-- 😀 Smileys: {row[3]}
-- 🎭 Actions: {row[4]}
-- 🚪 Joins: {row[5]}
-- 👋 Parts: {row[6]}
-- 👢 Kicks given: {row[7]}
-- 🥾 Times kicked: {row[8]}
-- 📌 Topics set: {row[9]}
-
- """
- await bot.api.send_markdown_message(room_id, msg)
+ rows = [
+ ("📩", "Messages", row[0]),
+ ("🔤", "Characters", row[1]),
+ ("📝", "Words", row[2]),
+ ("😀", "Smileys", row[3]),
+ ("🎭", "Actions", row[4]),
+ ("🚪", "Joins", row[5]),
+ ("👋", "Parts", row[6]),
+ ("👢", "Kicks given", row[7]),
+ ("🥾", "Times kicked", row[8]),
+ ("📌", "Topics set", row[9]),
+ ]
+ sections = [{"title": f"Stats for {disp}", "rows": rows}]
+ block = code_block(f"📊 Stats for {disp}", sections)
+ output = collapsible_summary(f"📊 Stats: {disp}", block)
+ await bot.api.send_markdown_message(room_id, output)
-# ------------------------------------------------------------------
-# Plugin metadata
-# ------------------------------------------------------------------
-__version__ = "1.0.1"
+# ---------------------------------------------------------------------------
+# Plugin Metadata
+# ---------------------------------------------------------------------------
+__version__ = "1.1.0"
__author__ = "Funguy Roomstats"
-__description__ = "Per‑user room statistics (Limnoria‑style), with multi‑word name support"
+__description__ = "Per‑user room statistics"
__help__ = """
Room Statistics Commands
!roomstats – Aggregate room stats + top 10 users
-!rank <stat> – Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)
-!stats [name] – Show stats for a user (supports multi‑word names)
+!rank <stat> – Top 10 by a specific stat
+!stats [name] – Show stats for a user
-All commands work in the current room; display names are automatically resolved.
"""
diff --git a/plugins/shodan.py b/plugins/shodan.py
index dc19ac4..cf9e631 100644
--- a/plugins/shodan.py
+++ b/plugins/shodan.py
@@ -1,77 +1,43 @@
"""
-This plugin provides Shodan.io integration for security research and reconnaissance.
+Shodan.io integration for security research and reconnaissance.
+Output uses shared code_block for aligned columns.
"""
import logging
import os
import aiohttp
import simplematrixbotlib as botlib
-from plugins.common import html_escape, collapsible_summary
+from plugins.common import html_escape, code_block, collapsible_summary
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
SHODAN_API_BASE = "https://api.shodan.io"
async def handle_command(room, message, bot, prefix, config):
- """
- Function to handle Shodan commands.
- """
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"):
- logging.info("Received !shodan command")
-
- # Check if API key is configured
if not SHODAN_API_KEY:
- await bot.api.send_text_message(
- room.room_id,
- "Shodan API key not configured. Please set SHODAN_KEY environment variable."
- )
- logging.error("Shodan API key not configured")
+ await bot.api.send_text_message(room.room_id, "Shodan API key not configured.")
return
-
args = match.args()
-
if len(args) < 1:
await show_usage(room, bot)
return
-
- subcommand = args[0].lower()
-
- if subcommand == "ip":
- if len(args) < 2:
- await bot.api.send_text_message(room.room_id, "Usage: !shodan ip ")
- return
- ip = args[1]
- await shodan_ip_lookup(room, bot, ip)
-
- elif subcommand == "search":
- if len(args) < 2:
- await bot.api.send_text_message(room.room_id, "Usage: !shodan search ")
- return
- query = ' '.join(args[1:])
+ sub = args[0].lower()
+ if sub == "ip" and len(args) >= 2:
+ await shodan_ip_lookup(room, bot, args[1])
+ elif sub == "search" and len(args) >= 2:
+ query = " ".join(args[1:])
await shodan_search(room, bot, query)
-
- elif subcommand == "host":
- if len(args) < 2:
- await bot.api.send_text_message(room.room_id, "Usage: !shodan host ")
- return
- host = args[1]
- await shodan_host(room, bot, host)
-
- elif subcommand == "count":
- if len(args) < 2:
- await bot.api.send_text_message(room.room_id, "Usage: !shodan count ")
- return
- query = ' '.join(args[1:])
+ elif sub == "host" and len(args) >= 2:
+ await shodan_host(room, bot, args[1])
+ elif sub == "count" and len(args) >= 2:
+ query = " ".join(args[1:])
await shodan_count(room, bot, query)
-
else:
await show_usage(room, bot)
async def show_usage(room, bot):
- """Display Shodan command usage."""
- usage = """
-🔍 Shodan Commands:
-
+ usage = """🔍 Shodan Commands:
!shodan ip <ip_address> - Get detailed information about an IP
!shodan search <query> - Search Shodan database
!shodan host <domain/ip> - Get host information
@@ -86,228 +52,112 @@ async def show_usage(room, bot):
await bot.api.send_markdown_message(room.room_id, usage)
async def shodan_ip_lookup(room, bot, ip):
- """Look up information about a specific IP address."""
+ safe_ip = html_escape(ip)
try:
url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}"
- logging.info(f"Fetching Shodan IP info for: {ip}")
async with aiohttp.ClientSession() as session:
- async with session.get(url, timeout=15) as response:
- if response.status == 404:
- await bot.api.send_text_message(room.room_id, f"No information found for IP: {html_escape(ip)}")
- return
- elif response.status == 401:
- await bot.api.send_text_message(room.room_id, "Invalid Shodan API key")
- return
- elif response.status != 200:
- await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status}")
+ async with session.get(url, timeout=15) as resp:
+ if resp.status == 404:
+ await bot.api.send_text_message(room.room_id, f"No information found for IP: {safe_ip}")
return
+ resp.raise_for_status()
+ data = await resp.json()
- data = await response.json()
-
- # Format the response
- output = f"🔍 Shodan IP Lookup: {html_escape(ip)}
"
-
- if data.get('country_name'):
- output += f"📍 Location: {html_escape(data.get('city', 'N/A'))}, {html_escape(data.get('country_name', 'N/A'))}
"
-
- if data.get('org'):
- output += f"🏢 Organization: {html_escape(data['org'])}
"
-
- if data.get('os'):
- output += f"💻 Operating System: {html_escape(data['os'])}
"
-
- if data.get('ports'):
- output += f"🔌 Open Ports: {', '.join(map(str, data['ports']))}
"
-
- output += f"🕒 Last Update: {data.get('last_update', 'N/A')}
"
-
- # Show services
+ rows = [
+ ("🌐", "IP", safe_ip),
+ ("📍", "Location", f"{data.get('city','N/A')}, {data.get('country_name','N/A')}"),
+ ("🏢", "Organization", data.get('org', 'N/A')),
+ ("💻", "OS", data.get('os', 'N/A')),
+ ("🔌", "Open Ports", ', '.join(map(str, data.get('ports', []))) or 'None'),
+ ]
if data.get('data'):
- output += "📡 Services:
"
- for service in data['data'][:5]: # Limit to first 5 services
- port = service.get('port', 'N/A')
- product = service.get('product', 'Unknown')
- version = service.get('version', '')
- banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '')
-
- output += f" • Port {port}: {html_escape(product)} {html_escape(version)}
"
- if banner:
- output += f" {html_escape(banner)}
"
-
- if len(data['data']) > 5:
- output += f" • ... and {len(data['data']) - 5} more services
"
-
- # Wrap in collapsible if output is large
- if len(output) > 500:
- output = collapsible_summary(f"🔍 Shodan IP Lookup: {html_escape(ip)}", output)
-
+ for svc in data['data'][:5]:
+ rows.append(("📡", f"Port {svc.get('port')}", svc.get('product','Unknown')))
+ sections = [{"title": f"Shodan IP Lookup: {safe_ip}", "rows": rows}]
+ block = code_block(f"🔍 Shodan IP Lookup: {safe_ip}", sections)
+ output = collapsible_summary(f"🔍 Shodan: {safe_ip}", block)
await bot.api.send_markdown_message(room.room_id, output)
- logging.info(f"Sent Shodan IP info for {ip}")
except aiohttp.ClientError as e:
- await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {e}")
- logging.error(f"Shodan API error: {e}")
- except Exception as e:
- await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
- logging.error(f"Error in shodan_ip_lookup: {e}")
+ await bot.api.send_text_message(room.room_id, f"API error: {e}")
async def shodan_search(room, bot, query):
- """Search the Shodan database."""
+ safe_query = html_escape(query)
try:
- url = f"{SHODAN_API_BASE}/shodan/host/search"
- params = {
- "key": SHODAN_API_KEY,
- "query": query,
- "minify": "true",
- "limit": 5
- }
- logging.info(f"Searching Shodan for: {query}")
+ url = f"{SHODAN_API_BASE}/shodan/host/search?key={SHODAN_API_KEY}&query={query}&minify=true&limit=5"
async with aiohttp.ClientSession() as session:
- async with session.get(url, params=params, timeout=15) as response:
- if response.status != 200:
- await handle_shodan_error(room, bot, response.status)
- return
- data = await response.json()
-
+ async with session.get(url, timeout=15) as resp:
+ resp.raise_for_status()
+ data = await resp.json()
if not data.get('matches'):
- await bot.api.send_text_message(room.room_id, f"No results found for: {html_escape(query)}")
+ await bot.api.send_text_message(room.room_id, f"No results for '{safe_query}'.")
return
- output = f"🔍 Shodan Search: '{html_escape(query)}'
"
- output += f"Total Results: {data.get('total', 0):,}
"
-
- for match in data['matches'][:5]: # Show first 5 results
+ rows = []
+ for match in data['matches'][:5]:
ip = match.get('ip_str', 'N/A')
- port = match.get('port', 'N/A')
+ port = match.get('port', '')
org = match.get('org', 'Unknown')
product = match.get('product', 'Unknown')
-
- output += f"🌐 {html_escape(ip)}:{port}
"
- output += f" • Organization: {html_escape(org)}
"
- output += f" • Service: {html_escape(product)}
"
-
- if match.get('location'):
- loc = match['location']
- if loc.get('city') and loc.get('country_name'):
- output += f" • Location: {html_escape(loc['city'])}, {html_escape(loc['country_name'])}
"
-
- output += "
"
-
- if data.get('total', 0) > 5:
- output += f"Showing 5 of {data['total']:,} results. Refine your search for more specific results."
-
+ rows.append(("🌐", f"{ip}:{port}", f"{product} – {org}"))
+ sections = [{"title": f"Search: {safe_query}", "rows": rows}]
+ block = code_block(f"🔍 Shodan Search: {safe_query}", sections)
+ output = collapsible_summary(f"Shodan Search: {safe_query}", block)
await bot.api.send_markdown_message(room.room_id, output)
- logging.info(f"Sent Shodan search results for: {query}")
except aiohttp.ClientError as e:
- await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {e}")
- logging.error(f"Shodan API error: {e}")
- except Exception as e:
- await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
- logging.error(f"Error in shodan_search: {e}")
+ await bot.api.send_text_message(room.room_id, f"API error: {e}")
async def shodan_host(room, bot, host):
- """Get host information (domain or IP)."""
+ safe_host = html_escape(host)
try:
url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}"
- logging.info(f"Fetching Shodan host info for: {host}")
async with aiohttp.ClientSession() as session:
- async with session.get(url, timeout=15) as response:
- if response.status == 404:
- # Try IP lookup instead
+ async with session.get(url, timeout=15) as resp:
+ if resp.status == 404:
await shodan_ip_lookup(room, bot, host)
return
- elif response.status != 200:
- await handle_shodan_error(room, bot, response.status)
- return
- data = await response.json()
-
- output = f"🔍 Shodan Host: {html_escape(host)}
"
-
+ resp.raise_for_status()
+ data = await resp.json()
+ rows = [("🌐", "Domain", safe_host)]
if data.get('subdomains'):
- output += f"🌐 Subdomains ({len(data['subdomains'])}):
"
- for subdomain in sorted(data['subdomains'])[:10]: # Show first 10
- output += f" • {html_escape(subdomain)}.{html_escape(host)}
"
-
+ for sub in sorted(data['subdomains'])[:10]:
+ rows.append(("", "Subdomain", f"{sub}.{safe_host}"))
if len(data['subdomains']) > 10:
- output += f" • ... and {len(data['subdomains']) - 10} more
"
-
- if data.get('tags'):
- output += f"
🏷️ Tags: {', '.join(html_escape(t) for t in data['tags'])}
"
-
- if data.get('data'):
- output += f"
📊 Records Found: {len(data['data'])}
"
-
+ rows.append(("", "", f"... and {len(data['subdomains']) - 10} more"))
+ sections = [{"title": f"Host: {safe_host}", "rows": rows}]
+ block = code_block(f"🔍 Shodan Host: {safe_host}", sections)
+ output = collapsible_summary(f"Shodan Host: {safe_host}", block)
await bot.api.send_markdown_message(room.room_id, output)
- logging.info(f"Sent Shodan host info for: {host}")
except aiohttp.ClientError as e:
- await bot.api.send_text_message(room.room_id, f"Error fetching host info: {e}")
- logging.error(f"Shodan API error: {e}")
- except Exception as e:
- await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
- logging.error(f"Error in shodan_host: {e}")
+ await bot.api.send_text_message(room.room_id, f"API error: {e}")
async def shodan_count(room, bot, query):
- """Count results for a search query."""
+ safe_query = html_escape(query)
try:
- url = f"{SHODAN_API_BASE}/shodan/host/count"
- params = {
- "key": SHODAN_API_KEY,
- "query": query
- }
- logging.info(f"Counting Shodan results for: {query}")
+ url = f"{SHODAN_API_BASE}/shodan/host/count?key={SHODAN_API_KEY}&query={query}"
async with aiohttp.ClientSession() as session:
- async with session.get(url, params=params, timeout=15) as response:
- if response.status != 200:
- await handle_shodan_error(room, bot, response.status)
- return
- data = await response.json()
-
- output = f"🔍 Shodan Count: '{html_escape(query)}'
"
- output += f"Total Results: {data.get('total', 0):,}
"
-
- # Show top countries if available
- if data.get('facets') and 'country' in data['facets']:
- output += "
🌍 Top Countries:
"
- for country in data['facets']['country'][:5]:
- output += f" • {html_escape(country['value'])}: {country['count']:,}
"
-
- # Show top organizations if available
- if data.get('facets') and 'org' in data['facets']:
- output += "
🏢 Top Organizations:
"
- for org in data['facets']['org'][:5]:
- output += f" • {html_escape(org['value'])}: {org['count']:,}
"
-
+ async with session.get(url, timeout=15) as resp:
+ resp.raise_for_status()
+ data = await resp.json()
+ rows = [("🔢", "Total Results", f"{data.get('total', 0):,}")]
+ if data.get('facets'):
+ for facet_name, facet_data in data['facets'].items():
+ for item in facet_data[:5]:
+ rows.append(("", facet_name.capitalize(), f"{item['value']}: {item['count']:,}"))
+ sections = [{"title": f"Count: {safe_query}", "rows": rows}]
+ block = code_block(f"🔍 Shodan Count: {safe_query}", sections)
+ output = collapsible_summary(f"Shodan Count: {safe_query}", block)
await bot.api.send_markdown_message(room.room_id, output)
- logging.info(f"Sent Shodan count for: {query}")
except aiohttp.ClientError as e:
- await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {e}")
- logging.error(f"Shodan API error: {e}")
- except Exception as e:
- await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
- logging.error(f"Error in shodan_count: {e}")
-
-async def handle_shodan_error(room, bot, status_code):
- """Handle Shodan API errors."""
- error_messages = {
- 401: "Invalid Shodan API key",
- 403: "Access denied - check API key permissions",
- 404: "No results found",
- 429: "Rate limit exceeded - try again later",
- 500: "Shodan API server error",
- 503: "Shodan API temporarily unavailable"
- }
- message = error_messages.get(status_code, f"Shodan API error: {status_code}")
- await bot.api.send_text_message(room.room_id, message)
- logging.error(f"Shodan API error: {status_code}")
+ await bot.api.send_text_message(room.room_id, f"API error: {e}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
-
-__version__ = "1.0.1"
+__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Shodan.io reconnaissance"
__help__ = """
@@ -319,13 +169,6 @@ __help__ = """
!shodan host <domain> – Host & subdomain enumeration
!shodan count <query> – Result counts
-Search Examples:
-
-!shodan search apache
-!shodan search "port:22"
-!shodan search "country:US product:nginx"
-!shodan search "net:192.168.1.0/24"
-
Requires SHODAN_KEY env var.
"""
diff --git a/plugins/sslscan.py b/plugins/sslscan.py
index 29ca8de..8e9b849 100644
--- a/plugins/sslscan.py
+++ b/plugins/sslscan.py
@@ -1,6 +1,7 @@
"""
Comprehensive SSL/TLS security scanning and analysis.
All blocking socket calls run in a thread pool; user input is sanitised.
+Output is a clean code block with aligned columns.
"""
import asyncio
@@ -10,7 +11,7 @@ import ssl
import OpenSSL
import datetime
import simplematrixbotlib as botlib
-from plugins.common import is_public_destination, html_escape, collapsible_summary
+from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
# SSL/TLS configuration – handle missing protocols in modern Python
TLS_VERSIONS = {
@@ -37,9 +38,6 @@ CIPHER_CATEGORIES = {
}
async def handle_command(room, message, bot, prefix, config):
- """
- Handle !sslscan command for comprehensive SSL/TLS analysis.
- """
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
args = match.args()
@@ -49,7 +47,6 @@ async def handle_command(room, message, bot, prefix, config):
target = args[0].strip()
port = 443
-
if ':' in target:
parts = target.split(':')
target = parts[0]
@@ -65,12 +62,8 @@ async def handle_command(room, message, bot, prefix, config):
await perform_ssl_scan(room, bot, target, port)
-
async def show_usage(room, bot):
- """Display sslscan command usage."""
- usage = """
-🔐 SSL/TLS Security Scanner
-
+ usage = """🔐 SSL/TLS Security Scanner
!sslscan <domain[:port]> - Comprehensive SSL/TLS security analysis
Examples:
@@ -88,28 +81,21 @@ async def show_usage(room, bot):
"""
await bot.api.send_markdown_message(room.room_id, usage)
-
-# ----- async wrappers for blocking socket calls -----
async def _run_blocking(func, *args, **kwargs):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
-
def _test_connectivity(target, port):
- """Test basic connectivity."""
try:
with socket.create_connection((target, port), timeout=10):
return True
except:
return False
-
def _get_certificate_info(target, port):
- """Retrieve detailed certificate info."""
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
-
with socket.create_connection((target, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=target) as ssock:
cert_bin = ssock.getpeercert(binary_form=True)
@@ -117,15 +103,12 @@ def _get_certificate_info(target, port):
subject = cert.get_subject()
issuer = cert.get_issuer()
-
not_before = cert.get_notBefore().decode('utf-8')
not_after = cert.get_notAfter().decode('utf-8')
sig_alg = cert.get_signature_algorithm().decode('utf-8')
-
not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
days_remaining = (not_after_dt - datetime.datetime.utcnow()).days
- # Extensions summary
extensions = []
for i in range(cert.get_extension_count()):
ext = cert.get_extension(i)
@@ -158,9 +141,7 @@ def _get_certificate_info(target, port):
}
return None
-
def _test_protocols(target, port):
- """Test support for various SSL/TLS protocols."""
protocols = {}
for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
if proto_name not in TLS_VERSIONS:
@@ -177,9 +158,7 @@ def _test_protocols(target, port):
protocols[proto_name] = False
return protocols
-
def _test_cipher_suites(target, port):
- """Return list of supported cipher suite names."""
test_ciphers = [
'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384',
@@ -207,130 +186,63 @@ def _test_cipher_suites(target, port):
pass
return supported
-
-# ----- analysis helpers (same logic as original) -----
def _check_vulnerabilities(protocols, cert_info, supported_ciphers):
vulns = []
-
if protocols.get('SSLv2'):
- vulns.append({
- 'name': 'SSLv2 Support',
- 'severity': 'CRITICAL',
- 'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
- 'cve': 'Multiple CVEs'
- })
-
+ vulns.append(('SSLv2 Support', 'CRITICAL'))
if protocols.get('SSLv3'):
- vulns.append({
- 'name': 'SSLv3 Support',
- 'severity': 'HIGH',
- 'description': 'SSLv3 is vulnerable to POODLE attack',
- 'cve': 'CVE-2014-3566'
- })
-
+ vulns.append(('SSLv3 Support', 'HIGH'))
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
- vulns.append({
- 'name': 'Certificate Expiring Soon',
- 'severity': 'MEDIUM',
- 'description': f"Certificate expires in {cert_info['days_until_expiry']} days",
- 'cve': 'N/A'
- })
-
- weak_ciphers = [c for c in supported_ciphers
- if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])]
+ vulns.append(('Certificate Expiring Soon', 'MEDIUM'))
+ weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
if weak_ciphers:
- vulns.append({
- 'name': 'Weak Cipher Suites',
- 'severity': 'HIGH',
- 'description': f'Weak ciphers supported: {", ".join(weak_ciphers[:3])}',
- 'cve': 'Multiple CVEs'
- })
-
+ vulns.append(('Weak Cipher Suites', 'HIGH'))
if not protocols.get('TLSv1.2', False):
- vulns.append({
- 'name': 'TLS 1.2 Not Supported',
- 'severity': 'HIGH',
- 'description': 'TLS 1.2 is required for modern security',
- 'cve': 'N/A'
- })
-
+ vulns.append(('TLS 1.2 Not Supported', 'HIGH'))
if not protocols.get('TLSv1.3', False):
- vulns.append({
- 'name': 'TLS 1.3 Not Supported',
- 'severity': 'MEDIUM',
- 'description': 'TLS 1.3 provides improved security and performance',
- 'cve': 'N/A'
- })
-
+ vulns.append(('TLS 1.3 Not Supported', 'MEDIUM'))
return vulns
-
def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities):
score = 100
-
if protocols.get('SSLv2'): score -= 30
if protocols.get('SSLv3'): score -= 20
if not protocols.get('TLSv1.2'): score -= 15
if not protocols.get('TLSv1.3'): score -= 10
-
if cert_info and cert_info.get('days_until_expiry', 0) < 30: score -= 10
if cert_info and cert_info.get('days_until_expiry', 0) < 7: score -= 20
-
- weak_cipher_count = sum(1 for c in supported_ciphers
- if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
+ weak_cipher_count = sum(1 for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
score -= min(weak_cipher_count * 5, 25)
-
- for vuln in vulnerabilities:
- if vuln['severity'] == 'CRITICAL': score -= 20
- elif vuln['severity'] == 'HIGH': score -= 15
- elif vuln['severity'] == 'MEDIUM': score -= 10
- elif vuln['severity'] == 'LOW': score -= 5
-
+ for name, severity in vulnerabilities:
+ if severity == 'CRITICAL': score -= 20
+ elif severity == 'HIGH': score -= 15
+ elif severity == 'MEDIUM': score -= 10
return max(0, score)
-
def _generate_recommendations(protocols, cert_info, supported_ciphers, score):
recs = []
- if protocols.get('SSLv2'): recs.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable")
- if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3 - vulnerable to POODLE attack")
- if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3 for best security and performance")
-
+ if protocols.get('SSLv2'): recs.append("🔴 Disable SSLv2")
+ if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3")
+ if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3")
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
- recs.append("🟡 Renew SSL certificate - expiring soon")
-
- weak_ciphers = [c for c in supported_ciphers
- if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
+ recs.append("🟡 Renew certificate")
+ weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
if weak_ciphers:
- recs.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)")
-
+ recs.append("🔴 Remove weak ciphers")
if score < 80:
- recs.append("🛡️ Implement modern TLS configuration following Mozilla guidelines")
-
+ recs.append("🛡️ Improve TLS configuration")
if not any('ECDHE' in c for c in supported_ciphers):
- recs.append("🟡 Enable Forward Secrecy with ECDHE cipher suites")
-
- recs.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features")
+ recs.append("🟡 Enable Forward Secrecy")
return recs
-
-def _format_cert_date(date_str):
- try:
- dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ')
- return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
- except:
- return date_str
-
-
-# ----- main scan orchestration -----
async def perform_ssl_scan(room, bot, target, port):
safe_target = html_escape(target)
- await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {safe_target}:{port}...")
+ await bot.api.send_text_message(room.room_id, f"🔍 Starting SSL/TLS scan for {safe_target}:{port}...")
if not await _run_blocking(_test_connectivity, target, port):
await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {safe_target}:{port}")
return
- # Run blocking checks in parallel
cert_task = _run_blocking(_get_certificate_info, target, port)
proto_task = _run_blocking(_test_protocols, target, port)
cipher_task = _run_blocking(_test_cipher_suites, target, port)
@@ -341,36 +253,25 @@ async def perform_ssl_scan(room, bot, target, port):
score = _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities)
recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score)
- # Build output (using safe domain/port)
- output = await _format_results(target, port, cert_info, protocols, supported_ciphers,
- vulnerabilities, score, recommendations)
- await bot.api.send_markdown_message(room.room_id, output)
- logging.info(f"Completed SSL scan for {target}:{port}")
+ sections = []
-
-async def _format_results(target, port, cert_info, protocols, supported_ciphers,
- vulnerabilities, score, recommendations):
- safe_target = html_escape(target)
+ # Score
score_emoji = "🟢" if score >= 90 else "🟡" if score >= 80 else "🟠" if score >= 60 else "🔴"
rating = "Excellent" if score >= 90 else "Good" if score >= 80 else "Fair" if score >= 60 else "Poor"
+ sections.append({"title": f"{score_emoji} Security Score", "rows": [("", "Score", f"{score}/100 ({rating})")]})
- body = f"🔐 SSL/TLS Security Scan: {safe_target}:{port}
"
- body += f"{score_emoji} Security Score: {score}/100 ({rating})
"
-
- # Certificate Information
+ # Certificate
if cert_info:
- body += "📜 Certificate Information
"
- body += f" • Subject: {html_escape(cert_info['subject'].get('common_name', 'N/A'))}
"
- body += f" • Issuer: {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}
"
- body += f" • Valid From: {_format_cert_date(cert_info['not_before'])}
"
- body += f" • Valid Until: {_format_cert_date(cert_info['not_after'])}
"
- days = cert_info.get('days_until_expiry', 'N/A')
- body += f" • Expires In: {days} days
"
- body += f" • Signature Algorithm: {html_escape(cert_info['signature_algorithm'])}
"
- body += "
"
+ cert_rows = [
+ ("📜", "Subject", cert_info['subject'].get('common_name', 'N/A')),
+ ("🏢", "Issuer", cert_info['issuer'].get('common_name', 'N/A')),
+ ("📅", "Valid Until", cert_info['not_after']),
+ ("⏳", "Expires In", f"{cert_info['days_until_expiry']} days"),
+ ]
+ sections.append({"title": "📜 Certificate", "rows": cert_rows})
- # Protocol Support
- body += "🔌 Protocol Support
"
+ # Protocols
+ proto_rows = []
for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
supported = protocols.get(proto, False)
if proto in ['SSLv2', 'SSLv3'] and supported:
@@ -381,67 +282,57 @@ async def _format_results(target, port, cert_info, protocols, supported_ciphers,
emoji = "✅" if supported else "❌"
status = "Supported" if supported else "Not Supported"
if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS:
- status = "Cannot test (Python security)"
+ status = "Cannot test"
emoji = "⚫"
- body += f" • {emoji} {proto}: {status}
"
- body += "
"
+ proto_rows.append((emoji, proto, status))
+ sections.append({"title": "🔌 Protocols", "rows": proto_rows})
# Cipher Suites
- body += "🔐 Cipher Suites
"
- body += f" • Total Supported: {len(supported_ciphers)}
"
-
- weak_ciphers = [c for c in supported_ciphers
- if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
+ weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
+ strong_ciphers = [c for c in supported_ciphers if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
+ cipher_rows = [("🔢", "Total Supported", str(len(supported_ciphers)))]
if weak_ciphers:
- body += f" • Weak Ciphers: {len(weak_ciphers)} found
"
- for cipher in weak_ciphers[:3]:
- body += f" └─ 🔴 {html_escape(cipher)}
"
- strong_ciphers = [c for c in supported_ciphers
- if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
+ cipher_rows.append(("🔴", "Weak Ciphers", str(len(weak_ciphers))))
+ for c in weak_ciphers[:3]:
+ cipher_rows.append(("", "", c))
if strong_ciphers:
- body += f" • Strong Ciphers: {len(strong_ciphers)} found
"
- body += "
"
+ cipher_rows.append(("🟢", "Strong Ciphers", str(len(strong_ciphers))))
+ sections.append({"title": "🔐 Cipher Suites", "rows": cipher_rows})
# Vulnerabilities
if vulnerabilities:
- body += "⚠️ Security Vulnerabilities
"
- for vuln in vulnerabilities[:5]:
- sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
- body += f" • {sev_emoji} {html_escape(vuln['name'])} ({vuln['severity']})
"
- body += f" └─ {html_escape(vuln['description'])}
"
- body += "
"
+ vuln_rows = []
+ for name, sev in vulnerabilities:
+ sev_emoji = "🔴" if sev == 'CRITICAL' else "🟠" if sev == 'HIGH' else "🟡"
+ vuln_rows.append((sev_emoji, name, sev))
+ sections.append({"title": "⚠️ Vulnerabilities", "rows": vuln_rows})
# Recommendations
if recommendations:
- body += "💡 Security Recommendations
"
- for rec in recommendations[:8]:
- body += f" • {rec}
"
- body += "
"
+ rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
+ sections.append({"title": "💡 Recommendations", "rows": rec_rows})
# Quick Assessment
- body += "📊 Quick Assessment
"
+ assessment_rows = []
if score >= 90:
- body += " • ✅ Excellent TLS configuration
"
- body += " • ✅ Modern protocols and ciphers
"
- body += " • ✅ Good certificate management
"
+ assessment_rows = [("", "Assessment", "✅ Excellent configuration")]
elif score >= 70:
- body += " • ⚠️ Good configuration with minor issues
"
- body += " • 🔧 Some improvements recommended
"
+ assessment_rows = [("", "Assessment", "⚠️ Good, minor improvements possible")]
else:
- body += " • 🚨 Significant security issues found
"
- body += " • 🔴 Immediate action required
"
-
- body += "
ℹ️ Note: Some protocol tests limited by Python security features"
-
- return collapsible_summary(f"🔐 SSL/TLS Scan: {safe_target}:{port} (Score: {score}/100)", body)
+ assessment_rows = [("", "Assessment", "🚨 Significant issues found")]
+ sections.append({"title": "📊 Quick Assessment", "rows": assessment_rows})
+ block = code_block(f"🔐 SSL/TLS Scan: {safe_target}:{port}", sections)
+ output = collapsible_summary(f"🔐 SSL/TLS: {safe_target} (Score: {score}/100)", block)
+ await bot.api.send_markdown_message(room.room_id, output)
+ logging.info(f"Completed SSL scan for {target}:{port}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.2"
__author__ = "Funguy Bot"
-__description__ = "SSL/TLS security scanner (SSRF‑safe, async)"
+__description__ = "SSL/TLS security scanner"
__help__ = """
!sslscan – SSL/TLS analysis
diff --git a/plugins/stable-diffusion.py b/plugins/stable-diffusion.py
index 45e51c0..9c45ff7 100644
--- a/plugins/stable-diffusion.py
+++ b/plugins/stable-diffusion.py
@@ -137,7 +137,7 @@ def print_help():
# ---------------------------------------------------------------------------
__version__ = "1.1.2"
__author__ = "Funguy Bot"
-__description__ = "Stable Diffusion image generation (async, LORA support)"
+__description__ = "Stable Diffusion image generation (LORA support)"
__help__ = """
!sd – Generate images via Stable Diffusion
diff --git a/plugins/subnet.py b/plugins/subnet.py
index aa0f04a..ddb406e 100644
--- a/plugins/subnet.py
+++ b/plugins/subnet.py
@@ -2,28 +2,26 @@
"""
plugins/subnet.py – Subnet calculator and network splitting plugin for Funguy Bot.
-Provides the following commands:
- !subnet info – Show detailed info about a network
- !subnet split --prefix – Split network into smaller subnets (new prefix length)
- !subnet split --diff – Split network into equal subnets (prefixlen delta)
- !subnet adjacent – Show given network and next adjacent ones
- !subnet help – Display this help
+Commands:
+ !subnet info
+ !subnet split --prefix
+ !subnet split --diff
+ !subnet adjacent
+ !subnet help
-Examples:
- !subnet info 192.168.4.0/26
- !subnet split 192.168.4.0/24 --prefix 26
- !subnet split 10.0.0.0/16 --diff 2
- !subnet adjacent 192.168.4.0/26 3
+Output is a clean code block with emojis and perfectly aligned columns.
"""
import ipaddress
-import sys
-from typing import Union
+import simplematrixbotlib as botlib
+from plugins.common import collapsible_summary, html_escape, code_block
-# ------------------------------- helper functions --------------------------------
+# -------------------------------------------------------------------
+# Helper functions (synchronous)
+# -------------------------------------------------------------------
-def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> str:
- """Return a human‑readable string with all relevant subnet details."""
+def _fmt_subnet_info_rows(net):
+ """Return list of (emoji, label, value) tuples."""
nw = net.network_address
bc = net.broadcast_address if hasattr(net, "broadcast_address") else None
total = net.num_addresses
@@ -50,102 +48,124 @@ def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -
first = last = None
usable_count = 0
- lines = [
- f"CIDR: {net.with_prefixlen}",
- f"Network: {nw}",
- f"Broadcast: {bc if bc is not None else 'N/A'}",
- f"Netmask: {net.netmask if hasattr(net, 'netmask') else 'N/A'}",
- f"Wildcard Mask: {net.hostmask if hasattr(net, 'hostmask') else 'N/A'}",
- f"Total IPs: {total}",
- f"Usable Hosts: {usable_count}",
+ rows = [
+ ("🌐", "CIDR", str(net.with_prefixlen)),
+ ("📡", "Network", str(nw)),
+ ("📢", "Broadcast", str(bc) if bc is not None else "N/A"),
+ ("🧱", "Netmask", str(net.netmask) if hasattr(net, "netmask") else "N/A"),
+ ("🕳️", "Wildcard Mask", str(net.hostmask) if hasattr(net, "hostmask") else "N/A"),
+ ("🔢", "Total IPs", str(total)),
+ ("👥", "Usable Hosts", str(usable_count)),
]
if first is not None and last is not None:
- lines.append(f"First Usable: {first}")
- lines.append(f"Last Usable: {last}")
- lines.append(f"Usable Range: {first} - {last}")
- return "\n".join(lines)
+ rows.append(("🏁", "First Usable", str(first)))
+ rows.append(("🏁", "Last Usable", str(last)))
+ rows.append(("↔️", "Usable Range", f"{first} - {last}"))
+ return rows
-def _split_by_prefix(net, new_prefix: int) -> str:
+def _split_by_prefix(net, new_prefix):
if new_prefix < net.prefixlen:
- return f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split."
- out = [f"# Splitting {net.with_prefixlen} into /{new_prefix} subnets:"]
- for i, sub in enumerate(net.subnets(new_prefix=new_prefix)):
- out.append(f"\n-- Subnet #{i+1} --")
- out.append(_fmt_subnet_info(sub))
- return "\n".join(out)
+ return None
+ return list(net.subnets(new_prefix=new_prefix))
-def _split_by_diff(net, diff: int) -> str:
- new_prefix = net.prefixlen + diff
- return _split_by_prefix(net, new_prefix)
+def _split_by_diff(net, diff):
+ return _split_by_prefix(net, net.prefixlen + diff)
-def _adjacent_networks(net, count: int) -> str:
- out = [f"# Adjacent networks of size /{net.prefixlen} (starting at {net.with_prefixlen}):"]
+def _adjacent_networks(net, count):
+ nets = [net]
current = net
- for i in range(count + 1):
- out.append(f"\n-- Adjacent #{i} --")
- out.append(_fmt_subnet_info(current))
+ for _ in range(count):
try:
- next_net_addr = current.network_address + current.num_addresses
- current = ipaddress.ip_network(f"{next_net_addr}/{current.prefixlen}", strict=True)
- except ValueError:
- out.append("[!] Reached address space limit.")
+ next_addr = current.network_address + current.num_addresses
+ current = ipaddress.ip_network(f"{next_addr}/{current.prefixlen}", strict=True)
+ nets.append(current)
+ except (ValueError, ipaddress.AddressValueError):
break
- return "\n".join(out)
+ return nets
-# ------------------------------- bot plugin entry -------------------------------
+# -------------------------------------------------------------------
+# Output builders (each returns a collapsible Markdown message)
+# -------------------------------------------------------------------
+
+def _info_output(net):
+ """Build a collapsible message for a single subnet."""
+ title = f"🔍 Subnet {net.with_prefixlen}"
+ rows = _fmt_subnet_info_rows(net)
+ block = code_block(title, [{"title": "", "rows": rows}])
+ return collapsible_summary(title, block)
+
+
+def _split_output(networks):
+ """Build a collapsible message for a split operation."""
+ total = len(networks)
+ title = f"🔀 Split into {total} subnets"
+ sections = []
+ for i, sub in enumerate(networks, 1):
+ rows = _fmt_subnet_info_rows(sub)
+ sections.append({"title": f"Subnet {sub.with_prefixlen}", "rows": rows})
+ block = code_block(title, sections)
+ return collapsible_summary(title, block)
+
+
+def _adjacent_output(networks):
+ """Build a collapsible message for adjacent networks."""
+ base = networks[0]
+ title = f"📐 Adjacent networks (base {base.with_prefixlen})"
+ sections = []
+ for i, net in enumerate(networks):
+ label = "Base network" if i == 0 else f"Adjacent #{i}"
+ rows = _fmt_subnet_info_rows(net)
+ sections.append({"title": label, "rows": rows})
+ block = code_block(title, sections)
+ return collapsible_summary(title, block)
+
+
+# -------------------------------------------------------------------
+# Help
+# -------------------------------------------------------------------
+
+_HELP_MD = """
+
+!subnet – Subnet calculator and exploration
+
+!subnet info <CIDR> Show detailed info for a network
+!subnet split <CIDR> --prefix <N> Split into smaller subnets (new prefix)
+!subnet split <CIDR> --diff <N> Split by prefix delta
+!subnet adjacent <CIDR> <count> Show current and adjacent networks
+
+Example: !subnet info 192.168.1.0/24
+
+- IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).
+- IPv6 networks list all addresses as hosts (no broadcast).
+
+
+"""
+
+
+# -------------------------------------------------------------------
+# 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("subnet")):
return
args = match.args()
if not args:
- await bot.api.send_text_message(
- room.room_id,
- "Usage: !subnet ...\n"
- " !subnet help – show full help"
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !subnet ...\n !subnet help")
return
subcmd = args[0].lower()
- # --- help ---
if subcmd in ("help", "-h", "--help"):
- # Send nicely formatted HTML in a details tag via markdown
- html = "!subnet – Subnet calculator and exploration
\n"
- html += "Calculate subnet details, split networks, or enumerate adjacent subnets.
\n"
- html += "Commands
\n"
- html += "\n"
- html += "- info – Show detailed info for a network
\n"
- html += "!subnet info <CIDR>
\n"
- html += "Example: !subnet info 192.168.1.0/24 \n"
- html += "- split – Split a network into smaller subnets
\n"
- html += "!subnet split <CIDR> --prefix <new_prefix>
\n"
- html += "Example: !subnet split 192.168.1.0/24 --prefix 26
\n"
- html += "Alternatively, use --diff to split by prefix delta:
\n"
- html += "!subnet split <CIDR> --diff <delta>
\n"
- html += "Example: !subnet split 10.0.0.0/16 --diff 2 (creates 4 subnets) \n"
- html += "- adjacent – Show the current network and adjacent ones
\n"
- html += "!subnet adjacent <CIDR> <count>
\n"
- html += "Example: !subnet adjacent 192.168.4.0/26 3 \n"
- html += "
\n"
- html += "Notes
\n"
- html += "\n"
- html += "- IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).
\n"
- html += "- IPv6 networks list all addresses as hosts (no broadcast).
\n"
- html += "
\n"
- html += " "
- await bot.api.send_markdown_message(room.room_id, html)
+ await bot.api.send_markdown_message(room.room_id, _HELP_MD)
return
- # --- info (or a CIDR passed directly) ---
if subcmd == "info" or "/" in subcmd:
cidr = args[1] if subcmd == "info" else subcmd
try:
@@ -153,16 +173,13 @@ async def handle_command(room, message, bot, prefix, config):
except ValueError as e:
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
return
- await bot.api.send_text_message(room.room_id, _fmt_subnet_info(net))
+ output = _info_output(net)
+ await bot.api.send_markdown_message(room.room_id, output)
return
- # --- split ---
if subcmd == "split":
if len(args) < 2:
- await bot.api.send_text_message(
- room.room_id,
- "Usage: !subnet split --prefix OR --diff "
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !subnet split --prefix OR !subnet split --diff ")
return
cidr = args[1]
try:
@@ -176,39 +193,31 @@ async def handle_command(room, message, bot, prefix, config):
idx = args.index("--prefix")
new_prefix = int(args[idx + 1])
except (ValueError, IndexError):
- await bot.api.send_text_message(
- room.room_id,
- "Usage: !subnet split --prefix "
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !subnet split --prefix ")
return
- result = _split_by_prefix(net, new_prefix)
+ subnets = _split_by_prefix(net, new_prefix)
elif "--diff" in args:
try:
idx = args.index("--diff")
diff = int(args[idx + 1])
except (ValueError, IndexError):
- await bot.api.send_text_message(
- room.room_id,
- "Usage: !subnet split --diff "
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !subnet split --diff ")
return
- result = _split_by_diff(net, diff)
+ subnets = _split_by_diff(net, diff)
else:
- await bot.api.send_text_message(
- room.room_id,
- "You must provide either --prefix or --diff for split."
- )
+ await bot.api.send_text_message(room.room_id, "You must provide --prefix or --diff for split.")
return
- await bot.api.send_text_message(room.room_id, result)
+
+ if subnets is None:
+ await bot.api.send_text_message(room.room_id, f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split.")
+ return
+ output = _split_output(subnets)
+ await bot.api.send_markdown_message(room.room_id, output)
return
- # --- adjacent ---
if subcmd == "adjacent":
if len(args) < 3:
- await bot.api.send_text_message(
- room.room_id,
- "Usage: !subnet adjacent "
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !subnet adjacent ")
return
cidr = args[1]
try:
@@ -219,39 +228,21 @@ async def handle_command(room, message, bot, prefix, config):
try:
count = int(args[2])
except ValueError:
- await bot.api.send_text_message(
- room.room_id,
- "Count must be an integer."
- )
+ await bot.api.send_text_message(room.room_id, "Count must be an integer.")
return
- result = _adjacent_networks(net, count)
- await bot.api.send_text_message(room.room_id, result)
+ networks = _adjacent_networks(net, count)
+ output = _adjacent_output(networks)
+ await bot.api.send_markdown_message(room.room_id, output)
return
- # Unknown subcommand
- await bot.api.send_text_message(
- room.room_id,
- f"Unknown subcommand '{subcmd}'. Use !subnet help to see available commands."
- )
+ await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{subcmd}'. Use !subnet help.")
-# Plugin metadata
-__version__ = "1.0.1"
+# ---------------------------------------------------------------------------
+# Plugin Metadata
+# ---------------------------------------------------------------------------
+
+__version__ = "1.3.2"
__author__ = "Funguy Bot"
-__description__ = "Subnet calculator, splitter, and adjacent network enumerator"
-__help__ = """
-
-!subnet – Subnet calculator and exploration
-Calculate subnet details, split networks, or enumerate adjacent subnets.
-
-!subnet info <CIDR> – Show detailed info for a network
- Example: !subnet info 192.168.1.0/24
-!subnet split <CIDR> --prefix <new_prefix> – Split into smaller subnets
- Example: !subnet split 192.168.1.0/24 --prefix 26
-!subnet split <CIDR> --diff <delta> – Split by prefix delta
- Example: !subnet split 10.0.0.0/16 --diff 2
-!subnet adjacent <CIDR> <count> – Show adjacent networks
- Example: !subnet adjacent 192.168.4.0/26 3
-
-
-"""
+__description__ = "Subnet calculator"
+__help__ = _HELP_MD
diff --git a/plugins/sysinfo.py b/plugins/sysinfo.py
index 84dfc67..2b03303 100644
--- a/plugins/sysinfo.py
+++ b/plugins/sysinfo.py
@@ -1,354 +1,313 @@
"""
-Comprehensive system information and resource monitoring.
-All blocking calls (psutil, subprocess) run in a thread pool.
+Comprehensive system information – code block with emoji + aligned columns.
+All blocking calls run in thread pool.
"""
-import logging
-import platform
-import os
-import asyncio
-import psutil
-import socket
-import datetime
-import subprocess
+import logging, platform, os, asyncio, psutil, socket, datetime, subprocess
import simplematrixbotlib as botlib
-from plugins.common import collapsible_summary, html_escape
+from plugins.common import collapsible_summary, html_escape, code_block
-async def handle_command(room, message, bot, prefix, config):
- """
- Handle !sysinfo command for system information.
- """
- match = botlib.MessageMatch(room, message, bot, prefix)
- if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
- args = match.args()
- if args and args[0].lower() == 'help':
- await show_usage(room, bot)
- return
- await get_system_info(room, bot)
-
-async def show_usage(room, bot):
- """Display sysinfo command usage."""
- usage = """
-💻 System Information Plugin
-
-!sysinfo - Display comprehensive system information
-!sysinfo help - Show this help message
-
-Information Provided:
-• System hardware (CPU, RAM, storage, GPU)
-• Operating system and kernel details
-• Network configuration and interfaces
-• Running processes and resource usage
-• Temperature and hardware sensors
-• System load and performance metrics
-• Docker container status (if available)
-"""
- await bot.api.send_markdown_message(room.room_id, usage)
-
-# ----- Async wrappers for blocking functions -----
async def _run_blocking(func, *args, **kwargs):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
-# ----- Individual data collectors (all sync, run in thread) -----
+# ---------- Data collectors (unchanged) ----------
def _system_overview():
+ boot = datetime.datetime.fromtimestamp(psutil.boot_time())
+ uptime_delta = datetime.datetime.now() - boot
+ uptime_str = str(datetime.timedelta(seconds=int(uptime_delta.total_seconds())))
return {
- 'hostname': socket.gethostname(),
- 'os': platform.system(),
- 'os_release': platform.release(),
- 'os_version': platform.version(),
- 'architecture': platform.architecture()[0],
- 'machine': platform.machine(),
- 'processor': platform.processor(),
- 'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"),
- 'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))),
- 'users': len(psutil.users())
+ "hostname": socket.gethostname(),
+ "os": f"{platform.system()} {platform.release()}",
+ "architecture": platform.architecture()[0],
+ "machine": platform.machine(),
+ "processor": platform.processor(),
+ "boot_time": boot.strftime("%Y-%m-%d %H:%M:%S"),
+ "uptime": uptime_str,
+ "users": len(psutil.users())
}
def _cpu_info():
- cpu_times = psutil.cpu_times_percent(interval=1)
cpu_freq = psutil.cpu_freq()
- load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else (0,0,0)
+ load = os.getloadavg() if hasattr(os, "getloadavg") else (0,0,0)
return {
- 'physical_cores': psutil.cpu_count(logical=False),
- 'total_cores': psutil.cpu_count(logical=True),
- 'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A",
- 'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A",
- 'usage_percent': psutil.cpu_percent(interval=1),
- 'user_time': cpu_times.user,
- 'system_time': cpu_times.system,
- 'idle_time': cpu_times.idle,
- 'load_avg': ", ".join(f"{l:.2f}" for l in load_avg)
+ "physical_cores": psutil.cpu_count(logical=False),
+ "logical_cores": psutil.cpu_count(logical=True),
+ "max_freq": f"{cpu_freq.max:.0f} MHz" if cpu_freq else "N/A",
+ "current_freq": f"{cpu_freq.current:.0f} MHz" if cpu_freq else "N/A",
+ "usage": f"{psutil.cpu_percent(interval=1)}%",
+ "load_avg": f"{load[0]:.2f} {load[1]:.2f} {load[2]:.2f}"
}
def _memory_info():
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
return {
- 'total': f"{mem.total / (1024**3):.2f} GB",
- 'available': f"{mem.available / (1024**3):.2f} GB",
- 'used': f"{mem.used / (1024**3):.2f} GB",
- 'usage_percent': mem.percent,
- 'swap_total': f"{swap.total / (1024**3):.2f} GB",
- 'swap_used': f"{swap.used / (1024**3):.2f} GB",
- 'swap_free': f"{swap.free / (1024**3):.2f} GB",
- 'swap_percent': swap.percent
+ "total_ram": f"{mem.total / (1024**3):.1f} GB",
+ "used_ram": f"{mem.used / (1024**3):.1f} GB",
+ "ram_percent": f"{mem.percent}%",
+ "available_ram": f"{mem.available / (1024**3):.1f} GB",
+ "total_swap": f"{swap.total / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
+ "used_swap": f"{swap.used / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
+ "swap_percent": f"{swap.percent}%" if swap.total > 0 else "N/A"
}
-def _storage_info():
+def _disk_info():
partitions = psutil.disk_partitions()
- storage_list = []
- for part in partitions:
+ mounted = []
+ for p in partitions:
try:
- usage = psutil.disk_usage(part.mountpoint)
- storage_list.append({
- 'device': part.device,
- 'mountpoint': part.mountpoint,
- 'fstype': part.fstype,
- 'total': f"{usage.total / (1024**3):.2f} GB",
- 'used': f"{usage.used / (1024**3):.2f} GB",
- 'free': f"{usage.free / (1024**3):.2f} GB",
- 'percent': usage.percent
+ usage = psutil.disk_usage(p.mountpoint)
+ mounted.append({
+ "mount": p.mountpoint,
+ "used": f"{usage.used / (1024**3):.1f} GB",
+ "total": f"{usage.total / (1024**3):.1f} GB",
+ "percent": usage.percent
})
except:
pass
- disk_io = psutil.disk_io_counters()
- io_info = {
- 'read_count': disk_io.read_count if disk_io else 0,
- 'write_count': disk_io.write_count if disk_io else 0,
- 'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB",
- 'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB"
- }
- return {'partitions': storage_list, 'io_stats': io_info}
+ io = psutil.disk_io_counters()
+ io_read = f"{io.read_bytes / (1024**3):.2f} GB" if io else "0 GB"
+ io_write = f"{io.write_bytes / (1024**3):.2f} GB" if io else "0 GB"
+ return mounted, io_read, io_write
def _network_info():
- interfaces = psutil.net_if_addrs()
+ ifaces = psutil.net_if_addrs()
io_counters = psutil.net_io_counters(pernic=True)
- net_list = []
- for iface, addrs in interfaces.items():
- if iface == 'lo':
+ net = []
+ for name, addrs in ifaces.items():
+ if name == "lo":
continue
- info = {
- 'interface': iface,
- 'ipv4': next((a.address for a in addrs if a.family == socket.AF_INET), 'N/A'),
- 'ipv6': next((a.address for a in addrs if a.family == socket.AF_INET6), 'N/A'),
- 'mac': next((a.address for a in addrs if a.family == psutil.AF_LINK), 'N/A'),
- }
- io = io_counters.get(iface)
- if io:
- info['bytes_sent'] = f"{io.bytes_sent / (1024**2):.2f} MB"
- info['bytes_recv'] = f"{io.bytes_recv / (1024**2):.2f} MB"
- else:
- info['bytes_sent'] = 'N/A'
- info['bytes_recv'] = 'N/A'
- net_list.append(info)
- return net_list
+ ip4 = next((a.address for a in addrs if a.family == socket.AF_INET), None)
+ if ip4:
+ stats = io_counters.get(name)
+ sent = f"{stats.bytes_sent / (1024**2):.1f} MB" if stats else "0 MB"
+ recv = f"{stats.bytes_recv / (1024**2):.1f} MB" if stats else "0 MB"
+ net.append((name, ip4, sent, recv))
+ return net
-def _process_info():
+def _top_processes():
procs = []
- for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
+ for p in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
try:
- procs.append(proc.info)
+ procs.append(p.info)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
top_cpu = sorted(procs, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5]
- return {'total_processes': len(procs), 'top_cpu': top_cpu}
+ return top_cpu, len(procs)
+
+def _gpu_info():
+ info = {}
+ try:
+ res = subprocess.run(
+ ['nvidia-smi', '--query-gpu=name,memory.used,memory.total,temperature.gpu,utilization.gpu',
+ '--format=csv,noheader,nounits'],
+ capture_output=True, text=True
+ )
+ if res.returncode == 0:
+ gpus = []
+ for line in res.stdout.strip().split('\n'):
+ parts = [p.strip() for p in line.split(',')]
+ if len(parts) >= 5:
+ gpus.append({
+ "name": parts[0],
+ "mem_used": f"{parts[1]} MB",
+ "mem_total": f"{parts[2]} MB",
+ "temp": f"{parts[3]}°C",
+ "usage": f"{parts[4]}%"
+ })
+ if gpus:
+ info["nvidia"] = gpus
+ except:
+ pass
+ try:
+ res = subprocess.run(['lspci'], capture_output=True, text=True)
+ if res.returncode == 0:
+ lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
+ if lines:
+ info["detected"] = lines[:2]
+ except:
+ pass
+ return info
def _docker_info():
try:
- result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
- if result.returncode != 0:
- return {'available': False}
- result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'],
- capture_output=True, text=True)
+ ver = subprocess.run(['docker', '--version'], capture_output=True, text=True)
+ if ver.returncode != 0:
+ return None
+ ps_res = subprocess.run(
+ ['docker', 'ps', '--format', '{{.Names}}|{{.Status}}'],
+ capture_output=True, text=True
+ )
containers = []
- for line in result.stdout.strip().split('\n'):
+ for line in ps_res.stdout.strip().split('\n'):
if line:
parts = line.split('|')
if len(parts) >= 2:
- containers.append({'name': parts[0], 'status': parts[1], 'ports': parts[2] if len(parts)>2 else 'N/A'})
- return {'available': True, 'containers': containers, 'total_running': len(containers)}
+ containers.append({"name": parts[0], "status": parts[1]})
+ return containers
except:
- return {'available': False}
+ return None
def _sensor_info():
temps = psutil.sensors_temperatures()
fans = psutil.sensors_fans()
battery = psutil.sensors_battery()
- sensor = {'temperatures': {}, 'fans': {}, 'battery': {}}
+ data = {"temps": [], "fans": [], "battery": None}
if temps:
- for name, entries in temps.items():
- sensor['temperatures'][name] = [f"{e.current}°C" for e in entries[:2]]
+ for chip, entries in temps.items():
+ for e in entries[:2]:
+ data["temps"].append(f"{e.label or chip}: {e.current}°C")
if fans:
- for name, entries in fans.items():
- sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]]
+ for chip, entries in fans.items():
+ for e in entries[:2]:
+ data["fans"].append(f"{e.label or chip}: {e.current} RPM")
if battery:
- sensor['battery'] = {
- 'percent': battery.percent,
- 'power_plugged': battery.power_plugged,
- 'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown"
- }
- return sensor
+ rem = ""
+ if battery.secsleft != psutil.POWER_TIME_UNLIMITED and battery.secsleft > 0:
+ h = battery.secsleft // 3600
+ m = (battery.secsleft % 3600) // 60
+ rem = f" ({h}h {m}m left)"
+ plugged = " 🔌" if battery.power_plugged else ""
+ data["battery"] = f"{battery.percent}%{plugged}{rem}"
+ return data
-def _gpu_info():
- gpu_data = {}
- # NVIDIA
- try:
- res = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu',
- '--format=csv,noheader,nounits'], capture_output=True, text=True)
- if res.returncode == 0:
- nvidia = []
- for line in res.stdout.strip().split('\n'):
- parts = [p.strip() for p in line.split(',')]
- if len(parts) >= 6:
- nvidia.append({
- 'name': parts[0],
- 'memory_total': f"{parts[1]} MB",
- 'memory_used': f"{parts[2]} MB",
- 'memory_free': f"{parts[3]} MB",
- 'temperature': f"{parts[4]}°C",
- 'utilization': f"{parts[5]}%"
- })
- if nvidia:
- gpu_data['nvidia'] = nvidia
- except:
- pass
- # lspci fallback
- try:
- res = subprocess.run(['lspci'], capture_output=True, text=True)
- if res.returncode == 0:
- gpu_lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
- if gpu_lines:
- gpu_data['detected'] = gpu_lines[:3]
- except:
- pass
- return gpu_data
-
-# ----- Main info gatherer -----
+# -------------------------------------------------------------------
+# Main builder
+# -------------------------------------------------------------------
async def get_system_info(room, bot):
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
- # Run all blocking collectors concurrently
system = await _run_blocking(_system_overview)
cpu = await _run_blocking(_cpu_info)
- memory = await _run_blocking(_memory_info)
- storage = await _run_blocking(_storage_info)
- network = await _run_blocking(_network_info)
- processes = await _run_blocking(_process_info)
+ mem = await _run_blocking(_memory_info)
+ disks, io_read, io_write = await _run_blocking(_disk_info)
+ net = await _run_blocking(_network_info)
+ top_procs, total_procs = await _run_blocking(_top_processes)
+ gpu = await _run_blocking(_gpu_info)
docker = await _run_blocking(_docker_info)
sensors = await _run_blocking(_sensor_info)
- gpu = await _run_blocking(_gpu_info)
- # Build output HTML
- output = await format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu)
+ sections = []
+
+ # System Overview
+ sys_rows = [
+ ("💻", "Hostname", system["hostname"]),
+ ("🖥️", "OS", system["os"]),
+ ("📐", "Architecture", system["architecture"]),
+ ("⚙️", "Machine", system["machine"]),
+ ("🔧", "Processor", system["processor"]),
+ ("⏰", "Uptime", system["uptime"]),
+ ("📅", "Boot Time", system["boot_time"]),
+ ("👥", "Users", str(system["users"]))
+ ]
+ sections.append({"title": "🖥️ System Overview", "rows": sys_rows})
+
+ # CPU
+ cpu_rows = [
+ ("⚡", "CPU Cores", f"{cpu['physical_cores']} physical, {cpu['logical_cores']} logical"),
+ ("📈", "Freq (Max/Cur)", f"{cpu['max_freq']} / {cpu['current_freq']}"),
+ ("📊", "CPU Usage", cpu["usage"]),
+ ("⚖️", "Load Avg", cpu["load_avg"])
+ ]
+ sections.append({"title": "⚡ CPU", "rows": cpu_rows})
+
+ # Memory
+ mem_rows = [
+ ("🧠", "RAM", f"{mem['used_ram']} / {mem['total_ram']} ({mem['ram_percent']})")
+ ]
+ if mem["total_swap"] != "N/A":
+ mem_rows.append(("💾", "Swap", f"{mem['used_swap']} / {mem['total_swap']} ({mem['swap_percent']})"))
+ sections.append({"title": "🧠 Memory", "rows": mem_rows})
+
+ # Storage
+ disk_rows = []
+ for d in disks[:5]:
+ disk_rows.append(("💽", d['mount'], f"{d['used']} / {d['total']} ({d['percent']}%)"))
+ disk_rows.append(("📀", "Disk I/O", f"Read {io_read} / Write {io_write}"))
+ sections.append({"title": "💾 Storage", "rows": disk_rows})
+
+ # Network
+ net_rows = []
+ if net:
+ for idx, (name, ip, sent, recv) in enumerate(net[:3]):
+ emoji = "🌐" if idx == 0 else ""
+ label = "Network" if idx == 0 else ""
+ net_rows.append((emoji, label, f"{name} - {ip} | ↓{recv} ↑{sent}"))
+ else:
+ net_rows.append(("🌐", "Network", "No active interfaces"))
+ sections.append({"title": "🌐 Network", "rows": net_rows})
+
+ # GPU
+ gpu_rows = []
+ if "nvidia" in gpu:
+ for g in gpu["nvidia"]:
+ gpu_rows.append(("🎮", "GPU", f"{g['name']} | {g['mem_used']}/{g['mem_total']} | {g['temp']} | {g['usage']} util"))
+ elif "detected" in gpu:
+ for line in gpu["detected"]:
+ gpu_rows.append(("🎮", "GPU", line))
+ else:
+ gpu_rows.append(("🎮", "GPU", "No dedicated GPU detected"))
+ sections.append({"title": "🎮 GPU", "rows": gpu_rows})
+
+ # Processes
+ proc_rows = [("🔄", "Processes", f"Total: {total_procs}")]
+ for p in top_procs:
+ name = p.get('name', '?')
+ cpu_p = p.get('cpu_percent') or 0
+ mem_p = p.get('memory_percent') or 0
+ proc_rows.append(("", "", f"{name} - CPU {cpu_p:.1f}% / RAM {mem_p:.1f}%"))
+ sections.append({"title": "🔄 Top Processes", "rows": proc_rows})
+
+ # Docker
+ docker_rows = []
+ if docker is not None:
+ if docker:
+ for c in docker[:5]:
+ docker_rows.append(("🐳", "Docker", f"{c['name']} - {c['status']}"))
+ else:
+ docker_rows.append(("🐳", "Docker", "No containers running"))
+ else:
+ docker_rows.append(("🐳", "Docker", "Docker not available"))
+ sections.append({"title": "🐳 Docker", "rows": docker_rows})
+
+ # Sensors
+ sensor_rows = []
+ if sensors["temps"]:
+ sensor_rows.append(("🌡️", "Temperature", ", ".join(sensors["temps"])))
+ if sensors["fans"]:
+ sensor_rows.append(("🌀", "Fans", ", ".join(sensors["fans"])))
+ if sensors["battery"]:
+ sensor_rows.append(("🔋", "Battery", sensors["battery"]))
+ if sensor_rows:
+ sections.append({"title": "🌡️ Sensors", "rows": sensor_rows})
+
+ block = code_block(f"💻 System Info: {system['hostname']}", sections)
+ output = collapsible_summary(f"💻 System Info – {html_escape(system['hostname'])}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent system information")
-async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu):
- hostname = html_escape(system.get('hostname', 'Unknown'))
- body = "💻 System Information
"
-
- # System Overview
- body += "🖥️ System Overview
"
- body += f" • Hostname: {hostname}
"
- body += f" • OS: {html_escape(system['os'])} {html_escape(system['os_release'])}
"
- body += f" • Architecture: {html_escape(system['architecture'])}
"
- body += f" • Uptime: {html_escape(system['uptime'])}
"
- body += f" • Boot Time: {html_escape(system['boot_time'])}
"
- body += f" • Users: {system['users']}
"
-
- # CPU
- body += "⚡ CPU Information
"
- body += f" • Cores: {cpu['physical_cores']} physical, {cpu['total_cores']} logical
"
- body += f" • Frequency: {html_escape(cpu['current_frequency'])} (max: {html_escape(cpu['max_frequency'])})
"
- body += f" • Usage: {cpu['usage_percent']}%
"
- body += f" • Load Average: {html_escape(cpu['load_avg'])}
"
-
- # Memory
- body += "🧠 Memory Information
"
- body += f" • Total: {html_escape(memory['total'])}
"
- body += f" • Used: {html_escape(memory['used'])} ({memory['usage_percent']}%)
"
- body += f" • Available: {html_escape(memory['available'])}
"
- body += f" • Swap: {html_escape(memory['swap_used'])} / {html_escape(memory['swap_total'])} ({memory['swap_percent']}%)
"
-
- # Storage
- if storage and 'error' not in storage:
- body += "💾 Storage Information
"
- for p in storage['partitions'][:3]:
- body += f" • {html_escape(p['device'])}: {p['used']} / {p['total']} ({p['percent']}%)
"
- # IO stats if wanted
- io = storage.get('io_stats')
- if io:
- body += f" • Disk I/O: read {io['read_bytes']}, write {io['write_bytes']}
"
- body += "
"
-
- # GPU
- if gpu:
- if 'nvidia' in gpu:
- body += "🎮 GPU Information (NVIDIA)
"
- for g in gpu['nvidia']:
- body += f" • {html_escape(g['name'])}: {g['utilization']} usage, {g['temperature']}
"
- body += "
"
- elif 'detected' in gpu:
- body += "🎮 GPU Information
"
- for line in gpu['detected'][:2]:
- body += f" • {html_escape(line)}
"
- body += "
"
-
- # Network
- if network:
- body += "🌐 Network Information
"
- for iface in network[:2]:
- body += f" • {html_escape(iface['interface'])}: {html_escape(iface['ipv4'])}
"
- body += "
"
-
- # Top Processes
- if processes:
- body += "🔄 Top Processes (by CPU)
"
- for proc in processes['top_cpu'][:3]:
- name = html_escape(proc.get('name', 'N/A'))
- cpu_p = proc.get('cpu_percent', 0) or 0
- mem_p = proc.get('memory_percent', 0) or 0
- body += f" • {name}: {cpu_p:.1f}% CPU, {mem_p:.1f}% RAM
"
- body += f" • Total Processes: {processes['total_processes']}
"
-
- # Docker
- if docker and docker.get('available'):
- body += "🐳 Docker Containers
"
- for c in docker['containers'][:3]:
- body += f" • {html_escape(c['name'])}: {html_escape(c['status'])}
"
- body += f" • Total Running: {docker['total_running']}
"
-
- # Sensors
- if sensors and 'error' not in sensors:
- if sensors.get('temperatures'):
- body += "🌡️ Temperature Sensors
"
- for sensor, temps in list(sensors['temperatures'].items())[:2]:
- body += f" • {html_escape(sensor)}: {', '.join(temps[:2])}
"
- body += "
"
- if sensors.get('battery'):
- bat = sensors['battery']
- body += "🔋 Battery Information
"
- body += f" • Charge: {bat['percent']}%
"
- body += f" • Plugged In: {'Yes' if bat['power_plugged'] else 'No'}
"
- if bat.get('time_left'):
- body += f" • Time Left: {bat['time_left']}
"
- body += "
"
-
- # Timestamp
- body += f"Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
-
- return collapsible_summary(f"💻 System Information - {hostname}", body)
+async def handle_command(room, message, bot, prefix, config):
+ match = botlib.MessageMatch(room, message, bot, prefix)
+ if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
+ if match.args() and match.args()[0].lower() == 'help':
+ usage = """
+💻 System Information
+!sysinfo – display comprehensive system info in a clean code block.
+"""
+ await bot.api.send_markdown_message(room.room_id, usage)
+ return
+ await get_system_info(room, bot)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
-__version__ = "1.0.1"
+__version__ = "1.3.1"
__author__ = "Funguy Bot"
-__description__ = "Comprehensive system information and monitoring"
+__description__ = "System information plugin"
__help__ = """
!sysinfo – System information
-Displays CPU, RAM, storage, network, Docker, GPU, sensors, and top processes.
+Displays CPU, RAM, storage, network, GPU, sensors, top processes, and more in a clean, aligned code block.
"""
diff --git a/plugins/timezone.py b/plugins/timezone.py
index 41ca687..ecd4891 100644
--- a/plugins/timezone.py
+++ b/plugins/timezone.py
@@ -1,210 +1,196 @@
#!/usr/bin/env python3
"""
-Time Zone Plugin – completely hardcoded-free using Open-Meteo APIs.
+Time Zone Plugin – uses pytz for IANA zones and Open‑Meteo for city geocoding.
+Outputs a clean code block with emojis and aligned columns via shared code_block.
"""
import logging
import aiohttp
import simplematrixbotlib as botlib
-from urllib.parse import quote
from datetime import datetime
+import pytz
+from plugins.common import collapsible_summary, html_escape, code_block
-def format_ampm(dt_str: str) -> str:
- """Convert ISO datetime to AM/PM format."""
+# -------------------------------------------------------------------
+# Offline helper for IANA timezone names
+# -------------------------------------------------------------------
+def _get_time_for_iana_zone(zone: str) -> dict | None:
+ """Return a dict with datetime, timezone, and optional temperature using pytz."""
try:
- if '+' in dt_str:
- dt_str = dt_str.split('+')[0]
- if '.' in dt_str:
- dt_str = dt_str.split('.')[0]
- dt_str = dt_str.replace('T', ' ')
- dt = datetime.fromisoformat(dt_str)
- return dt.strftime("%I:%M:%S %p").lstrip("0")
- except:
- return dt_str
+ tz = pytz.timezone(zone)
+ now = datetime.now(tz)
+ return {
+ "datetime": now.isoformat(),
+ "timezone": zone,
+ "temperature": None # no weather for zone lookups
+ }
+ except pytz.UnknownTimeZoneError:
+ return None
-async def geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
- """
- Open-Meteo Geocoding API (free, no key, no hardcoding).
- Returns (latitude, longitude, display_name) or None.
- """
+
+# -------------------------------------------------------------------
+# Online helpers (Open‑Meteo)
+# -------------------------------------------------------------------
+async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
+ """Geocode a city name via Open‑Meteo. Returns (lat, lon, display_name) or None."""
+ from urllib.parse import quote
url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
-
try:
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
- if data.get("results") and len(data["results"]) > 0:
- result = data["results"][0]
- lat = result["latitude"]
- lon = result["longitude"]
- name = result.get("name", city)
- country = result.get("country", "")
- admin1 = result.get("admin1", "")
-
- # Build display name: "Lahore, Punjab, Pakistan"
- display_parts = [name]
- if admin1 and admin1 != name:
- display_parts.append(admin1)
- if country:
- display_parts.append(country)
- display_name = ", ".join(display_parts)
-
- logging.info(f"Geocoded: {city} → {display_name} ({lat}, {lon})")
- return lat, lon, display_name
- else:
- logging.warning(f"Geocoding API HTTP {resp.status} for {city}")
+ results = data.get("results", [])
+ if results:
+ r = results[0]
+ lat = float(r["latitude"])
+ lon = float(r["longitude"])
+ name = r.get("name", city)
+ country = r.get("country", "")
+ admin1 = r.get("admin1", "")
+ display = ", ".join(filter(None, [name, admin1, country]))
+ return lat, lon, display
except Exception as e:
logging.warning(f"Geocoding error: {e}")
return None
-async def get_timezone(lat: float, lon: float) -> str | None:
- """
- Get timezone name from coordinates using timezonedb (free tier, no key).
- Alternative: use Open-Meteo's time API directly.
- """
- # Open-Meteo's time API accepts coordinates directly
- # We'll use this instead of timezonedb
- return None # Will be handled in fetch_time_by_coords
-async def fetch_time_by_coords(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
+async def _fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
"""
- Get current time using Open-Meteo (no key required).
+ Fetch current time and temperature from Open‑Meteo (free, no key).
+ The API returns an ISO 8601 string for the current time.
"""
- url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto&timeformat=unixtime"
-
+ url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto"
try:
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
current = data.get("current_weather", {})
- timezone = data.get("timezone", "Unknown")
- unixtime = current.get("time")
- temperature = current.get("temperature")
-
- if unixtime:
- # Convert UNIX timestamp to datetime
- dt = datetime.fromtimestamp(unixtime)
+ time_str = current.get("time") # ISO 8601, local time
+ temp_c = current.get("temperature")
+ tz = data.get("timezone", "Unknown")
+ if time_str:
return {
- "datetime": dt.isoformat(),
- "timezone": timezone,
- "temperature": temperature
+ "datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
+ "timezone": tz,
+ "temperature": temp_c
}
except Exception as e:
- logging.warning(f"Time fetch error: {e}")
+ logging.warning(f"Weather fetch error: {e}")
return None
-async def fetch_time_by_zone(session: aiohttp.ClientSession, zone: str) -> dict | None:
- """Get current time for a named timezone using Open-Meteo."""
- # Open-Meteo doesn't have named timezone endpoint, need to geocode a representative city
- # Fallback to worldtimeapi.org for IANA zones
- url = f"http://worldtimeapi.org/api/timezone/{zone}"
- try:
- async with session.get(url, timeout=10) as resp:
- if resp.status == 200:
- return await resp.json()
- except Exception as e:
- logging.warning(f"Timezone API error: {e}")
- return None
+# -------------------------------------------------------------------
+# Main resolver
+# -------------------------------------------------------------------
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
- """Main resolution: geocode any city, then get time."""
- query = query.strip().lower()
+ """Return (data_dict, display_name) or (None, error_message)."""
+ query = query.strip()
- # Check if it's an IANA zone (contains '/')
- if '/' in query or query in ("utc", "gmt"):
- data = await fetch_time_by_zone(session, query)
+ # 1. Try as IANA zone (offline, always works)
+ if '/' in query or query.lower() in ("utc", "gmt"):
+ data = _get_time_for_iana_zone(query)
if data:
return data, query.upper()
- return None, f"Timezone '{query}' not found"
+ else:
+ return None, f"Timezone '{html_escape(query)}' not recognised."
- # Geocode the city (no hardcoding!)
- geocode_result = await geocode_city(session, query)
+ # 2. Otherwise geocode as a city name
+ geocode_result = await _geocode_city(session, query)
if not geocode_result:
- return None, f"Could not find city '{query}'. Try being more specific."
+ return None, f"Could not find city '{html_escape(query)}'. Try a more specific name or use an IANA zone."
lat, lon, display_name = geocode_result
+ weather_data = await _fetch_weather(session, lat, lon)
+ if weather_data:
+ return weather_data, display_name
+ return None, f"Could not fetch time/weather for '{html_escape(display_name)}'."
- # Get time from coordinates
- data = await fetch_time_by_coords(session, lat, lon)
- if not data:
- return None, f"Could not get time for '{display_name}'"
- return data, display_name
-
-def format_response(data: dict, display_name: str) -> str:
- """Format time data into HTML."""
+# -------------------------------------------------------------------
+# Formatting – uses shared code_block from common.py
+# -------------------------------------------------------------------
+def _format_time_output(data: dict, display_name: str) -> str:
+ """Convert time data into a code block via the shared formatter."""
raw_time = data.get("datetime", "")
- local_time = format_ampm(raw_time) if raw_time else "Unknown"
- tz = data.get("timezone", "Unknown")
+ # Convert ISO string to AM/PM format
+ try:
+ if '+' in raw_time:
+ raw_time = raw_time.split('+')[0]
+ dt = datetime.fromisoformat(raw_time)
+ local_time = dt.strftime("%I:%M:%S %p").lstrip("0")
+ except Exception:
+ local_time = raw_time
+
+ tz_display = data.get("timezone", "Unknown")
temp = data.get("temperature")
- temp_str = f"
🌡️ Temperature: {temp}°C" if temp is not None else ""
+ if temp is not None:
+ temp_f = round(temp * 9/5 + 32, 1)
+ temp_str = f"{temp:.1f}°C / {temp_f:.1f}°F"
+ else:
+ temp_str = "N/A"
- return f"""
-
-🕒 Time in {display_name}
-
-📍 Timezone: {tz}
-📅 Local time: {local_time}{temp_str}
-
-
-"""
+ rows = [
+ ("🌐", "Location", display_name),
+ ("🕒", "Local Time", local_time),
+ ("📅", "Timezone", tz_display),
+ ("🌡️", "Temperature", temp_str),
+ ]
+ # Wrap rows in a single section with no title (title is part of code_block's main title)
+ sections = [{"title": "", "rows": rows}]
+ return code_block("🕒 Time Info", sections)
-def help_text() -> str:
- return """
+
+# -------------------------------------------------------------------
+# Help
+# -------------------------------------------------------------------
+_HELP_MD = """
🕒 Time Plugin Help
-
-!time <any city> – Get current time for ANY city worldwide
-!time <IANA zone> – e.g., Europe/London, Asia/Karachi
-!time help – Show this help
+
!time <any city> – Get current time for ANY city worldwide
+!time <IANA zone> – e.g., Europe/London, Asia/Karachi
+!time help – Show this help
Examples:
!time Lahore
!time New York
-!time Paris
-!time Asia/Karachi
-No city names are hardcoded. The bot uses Open-Meteo's geocoding API.
+!time Europe/London
+No city names are hardcoded. IANA zones work completely offline.
"""
+
+# -------------------------------------------------------------------
+# Plugin lifecycle
+# -------------------------------------------------------------------
def setup(bot):
- logging.info("Time plugin (zero hardcoded cities) loaded.")
+ logging.info("Time plugin (offline IANA zones + Open‑Meteo cities) loaded.")
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("time")):
return
args = match.args()
if not args or args[0].lower() == "help":
- await bot.api.send_markdown_message(room.room_id, help_text())
+ await bot.api.send_markdown_message(room.room_id, _HELP_MD)
return
query = " ".join(args).strip()
- await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {query}...")
+ await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {html_escape(query)}...")
async with aiohttp.ClientSession() as session:
data, display = await resolve_time(session, query)
if data is None:
await bot.api.send_text_message(room.room_id, f"❌ {display}")
return
- await bot.api.send_markdown_message(room.room_id, format_response(data, display))
+ block = _format_time_output(data, display)
+ output = collapsible_summary(f"🕒 Time in {html_escape(display)}", block)
+ await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Time sent for {query}")
-# ---------------------------------------------------------------------------
-# Plugin Metadata
-# ---------------------------------------------------------------------------
-__version__ = "1.0.0"
+__version__ = "1.1.2"
__author__ = "Funguy Bot"
-__description__ = "World clock (no hardcoded cities)"
-__help__ = """
-
-!time – Current time for any city
-
-!time <city> – Geocode any city (free Open-Meteo API)
-!time <IANA zone> – e.g., Europe/London
-
-Also shows current temperature if available.
-
-"""
+__description__ = "World clock (offline IANA zones + free geocoding)"
+__help__ = _HELP_MD
diff --git a/plugins/urbandictionary.py b/plugins/urbandictionary.py
index 73c6cd6..ec7a7b4 100644
--- a/plugins/urbandictionary.py
+++ b/plugins/urbandictionary.py
@@ -87,6 +87,6 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.1"
__author__ = "Funguy Bot"
-__description__ = "Urban Dictionary definitions (async)"
+__description__ = "Urban Dictionary definitions"
__help__ = """!ud – Urban Dictionary
!ud random, !ud <term> top, !ud <term> <index>
"""
diff --git a/plugins/weather.py b/plugins/weather.py
index 950bbd6..0817d39 100644
--- a/plugins/weather.py
+++ b/plugins/weather.py
@@ -1,11 +1,6 @@
"""
Weather plugin – primary: OpenWeatherMap, fallback: Open‑Meteo.
-
-Uses OpenWeatherMap when a valid API key is present and the request succeeds.
-Falls back to Open‑Meteo (no key required) otherwise.
-
-Commands:
- !weather e.g. !weather London or !weather "New York,US"
+Outputs a formatted code block with emojis and perfectly aligned columns.
"""
import logging
@@ -14,56 +9,14 @@ import aiohttp
import simplematrixbotlib as botlib
from dotenv import load_dotenv
from urllib.parse import quote
-
-# ---------------------------------------------------------------------------
-# Load .env (for OPENWEATHER_API_KEY)
-# ---------------------------------------------------------------------------
-plugin_dir = os.path.dirname(os.path.abspath(__file__))
-parent_dir = os.path.dirname(plugin_dir)
-dotenv_path = os.path.join(parent_dir, ".env")
-load_dotenv(dotenv_path)
+from plugins.common import html_escape, collapsible_summary, code_block
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
# ---------------------------------------------------------------------------
-# WMO codes → description + emoji (for Open‑Meteo)
-# ---------------------------------------------------------------------------
-WMO_CODES = {
- 0: ("Clear sky", "☀️"),
- 1: ("Mainly clear", "🌤️"),
- 2: ("Partly cloudy", "⛅"),
- 3: ("Overcast", "☁️"),
- 45: ("Fog", "🌫️"),
- 48: ("Depositing rime fog", "🌫️"),
- 51: ("Light drizzle", "🌦️"),
- 53: ("Moderate drizzle", "🌦️"),
- 55: ("Dense drizzle", "🌧️"),
- 56: ("Light freezing drizzle", "🌧️"),
- 57: ("Dense freezing drizzle", "🌧️"),
- 61: ("Slight rain", "🌧️"),
- 63: ("Moderate rain", "🌧️"),
- 65: ("Heavy rain", "🌧️"),
- 66: ("Light freezing rain", "🌧️"),
- 67: ("Heavy freezing rain", "🌧️"),
- 71: ("Slight snow fall", "❄️"),
- 73: ("Moderate snow fall", "❄️"),
- 75: ("Heavy snow fall", "❄️"),
- 77: ("Snow grains", "❄️"),
- 80: ("Slight rain showers", "🌦️"),
- 81: ("Moderate rain showers", "🌧️"),
- 82: ("Violent rain showers", "🌧️"),
- 85: ("Slight snow showers", "🌨️"),
- 86: ("Heavy snow showers", "🌨️"),
- 95: ("Thunderstorm", "⛈️"),
- 96: ("Thunderstorm with slight hail", "⛈️"),
- 99: ("Thunderstorm with heavy hail", "⛈️"),
-}
-
-# ---------------------------------------------------------------------------
-# Primary: OpenWeatherMap
+# OpenWeatherMap helpers
# ---------------------------------------------------------------------------
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None:
- """Fetch current weather from OpenWeatherMap. Returns None on failure."""
if not OPENWEATHER_API_KEY:
logging.info("OpenWeatherMap key missing, skipping primary")
return None
@@ -72,7 +25,7 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
params = {
"q": location,
"appid": OPENWEATHER_API_KEY,
- "units": "metric", # Celsius
+ "units": "metric",
}
try:
async with session.get(url, params=params, timeout=10) as resp:
@@ -83,46 +36,10 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
logging.warning(f"OpenWeatherMap request error: {e}")
return None
-
-def format_openweathermap(data: dict) -> str:
- """Build the one-line weather message from OpenWeatherMap data."""
- city = data.get("name", "Unknown")
- sys_data = data.get("sys", {})
- country = sys_data.get("country", "")
-
- main_data = data.get("main", {})
- temp_c = main_data.get("temp", 0)
- temp_f = round(temp_c * 9 / 5 + 32, 1)
- humidity = main_data.get("humidity", 0)
-
- weather_list = data.get("weather", [])
- description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
- emoji = "🌡️"
- if weather_list:
- wmain = weather_list[0].get("main", "")
- emoji = {
- "Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
- "Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
- "Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
- "Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
- }.get(wmain, "🌡️")
-
- wind = data.get("wind", {}).get("speed", 0)
-
- return (
- f"[{emoji} Weather for {city}, {country}]: "
- f"Condition: {description} | "
- f"Temperature: {temp_c:.1f}°C ({temp_f:.1f}°F) | "
- f"Humidity: {humidity}% | "
- f"Wind Speed: {wind} m/s"
- )
-
-
# ---------------------------------------------------------------------------
-# Fallback: Open‑Meteo (no key, free)
+# Open‑Meteo helpers (fallback)
# ---------------------------------------------------------------------------
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
- """Geocode a city name via Open‑Meteo. Returns location info dict or None."""
url = "https://geocoding-api.open-meteo.com/v1/search"
params = {"name": location, "count": 1, "language": "en"}
try:
@@ -144,10 +61,7 @@ async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict |
logging.warning(f"Open‑Meteo geocode error: {e}")
return None
-
-async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
- timezone: str = "auto") -> dict | None:
- """Fetch current weather from Open‑Meteo. Returns JSON or None."""
+async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float, timezone: str = "auto") -> dict | None:
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
@@ -165,35 +79,82 @@ async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
logging.warning(f"Open‑Meteo weather error: {e}")
return None
+# ---------------------------------------------------------------------------
+# Formatting
+# ---------------------------------------------------------------------------
+
+def format_openweathermap(data: dict) -> str:
+ """Build a code block from OpenWeatherMap response."""
+ city = data.get("name", "Unknown")
+ sys_data = data.get("sys", {})
+ country = sys_data.get("country", "")
+ main = data.get("main", {})
+ temp_c = main.get("temp", 0)
+ temp_f = round(temp_c * 9 / 5 + 32, 1)
+ humidity = main.get("humidity", 0)
+ wind_speed = data.get("wind", {}).get("speed", 0)
+ weather_list = data.get("weather", [])
+ description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
+
+ emoji_map = {
+ "Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
+ "Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
+ "Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
+ "Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
+ }
+ main_weather = weather_list[0].get("main", "") if weather_list else ""
+ weather_emoji = emoji_map.get(main_weather, "🌡️")
+
+ location = f"{city}, {country}" if country else city
+
+ rows = [
+ ("🌍", "Location", location),
+ (weather_emoji, "Condition", description),
+ ("🌡️", "Temperature", f"{temp_c:.1f}°C / {temp_f:.1f}°F"),
+ ("💧", "Humidity", f"{humidity}%"),
+ ("💨", "Wind Speed", f"{wind_speed} m/s"),
+ ]
+ sections = [{"title": "", "rows": rows}]
+ return code_block(f"🌤️ Weather for {location}", sections)
+
def format_meteo(loc_info: dict, weather_data: dict) -> str:
- """Format Open‑Meteo result into the same one‑line style."""
+ """Build a code block from Open‑Meteo response."""
c = weather_data["current_weather"]
code = c["weathercode"]
- desc, emoji = WMO_CODES.get(code, ("Unknown", "🌡️"))
+ wmo_emoji = {
+ 0: ("Clear sky", "☀️"),
+ 1: ("Mainly clear", "🌤️"),
+ 2: ("Partly cloudy", "⛅"),
+ 3: ("Overcast", "☁️"),
+ 45: ("Fog", "🌫️"),
+ 51: ("Light drizzle", "🌦️"),
+ 61: ("Slight rain", "🌧️"),
+ 63: ("Moderate rain", "🌧️"),
+ 71: ("Slight snow", "❄️"),
+ 95: ("Thunderstorm", "⛈️"),
+ }
+ desc, emoji = wmo_emoji.get(code, ("Unknown", "🌡️"))
- city = loc_info["name"]
- country = loc_info.get("country", "")
- state = loc_info.get("state", "")
-
- # Build location string
- parts = [city]
- if state and state != city:
- parts.append(state)
- if country:
- parts.append(country)
- loc_str = ", ".join(parts)
+ location_parts = [loc_info["name"]]
+ if loc_info.get("state") and loc_info["state"] != loc_info["name"]:
+ location_parts.append(loc_info["state"])
+ if loc_info.get("country"):
+ location_parts.append(loc_info["country"])
+ location = ", ".join(location_parts)
temp_f = c["temperature"]
temp_c = round((temp_f - 32) * 5 / 9, 1)
- wind = c["windspeed"]
+ wind = c["windspeed"] # mph
- return (
- f"[{emoji} Weather for {loc_str}]: "
- f"Condition: {desc} | "
- f"Temperature: {temp_c}°C ({temp_f}°F) | "
- f"Wind Speed: {wind} mph"
- )
+ rows = [
+ ("🌍", "Location", location),
+ (emoji, "Condition", desc),
+ ("🌡️", "Temperature", f"{temp_c}°C / {temp_f}°F"),
+ ("💨", "Wind Speed", f"{wind} mph"),
+ ]
+ sections = [{"title": "", "rows": rows}]
+ return code_block(f"🌤️ Weather for {location}", sections)
# ---------------------------------------------------------------------------
@@ -218,14 +179,11 @@ async def handle_command(room, message, bot, prefix, config):
async with aiohttp.ClientSession() as session:
# 1. Try OpenWeatherMap
owm_data = await openweathermap_get(session, location)
- if owm_data:
- if owm_data.get("cod") == 200:
- msg = format_openweathermap(owm_data)
- await bot.api.send_markdown_message(room.room_id, msg)
- logging.info("Sent weather via OpenWeatherMap")
- return
- # OpenWeatherMap returned an error status inside JSON (e.g., 401, 404)
- logging.info("OpenWeatherMap returned error code %s, falling back", owm_data.get("cod"))
+ if owm_data and owm_data.get("cod") == 200:
+ block = format_openweathermap(owm_data)
+ output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
+ await bot.api.send_markdown_message(room.room_id, output)
+ return
# 2. Fallback: Open‑Meteo
logging.info("Falling back to Open‑Meteo")
@@ -233,7 +191,7 @@ async def handle_command(room, message, bot, prefix, config):
if not loc_info:
await bot.api.send_text_message(
room.room_id,
- f"Location '{location}' not found."
+ f"Location '{html_escape(location)}' not found."
)
return
@@ -247,28 +205,24 @@ async def handle_command(room, message, bot, prefix, config):
)
return
- msg = format_meteo(loc_info, wdata)
- await bot.api.send_markdown_message(room.room_id, msg)
+ block = format_meteo(loc_info, wdata)
+ output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
+ await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent weather via Open‑Meteo (fallback)")
-
-# ---------------------------------------------------------------------------
-# Plugin setup
-# ---------------------------------------------------------------------------
def setup(bot):
logging.info("Weather plugin loaded (OpenWeatherMap + Open‑Meteo fallback)")
-
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
-__version__ = "1.0.0"
+
+__version__ = "1.1.1"
__author__ = "Funguy Bot"
-__description__ = "Weather forecast (OWM primary, Open‑Meteo fallback)"
+__description__ = "Weather data plugin"
__help__ = """
!weather – Current weather
-!weather <location> – Shows temperature, conditions, humidity, wind.
-Uses OpenWeatherMap if a valid API key is present; falls back to free Open‑Meteo otherwise.
+!weather <location> – Shows temperature, conditions, humidity, wind in a clean, aligned table. Uses OpenWeatherMap primary, Open‑Meteo fallback.
"""
diff --git a/plugins/whois.py b/plugins/whois.py
index 059794f..d04c584 100644
--- a/plugins/whois.py
+++ b/plugins/whois.py
@@ -1,219 +1,130 @@
"""
-This plugin provides WHOIS lookup functionality for domains, IPs, and related network information.
+WHOIS lookup plugin – outputs a formatted code block with emojis and aligned columns.
"""
import logging
import whois
import ipaddress
import re
+import asyncio
import simplematrixbotlib as botlib
-
+from plugins.common import collapsible_summary, html_escape, code_block
def is_valid_domain(domain):
- """
- Validate if the provided string is a valid domain name.
-
- Args:
- domain (str): The domain to validate.
-
- Returns:
- bool: True if valid, False otherwise.
- """
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9-]{1,63}$'
return re.match(pattern, domain) is not None
-
def is_valid_ip(ip):
- """
- Validate if the provided string is a valid IPv4 or IPv6 address.
-
- Args:
- ip (str): The IP address to validate.
-
- Returns:
- bool: True if valid, False otherwise.
- """
try:
ipaddress.ip_address(ip)
return True
except ValueError:
return False
+def _build_rows(data):
+ """Build a list of (emoji, label, value) tuples from WHOIS data."""
+ rows = []
-def format_whois_data(domain, data):
- """
- Format WHOIS data into a readable format.
+ # Domain
+ domain_name = data.domain_name
+ if isinstance(domain_name, list):
+ domain_name = ', '.join(domain_name)
+ rows.append(('🌐', 'Domain', domain_name or 'N/A'))
- Args:
- domain (str): The queried domain/IP.
- data (whois domain object): The WHOIS data object.
-
- Returns:
- str: Formatted HTML message.
- """
- sections = []
-
- # Domain/Query Information
- if hasattr(data, 'domain_name') or hasattr(data, 'query'):
- domain_names = getattr(data, 'domain_name', domain)
- if isinstance(domain_names, list):
- domain_names = ', '.join(domain_names)
- sections.append(f"🔍 Query: {domain_names}")
-
- # Registrar Information
- registrar_items = []
- if hasattr(data, 'registrar'):
- registrar_items.append(f"Registrar: {data.registrar}")
- if hasattr(data, 'whois_server'):
- registrar_items.append(f"WHOIS Server: {data.whois_server}")
- if registrar_items:
- sections.append('
'.join(registrar_items))
+ # Registrar / WHOIS Server
+ if data.registrar:
+ rows.append(('🏢', 'Registrar', data.registrar))
+ if data.whois_server:
+ rows.append(('📡', 'WHOIS Server', data.whois_server))
# Dates
- date_items = []
- if hasattr(data, 'creation_date'):
- creation = data.creation_date
- if isinstance(creation, list):
- creation = creation[0]
- date_items.append(f"Created: {creation}")
+ creation_date = data.creation_date
+ if creation_date:
+ if isinstance(creation_date, list):
+ creation_date = creation_date[0]
+ rows.append(('📅', 'Created', str(creation_date)))
- if hasattr(data, 'updated_date'):
- updated = data.updated_date
- if isinstance(updated, list):
- updated = updated[0]
- date_items.append(f"Updated: {updated}")
+ updated_date = data.updated_date
+ if updated_date:
+ if isinstance(updated_date, list):
+ updated_date = updated_date[0]
+ rows.append(('📝', 'Updated', str(updated_date)))
- if hasattr(data, 'expiration_date'):
- expiration = data.expiration_date
- if isinstance(expiration, list):
- expiration = expiration[0]
- date_items.append(f"Expires: {expiration}")
+ expiration_date = data.expiration_date
+ if expiration_date:
+ if isinstance(expiration_date, list):
+ expiration_date = expiration_date[0]
+ rows.append(('⏰', 'Expires', str(expiration_date)))
- if date_items:
- sections.append('
'.join(date_items))
+ # Name servers
+ if data.name_servers:
+ ns_sorted = sorted(data.name_servers)
+ ns_text = ', '.join(ns_sorted[:5])
+ if len(ns_sorted) > 5:
+ ns_text += f' (+{len(ns_sorted)-5} more)'
+ rows.append(('🌍', 'Name Servers', ns_text))
# Status
- if hasattr(data, 'status'):
+ if data.status:
status = data.status
if isinstance(status, list):
- status = '
'.join(status[:3]) # Limit to first 3 status entries
- sections.append(f"Status:
{status}")
+ status = ', '.join(status[:3])
+ rows.append(('🔒', 'Status', str(status)))
- # Name Servers
- if hasattr(data, 'name_servers'):
- name_servers = data.name_servers
- if isinstance(name_servers, list):
- if len(name_servers) > 5:
- name_servers_list = '
'.join(sorted(name_servers)[:5])
- name_servers_list += f"
...(+{len(name_servers) - 5} more)"
- else:
- name_servers_list = '
'.join(sorted(name_servers))
- else:
- name_servers_list = str(name_servers)
- sections.append(f"Name Servers:
{name_servers_list}")
-
- # Contact Information
- contact_items = []
- if hasattr(data, 'org'):
- contact_items.append(f"Organization: {data.org}")
- if hasattr(data, 'country'):
- contact_items.append(f"Country: {data.country}")
- if hasattr(data, 'state'):
- contact_items.append(f"State: {data.state}")
- if hasattr(data, 'city'):
- contact_items.append(f"City: {data.city}")
-
- if contact_items:
- sections.append('
'.join(contact_items))
-
- # Build the final message
- if sections:
- content = f"🌐 WHOIS Report: {domain}
"
- content += '
'.join(sections)
- else:
- content = f"🌐 WHOIS Information for {domain}
"
- content += "No detailed information available or query returned minimal data."
-
- # Wrap in collapsible details block for Matrix compatibility
- message = f"🌐 WHOIS Report: {domain} (Click to expand)
{content} "
-
- return message
+ # Contact info
+ if data.org:
+ rows.append(('🏛️', 'Organization', data.org))
+ if data.country:
+ rows.append(('🌍', 'Country', data.country))
+ if data.state:
+ rows.append(('🏙️', 'State', data.state))
+ if data.city:
+ rows.append(('🏡', 'City', data.city))
+ return rows
async def handle_command(room, message, bot, prefix, config):
- """
- Function to handle the !whois command.
-
- Args:
- room (Room): The Matrix room where the command was invoked.
- message (RoomMessage): The message object containing the command.
- bot (Bot): The bot object.
- prefix (str): The command prefix.
- config (dict): Configuration parameters.
-
- Returns:
- None
- """
match = botlib.MessageMatch(room, message, bot, prefix)
-
if match.is_not_from_this_bot() and match.prefix() and match.command("whois"):
args = match.args()
-
if len(args) < 1:
- await bot.api.send_text_message(
- room.room_id,
- "Usage: !whois \nExample: !whois example.com\nExample: !whois 8.8.8.8"
- )
+ await bot.api.send_text_message(room.room_id, "Usage: !whois \nExample: !whois example.com")
return
query = args[0].strip()
- logging.info(f"Received !whois command for: {query}")
-
- # Validate the query
if not is_valid_domain(query) and not is_valid_ip(query):
- await bot.api.send_text_message(
- room.room_id,
- f"Invalid domain or IP address format: {query}\nPlease provide a valid domain (e.g., example.com) or IP address."
- )
- logging.warning(f"Invalid WHOIS query format: {query}")
+ await bot.api.send_text_message(room.room_id, f"Invalid input: {html_escape(query)}")
return
+ await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {html_escape(query)}...")
+
try:
- # Perform WHOIS lookup
- logging.info(f"Performing WHOIS lookup for: {query}")
- await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {query}...")
+ loop = asyncio.get_running_loop()
+ data = await loop.run_in_executor(None, whois.whois, query)
- # Use python-whois library
- whois_data = whois.whois(query)
-
- # Format and send the results
- result_message = format_whois_data(query, whois_data)
- await bot.api.send_markdown_message(room.room_id, result_message)
- logging.info(f"Successfully sent WHOIS results for {query}")
+ rows = _build_rows(data)
+ sections = [{"title": "", "rows": rows}] # no section header
+ block = code_block(f"🌐 WHOIS Report: {html_escape(query)}", sections)
+ output = collapsible_summary(f"🌐 WHOIS Report: {html_escape(query)}", block)
+ await bot.api.send_markdown_message(room.room_id, output)
except whois.parser.PywhoisError as e:
- error_msg = f"WHOIS lookup failed for {query}.\n"
- error_msg += "Possible reasons:\n- Domain/IP not found\n- WHOIS server unavailable\n- Rate limited by registrar"
- await bot.api.send_text_message(room.room_id, error_msg)
- logging.error(f"WHOIS lookup error for {query}: {e}")
-
+ await bot.api.send_text_message(room.room_id, f"❌ WHOIS lookup failed: {html_escape(str(e))}")
except Exception as e:
- await bot.api.send_text_message(
- room.room_id,
- f"An unexpected error occurred during WHOIS lookup for {query}. Please try again later."
- )
- logging.error(f"Unexpected error in WHOIS plugin for {query}: {e}", exc_info=True)
+ await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {html_escape(str(e))}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
-__version__ = "1.0.0"
+__version__ = "1.2.1"
__author__ = "Funguy Bot"
-__description__ = "WHOIS lookup"
+__description__ = "Domain WHOIS lookup"
__help__ = """
!whois – WHOIS lookup
-!whois <domain or IP> – Shows registrar, creation/expiry dates, nameservers, contacts.
+
+!whois <domain or IP> Shows registrar, dates, nameservers, etc. in a clean table.
+
"""
diff --git a/plugins/youtube-search.py b/plugins/youtube-search.py
index 980a96e..7044d52 100644
--- a/plugins/youtube-search.py
+++ b/plugins/youtube-search.py
@@ -44,6 +44,6 @@ def generate_output(results):
__version__ = "1.0.1"
__author__ = "Funguy Bot"
-__description__ = "YouTube video search (async)"
+__description__ = "YouTube video search"
__help__ = """!yt – Search YouTube
!yt <search terms>
"""
diff --git a/requirements.txt b/requirements.txt
index 40614f7..5c3d8c1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -31,3 +31,4 @@ yara-python
asn1crypto
PyYAML
lxml
+wcwidth
\ No newline at end of file