From 5c6234a317b1a91b3543a03d10888f5db21a8a2a Mon Sep 17 00:00:00 2001 From: Hash Borgir Date: Sat, 9 May 2026 04:51:50 -0500 Subject: [PATCH] various plugin refactors and fixes --- plugins/arxiv.py | 2 +- plugins/common.py | 56 + plugins/ddg.py | 2 +- plugins/dns.py | 169 +-- plugins/dnsdumpster.py | 119 +- plugins/geo.py | 163 ++- plugins/hashid.py | 285 +---- plugins/headers.py | 475 +++----- plugins/infermatic-text.py | 2 +- plugins/lastfm.py | 2092 +++++++++++------------------------ plugins/plugins.py | 2 +- plugins/proxy.py | 2 +- plugins/quote.py | 2 +- plugins/roomstats.py | 319 ++---- plugins/shodan.py | 307 ++--- plugins/sslscan.py | 239 ++-- plugins/stable-diffusion.py | 2 +- plugins/subnet.py | 269 +++-- plugins/sysinfo.py | 513 ++++----- plugins/timezone.py | 248 ++--- plugins/urbandictionary.py | 2 +- plugins/weather.py | 218 ++-- plugins/whois.py | 227 ++-- plugins/youtube-search.py | 2 +- requirements.txt | 1 + 25 files changed, 2044 insertions(+), 3674 deletions(-) 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" ![Album Art]({image_url})") - lines.append(f" **{name}** by **{artist}**") + if img: + lines.append(f"![Album Art]({img})") + 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