various plugin refactors and fixes

This commit is contained in:
2026-05-09 04:51:50 -05:00
parent f822d6a450
commit 5c6234a317
25 changed files with 2044 additions and 3674 deletions
+1 -1
View File
@@ -383,7 +383,7 @@ def setup(bot):
__version__ = "1.0.2" __version__ = "1.0.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "arXiv academic paper search (with rate limiting and error reporting)" __description__ = "arXiv academic paper search"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!arxiv</strong> Search academic papers on arXiv</summary> <summary><strong>!arxiv</strong> Search academic papers on arXiv</summary>
+56
View File
@@ -5,6 +5,7 @@ import html
import ipaddress import ipaddress
import socket import socket
import logging import logging
from wcwidth import wcswidth
logger = logging.getLogger(__name__) 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", message_type="m.room.message",
content=content content=content
) )
def code_block(title: str, sections: list) -> str:
"""
Build a Markdown code block with perfectly aligned columns (emojiaware).
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```"
+1 -1
View File
@@ -265,7 +265,7 @@ async def send_help(room, bot):
__version__ = "2.1.1" __version__ = "2.1.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "DuckDuckGo search collapsible results (ddgs library, no API key)" __description__ = "DuckDuckGo search plugin"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!ddg</strong> DuckDuckGo search (web, images, news, etc.)</summary> <summary><strong>!ddg</strong> DuckDuckGo search (web, images, news, etc.)</summary>
+62 -43
View File
@@ -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 logging
import asyncio
import dns.resolver import dns.resolver
import dns.reversename import dns.reversename
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
import re import re
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
from plugins.utils import is_public_destination
RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV'] RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV']
@@ -16,22 +17,15 @@ 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,}$' 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 return re.match(pattern, domain) is not None
def format_dns_record(record_type, records):
if not records:
return ""
output = f"<strong>{record_type} Records:</strong><br>"
for record in records:
output += f"{record}<br>"
return output
async def query_dns_records(domain): async def query_dns_records(domain):
loop = asyncio.get_running_loop()
def _resolve():
results = {} results = {}
resolver = dns.resolver.Resolver() resolver = dns.resolver.Resolver()
resolver.timeout = 5 resolver.timeout = 5
resolver.lifetime = 5 resolver.lifetime = 5
for record_type in RECORD_TYPES: for record_type in RECORD_TYPES:
try: try:
logging.info(f"Querying {record_type} records for {domain}")
answers = resolver.resolve(domain, record_type) answers = resolver.resolve(domain, record_type)
records = [] records = []
for rdata in answers: for rdata in answers:
@@ -48,11 +42,9 @@ async def query_dns_records(domain):
records.append(str(rdata)) records.append(str(rdata))
if records: if records:
results[record_type] = records results[record_type] = records
logging.info(f"Found {len(records)} {record_type} record(s)")
except dns.resolver.NoAnswer: except dns.resolver.NoAnswer:
continue continue
except dns.resolver.NXDOMAIN: except dns.resolver.NXDOMAIN:
logging.warning(f"Domain {domain} does not exist")
return None return None
except dns.resolver.Timeout: except dns.resolver.Timeout:
continue continue
@@ -60,69 +52,96 @@ async def query_dns_records(domain):
logging.error(f"Error querying {record_type} for {domain}: {e}") logging.error(f"Error querying {record_type} for {domain}: {e}")
continue continue
return results 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): async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("dns"): if match.is_not_from_this_bot() and match.prefix() and match.command("dns"):
logging.info("Received !dns command")
args = match.args() args = match.args()
if len(args) != 1: if len(args) != 1:
await bot.api.send_text_message(room.room_id, await bot.api.send_text_message(room.room_id, "Usage: !dns <domain>\nExample: !dns example.com")
"Usage: !dns <domain>\nExample: !dns example.com")
return return
domain = args[0].lower().strip() domain = args[0].lower().strip()
domain = domain.replace('http://', '').replace('https://', '').rstrip('/') domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
if not is_valid_domain(domain): 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 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: try:
await bot.api.send_text_message(room.room_id,
f"🔍 Performing DNS reconnaissance on {domain}...")
results = await query_dns_records(domain) results = await query_dns_records(domain)
if results is None: if results is None:
await bot.api.send_text_message(room.room_id, await bot.api.send_text_message(room.room_id, f"Domain {html_escape(domain)} does not exist (NXDOMAIN)")
f"Domain {domain} does not exist (NXDOMAIN)")
return return
if not results: if not results:
await bot.api.send_text_message(room.room_id, await bot.api.send_text_message(room.room_id, f"No DNS records found for {html_escape(domain)}")
f"No DNS records found for {domain}")
return return
# SSRF / privacy check: if all A/AAAA records are private, refuse.
a_records = results.get('A', []) a_records = results.get('A', [])
aaaa_records = results.get('AAAA', []) aaaa_records = results.get('AAAA', [])
all_ips = a_records + aaaa_records all_ips = a_records + aaaa_records
if all_ips and not any(is_public_destination(ip) for ip in all_ips): if all_ips and not any(is_public_destination(ip) for ip in all_ips):
await bot.api.send_text_message(room.room_id, await bot.api.send_text_message(room.room_id, "❌ This domain resolves exclusively to private/internal IPs.")
"❌ This domain resolves exclusively to private/internal IPs.")
return return
output = f"<strong>🔍 DNS Records for {domain}</strong><br><br>"
preferred_order = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR'] rows = []
for record_type in preferred_order: preferred = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
if record_type in results: for rtype in preferred:
output += format_dns_record(record_type, results[record_type]) if rtype in results:
output += "<br>" emoji, label = RECORD_META.get(rtype, ('', rtype))
for record_type in results: for rec in results[rtype]:
if record_type not in preferred_order: rows.append((emoji, label, rec))
output += format_dns_record(record_type, results[record_type]) emoji = ""
output += "<br>" label = ""
if output.count('<br>') > 15: for rtype in results:
output = f"<details><summary><strong>🔍 DNS Records for {domain}</strong></summary>{output}</details>" 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) await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent DNS records for {domain}") logging.info(f"Sent DNS records for {domain}")
except Exception as e: except Exception as e:
await bot.api.send_text_message(room.room_id, await bot.api.send_text_message(room.room_id, f"An error occurred while performing DNS lookup: {str(e)}")
f"An error occurred while performing DNS lookup: {str(e)}")
logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True) logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin Metadata # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.0.1" __version__ = "1.1.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "DNS reconnaissance (SSRFsafe)" __description__ = "DNS reconnaissance (SSRFsafe)"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!dns</strong> DNS reconnaissance</summary> <summary><strong>!dns</strong> DNS reconnaissance</summary>
<p><code>!dns &lt;domain&gt;</code> Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.</p> <p><code>!dns &lt;domain&gt;</code> Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records and displays them in a clean, aligned table.</p>
</details> </details>
""" """
+63 -56
View File
@@ -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 logging
import os import os
import aiohttp import aiohttp
import simplematrixbotlib as botlib 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_KEY = os.getenv("DNSDUMPSTER_KEY", "")
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com" 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): async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"): if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
logging.info("Received !dnsdumpster command")
if not DNSDUMPSTER_API_KEY: if not DNSDUMPSTER_API_KEY:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env.")
room.room_id,
"DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env."
)
return return
args = match.args() args = match.args()
if len(args) < 1: if len(args) < 1:
await show_usage(room, bot) await show_usage(room, bot)
return return
if args[0].lower() == "test": if args[0].lower() == "test":
await test_dnsdumpster_connection(room, bot) await test_dnsdumpster_connection(room, bot)
else: else:
@@ -37,9 +32,6 @@ async def show_usage(room, bot):
usage = """<strong>🔍 DNSDumpster Commands:</strong> usage = """<strong>🔍 DNSDumpster Commands:</strong>
<strong>!dnsdumpster &lt;domain_name&gt;</strong> - Get comprehensive DNS reconnaissance for a domain <strong>!dnsdumpster &lt;domain_name&gt;</strong> - Get comprehensive DNS reconnaissance for a domain
<strong>!dnsdumpster test</strong> - Test API connection <strong>!dnsdumpster test</strong> - Test API connection
<strong>Examples:</strong>
• <code>!dnsdumpster google.com</code>
• <code>!dnsdumpster github.com</code>
""" """
await bot.api.send_markdown_message(room.room_id, usage) 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 aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=15) as response: async with session.get(url, headers=headers, timeout=15) as response:
status = response.status status = response.status
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>Test Domain: {test_domain}<br>" debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>"
if status == 200: if status == 200:
data = await response.json() data = await response.json()
debug_info += "<strong>✅ SUCCESS</strong><br>" debug_info += "<strong>✅ SUCCESS</strong><br>"
@@ -81,50 +72,66 @@ async def dnsdumpster_domain_lookup(room, bot, domain):
return return
data = await response.json() 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) await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent DNSDumpster data for {domain}")
except asyncio.TimeoutError: except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, "Request timed out.")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Error: {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) # Plugin Metadata
output = f"<strong>🔍 DNSDumpster Report: {safe_domain}</strong><br><br>" # ---------------------------------------------------------------------------
if data.get('total_a_recs'): __version__ = "1.0.2"
output += f"<strong>📊 Summary</strong><br>Total A Records: {data['total_a_recs']}<br>"
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"<br><strong>{label} ({len(data[record_type])} found)</strong><br>"
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}<br>"
elif record_type == 'a':
host = html_escape(rec.get('host','N/A'))
ips = rec.get('ips',[])
output += f" • <strong>{host}</strong><br>"
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})<br>"
else:
host = html_escape(rec.get('host','N/A'))
ips = rec.get('ips',[])
output += f" • <strong>{host}</strong><br>"
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})<br>"
output += "<br><em>💡 Rate Limit: 1 request per 2 seconds</em>"
return collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain} (Click to expand)", output)
__version__ = "1.0.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "DNSDumpster domain reconnaissance" __description__ = "DNSDumpster domain reconnaissance"
__help__ = """ __help__ = """
+103 -40
View File
@@ -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 logging
import aiohttp import aiohttp
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
import socket import socket
import re 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): async def is_valid_ip(ip):
"""Check if the provided string is a valid IP address."""
try: try:
socket.inet_pton(socket.AF_INET, ip) socket.inet_pton(socket.AF_INET, ip)
return True return True
@@ -20,18 +23,21 @@ async def is_valid_ip(ip):
return False return False
def is_domain(domain): def is_domain(domain):
"""Check if the provided string is a domain name."""
domain_pattern = re.compile( domain_pattern = re.compile(
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$' 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)) return bool(domain_pattern.match(domain))
async def resolve_domain(domain): async def resolve_domain(domain):
"""Resolve a domain name to an IP address."""
try: try:
return socket.gethostbyname(domain) return socket.gethostbyname(domain)
except socket.gaierror: except socket.gaierror:
return None return None
async def query_ip_api_com(ip): async def query_ip_api_com(ip):
"""Query ip-api.com for geolocation information."""
url = f"http://ip-api.com/json/{ip}" url = f"http://ip-api.com/json/{ip}"
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -43,6 +49,7 @@ async def query_ip_api_com(ip):
return None return None
async def query_ipapi_co(ip): async def query_ipapi_co(ip):
"""Query ipapi.co for geolocation information (fallback)."""
url = f"https://ipapi.co/{ip}/json/" url = f"https://ipapi.co/{ip}/json/"
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -54,69 +61,125 @@ async def query_ipapi_co(ip):
return None return None
async def query_geolocation(ip): async def query_geolocation(ip):
"""Query geolocation using primary and fallback APIs."""
data = await query_ip_api_com(ip) data = await query_ip_api_com(ip)
if not data or data.get('status') == 'fail': if not data or data.get('status') == 'fail':
data = await query_ipapi_co(ip) data = await query_ipapi_co(ip)
return data 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"<strong>Country:</strong> {country} ({country_code})<br>"
f"<strong>Region:</strong> {region}<br>"
f"<strong>City:</strong> {city}<br>"
f"<strong>Postal Code:</strong> {postal}<br>"
f"<strong>Coordinates:</strong> {latitude}, {longitude}<br>"
f"<strong>Timezone:</strong> {timezone}<br>"
f"<strong>ISP/Organization:</strong> {isp}<br>"
f"<strong>ASN:</strong> {asn}<br>")
return collapsible_summary(f"🔍 Geolocation: {ip}", content)
async def handle_command(room, message, bot, prefix, config): async def handle_command(room, message, bot, prefix, config):
"""Handle the !geo command."""
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("geo"): if match.is_not_from_this_bot() and match.prefix() and match.command("geo"):
args = match.args() args = match.args()
if len(args) < 1: if len(args) < 1:
await bot.api.send_text_message(room.room_id, "Usage: !geo <ip/domain>") await bot.api.send_text_message(
room.room_id,
"Usage: !geo <ip_address/domain>\nExample: !geo 8.8.8.8\nExample: !geo example.com"
)
return return
query = args[0].strip() query = args[0].strip()
logging.info(f"Received !geo command for: {query}")
try:
ip = query ip = query
if is_domain(query): if is_domain(query):
await bot.api.send_text_message(room.room_id, f"🔍 Resolving domain {html_escape(query)}...") await bot.api.send_text_message(
room.room_id,
f"🔍 Resolving domain {html_escape(query)} to IP address..."
)
ip = await resolve_domain(query) ip = await resolve_domain(query)
if not ip: if not ip:
await bot.api.send_text_message(room.room_id, f"Failed to resolve {html_escape(query)}.") await bot.api.send_text_message(room.room_id,
f"Failed to resolve domain {html_escape(query)} to IP address.")
return return
if not is_public_destination(ip): if not is_public_destination(ip):
await bot.api.send_text_message(room.room_id, "❌ Domain resolves to private IP.") await bot.api.send_text_message(room.room_id,
"❌ That domain resolves to a private/internal IP, geo not allowed.")
return return
await bot.api.send_text_message(room.room_id, f"Resolved to {ip}") 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): elif not await is_valid_ip(query):
await bot.api.send_text_message(room.room_id, f"Invalid IP/domain: {html_escape(query)}") await bot.api.send_text_message(room.room_id,
f"Invalid IP address or domain format: {html_escape(query)}")
return return
else: else:
if not is_public_destination(ip): if not is_public_destination(ip):
await bot.api.send_text_message(room.room_id, "❌ Private IP not allowed.") await bot.api.send_text_message(room.room_id,
"❌ Geolocation of private IP addresses is not allowed.")
return return
geo_data = await query_geolocation(ip) await bot.api.send_text_message(room.room_id,
result = await format_geolocation_results(ip, geo_data) f"🔍 Looking up geolocation for {ip}...")
await bot.api.send_markdown_message(room.room_id, result)
__version__ = "1.0.2" 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
# 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')
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" __author__ = "Funguy Bot"
__description__ = "IP geolocation lookup" __description__ = "IP geolocation lookup"
__help__ = """<details><summary><strong>!geo</strong> IP / domain geolocation</summary> __help__ = """
<ul><li><code>!geo &lt;ip&gt;</code> or <code>!geo &lt;domain&gt;</code></li></ul></details>""" <details>
<summary><strong>!geo</strong> IP / domain geolocation</summary>
<p><code>!geo &lt;ip or domain&gt;</code> Locate an IP address or domain. Shows country, city, coordinates, ISP, ASN, etc.</p>
</details>
"""
+40 -243
View File
@@ -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 logging
import re import re
import simplematrixbotlib as botlib 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): 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_string = hash_string.strip()
hash_lower = hash_string.lower() hash_lower = hash_string.lower()
length = len(hash_string) length = len(hash_string)
@@ -25,15 +20,10 @@ def identify_hash(hash_string):
# Unix crypt and modular crypt formats (most specific first) # Unix crypt and modular crypt formats (most specific first)
if hash_string.startswith('$'): if hash_string.startswith('$'):
# yescrypt (modern Linux /etc/shadow)
if re.match(r'^\$y\$', hash_string): if re.match(r'^\$y\$', hash_string):
possible_types.append(("yescrypt", None, "yescrypt", 95)) possible_types.append(("yescrypt", None, "yescrypt", 95))
# scrypt
elif re.match(r'^\$7\$', hash_string): elif re.match(r'^\$7\$', hash_string):
possible_types.append(("scrypt", "8900", "scrypt", 95)) possible_types.append(("scrypt", "8900", "scrypt", 95))
# Argon2
elif re.match(r'^\$argon2(id?|d)\$', hash_string): elif re.match(r'^\$argon2(id?|d)\$', hash_string):
if '$argon2i$' in hash_string: if '$argon2i$' in hash_string:
possible_types.append(("Argon2i", "10900", "argon2", 95)) possible_types.append(("Argon2i", "10900", "argon2", 95))
@@ -41,72 +31,39 @@ def identify_hash(hash_string):
possible_types.append(("Argon2d", None, "argon2", 95)) possible_types.append(("Argon2d", None, "argon2", 95))
elif '$argon2id$' in hash_string: elif '$argon2id$' in hash_string:
possible_types.append(("Argon2id", "10900", "argon2", 95)) possible_types.append(("Argon2id", "10900", "argon2", 95))
# bcrypt variants
elif re.match(r'^\$(2[abxy]?)\$', hash_string): elif re.match(r'^\$(2[abxy]?)\$', hash_string):
bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1) bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1)
possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95)) possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95))
# SHA-512 Crypt (common in Linux)
elif re.match(r'^\$6\$', hash_string): elif re.match(r'^\$6\$', hash_string):
possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95)) possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95))
# SHA-256 Crypt (Unix)
elif re.match(r'^\$5\$', hash_string): elif re.match(r'^\$5\$', hash_string):
possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95)) possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95))
# MD5 Crypt (Unix)
elif re.match(r'^\$1\$', hash_string): elif re.match(r'^\$1\$', hash_string):
possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95)) possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95))
# Apache MD5
elif re.match(r'^\$apr1\$', hash_string): elif re.match(r'^\$apr1\$', hash_string):
possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95)) possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95))
# AIX SMD5
elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE): elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {smd5}", "6300", None, 90)) possible_types.append(("AIX {smd5}", "6300", None, 90))
# AIX SSHA256
elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE): elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {ssha256}", "6700", None, 90)) possible_types.append(("AIX {ssha256}", "6700", None, 90))
# AIX SSHA512
elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE): elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {ssha512}", "6800", None, 90)) possible_types.append(("AIX {ssha512}", "6800", None, 90))
# phpBB3
elif re.match(r'^\$H\$', hash_string): elif re.match(r'^\$H\$', hash_string):
possible_types.append(("phpBB3", "400", "phpass", 90)) possible_types.append(("phpBB3", "400", "phpass", 90))
# Wordpress
elif re.match(r'^\$P\$', hash_string): elif re.match(r'^\$P\$', hash_string):
possible_types.append(("Wordpress", "400", "phpass", 90)) possible_types.append(("Wordpress", "400", "phpass", 90))
# Drupal 7+
elif re.match(r'^\$S\$', hash_string): elif re.match(r'^\$S\$', hash_string):
possible_types.append(("Drupal 7+", "7900", "drupal7", 90)) possible_types.append(("Drupal 7+", "7900", "drupal7", 90))
# WBB3 (Woltlab Burning Board)
elif re.match(r'^\$wbb3\$', hash_string): elif re.match(r'^\$wbb3\$', hash_string):
possible_types.append(("WBB3 (Woltlab)", None, None, 85)) possible_types.append(("WBB3 (Woltlab)", None, None, 85))
# PBKDF2-HMAC-SHA256
elif re.match(r'^\$pbkdf2-sha256\$', hash_string): elif re.match(r'^\$pbkdf2-sha256\$', hash_string):
possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90)) possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90))
# PBKDF2-HMAC-SHA512
elif re.match(r'^\$pbkdf2-sha512\$', hash_string): elif re.match(r'^\$pbkdf2-sha512\$', hash_string):
possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90)) possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90))
# Django PBKDF2
elif re.match(r'^pbkdf2_sha256\$', hash_string): elif re.match(r'^pbkdf2_sha256\$', hash_string):
possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90)) possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90))
# Unknown modular crypt format
else: else:
possible_types.append(("Unknown Modular Crypt Format", None, None, 30)) possible_types.append(("Unknown Modular Crypt Format", None, None, 30))
return possible_types return possible_types
# LDAP formats # LDAP formats
@@ -123,31 +80,22 @@ def identify_hash(hash_string):
possible_types.append(("LDAP CRYPT", None, None, 85)) possible_types.append(("LDAP CRYPT", None, None, 85))
return possible_types return possible_types
# Check for colon-separated formats (LM:NTLM, username:hash, etc.) # Colon-separated formats
if ':' in hash_string: if ':' in hash_string:
parts = hash_string.split(':') parts = hash_string.split(':')
# NetNTLMv1 / NetNTLMv2
if len(parts) >= 5: if len(parts) >= 5:
possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85)) possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85))
possible_types.append(("NetNTLMv1", "5500", "netntlm", 75)) possible_types.append(("NetNTLMv1", "5500", "netntlm", 75))
# LM:NTLM format
elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32: elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32:
possible_types.append(("LM:NTLM", "1000", "nt", 90)) possible_types.append(("LM:NTLM", "1000", "nt", 90))
# Username:Hash or similar
elif len(parts) == 2: elif len(parts) == 2:
hash_part = parts[1] hash_part = parts[1]
if len(hash_part) == 32: if len(hash_part) == 32:
possible_types.append(("NTLM (with username)", "1000", "nt", 80)) possible_types.append(("NTLM (with username)", "1000", "nt", 80))
elif len(hash_part) == 40: elif len(hash_part) == 40:
possible_types.append(("SHA-1 (with salt/username)", "110", None, 70)) possible_types.append(("SHA-1 (with salt/username)", "110", None, 70))
# PostgreSQL md5
if hash_string.startswith('md5') and len(hash_string) == 35: if hash_string.startswith('md5') and len(hash_string) == 35:
possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90)) possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90))
return possible_types if possible_types else None return possible_types if possible_types else None
# MySQL formats # 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()): if re.match(r'^[A-F0-9]{16}:[A-F0-9]{16}$', hash_string.upper()):
possible_types.append(("Oracle 11g", "112", "oracle11", 90)) possible_types.append(("Oracle 11g", "112", "oracle11", 90))
return possible_types return possible_types
if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()): if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()):
possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90)) possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90))
return possible_types 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()): if re.match(r'^0x0100[A-F0-9]{8}[A-F0-9]{40}$', hash_string.upper()):
possible_types.append(("MSSQL 2000", "131", "mssql", 90)) possible_types.append(("MSSQL 2000", "131", "mssql", 90))
return possible_types return possible_types
if re.match(r'^0x0200[A-F0-9]{8}[A-F0-9]{128}$', hash_string.upper()): 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)) possible_types.append(("MSSQL 2012/2014", "1731", "mssql12", 90))
return possible_types 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 # Raw hash identification by length
is_hex = re.match(r'^[a-f0-9]+$', hash_lower) is_hex = re.match(r'^[a-f0-9]+$', hash_lower)
if is_hex: if is_hex:
if length == 16: if length == 16:
possible_types.append(("MySQL < 4.1", "200", "mysql", 85)) possible_types.append(("MySQL < 4.1", "200", "mysql", 85))
possible_types.append(("Half MD5", None, None, 60)) possible_types.append(("Half MD5", None, None, 60))
elif length == 32: elif length == 32:
possible_types.append(("MD5", "0", "raw-md5", 80)) possible_types.append(("MD5", "0", "raw-md5", 80))
possible_types.append(("MD4", "900", "raw-md4", 70)) possible_types.append(("MD4", "900", "raw-md4", 70))
possible_types.append(("NTLM", "1000", "nt", 75)) possible_types.append(("NTLM", "1000", "nt", 75))
possible_types.append(("LM", "3000", "lm", 60)) 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: elif length == 40:
possible_types.append(("SHA-1", "100", "raw-sha1", 85)) possible_types.append(("SHA-1", "100", "raw-sha1", 85))
possible_types.append(("RIPEMD-160", "6000", "ripemd-160", 65)) 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: elif length == 64:
possible_types.append(("SHA-256", "1400", "raw-sha256", 85)) 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(("SHA3-256", "17400", "raw-sha3", 70))
possible_types.append(("Keccak-256", "17800", "raw-keccak-256", 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: elif length == 128:
possible_types.append(("SHA-512", "1700", "raw-sha512", 85)) possible_types.append(("SHA-512", "1700", "raw-sha512", 85))
possible_types.append(("Whirlpool", "6100", "whirlpool", 75)) 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)] 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): 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) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"): if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"):
logging.info("Received !hashid command")
args = match.args() args = match.args()
if len(args) < 1: if len(args) < 1:
usage_msg = """<strong>🔐 Hash Identifier Usage</strong> await bot.api.send_markdown_message(room.room_id, "<strong>Usage:</strong> <code>!hashid &lt;hash&gt;</code>")
<strong>Usage:</strong> <code>!hashid &lt;hash&gt;</code>
<strong>Examples:</strong>
• <code>!hashid 5f4dcc3b5aa765d61d8327deb882cf99</code>
• <code>!hashid 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8</code>
• <code>!hashid $6$rounds=5000$salt$hash...</code>
• <code>!hashid $y$j9T$...</code> (yescrypt from /etc/shadow)
<strong>Supported Hash Types:</strong>
• <strong>Modern:</strong> yescrypt, scrypt, Argon2, bcrypt
• <strong>Unix Crypt:</strong> SHA-512 Crypt, SHA-256 Crypt, MD5 Crypt
• <strong>Raw Hashes:</strong> MD5, SHA-1/224/256/384/512, SHA-3, NTLM, LM
• <strong>Database:</strong> MySQL, PostgreSQL, Oracle, MSSQL
• <strong>CMS:</strong> Wordpress, phpBB3, Drupal, Django
• <strong>LDAP:</strong> SSHA, SMD5, and various LDAP formats
• <strong>Network:</strong> NetNTLMv1/v2, Kerberos
• <strong>Exotic:</strong> Whirlpool, RIPEMD, BLAKE2, Keccak, GOST
"""
await bot.api.send_markdown_message(room.room_id, usage_msg)
return return
hash_input = ' '.join(args) hash_input = ' '.join(args)
results = identify_hash(hash_input)
try: if not results or results[0][0] == "Unknown":
# Identify the hash await bot.api.send_text_message(room.room_id, "Could not identify the hash type.")
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 return
# Sort by confidence descending
# Sort by confidence (highest first) results.sort(key=lambda x: x[3], reverse=True)
identified = sorted(identified, 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)
# 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 = "<details><summary><strong>🔐 Hash Identification Results</strong></summary>\n"
response += "<br>\n"
response += f"<strong>Input:</strong> <code>{hash_preview}</code><br>\n"
response += f"<strong>Length:</strong> {len(hash_input)} characters<br>\n"
response += f"<strong>Overall Confidence:</strong> {confidence_emoji} {confidence_label} ({top_confidence}%)<br>\n"
response += "<br>\n"
response += f"<strong>Possible Hash Types ({len(identified)}):</strong><br>\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" <strong>{idx}. {hash_type}</strong> {conf_emoji} {confidence}%<br>\n"
tools = []
if hashcat_mode:
tools.append(f"Hashcat: <code>-m {hashcat_mode}</code>")
if john_format:
tools.append(f"John: <code>--format={john_format}</code>")
if tools:
response += f" {' | '.join(tools)}<br>\n"
response += "<br>\n"
# Add useful tips
if len(identified) == 1 and identified[0][0] not in ["Unknown", "Unknown Modular Crypt Format"]:
response += "<br><strong>💡 Single match with high confidence</strong><br>\n"
elif len(identified) > 5:
response += "<br><em>️ Multiple possibilities - context may help narrow it down</em><br>\n"
# Add legend
response += "<br>\n"
response += "<strong>Confidence Legend:</strong><br>\n"
response += "🟢 Very High (90-100%) | 🟡 High (80-89%) | 🟠 Medium (60-79%) | 🔴 Low (0-59%)<br>\n"
response += "</details>"
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin Metadata # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.1.0"
__version__ = "1.0.0"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Hash type identifier" __description__ = "Hash type identifier"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!hashid</strong> Identify hash type</summary> <summary><strong>!hashid</strong> Identify hash type</summary>
<p><code>!hashid &lt;hash&gt;</code> Recognises 100+ hash formats (MD5, SHA, bcrypt, etc.).<br> <p><code>!hashid &lt;hash&gt;</code> Recognises 100+ formats and displays tool modes in a clean table.</p>
Shows confidence level, Hashcat mode, and John the Ripper format.</p>
</details> </details>
""" """
+139 -282
View File
@@ -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 import logging
@@ -10,133 +11,31 @@ from urllib.parse import urlparse
import ssl import ssl
import socket import socket
import datetime 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 handle_command(room, message, bot, prefix, config): async def _run_in_thread(func, *args, **kwargs):
""" loop = asyncio.get_running_loop()
Function to handle !headers command for HTTP security header analysis. return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
"""
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")
args = match.args() async def analyze_http_response(url):
if len(args) < 1:
await show_usage(room, bot)
return
url = args[0].strip()
# Add protocol if missing
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
# 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
await analyze_headers(room, bot, url)
async def show_usage(room, bot):
"""Display headers command usage."""
usage = """
<strong>🔒 HTTP Security Headers Analysis</strong>
<strong>!headers &lt;url&gt;</strong> - Comprehensive HTTP security header analysis
<strong>Examples:</strong>
• <code>!headers example.com</code>
• <code>!headers https://github.com</code>
• <code>!headers http://localhost:8080</code>
<strong>Analyzes:</strong>
• 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: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response: async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as resp:
results['final_url'] = str(response.url) return str(resp.url), resp.status, dict(resp.headers), resp.url.scheme == 'https'
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: except aiohttp.ClientError as e:
results['http_error'] = str(e) logging.warning(f"HTTP analysis error: {e}")
return url, None, {}, False
async def analyze_https_response(results, url): async def analyze_https_response(url):
"""Analyze HTTPS response headers."""
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as response: async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as resp:
results['https_headers'] = dict(response.headers) return resp.status, dict(resp.headers)
results['https_status'] = response.status
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
results['https_error'] = str(e) logging.warning(f"HTTPS analysis error: {e}")
return None, {}
async def analyze_ssl_certificate(results, domain): def _get_cert_info(domain):
"""Analyze SSL certificate information (run in thread to avoid event loop blocking)."""
def _get_cert():
try: try:
context = ssl.create_default_context() context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=10) as sock: with socket.create_connection((domain, 443), timeout=10) as sock:
@@ -148,204 +47,162 @@ async def analyze_ssl_certificate(results, domain):
'not_before': cert['notBefore'], 'not_before': cert['notBefore'],
'not_after': cert['notAfter'], 'not_after': cert['notAfter'],
'san': cert.get('subjectAltName', []), 'san': cert.get('subjectAltName', []),
'version': cert.get('version'),
'serial_number': cert.get('serialNumber')
} }
except Exception as e: except Exception as e:
return f"Error: {e}" logging.warning(f"SSL cert error: {e}")
return None
loop = asyncio.get_running_loop() def calculate_score(headers, redirects_to_https, cert_info):
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 score = 100
missing_headers = [] if 'Strict-Transport-Security' not in headers: score -= 15
if 'Content-Security-Policy' not in headers: score -= 15
critical_headers = [ if 'X-Content-Type-Options' not in headers: score -= 15
'Strict-Transport-Security', if 'X-Frame-Options' not in headers: score -= 15
'Content-Security-Policy', if 'X-XSS-Protection' not in headers: score -= 15
'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', '') hsts = headers.get('Strict-Transport-Security', '')
if 'max-age=31536000' not in hsts: if 'max-age=31536000' not in hsts: score -= 10
score -= 10 if 'includeSubDomains' not in hsts: score -= 5
if 'includeSubDomains' not in hsts: if 'preload' not in hsts: score -= 5
score -= 5 if headers.get('Referrer-Policy'): score += 5
if 'preload' not in hsts: if headers.get('Feature-Policy') or headers.get('Permissions-Policy'): score += 5
score -= 5 if headers.get('X-Content-Type-Options') == 'nosniff': score += 5
if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']: score += 5
# Check CSP configuration if redirects_to_https: score += 10
csp = headers.get('Content-Security-Policy', '') if cert_info and cert_info.get('not_after'):
if not csp: try:
score -= 10 expires = datetime.datetime.strptime(cert_info['not_after'], '%b %d %H:%M:%S %Y %Z')
elif "default-src 'none'" not in csp and "default-src 'self'" not in csp: if (expires - datetime.datetime.utcnow()).days < 30: score -= 10
score -= 5 except: pass
return max(0, score)
# 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', {})
def generate_recommendations(headers, redirects_to_https):
recs = []
if 'Strict-Transport-Security' not in headers: if 'Strict-Transport-Security' not in headers:
recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload") recs.append("🔒 Implement HSTS with max-age=31536000, includeSubDomains, 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: if 'Content-Security-Policy' not in headers:
recommendations.append("🛡️ Implement Content Security Policy to prevent XSS attacks") recs.append("🛡️ Add Content-Security-Policy")
if 'X-Frame-Options' not in headers: if 'X-Frame-Options' not in headers:
recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)") recs.append("🚫 Add X-Frame-Options (DENY or SAMEORIGIN)")
if 'X-Content-Type-Options' not in headers: if 'X-Content-Type-Options' not in headers:
recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing") recs.append("📄 Add X-Content-Type-Options: nosniff")
if not redirects_to_https:
if 'Referrer-Policy' not in headers: recs.append("🔐 Redirect HTTP to HTTPS")
recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage")
if 'Server' in headers or 'X-Powered-By' in headers: if 'Server' in headers or 'X-Powered-By' in headers:
recommendations.append("🕵️ Remove Server and X-Powered-By headers to avoid information disclosure") recs.append("🕵️ Remove info disclosure headers (Server, X-Powered-By)")
return recs
if not results.get('redirects_to_https') and not results['url'].startswith('https://'): async def handle_command(room, message, bot, prefix, config):
recommendations.append("🔐 Implement HTTP to HTTPS redirects") match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix() and match.command("headers")):
return
results['recommendations'] = recommendations args = match.args()
if len(args) < 1:
await bot.api.send_markdown_message(room.room_id,
"<strong>🔒 HTTP Security Headers Analysis</strong>\n<code>!headers &lt;url&gt;</code>")
return
async def format_header_analysis(results): original_input = args[0].strip()
"""Format the header analysis results for display.""" url = original_input
safe_url = html_escape(results['url']) if not url.startswith(('http://', 'https://')):
output = f"<strong>🔒 Security Headers Analysis: {safe_url}</strong><br><br>" url = 'https://' + url
# Security Score parsed = urlparse(url)
score = results['security_score'] 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
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}...")
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, {})
headers = https_headers or http_headers
cert_info = None
if url.startswith('https://'):
cert_info = await _run_in_thread(_get_cert_info, host)
score = calculate_score(headers, redirects_to_https, cert_info)
recommendations = generate_recommendations(headers, redirects_to_https)
sections = []
# Score
score_emoji = "🟢" if score >= 80 else "🟡" if score >= 60 else "🔴" score_emoji = "🟢" if score >= 80 else "🟡" if score >= 60 else "🔴"
output += f"<strong>{score_emoji} Security Score: {score}/100</strong><br><br>" sections.append({
"title": f"{score_emoji} Security Score",
"rows": [("", "Score", f"{score}/100")]
})
# Basic Information # Basic Information
output += "<strong>📊 Basic Information</strong><br>" basic_rows = [
output += f" • <strong>Final URL:</strong> {html_escape(results.get('final_url', 'N/A'))}<br>" ("🌐", "Final URL", final_url),
output += f" • <strong>Status Code:</strong> {results.get('status_code', 'N/A')}<br>" ("📊", "Status Code", str(status_code) if status_code else "N/A"),
if results.get('redirects_to_https'): ("🔐", "HTTPS Redirect", "✅ Yes" if redirects_to_https else "❌ No"),
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>" ]
else: sections.append({"title": "📊 Basic Information", "rows": basic_rows})
output += f" • <strong>HTTPS Redirect:</strong> ❌ Not enforced<br>"
output += "<br>"
# Security Headers Analysis
headers = results.get('https_headers') or results.get('http_headers', {})
output += "<strong>🛡️ Security Headers Analysis</strong><br>"
# Security Headers
security_headers = { security_headers = {
'Strict-Transport-Security': ('🔒', 'HSTS'), 'Strict-Transport-Security': ('🔒', 'HSTS'),
'Content-Security-Policy': ('🛡️', 'CSP'), 'Content-Security-Policy': ('🛡️', 'CSP'),
'X-Frame-Options': ('🚫', 'Clickjacking Protection'), 'X-Frame-Options': ('🚫', 'Frame Options'),
'X-Content-Type-Options': ('📄', 'MIME Sniffing'), 'X-Content-Type-Options': ('📄', 'Content Type'),
'X-XSS-Protection': ('', 'XSS Protection (Deprecated)'), 'X-XSS-Protection': ('', 'XSS Protection'),
'Referrer-Policy': ('🔗', 'Referrer Policy'), 'Referrer-Policy': ('🔗', 'Referrer Policy'),
'Feature-Policy': ('⚙️', 'Feature Policy'),
'Permissions-Policy': ('🔧', 'Permissions Policy'), 'Permissions-Policy': ('🔧', 'Permissions Policy'),
'Feature-Policy': ('⚙️', 'Feature Policy'),
} }
header_rows = []
for header, (emoji, description) in security_headers.items(): for hdr, (emoji, label) in security_headers.items():
if header in headers: if hdr in headers:
value = html_escape(str(headers[header]))[:100] val = headers[hdr][:100]
output += f"{emoji} <strong>{header}:</strong> {value}<br>" header_rows.append((emoji, label, f"{val}"))
else: else:
output += f"{emoji} <strong>{header}:</strong> ❌ Missing<br>" header_rows.append((emoji, label, "❌ Missing"))
output += "<br>" sections.append({"title": "🛡️ Security Headers", "rows": header_rows})
# Other Headers (Information Disclosure) # Other Headers
output += "<strong>📋 Other Headers</strong><br>" other_rows = []
for header in ['Server', 'X-Powered-By']: for hdr in ['Server', 'X-Powered-By']:
if header in headers: if hdr in headers:
output += f" • 🔍 <strong>{header}:</strong> {html_escape(str(headers[header]))}<br>" other_rows.append(("🔍", hdr, headers[hdr]))
output += "<br>" if other_rows:
sections.append({"title": "📋 Other Headers", "rows": other_rows})
# SSL Certificate Information (if available) # SSL Certificate
if results.get('ssl_info') and 'subject' in results['ssl_info']: if cert_info:
output += "<strong>🔐 SSL Certificate</strong><br>" ssl_rows = [
ssl_info = results['ssl_info'] ("📜", "Subject", cert_info['subject'].get('commonName', 'N/A')),
if ssl_info.get('subject'): ("🏢", "Issuer", cert_info['issuer'].get('organizationName', 'N/A')),
output += f" • <strong>Subject:</strong> {html_escape(ssl_info['subject'].get('commonName', 'N/A'))}<br>" ("📅", "Expires", cert_info.get('not_after', 'N/A')),
if ssl_info.get('issuer'): ]
output += f" • <strong>Issuer:</strong> {html_escape(ssl_info['issuer'].get('organizationName', 'N/A'))}<br>" san = [san[1] for san in cert_info.get('san', []) if san[0] == 'DNS']
if ssl_info.get('not_after'): if san:
output += f" • <strong>Expires:</strong> {html_escape(ssl_info['not_after'])}<br>" ssl_rows.append(("🌐", "SANs", ", ".join(san[:5])))
output += "<br>" sections.append({"title": "🔐 SSL Certificate", "rows": ssl_rows})
# Recommendations # Recommendations
if results.get('recommendations'): if recommendations:
output += "<strong>💡 Security Recommendations</strong><br>" rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
for rec in results['recommendations'][:8]: sections.append({"title": "💡 Recommendations", "rows": rec_rows})
output += f"{rec}<br>"
output += "<br>"
# Final rating block = code_block(f"🔒 Security Headers: {safe_host}", sections)
if score >= 80: output = collapsible_summary(f"🔒 Headers: {safe_host}", block)
rating = "🟢 Excellent" await bot.api.send_markdown_message(room.room_id, output)
elif score >= 60:
rating = "🟡 Good"
elif score >= 40:
rating = "🟠 Fair"
else:
rating = "🔴 Poor"
output += f"<strong>📈 Security Rating:</strong> {rating}<br>"
# Wrap in collapsible details # ---------------------------------------------------------------------------
return collapsible_summary(f"🔒 Security Headers Analysis: {safe_url} (Score: {score}/100)", output) # Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.2" __version__ = "1.1.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "HTTP security header analysis (SSRFsafe, async)" __description__ = "HTTP security header analysis"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!headers</strong> HTTP security header scanner</summary> <summary><strong>!headers</strong> HTTP security headers analysis</summary>
<p><code>!headers &lt;url&gt;</code> Checks HSTS, CSP, X-Frame-Options, etc.<br> <p><code>!headers &lt;url&gt;</code> Analyzes security headers, SSL cert, gives score and recommendations in a clean, aligned table.</p>
Provides security score (0-100) and recommendations. Also shows SSL certificate info.</p>
</details> </details>
""" """
+1 -1
View File
@@ -172,7 +172,7 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens):
__version__ = "1.0.3" __version__ = "1.0.3"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "AI text generation via Infermatic API (async, safe)" __description__ = "AI text generation via Infermatic API"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!text</strong> AI text generation (Infermatic)</summary> <summary><strong>!text</strong> AI text generation (Infermatic)</summary>
+609 -1441
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -51,7 +51,7 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.4" __version__ = "1.0.4"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "List all loaded plugins with count, collapsible" __description__ = "List all loaded plugins with count"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!plugins</strong> List active plugins</summary> <summary><strong>!plugins</strong> List active plugins</summary>
+1 -1
View File
@@ -138,7 +138,7 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.2" __version__ = "1.0.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Working SOCKS5 proxy finder (SSRFsafe, async)" __description__ = "Working SOCKS5 proxy finder"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!proxy</strong> Random working SOCKS5 proxy</summary> <summary><strong>!proxy</strong> Random working SOCKS5 proxy</summary>
+1 -1
View File
@@ -120,6 +120,6 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.2" __version__ = "1.0.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Goodreads quotes via Playwright (headless)" __description__ = "Fetch Goodreads quotes"
__help__ = """<details><summary><strong>!quote</strong> Quotes from Goodreads</summary> __help__ = """<details><summary><strong>!quote</strong> Quotes from Goodreads</summary>
<p><code>!quote</code> random, <code>!quote &lt;author&gt;</code>.</p></details>""" <p><code>!quote</code> random, <code>!quote &lt;author&gt;</code>.</p></details>"""
+92 -225
View File
@@ -2,79 +2,56 @@
""" """
plugins/roomstats.py peruser room statistics (Limnoriastyle). plugins/roomstats.py peruser room statistics (Limnoriastyle).
Commands: !roomstats, !rank, !stats Commands: !roomstats, !rank, !stats
Output is a clean code block with emojis and aligned columns.
""" """
import time import time
import re import re
import sqlite3 import sqlite3
import logging import logging
import nio import nio
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, code_block
logger = logging.getLogger("roomstats") logger = logging.getLogger("roomstats")
DB_PATH = "roomstats.db" DB_PATH = "roomstats.db"
# ------------------------------------------------------------------ # Emoji regex (unchanged)
# Emoji / smiley regex (Unicode blocks)
# ------------------------------------------------------------------
EMOJI_RE = re.compile( EMOJI_RE = re.compile(
"[" "["
"\U0001F600-\U0001F64F" # Emoticons "\U0001F600-\U0001F64F"
"\U0001F300-\U0001F5FF" # Symbols & pictographs "\U0001F300-\U0001F5FF"
"\U0001F680-\U0001F6FF" # Transport & map "\U0001F680-\U0001F6FF"
"\U0001F1E0-\U0001F1FF" # Flags "\U0001F1E0-\U0001F1FF"
"\U00002702-\U000027B0" # Dingbats "\U00002702-\U000027B0"
"\U000024C2-\U0001F251" # Misc "\U000024C2-\U0001F251"
"]+", re.UNICODE) "]+", re.UNICODE
)
def count_smileys(text): def count_smileys(text):
"""Return number of emoji occurrences."""
return len(EMOJI_RE.findall(text)) return len(EMOJI_RE.findall(text))
# ------------------------------------------------------------------
# Database init
# ------------------------------------------------------------------
def init_db(): def init_db():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS user_room_stats ( CREATE TABLE IF NOT EXISTS user_room_stats (
room_id TEXT, room_id TEXT, user_id TEXT,
user_id TEXT, msgs INTEGER DEFAULT 0, chars INTEGER DEFAULT 0, words INTEGER DEFAULT 0,
msgs INTEGER DEFAULT 0, smileys INTEGER DEFAULT 0, actions INTEGER DEFAULT 0,
chars INTEGER DEFAULT 0, joins INTEGER DEFAULT 0, parts INTEGER DEFAULT 0,
words INTEGER DEFAULT 0, kicks_given INTEGER DEFAULT 0, kicked_received INTEGER DEFAULT 0,
smileys INTEGER DEFAULT 0, topics_set INTEGER DEFAULT 0, last_updated INTEGER,
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) PRIMARY KEY (room_id, user_id)
) )
""") """)
conn.commit() conn.commit()
conn.close() conn.close()
# ------------------------------------------------------------------
# Multiword user resolution helper
# ------------------------------------------------------------------
async def resolve_user_from_tokens(bot, room_id, tokens): 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) resp = await bot.async_client.joined_members(room_id)
if resp.members is None: if resp.members is None:
raise ValueError("Could not fetch member list.") 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 = {} cache = {}
for member in resp.members: for member in resp.members:
display = (member.display_name or "").strip() display = (member.display_name or "").strip()
@@ -85,68 +62,31 @@ async def resolve_user_from_tokens(bot, room_id, tokens):
cache[key] = None cache[key] = None
else: else:
cache[key] = (member.user_id, display) cache[key] = (member.user_id, display)
# Try progressively longer prefixes of the tokens
for end in range(len(tokens), 0, -1): for end in range(len(tokens), 0, -1):
candidate = " ".join(tokens[:end]).strip().lower() candidate = " ".join(tokens[:end]).strip().lower()
if candidate in cache: if candidate in cache:
entry = cache[candidate] entry = cache[candidate]
if entry is not None: if entry is not None:
return entry # (mxid, display_name) return entry
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
raise ValueError(f"No member found for '{' '.join(tokens)}'.") 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 multiword
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): def setup(bot):
init_db() init_db()
@bot.listener.on_custom_event(nio.RoomMemberEvent) @bot.listener.on_custom_event(nio.RoomMemberEvent)
async def member_event(room, event): async def member_event(room, event):
room_id = room.room_id room_id = room.room_id
membership = event.content.get("membership") membership = event.content.get("membership")
state_key = event.state_key state_key = event.state_key
sender = event.sender sender = event.sender
# Ignore the bot's own membership changes
if state_key == bot.async_client.user_id: if state_key == bot.async_client.user_id:
return return
if membership == "join": if membership == "join":
_incr(room_id, state_key, "joins") _incr(room_id, state_key, "joins")
elif membership == "leave": elif membership == "leave":
if sender != state_key: # kick if sender != state_key:
_incr(room_id, sender, "kicks_given") _incr(room_id, sender, "kicks_given")
_incr(room_id, state_key, "kicked_received") _incr(room_id, state_key, "kicked_received")
else: # part else:
_incr(room_id, state_key, "parts") _incr(room_id, state_key, "parts")
@bot.listener.on_custom_event(nio.RoomTopicEvent) @bot.listener.on_custom_event(nio.RoomTopicEvent)
@@ -156,53 +96,34 @@ def setup(bot):
_incr(room_id, sender, "topics_set") _incr(room_id, sender, "topics_set")
def _incr(room_id, user_id, column): def _incr(room_id, user_id, column):
"""Increment a stat column by 1, creating row if needed."""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute( c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, user_id))
"INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", c.execute(f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
(room_id, user_id) (int(time.time()), 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.commit()
conn.close() conn.close()
# ------------------------------------------------------------------
# Message handler silently records stats, and handles commands
# ------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config): async def handle_command(room, message, bot, prefix, config):
room_id = room.room_id room_id = room.room_id
sender = message.sender sender = message.sender
# ----- silently record stats for any nonbot message ----- # silently record stats
if sender != bot.async_client.user_id: # <-- FIXED if sender != bot.async_client.user_id:
body = message.body or "" body = message.body or ""
words = len(body.split()) words = len(body.split())
chars = len(body) chars = len(body)
smileys = count_smileys(body) smileys = count_smileys(body)
is_action = getattr(message, "msgtype", None) == "m.emote" is_action = getattr(message, "msgtype", None) == "m.emote"
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender)) c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
c.execute( c.execute("""UPDATE user_room_stats SET msgs=msgs+1, chars=chars+?, words=words+?, smileys=smileys+?, actions=actions+?, last_updated=?
"""UPDATE user_room_stats
SET msgs = msgs + 1,
chars = chars + ?,
words = words + ?,
smileys = smileys + ?,
actions = actions + ?,
last_updated = ?
WHERE room_id=? AND user_id=?""", WHERE room_id=? AND user_id=?""",
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender) (chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender))
)
conn.commit() conn.commit()
conn.close() conn.close()
# ----- command matching -----
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if not match.is_not_from_this_bot() or not match.prefix(): if not match.is_not_from_this_bot() or not match.prefix():
return return
@@ -210,33 +131,16 @@ async def handle_command(room, message, bot, prefix, config):
cmd = match.command() cmd = match.command()
args = match.args() args = match.args()
# ===============================
# !roomstats
# ===============================
if cmd == "roomstats": if cmd == "roomstats":
await _handle_roomstats(bot, room_id) await _handle_roomstats(bot, room_id)
# ===============================
# !rank <expr>
# ===============================
elif cmd == "rank": elif cmd == "rank":
if not args: if not args:
await bot.api.send_text_message( await bot.api.send_text_message(room_id, "Usage: !rank <stat>")
room_id,
"Usage: !rank <stat>\n"
"Stats: msgs, chars, words, smileys, actions, joins, parts, "
"kicks_given, kicked_received, topics_set"
)
return return
col = args[0].lower() col = args[0].lower()
await _handle_rank(bot, room_id, col) await _handle_rank(bot, room_id, col)
# ===============================
# !stats [<name>]
# ===============================
elif cmd == "stats": elif cmd == "stats":
if args: if args:
# Use all tokens as the display name (multiword)
try: try:
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args) target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
except ValueError as e: except ValueError as e:
@@ -244,44 +148,27 @@ async def handle_command(room, message, bot, prefix, config):
return return
else: else:
target_mxid = sender 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 = { VALID_STATS = {
"msgs": "Messages", "msgs": "Messages", "chars": "Characters", "words": "Words", "smileys": "Smileys",
"chars": "Characters", "actions": "Actions", "joins": "Joins", "parts": "Parts", "kicks_given": "Kicks given",
"words": "Words", "kicked_received": "Times kicked", "topics_set": "Topics set",
"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): async def _get_aggregate(room_id):
"""Return dict of aggregate stats for a room."""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute("""SELECT c.execute("""SELECT COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), COALESCE(SUM(words),0),
COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), COALESCE(SUM(smileys),0), COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
COALESCE(SUM(words),0), COALESCE(SUM(smileys),0), COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0), COALESCE(SUM(kicked_received),0),
COALESCE(SUM(actions),0), COALESCE(SUM(joins),0), COALESCE(SUM(topics_set),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,)) FROM user_room_stats WHERE room_id=?""", (room_id,))
row = c.fetchone() row = c.fetchone()
conn.close() conn.close()
if not row or all(v == 0 for v in row): if not row or all(v == 0 for v in row):
return None return None
return { return dict(zip(VALID_STATS.keys(), row))
"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]
}
async def _handle_roomstats(bot, room_id): async def _handle_roomstats(bot, room_id):
agg = await _get_aggregate(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.") await bot.api.send_text_message(room_id, "No stats collected yet.")
return return
# Get top 10 by msgs
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute("""SELECT user_id, msgs FROM user_room_stats c.execute("SELECT user_id, msgs FROM user_room_stats WHERE room_id=? ORDER BY msgs DESC LIMIT 10", (room_id,))
WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
top = c.fetchall() top = c.fetchall()
conn.close() conn.close()
# Resolve display names for top users
top_lines = []
resp = await bot.async_client.joined_members(room_id) resp = await bot.async_client.joined_members(room_id)
top_rows = []
for uid, cnt in top: for uid, cnt in top:
disp = uid disp = uid
if resp.members: if resp.members:
@@ -307,78 +191,63 @@ async def _handle_roomstats(bot, room_id):
if m.user_id == uid: if m.user_id == uid:
disp = m.display_name or uid disp = m.display_name or uid
break break
top_lines.append(f"<li><code>{disp}</code> — {cnt} msgs</li>") top_rows.append(("📈", disp, f"{cnt} msgs"))
msg = f"""<details> sections = [
<summary><strong>Room Statistics</strong></summary> {"title": "Room Statistics", "rows": [
<ul> ("📩", "Messages", agg["msgs"]),
<li>📩 Messages: {agg['msgs']}</li> ("🔤", "Characters", agg["chars"]),
<li>🔤 Characters: {agg['chars']}</li> ("📝", "Words", agg["words"]),
<li>📝 Words: {agg['words']}</li> ("😀", "Smileys", agg["smileys"]),
<li>😀 Smileys: {agg['smileys']}</li> ("🎭", "Actions", agg["actions"]),
<li>🎭 Actions: {agg['actions']}</li> ("🚪", "Joins", agg["joins"]),
<li>🚪 Joins: {agg['joins']}</li> ("👋", "Parts", agg["parts"]),
<li>👋 Parts: {agg['parts']}</li> ("👢", "Kicks given", agg["kicks_given"]),
<li>👢 Kicks given: {agg['kicks_given']}</li> ("🥾", "Times kicked", agg["kicked_received"]),
<li>🥾 Times kicked: {agg['kicked_received']}</li> ("📌", "Topics set", agg["topics_set"]),
<li>📌 Topics set: {agg['topics_set']}</li> ]},
</ul> {"title": "Top 10 by messages", "rows": top_rows},
<p><strong>Top 10 by messages:</strong></p> ]
<ol> block = code_block("📊 Room Statistics", sections)
{''.join(top_lines)} output = collapsible_summary("📊 Room Statistics", block)
</ol> await bot.api.send_markdown_message(room_id, output)
</details>"""
await bot.api.send_markdown_message(room_id, msg)
async def _handle_rank(bot, room_id, col): async def _handle_rank(bot, room_id, col):
# Validate column
if col not in VALID_STATS: if col not in VALID_STATS:
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}") await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
return return
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() 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() rows = c.fetchall()
conn.close() conn.close()
if not rows: if not rows:
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.") await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
return return
resp = await bot.async_client.joined_members(room_id) resp = await bot.async_client.joined_members(room_id)
items = [] rank_rows = []
for i, (uid, val) in enumerate(rows, 1): for uid, val in rows:
disp = uid disp = uid
if resp.members: if resp.members:
for m in resp.members: for m in resp.members:
if m.user_id == uid: if m.user_id == uid:
disp = m.display_name or uid disp = m.display_name or uid
break break
items.append(f"<li>{i}. <code>{disp}</code> — {val}</li>") 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"""<details> async def _handle_user_stats(bot, room_id, user_id):
<summary><strong>Ranking by {VALID_STATS[col]}</strong></summary>
<ol>
{''.join(items)}
</ol>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
async def _handle_user_stats(bot, room_id, user_id, sender):
# Fetch stats
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set
kicks_given, kicked_received, topics_set
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id)) FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
row = c.fetchone() row = c.fetchone()
conn.close() conn.close()
if not row or all(v == 0 for v in row): 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 disp = user_id
resp = await bot.async_client.joined_members(room_id) resp = await bot.async_client.joined_members(room_id)
if resp.members: 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}.") await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
return return
# Get display name
disp = user_id
resp = await bot.async_client.joined_members(room_id) resp = await bot.async_client.joined_members(room_id)
disp = user_id
if resp.members: if resp.members:
for m in resp.members: for m in resp.members:
if m.user_id == user_id: if m.user_id == user_id:
disp = m.display_name or user_id disp = m.display_name or user_id
break break
msg = f"""<details> rows = [
<summary><strong>Stats for {disp}</strong></summary> ("📩", "Messages", row[0]),
<ul> ("🔤", "Characters", row[1]),
<li>📩 Messages: {row[0]}</li> ("📝", "Words", row[2]),
<li>🔤 Characters: {row[1]}</li> ("😀", "Smileys", row[3]),
<li>📝 Words: {row[2]}</li> ("🎭", "Actions", row[4]),
<li>😀 Smileys: {row[3]}</li> ("🚪", "Joins", row[5]),
<li>🎭 Actions: {row[4]}</li> ("👋", "Parts", row[6]),
<li>🚪 Joins: {row[5]}</li> ("👢", "Kicks given", row[7]),
<li>👋 Parts: {row[6]}</li> ("🥾", "Times kicked", row[8]),
<li>👢 Kicks given: {row[7]}</li> ("📌", "Topics set", row[9]),
<li>🥾 Times kicked: {row[8]}</li> ]
<li>📌 Topics set: {row[9]}</li> sections = [{"title": f"Stats for {disp}", "rows": rows}]
</ul> block = code_block(f"📊 Stats for {disp}", sections)
</details>""" output = collapsible_summary(f"📊 Stats: {disp}", block)
await bot.api.send_markdown_message(room_id, msg) await bot.api.send_markdown_message(room_id, output)
# ------------------------------------------------------------------ # ---------------------------------------------------------------------------
# Plugin metadata # Plugin Metadata
# ------------------------------------------------------------------ # ---------------------------------------------------------------------------
__version__ = "1.0.1" __version__ = "1.1.0"
__author__ = "Funguy Roomstats" __author__ = "Funguy Roomstats"
__description__ = "Peruser room statistics (Limnoriastyle), with multiword name support" __description__ = "Peruser room statistics"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>Room Statistics Commands</strong></summary> <summary><strong>Room Statistics Commands</strong></summary>
<ul> <ul>
<li><code>!roomstats</code> Aggregate room stats + top 10 users</li> <li><code>!roomstats</code> Aggregate room stats + top 10 users</li>
<li><code>!rank &lt;stat&gt;</code> Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)</li> <li><code>!rank &lt;stat&gt;</code> Top 10 by a specific stat</li>
<li><code>!stats [name]</code> Show stats for a user (supports multiword names)</li> <li><code>!stats [name]</code> Show stats for a user</li>
</ul> </ul>
<p>All commands work in the current room; display names are automatically resolved.</p>
</details> </details>
""" """
+75 -232
View File
@@ -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 logging
import os import os
import aiohttp import aiohttp
import simplematrixbotlib as botlib 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_KEY = os.getenv("SHODAN_KEY", "")
SHODAN_API_BASE = "https://api.shodan.io" SHODAN_API_BASE = "https://api.shodan.io"
async def handle_command(room, message, bot, prefix, config): async def handle_command(room, message, bot, prefix, config):
"""
Function to handle Shodan commands.
"""
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"): 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: if not SHODAN_API_KEY:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Shodan API key not configured.")
room.room_id,
"Shodan API key not configured. Please set SHODAN_KEY environment variable."
)
logging.error("Shodan API key not configured")
return return
args = match.args() args = match.args()
if len(args) < 1: if len(args) < 1:
await show_usage(room, bot) await show_usage(room, bot)
return return
sub = args[0].lower()
subcommand = args[0].lower() if sub == "ip" and len(args) >= 2:
await shodan_ip_lookup(room, bot, args[1])
if subcommand == "ip": elif sub == "search" and len(args) >= 2:
if len(args) < 2: query = " ".join(args[1:])
await bot.api.send_text_message(room.room_id, "Usage: !shodan ip <ip_address>")
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 <query>")
return
query = ' '.join(args[1:])
await shodan_search(room, bot, query) await shodan_search(room, bot, query)
elif sub == "host" and len(args) >= 2:
elif subcommand == "host": await shodan_host(room, bot, args[1])
if len(args) < 2: elif sub == "count" and len(args) >= 2:
await bot.api.send_text_message(room.room_id, "Usage: !shodan host <domain/ip>") query = " ".join(args[1:])
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 <query>")
return
query = ' '.join(args[1:])
await shodan_count(room, bot, query) await shodan_count(room, bot, query)
else: else:
await show_usage(room, bot) await show_usage(room, bot)
async def show_usage(room, bot): async def show_usage(room, bot):
"""Display Shodan command usage.""" usage = """<strong>🔍 Shodan Commands:</strong>
usage = """
<strong>🔍 Shodan Commands:</strong>
<strong>!shodan ip &lt;ip_address&gt;</strong> - Get detailed information about an IP <strong>!shodan ip &lt;ip_address&gt;</strong> - Get detailed information about an IP
<strong>!shodan search &lt;query&gt;</strong> - Search Shodan database <strong>!shodan search &lt;query&gt;</strong> - Search Shodan database
<strong>!shodan host &lt;domain/ip&gt;</strong> - Get host information <strong>!shodan host &lt;domain/ip&gt;</strong> - Get host information
@@ -86,228 +52,112 @@ async def show_usage(room, bot):
await bot.api.send_markdown_message(room.room_id, usage) await bot.api.send_markdown_message(room.room_id, usage)
async def shodan_ip_lookup(room, bot, ip): async def shodan_ip_lookup(room, bot, ip):
"""Look up information about a specific IP address.""" safe_ip = html_escape(ip)
try: try:
url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}" 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 aiohttp.ClientSession() as session:
async with session.get(url, timeout=15) as response: async with session.get(url, timeout=15) as resp:
if response.status == 404: if resp.status == 404:
await bot.api.send_text_message(room.room_id, f"No information found for IP: {html_escape(ip)}") await bot.api.send_text_message(room.room_id, f"No information found for IP: {safe_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}")
return return
resp.raise_for_status()
data = await resp.json()
data = await response.json() rows = [
("🌐", "IP", safe_ip),
# Format the response ("📍", "Location", f"{data.get('city','N/A')}, {data.get('country_name','N/A')}"),
output = f"<strong>🔍 Shodan IP Lookup: {html_escape(ip)}</strong><br><br>" ("🏢", "Organization", data.get('org', 'N/A')),
("💻", "OS", data.get('os', 'N/A')),
if data.get('country_name'): ("🔌", "Open Ports", ', '.join(map(str, data.get('ports', []))) or 'None'),
output += f"<strong>📍 Location:</strong> {html_escape(data.get('city', 'N/A'))}, {html_escape(data.get('country_name', 'N/A'))}<br>" ]
if data.get('org'):
output += f"<strong>🏢 Organization:</strong> {html_escape(data['org'])}<br>"
if data.get('os'):
output += f"<strong>💻 Operating System:</strong> {html_escape(data['os'])}<br>"
if data.get('ports'):
output += f"<strong>🔌 Open Ports:</strong> {', '.join(map(str, data['ports']))}<br>"
output += f"<strong>🕒 Last Update:</strong> {data.get('last_update', 'N/A')}<br><br>"
# Show services
if data.get('data'): if data.get('data'):
output += "<strong>📡 Services:</strong><br>" for svc in data['data'][:5]:
for service in data['data'][:5]: # Limit to first 5 services rows.append(("📡", f"Port {svc.get('port')}", svc.get('product','Unknown')))
port = service.get('port', 'N/A') sections = [{"title": f"Shodan IP Lookup: {safe_ip}", "rows": rows}]
product = service.get('product', 'Unknown') block = code_block(f"🔍 Shodan IP Lookup: {safe_ip}", sections)
version = service.get('version', '') output = collapsible_summary(f"🔍 Shodan: {safe_ip}", block)
banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '')
output += f" • <strong>Port {port}:</strong> {html_escape(product)} {html_escape(version)}<br>"
if banner:
output += f" <em>{html_escape(banner)}</em><br>"
if len(data['data']) > 5:
output += f" • ... and {len(data['data']) - 5} more services<br>"
# Wrap in collapsible if output is large
if len(output) > 500:
output = collapsible_summary(f"🔍 Shodan IP Lookup: {html_escape(ip)}", output)
await bot.api.send_markdown_message(room.room_id, output) await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan IP info for {ip}")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {e}") await bot.api.send_text_message(room.room_id, f"API error: {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}")
async def shodan_search(room, bot, query): async def shodan_search(room, bot, query):
"""Search the Shodan database.""" safe_query = html_escape(query)
try: try:
url = f"{SHODAN_API_BASE}/shodan/host/search" url = f"{SHODAN_API_BASE}/shodan/host/search?key={SHODAN_API_KEY}&query={query}&minify=true&limit=5"
params = {
"key": SHODAN_API_KEY,
"query": query,
"minify": "true",
"limit": 5
}
logging.info(f"Searching Shodan for: {query}")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=15) as response: async with session.get(url, timeout=15) as resp:
if response.status != 200: resp.raise_for_status()
await handle_shodan_error(room, bot, response.status) data = await resp.json()
return
data = await response.json()
if not data.get('matches'): 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 return
output = f"<strong>🔍 Shodan Search: '{html_escape(query)}'</strong><br>" rows = []
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br><br>" for match in data['matches'][:5]:
for match in data['matches'][:5]: # Show first 5 results
ip = match.get('ip_str', 'N/A') ip = match.get('ip_str', 'N/A')
port = match.get('port', 'N/A') port = match.get('port', '')
org = match.get('org', 'Unknown') org = match.get('org', 'Unknown')
product = match.get('product', 'Unknown') product = match.get('product', 'Unknown')
rows.append(("🌐", f"{ip}:{port}", f"{product} {org}"))
output += f"<strong>🌐 {html_escape(ip)}:{port}</strong><br>" sections = [{"title": f"Search: {safe_query}", "rows": rows}]
output += f" • <strong>Organization:</strong> {html_escape(org)}<br>" block = code_block(f"🔍 Shodan Search: {safe_query}", sections)
output += f" • <strong>Service:</strong> {html_escape(product)}<br>" output = collapsible_summary(f"Shodan Search: {safe_query}", block)
if match.get('location'):
loc = match['location']
if loc.get('city') and loc.get('country_name'):
output += f" • <strong>Location:</strong> {html_escape(loc['city'])}, {html_escape(loc['country_name'])}<br>"
output += "<br>"
if data.get('total', 0) > 5:
output += f"<em>Showing 5 of {data['total']:,} results. Refine your search for more specific results.</em>"
await bot.api.send_markdown_message(room.room_id, output) await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan search results for: {query}")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {e}") await bot.api.send_text_message(room.room_id, f"API error: {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}")
async def shodan_host(room, bot, host): async def shodan_host(room, bot, host):
"""Get host information (domain or IP).""" safe_host = html_escape(host)
try: try:
url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}" 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 aiohttp.ClientSession() as session:
async with session.get(url, timeout=15) as response: async with session.get(url, timeout=15) as resp:
if response.status == 404: if resp.status == 404:
# Try IP lookup instead
await shodan_ip_lookup(room, bot, host) await shodan_ip_lookup(room, bot, host)
return return
elif response.status != 200: resp.raise_for_status()
await handle_shodan_error(room, bot, response.status) data = await resp.json()
return rows = [("🌐", "Domain", safe_host)]
data = await response.json()
output = f"<strong>🔍 Shodan Host: {html_escape(host)}</strong><br><br>"
if data.get('subdomains'): if data.get('subdomains'):
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>" for sub in sorted(data['subdomains'])[:10]:
for subdomain in sorted(data['subdomains'])[:10]: # Show first 10 rows.append(("", "Subdomain", f"{sub}.{safe_host}"))
output += f"{html_escape(subdomain)}.{html_escape(host)}<br>"
if len(data['subdomains']) > 10: if len(data['subdomains']) > 10:
output += f"... and {len(data['subdomains']) - 10} more<br>" rows.append(("", "", f"... and {len(data['subdomains']) - 10} more"))
sections = [{"title": f"Host: {safe_host}", "rows": rows}]
if data.get('tags'): block = code_block(f"🔍 Shodan Host: {safe_host}", sections)
output += f"<br><strong>🏷️ Tags:</strong> {', '.join(html_escape(t) for t in data['tags'])}<br>" output = collapsible_summary(f"Shodan Host: {safe_host}", block)
if data.get('data'):
output += f"<br><strong>📊 Records Found:</strong> {len(data['data'])}<br>"
await bot.api.send_markdown_message(room.room_id, output) await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan host info for: {host}")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error fetching host info: {e}") await bot.api.send_text_message(room.room_id, f"API error: {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}")
async def shodan_count(room, bot, query): async def shodan_count(room, bot, query):
"""Count results for a search query.""" safe_query = html_escape(query)
try: try:
url = f"{SHODAN_API_BASE}/shodan/host/count" url = f"{SHODAN_API_BASE}/shodan/host/count?key={SHODAN_API_KEY}&query={query}"
params = {
"key": SHODAN_API_KEY,
"query": query
}
logging.info(f"Counting Shodan results for: {query}")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=15) as response: async with session.get(url, timeout=15) as resp:
if response.status != 200: resp.raise_for_status()
await handle_shodan_error(room, bot, response.status) data = await resp.json()
return rows = [("🔢", "Total Results", f"{data.get('total', 0):,}")]
data = await response.json() if data.get('facets'):
for facet_name, facet_data in data['facets'].items():
output = f"<strong>🔍 Shodan Count: '{html_escape(query)}'</strong><br><br>" for item in facet_data[:5]:
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br>" rows.append(("", facet_name.capitalize(), f"{item['value']}: {item['count']:,}"))
sections = [{"title": f"Count: {safe_query}", "rows": rows}]
# Show top countries if available block = code_block(f"🔍 Shodan Count: {safe_query}", sections)
if data.get('facets') and 'country' in data['facets']: output = collapsible_summary(f"Shodan Count: {safe_query}", block)
output += "<br><strong>🌍 Top Countries:</strong><br>"
for country in data['facets']['country'][:5]:
output += f"{html_escape(country['value'])}: {country['count']:,}<br>"
# Show top organizations if available
if data.get('facets') and 'org' in data['facets']:
output += "<br><strong>🏢 Top Organizations:</strong><br>"
for org in data['facets']['org'][:5]:
output += f"{html_escape(org['value'])}: {org['count']:,}<br>"
await bot.api.send_markdown_message(room.room_id, output) await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan count for: {query}")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {e}") await bot.api.send_text_message(room.room_id, f"API error: {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}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin Metadata # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.0.2"
__version__ = "1.0.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Shodan.io reconnaissance" __description__ = "Shodan.io reconnaissance"
__help__ = """ __help__ = """
@@ -319,13 +169,6 @@ __help__ = """
<li><code>!shodan host &lt;domain&gt;</code> Host & subdomain enumeration</li> <li><code>!shodan host &lt;domain&gt;</code> Host & subdomain enumeration</li>
<li><code>!shodan count &lt;query&gt;</code> Result counts</li> <li><code>!shodan count &lt;query&gt;</code> Result counts</li>
</ul> </ul>
<strong>Search Examples:</strong>
<ul>
<li><code>!shodan search apache</code></li>
<li><code>!shodan search "port:22"</code></li>
<li><code>!shodan search "country:US product:nginx"</code></li>
<li><code>!shodan search "net:192.168.1.0/24"</code></li>
</ul>
<p>Requires <strong>SHODAN_KEY</strong> env var.</p> <p>Requires <strong>SHODAN_KEY</strong> env var.</p>
</details> </details>
""" """
+65 -174
View File
@@ -1,6 +1,7 @@
""" """
Comprehensive SSL/TLS security scanning and analysis. Comprehensive SSL/TLS security scanning and analysis.
All blocking socket calls run in a thread pool; user input is sanitised. All blocking socket calls run in a thread pool; user input is sanitised.
Output is a clean code block with aligned columns.
""" """
import asyncio import asyncio
@@ -10,7 +11,7 @@ import ssl
import OpenSSL import OpenSSL
import datetime import datetime
import simplematrixbotlib as botlib 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 # SSL/TLS configuration handle missing protocols in modern Python
TLS_VERSIONS = { TLS_VERSIONS = {
@@ -37,9 +38,6 @@ CIPHER_CATEGORIES = {
} }
async def handle_command(room, message, bot, prefix, config): async def handle_command(room, message, bot, prefix, config):
"""
Handle !sslscan command for comprehensive SSL/TLS analysis.
"""
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"): if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
args = match.args() args = match.args()
@@ -49,7 +47,6 @@ async def handle_command(room, message, bot, prefix, config):
target = args[0].strip() target = args[0].strip()
port = 443 port = 443
if ':' in target: if ':' in target:
parts = target.split(':') parts = target.split(':')
target = parts[0] target = parts[0]
@@ -65,12 +62,8 @@ async def handle_command(room, message, bot, prefix, config):
await perform_ssl_scan(room, bot, target, port) await perform_ssl_scan(room, bot, target, port)
async def show_usage(room, bot): async def show_usage(room, bot):
"""Display sslscan command usage.""" usage = """<strong>🔐 SSL/TLS Security Scanner</strong>
usage = """
<strong>🔐 SSL/TLS Security Scanner</strong>
<strong>!sslscan &lt;domain[:port]&gt;</strong> - Comprehensive SSL/TLS security analysis <strong>!sslscan &lt;domain[:port]&gt;</strong> - Comprehensive SSL/TLS security analysis
<strong>Examples:</strong> <strong>Examples:</strong>
@@ -88,28 +81,21 @@ async def show_usage(room, bot):
""" """
await bot.api.send_markdown_message(room.room_id, usage) await bot.api.send_markdown_message(room.room_id, usage)
# ----- async wrappers for blocking socket calls -----
async def _run_blocking(func, *args, **kwargs): async def _run_blocking(func, *args, **kwargs):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
def _test_connectivity(target, port): def _test_connectivity(target, port):
"""Test basic connectivity."""
try: try:
with socket.create_connection((target, port), timeout=10): with socket.create_connection((target, port), timeout=10):
return True return True
except: except:
return False return False
def _get_certificate_info(target, port): def _get_certificate_info(target, port):
"""Retrieve detailed certificate info."""
context = ssl.create_default_context() context = ssl.create_default_context()
context.check_hostname = False context.check_hostname = False
context.verify_mode = ssl.CERT_NONE context.verify_mode = ssl.CERT_NONE
with socket.create_connection((target, port), timeout=10) as sock: with socket.create_connection((target, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=target) as ssock: with context.wrap_socket(sock, server_hostname=target) as ssock:
cert_bin = ssock.getpeercert(binary_form=True) cert_bin = ssock.getpeercert(binary_form=True)
@@ -117,15 +103,12 @@ def _get_certificate_info(target, port):
subject = cert.get_subject() subject = cert.get_subject()
issuer = cert.get_issuer() issuer = cert.get_issuer()
not_before = cert.get_notBefore().decode('utf-8') not_before = cert.get_notBefore().decode('utf-8')
not_after = cert.get_notAfter().decode('utf-8') not_after = cert.get_notAfter().decode('utf-8')
sig_alg = cert.get_signature_algorithm().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') not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
days_remaining = (not_after_dt - datetime.datetime.utcnow()).days days_remaining = (not_after_dt - datetime.datetime.utcnow()).days
# Extensions summary
extensions = [] extensions = []
for i in range(cert.get_extension_count()): for i in range(cert.get_extension_count()):
ext = cert.get_extension(i) ext = cert.get_extension(i)
@@ -158,9 +141,7 @@ def _get_certificate_info(target, port):
} }
return None return None
def _test_protocols(target, port): def _test_protocols(target, port):
"""Test support for various SSL/TLS protocols."""
protocols = {} protocols = {}
for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']: for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
if proto_name not in TLS_VERSIONS: if proto_name not in TLS_VERSIONS:
@@ -177,9 +158,7 @@ def _test_protocols(target, port):
protocols[proto_name] = False protocols[proto_name] = False
return protocols return protocols
def _test_cipher_suites(target, port): def _test_cipher_suites(target, port):
"""Return list of supported cipher suite names."""
test_ciphers = [ test_ciphers = [
'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384', 'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384',
@@ -207,130 +186,63 @@ def _test_cipher_suites(target, port):
pass pass
return supported return supported
# ----- analysis helpers (same logic as original) -----
def _check_vulnerabilities(protocols, cert_info, supported_ciphers): def _check_vulnerabilities(protocols, cert_info, supported_ciphers):
vulns = [] vulns = []
if protocols.get('SSLv2'): if protocols.get('SSLv2'):
vulns.append({ vulns.append(('SSLv2 Support', 'CRITICAL'))
'name': 'SSLv2 Support',
'severity': 'CRITICAL',
'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
'cve': 'Multiple CVEs'
})
if protocols.get('SSLv3'): if protocols.get('SSLv3'):
vulns.append({ vulns.append(('SSLv3 Support', 'HIGH'))
'name': 'SSLv3 Support',
'severity': 'HIGH',
'description': 'SSLv3 is vulnerable to POODLE attack',
'cve': 'CVE-2014-3566'
})
if cert_info and cert_info.get('days_until_expiry', 0) < 30: if cert_info and cert_info.get('days_until_expiry', 0) < 30:
vulns.append({ vulns.append(('Certificate Expiring Soon', 'MEDIUM'))
'name': 'Certificate Expiring Soon', weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
'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'])]
if weak_ciphers: if weak_ciphers:
vulns.append({ vulns.append(('Weak Cipher Suites', 'HIGH'))
'name': 'Weak Cipher Suites',
'severity': 'HIGH',
'description': f'Weak ciphers supported: {", ".join(weak_ciphers[:3])}',
'cve': 'Multiple CVEs'
})
if not protocols.get('TLSv1.2', False): if not protocols.get('TLSv1.2', False):
vulns.append({ vulns.append(('TLS 1.2 Not Supported', 'HIGH'))
'name': 'TLS 1.2 Not Supported',
'severity': 'HIGH',
'description': 'TLS 1.2 is required for modern security',
'cve': 'N/A'
})
if not protocols.get('TLSv1.3', False): if not protocols.get('TLSv1.3', False):
vulns.append({ vulns.append(('TLS 1.3 Not Supported', 'MEDIUM'))
'name': 'TLS 1.3 Not Supported',
'severity': 'MEDIUM',
'description': 'TLS 1.3 provides improved security and performance',
'cve': 'N/A'
})
return vulns return vulns
def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities): def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities):
score = 100 score = 100
if protocols.get('SSLv2'): score -= 30 if protocols.get('SSLv2'): score -= 30
if protocols.get('SSLv3'): score -= 20 if protocols.get('SSLv3'): score -= 20
if not protocols.get('TLSv1.2'): score -= 15 if not protocols.get('TLSv1.2'): score -= 15
if not protocols.get('TLSv1.3'): score -= 10 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) < 30: score -= 10
if cert_info and cert_info.get('days_until_expiry', 0) < 7: score -= 20 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) score -= min(weak_cipher_count * 5, 25)
for name, severity in vulnerabilities:
for vuln in vulnerabilities: if severity == 'CRITICAL': score -= 20
if vuln['severity'] == 'CRITICAL': score -= 20 elif severity == 'HIGH': score -= 15
elif vuln['severity'] == 'HIGH': score -= 15 elif severity == 'MEDIUM': score -= 10
elif vuln['severity'] == 'MEDIUM': score -= 10
elif vuln['severity'] == 'LOW': score -= 5
return max(0, score) return max(0, score)
def _generate_recommendations(protocols, cert_info, supported_ciphers, score): def _generate_recommendations(protocols, cert_info, supported_ciphers, score):
recs = [] recs = []
if protocols.get('SSLv2'): recs.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable") if protocols.get('SSLv2'): recs.append("🔴 Disable SSLv2")
if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3 - vulnerable to POODLE attack") if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3")
if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3 for best security and performance") if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3")
if cert_info and cert_info.get('days_until_expiry', 0) < 30: if cert_info and cert_info.get('days_until_expiry', 0) < 30:
recs.append("🟡 Renew SSL certificate - expiring soon") recs.append("🟡 Renew certificate")
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'])]
if weak_ciphers: if weak_ciphers:
recs.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)") recs.append("🔴 Remove weak ciphers")
if score < 80: 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): if not any('ECDHE' in c for c in supported_ciphers):
recs.append("🟡 Enable Forward Secrecy with ECDHE cipher suites") recs.append("🟡 Enable Forward Secrecy")
recs.append("️ Note: SSLv2/SSLv3 testing limited by Python security features")
return recs 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): async def perform_ssl_scan(room, bot, target, port):
safe_target = html_escape(target) 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): 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}") await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {safe_target}:{port}")
return return
# Run blocking checks in parallel
cert_task = _run_blocking(_get_certificate_info, target, port) cert_task = _run_blocking(_get_certificate_info, target, port)
proto_task = _run_blocking(_test_protocols, target, port) proto_task = _run_blocking(_test_protocols, target, port)
cipher_task = _run_blocking(_test_cipher_suites, 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) score = _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities)
recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score) recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score)
# Build output (using safe domain/port) sections = []
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}")
# Score
async def _format_results(target, port, cert_info, protocols, supported_ciphers,
vulnerabilities, score, recommendations):
safe_target = html_escape(target)
score_emoji = "🟢" if score >= 90 else "🟡" if score >= 80 else "🟠" if score >= 60 else "🔴" 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" 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"<strong>🔐 SSL/TLS Security Scan: {safe_target}:{port}</strong><br><br>" # Certificate
body += f"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
# Certificate Information
if cert_info: if cert_info:
body += "<strong>📜 Certificate Information</strong><br>" cert_rows = [
body += f" • <strong>Subject:</strong> {html_escape(cert_info['subject'].get('common_name', 'N/A'))}<br>" ("📜", "Subject", cert_info['subject'].get('common_name', 'N/A')),
body += f" • <strong>Issuer:</strong> {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}<br>" ("🏢", "Issuer", cert_info['issuer'].get('common_name', 'N/A')),
body += f" • <strong>Valid From:</strong> {_format_cert_date(cert_info['not_before'])}<br>" ("📅", "Valid Until", cert_info['not_after']),
body += f" • <strong>Valid Until:</strong> {_format_cert_date(cert_info['not_after'])}<br>" ("", "Expires In", f"{cert_info['days_until_expiry']} days"),
days = cert_info.get('days_until_expiry', 'N/A') ]
body += f" • <strong>Expires In:</strong> {days} days<br>" sections.append({"title": "📜 Certificate", "rows": cert_rows})
body += f" • <strong>Signature Algorithm:</strong> {html_escape(cert_info['signature_algorithm'])}<br>"
body += "<br>"
# Protocol Support # Protocols
body += "<strong>🔌 Protocol Support</strong><br>" proto_rows = []
for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']: for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
supported = protocols.get(proto, False) supported = protocols.get(proto, False)
if proto in ['SSLv2', 'SSLv3'] and supported: 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 "" emoji = "" if supported else ""
status = "Supported" if supported else "Not Supported" status = "Supported" if supported else "Not Supported"
if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS: if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS:
status = "Cannot test (Python security)" status = "Cannot test"
emoji = "" emoji = ""
body += f"{emoji} <strong>{proto}:</strong> {status}<br>" proto_rows.append((emoji, proto, status))
body += "<br>" sections.append({"title": "🔌 Protocols", "rows": proto_rows})
# Cipher Suites # Cipher Suites
body += "<strong>🔐 Cipher Suites</strong><br>" weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
body += f" • <strong>Total Supported:</strong> {len(supported_ciphers)}<br>" 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)))]
weak_ciphers = [c for c in supported_ciphers
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
if weak_ciphers: if weak_ciphers:
body += f" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>" cipher_rows.append(("🔴", "Weak Ciphers", str(len(weak_ciphers))))
for cipher in weak_ciphers[:3]: for c in weak_ciphers[:3]:
body += f" └─ 🔴 {html_escape(cipher)}<br>" cipher_rows.append(("", "", c))
strong_ciphers = [c for c in supported_ciphers
if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
if strong_ciphers: if strong_ciphers:
body += f" • <strong>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>" cipher_rows.append(("🟢", "Strong Ciphers", str(len(strong_ciphers))))
body += "<br>" sections.append({"title": "🔐 Cipher Suites", "rows": cipher_rows})
# Vulnerabilities # Vulnerabilities
if vulnerabilities: if vulnerabilities:
body += "<strong>⚠️ Security Vulnerabilities</strong><br>" vuln_rows = []
for vuln in vulnerabilities[:5]: for name, sev in vulnerabilities:
sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡" sev_emoji = "🔴" if sev == 'CRITICAL' else "🟠" if sev == 'HIGH' else "🟡"
body += f"{sev_emoji} <strong>{html_escape(vuln['name'])}</strong> ({vuln['severity']})<br>" vuln_rows.append((sev_emoji, name, sev))
body += f" └─ {html_escape(vuln['description'])}<br>" sections.append({"title": "⚠️ Vulnerabilities", "rows": vuln_rows})
body += "<br>"
# Recommendations # Recommendations
if recommendations: if recommendations:
body += "<strong>💡 Security Recommendations</strong><br>" rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
for rec in recommendations[:8]: sections.append({"title": "💡 Recommendations", "rows": rec_rows})
body += f"{rec}<br>"
body += "<br>"
# Quick Assessment # Quick Assessment
body += "<strong>📊 Quick Assessment</strong><br>" assessment_rows = []
if score >= 90: if score >= 90:
body += "✅ Excellent TLS configuration<br>" assessment_rows = [("", "Assessment", "✅ Excellent configuration")]
body += " • ✅ Modern protocols and ciphers<br>"
body += " • ✅ Good certificate management<br>"
elif score >= 70: elif score >= 70:
body += " • ⚠️ Good configuration with minor issues<br>" assessment_rows = [("", "Assessment", "⚠️ Good, minor improvements possible")]
body += " • 🔧 Some improvements recommended<br>"
else: else:
body += "🚨 Significant security issues found<br>" assessment_rows = [("", "Assessment", "🚨 Significant issues found")]
body += " • 🔴 Immediate action required<br>" sections.append({"title": "📊 Quick Assessment", "rows": assessment_rows})
body += "<br><em>️ Note: Some protocol tests limited by Python security features</em>"
return collapsible_summary(f"🔐 SSL/TLS Scan: {safe_target}:{port} (Score: {score}/100)", body)
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 # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.0.2" __version__ = "1.0.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "SSL/TLS security scanner (SSRFsafe, async)" __description__ = "SSL/TLS security scanner"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!sslscan</strong> SSL/TLS analysis</summary> <summary><strong>!sslscan</strong> SSL/TLS analysis</summary>
+1 -1
View File
@@ -137,7 +137,7 @@ def print_help():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.1.2" __version__ = "1.1.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Stable Diffusion image generation (async, LORA support)" __description__ = "Stable Diffusion image generation (LORA support)"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!sd</strong> Generate images via Stable Diffusion</summary> <summary><strong>!sd</strong> Generate images via Stable Diffusion</summary>
+131 -140
View File
@@ -2,28 +2,26 @@
""" """
plugins/subnet.py Subnet calculator and network splitting plugin for Funguy Bot. plugins/subnet.py Subnet calculator and network splitting plugin for Funguy Bot.
Provides the following commands: Commands:
!subnet info <CIDR> Show detailed info about a network !subnet info <CIDR>
!subnet split <CIDR> --prefix <N> Split network into smaller subnets (new prefix length) !subnet split <CIDR> --prefix <N>
!subnet split <CIDR> --diff <N> Split network into equal subnets (prefixlen delta) !subnet split <CIDR> --diff <N>
!subnet adjacent <CIDR> <count> Show given network and next <count> adjacent ones !subnet adjacent <CIDR> <count>
!subnet help Display this help !subnet help
Examples: Output is a clean code block with emojis and perfectly aligned columns.
!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
""" """
import ipaddress import ipaddress
import sys import simplematrixbotlib as botlib
from typing import Union 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: def _fmt_subnet_info_rows(net):
"""Return a humanreadable string with all relevant subnet details.""" """Return list of (emoji, label, value) tuples."""
nw = net.network_address nw = net.network_address
bc = net.broadcast_address if hasattr(net, "broadcast_address") else None bc = net.broadcast_address if hasattr(net, "broadcast_address") else None
total = net.num_addresses total = net.num_addresses
@@ -50,102 +48,124 @@ def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -
first = last = None first = last = None
usable_count = 0 usable_count = 0
lines = [ rows = [
f"CIDR: {net.with_prefixlen}", ("🌐", "CIDR", str(net.with_prefixlen)),
f"Network: {nw}", ("📡", "Network", str(nw)),
f"Broadcast: {bc if bc is not None else 'N/A'}", ("📢", "Broadcast", str(bc) if bc is not None else "N/A"),
f"Netmask: {net.netmask if hasattr(net, 'netmask') else 'N/A'}", ("🧱", "Netmask", str(net.netmask) if hasattr(net, "netmask") else "N/A"),
f"Wildcard Mask: {net.hostmask if hasattr(net, 'hostmask') else 'N/A'}", ("🕳️", "Wildcard Mask", str(net.hostmask) if hasattr(net, "hostmask") else "N/A"),
f"Total IPs: {total}", ("🔢", "Total IPs", str(total)),
f"Usable Hosts: {usable_count}", ("👥", "Usable Hosts", str(usable_count)),
] ]
if first is not None and last is not None: if first is not None and last is not None:
lines.append(f"First Usable: {first}") rows.append(("🏁", "First Usable", str(first)))
lines.append(f"Last Usable: {last}") rows.append(("🏁", "Last Usable", str(last)))
lines.append(f"Usable Range: {first} - {last}") rows.append(("↔️", "Usable Range", f"{first} - {last}"))
return "\n".join(lines) return rows
def _split_by_prefix(net, new_prefix: int) -> str: def _split_by_prefix(net, new_prefix):
if new_prefix < net.prefixlen: if new_prefix < net.prefixlen:
return f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split." return None
out = [f"# Splitting {net.with_prefixlen} into /{new_prefix} subnets:"] return list(net.subnets(new_prefix=new_prefix))
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)
def _split_by_diff(net, diff: int) -> str: def _split_by_diff(net, diff):
new_prefix = net.prefixlen + diff return _split_by_prefix(net, net.prefixlen + diff)
return _split_by_prefix(net, new_prefix)
def _adjacent_networks(net, count: int) -> str: def _adjacent_networks(net, count):
out = [f"# Adjacent networks of size /{net.prefixlen} (starting at {net.with_prefixlen}):"] nets = [net]
current = net current = net
for i in range(count + 1): for _ in range(count):
out.append(f"\n-- Adjacent #{i} --")
out.append(_fmt_subnet_info(current))
try: try:
next_net_addr = current.network_address + current.num_addresses next_addr = current.network_address + current.num_addresses
current = ipaddress.ip_network(f"{next_net_addr}/{current.prefixlen}", strict=True) current = ipaddress.ip_network(f"{next_addr}/{current.prefixlen}", strict=True)
except ValueError: nets.append(current)
out.append("[!] Reached address space limit.") except (ValueError, ipaddress.AddressValueError):
break 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 = """
<details>
<summary><strong>!subnet</strong> Subnet calculator and exploration</summary>
<pre>
!subnet info &lt;CIDR&gt; Show detailed info for a network
!subnet split &lt;CIDR&gt; --prefix &lt;N&gt; Split into smaller subnets (new prefix)
!subnet split &lt;CIDR&gt; --diff &lt;N&gt; Split by prefix delta
!subnet adjacent &lt;CIDR&gt; &lt;count&gt; Show current and adjacent networks
</pre>
<p>Example: <code>!subnet info 192.168.1.0/24</code></p>
<ul>
<li>IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).</li>
<li>IPv6 networks list all addresses as hosts (no broadcast).</li>
</ul>
</details>
"""
# -------------------------------------------------------------------
# Command handler
# -------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config): async def handle_command(room, message, bot, prefix, config):
import simplematrixbotlib as botlib
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix() and match.command("subnet")): if not (match.is_not_from_this_bot() and match.prefix() and match.command("subnet")):
return return
args = match.args() args = match.args()
if not args: if not args:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Usage: !subnet <info|split|adjacent> ...\n !subnet help")
room.room_id,
"Usage: !subnet <info|split|adjacent> ...\n"
" !subnet help show full help"
)
return return
subcmd = args[0].lower() subcmd = args[0].lower()
# --- help ---
if subcmd in ("help", "-h", "--help"): if subcmd in ("help", "-h", "--help"):
# Send nicely formatted HTML in a details tag via markdown await bot.api.send_markdown_message(room.room_id, _HELP_MD)
html = "<details><summary><strong>!subnet</strong> Subnet calculator and exploration</summary>\n"
html += "<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>\n"
html += "<h4>Commands</h4>\n"
html += "<ul>\n"
html += "<li><b>info</b> Show detailed info for a network<br>\n"
html += "<code>!subnet info &lt;CIDR&gt;</code><br>\n"
html += "Example: <code>!subnet info 192.168.1.0/24</code></li>\n"
html += "<li><b>split</b> Split a network into smaller subnets<br>\n"
html += "<code>!subnet split &lt;CIDR&gt; --prefix &lt;new_prefix&gt;</code><br>\n"
html += "Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code><br>\n"
html += "<i>Alternatively, use --diff to split by prefix delta:</i><br>\n"
html += "<code>!subnet split &lt;CIDR&gt; --diff &lt;delta&gt;</code><br>\n"
html += "Example: <code>!subnet split 10.0.0.0/16 --diff 2</code> (creates 4 subnets)</li>\n"
html += "<li><b>adjacent</b> Show the current network and adjacent ones<br>\n"
html += "<code>!subnet adjacent &lt;CIDR&gt; &lt;count&gt;</code><br>\n"
html += "Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>\n"
html += "</ul>\n"
html += "<h4>Notes</h4>\n"
html += "<ul>\n"
html += "<li>IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).</li>\n"
html += "<li>IPv6 networks list all addresses as hosts (no broadcast).</li>\n"
html += "</ul>\n"
html += "</details>"
await bot.api.send_markdown_message(room.room_id, html)
return return
# --- info (or a CIDR passed directly) ---
if subcmd == "info" or "/" in subcmd: if subcmd == "info" or "/" in subcmd:
cidr = args[1] if subcmd == "info" else subcmd cidr = args[1] if subcmd == "info" else subcmd
try: try:
@@ -153,16 +173,13 @@ async def handle_command(room, message, bot, prefix, config):
except ValueError as e: except ValueError as e:
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}") await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
return 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 return
# --- split ---
if subcmd == "split": if subcmd == "split":
if len(args) < 2: if len(args) < 2:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --prefix <N> OR !subnet split <CIDR> --diff <delta>")
room.room_id,
"Usage: !subnet split <CIDR> --prefix <new_prefix> OR --diff <delta>"
)
return return
cidr = args[1] cidr = args[1]
try: try:
@@ -176,39 +193,31 @@ async def handle_command(room, message, bot, prefix, config):
idx = args.index("--prefix") idx = args.index("--prefix")
new_prefix = int(args[idx + 1]) new_prefix = int(args[idx + 1])
except (ValueError, IndexError): except (ValueError, IndexError):
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --prefix <number>")
room.room_id,
"Usage: !subnet split <CIDR> --prefix <number>"
)
return return
result = _split_by_prefix(net, new_prefix) subnets = _split_by_prefix(net, new_prefix)
elif "--diff" in args: elif "--diff" in args:
try: try:
idx = args.index("--diff") idx = args.index("--diff")
diff = int(args[idx + 1]) diff = int(args[idx + 1])
except (ValueError, IndexError): except (ValueError, IndexError):
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --diff <delta>")
room.room_id,
"Usage: !subnet split <CIDR> --diff <delta>"
)
return return
result = _split_by_diff(net, diff) subnets = _split_by_diff(net, diff)
else: else:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "You must provide --prefix <N> or --diff <N> for split.")
room.room_id, return
"You must provide either --prefix <N> or --diff <N> for split."
) if subnets is None:
return await bot.api.send_text_message(room.room_id, f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split.")
await bot.api.send_text_message(room.room_id, result) return
output = _split_output(subnets)
await bot.api.send_markdown_message(room.room_id, output)
return return
# --- adjacent ---
if subcmd == "adjacent": if subcmd == "adjacent":
if len(args) < 3: if len(args) < 3:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Usage: !subnet adjacent <CIDR> <count>")
room.room_id,
"Usage: !subnet adjacent <CIDR> <count>"
)
return return
cidr = args[1] cidr = args[1]
try: try:
@@ -219,39 +228,21 @@ async def handle_command(room, message, bot, prefix, config):
try: try:
count = int(args[2]) count = int(args[2])
except ValueError: except ValueError:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Count must be an integer.")
room.room_id,
"Count must be an integer."
)
return return
result = _adjacent_networks(net, count) networks = _adjacent_networks(net, count)
await bot.api.send_text_message(room.room_id, result) output = _adjacent_output(networks)
await bot.api.send_markdown_message(room.room_id, output)
return return
# Unknown subcommand await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{subcmd}'. Use !subnet help.")
await bot.api.send_text_message(
room.room_id,
f"Unknown subcommand '{subcmd}'. Use !subnet help to see available commands."
)
# Plugin metadata # ---------------------------------------------------------------------------
__version__ = "1.0.1" # Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.3.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Subnet calculator, splitter, and adjacent network enumerator" __description__ = "Subnet calculator"
__help__ = """ __help__ = _HELP_MD
<details>
<summary><strong>!subnet</strong> Subnet calculator and exploration</summary>
<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>
<ul>
<li><code>!subnet info &lt;CIDR&gt;</code> Show detailed info for a network<br>
Example: <code>!subnet info 192.168.1.0/24</code></li>
<li><code>!subnet split &lt;CIDR&gt; --prefix &lt;new_prefix&gt;</code> Split into smaller subnets<br>
Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code></li>
<li><code>!subnet split &lt;CIDR&gt; --diff &lt;delta&gt;</code> Split by prefix delta<br>
Example: <code>!subnet split 10.0.0.0/16 --diff 2</code></li>
<li><code>!subnet adjacent &lt;CIDR&gt; &lt;count&gt;</code> Show adjacent networks<br>
Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>
</ul>
</details>
"""
+236 -277
View File
@@ -1,354 +1,313 @@
""" """
Comprehensive system information and resource monitoring. Comprehensive system information code block with emoji + aligned columns.
All blocking calls (psutil, subprocess) run in a thread pool. All blocking calls run in thread pool.
""" """
import logging import logging, platform, os, asyncio, psutil, socket, datetime, subprocess
import platform
import os
import asyncio
import psutil
import socket
import datetime
import subprocess
import simplematrixbotlib as botlib 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 = """
<strong>💻 System Information Plugin</strong>
<strong>!sysinfo</strong> - Display comprehensive system information
<strong>!sysinfo help</strong> - Show this help message
<strong>Information Provided:</strong>
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): async def _run_blocking(func, *args, **kwargs):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) 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(): 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 { return {
'hostname': socket.gethostname(), "hostname": socket.gethostname(),
'os': platform.system(), "os": f"{platform.system()} {platform.release()}",
'os_release': platform.release(), "architecture": platform.architecture()[0],
'os_version': platform.version(), "machine": platform.machine(),
'architecture': platform.architecture()[0], "processor": platform.processor(),
'machine': platform.machine(), "boot_time": boot.strftime("%Y-%m-%d %H:%M:%S"),
'processor': platform.processor(), "uptime": uptime_str,
'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"), "users": len(psutil.users())
'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))),
'users': len(psutil.users())
} }
def _cpu_info(): def _cpu_info():
cpu_times = psutil.cpu_times_percent(interval=1)
cpu_freq = psutil.cpu_freq() 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 { return {
'physical_cores': psutil.cpu_count(logical=False), "physical_cores": psutil.cpu_count(logical=False),
'total_cores': psutil.cpu_count(logical=True), "logical_cores": psutil.cpu_count(logical=True),
'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A", "max_freq": f"{cpu_freq.max:.0f} MHz" if cpu_freq else "N/A",
'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A", "current_freq": f"{cpu_freq.current:.0f} MHz" if cpu_freq else "N/A",
'usage_percent': psutil.cpu_percent(interval=1), "usage": f"{psutil.cpu_percent(interval=1)}%",
'user_time': cpu_times.user, "load_avg": f"{load[0]:.2f} {load[1]:.2f} {load[2]:.2f}"
'system_time': cpu_times.system,
'idle_time': cpu_times.idle,
'load_avg': ", ".join(f"{l:.2f}" for l in load_avg)
} }
def _memory_info(): def _memory_info():
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
swap = psutil.swap_memory() swap = psutil.swap_memory()
return { return {
'total': f"{mem.total / (1024**3):.2f} GB", "total_ram": f"{mem.total / (1024**3):.1f} GB",
'available': f"{mem.available / (1024**3):.2f} GB", "used_ram": f"{mem.used / (1024**3):.1f} GB",
'used': f"{mem.used / (1024**3):.2f} GB", "ram_percent": f"{mem.percent}%",
'usage_percent': mem.percent, "available_ram": f"{mem.available / (1024**3):.1f} GB",
'swap_total': f"{swap.total / (1024**3):.2f} GB", "total_swap": f"{swap.total / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
'swap_used': f"{swap.used / (1024**3):.2f} GB", "used_swap": f"{swap.used / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
'swap_free': f"{swap.free / (1024**3):.2f} GB", "swap_percent": f"{swap.percent}%" if swap.total > 0 else "N/A"
'swap_percent': swap.percent
} }
def _storage_info(): def _disk_info():
partitions = psutil.disk_partitions() partitions = psutil.disk_partitions()
storage_list = [] mounted = []
for part in partitions: for p in partitions:
try: try:
usage = psutil.disk_usage(part.mountpoint) usage = psutil.disk_usage(p.mountpoint)
storage_list.append({ mounted.append({
'device': part.device, "mount": p.mountpoint,
'mountpoint': part.mountpoint, "used": f"{usage.used / (1024**3):.1f} GB",
'fstype': part.fstype, "total": f"{usage.total / (1024**3):.1f} GB",
'total': f"{usage.total / (1024**3):.2f} GB", "percent": usage.percent
'used': f"{usage.used / (1024**3):.2f} GB",
'free': f"{usage.free / (1024**3):.2f} GB",
'percent': usage.percent
}) })
except: except:
pass pass
disk_io = psutil.disk_io_counters() io = psutil.disk_io_counters()
io_info = { io_read = f"{io.read_bytes / (1024**3):.2f} GB" if io else "0 GB"
'read_count': disk_io.read_count if disk_io else 0, io_write = f"{io.write_bytes / (1024**3):.2f} GB" if io else "0 GB"
'write_count': disk_io.write_count if disk_io else 0, return mounted, io_read, io_write
'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}
def _network_info(): def _network_info():
interfaces = psutil.net_if_addrs() ifaces = psutil.net_if_addrs()
io_counters = psutil.net_io_counters(pernic=True) io_counters = psutil.net_io_counters(pernic=True)
net_list = [] net = []
for iface, addrs in interfaces.items(): for name, addrs in ifaces.items():
if iface == 'lo': if name == "lo":
continue continue
info = { ip4 = next((a.address for a in addrs if a.family == socket.AF_INET), None)
'interface': iface, if ip4:
'ipv4': next((a.address for a in addrs if a.family == socket.AF_INET), 'N/A'), stats = io_counters.get(name)
'ipv6': next((a.address for a in addrs if a.family == socket.AF_INET6), 'N/A'), sent = f"{stats.bytes_sent / (1024**2):.1f} MB" if stats else "0 MB"
'mac': next((a.address for a in addrs if a.family == psutil.AF_LINK), 'N/A'), recv = f"{stats.bytes_recv / (1024**2):.1f} MB" if stats else "0 MB"
} net.append((name, ip4, sent, recv))
io = io_counters.get(iface) return net
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
def _process_info(): def _top_processes():
procs = [] 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: try:
procs.append(proc.info) procs.append(p.info)
except (psutil.NoSuchProcess, psutil.AccessDenied): except (psutil.NoSuchProcess, psutil.AccessDenied):
pass pass
top_cpu = sorted(procs, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5] 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(): def _docker_info():
try: try:
result = subprocess.run(['docker', '--version'], capture_output=True, text=True) ver = subprocess.run(['docker', '--version'], capture_output=True, text=True)
if result.returncode != 0: if ver.returncode != 0:
return {'available': False} return None
result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'], ps_res = subprocess.run(
capture_output=True, text=True) ['docker', 'ps', '--format', '{{.Names}}|{{.Status}}'],
capture_output=True, text=True
)
containers = [] containers = []
for line in result.stdout.strip().split('\n'): for line in ps_res.stdout.strip().split('\n'):
if line: if line:
parts = line.split('|') parts = line.split('|')
if len(parts) >= 2: if len(parts) >= 2:
containers.append({'name': parts[0], 'status': parts[1], 'ports': parts[2] if len(parts)>2 else 'N/A'}) containers.append({"name": parts[0], "status": parts[1]})
return {'available': True, 'containers': containers, 'total_running': len(containers)} return containers
except: except:
return {'available': False} return None
def _sensor_info(): def _sensor_info():
temps = psutil.sensors_temperatures() temps = psutil.sensors_temperatures()
fans = psutil.sensors_fans() fans = psutil.sensors_fans()
battery = psutil.sensors_battery() battery = psutil.sensors_battery()
sensor = {'temperatures': {}, 'fans': {}, 'battery': {}} data = {"temps": [], "fans": [], "battery": None}
if temps: if temps:
for name, entries in temps.items(): for chip, entries in temps.items():
sensor['temperatures'][name] = [f"{e.current}°C" for e in entries[:2]] for e in entries[:2]:
data["temps"].append(f"{e.label or chip}: {e.current}°C")
if fans: if fans:
for name, entries in fans.items(): for chip, entries in fans.items():
sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]] for e in entries[:2]:
data["fans"].append(f"{e.label or chip}: {e.current} RPM")
if battery: if battery:
sensor['battery'] = { rem = ""
'percent': battery.percent, if battery.secsleft != psutil.POWER_TIME_UNLIMITED and battery.secsleft > 0:
'power_plugged': battery.power_plugged, h = battery.secsleft // 3600
'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown" m = (battery.secsleft % 3600) // 60
} rem = f" ({h}h {m}m left)"
return sensor plugged = " 🔌" if battery.power_plugged else ""
data["battery"] = f"{battery.percent}%{plugged}{rem}"
return data
def _gpu_info(): # -------------------------------------------------------------------
gpu_data = {} # Main builder
# 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 -----
async def get_system_info(room, bot): async def get_system_info(room, bot):
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...") await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
# Run all blocking collectors concurrently
system = await _run_blocking(_system_overview) system = await _run_blocking(_system_overview)
cpu = await _run_blocking(_cpu_info) cpu = await _run_blocking(_cpu_info)
memory = await _run_blocking(_memory_info) mem = await _run_blocking(_memory_info)
storage = await _run_blocking(_storage_info) disks, io_read, io_write = await _run_blocking(_disk_info)
network = await _run_blocking(_network_info) net = await _run_blocking(_network_info)
processes = await _run_blocking(_process_info) top_procs, total_procs = await _run_blocking(_top_processes)
gpu = await _run_blocking(_gpu_info)
docker = await _run_blocking(_docker_info) docker = await _run_blocking(_docker_info)
sensors = await _run_blocking(_sensor_info) sensors = await _run_blocking(_sensor_info)
gpu = await _run_blocking(_gpu_info)
# Build output HTML sections = []
output = await format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu)
# 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) await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent system information") logging.info("Sent system information")
async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu): async def handle_command(room, message, bot, prefix, config):
hostname = html_escape(system.get('hostname', 'Unknown')) match = botlib.MessageMatch(room, message, bot, prefix)
body = "<strong>💻 System Information</strong><br><br>" if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
if match.args() and match.args()[0].lower() == 'help':
# System Overview usage = """
body += "<strong>🖥️ System Overview</strong><br>" <strong>💻 System Information</strong>
body += f" • <strong>Hostname:</strong> {hostname}<br>" <code>!sysinfo</code> display comprehensive system info in a clean code block.
body += f" • <strong>OS:</strong> {html_escape(system['os'])} {html_escape(system['os_release'])}<br>" """
body += f" • <strong>Architecture:</strong> {html_escape(system['architecture'])}<br>" await bot.api.send_markdown_message(room.room_id, usage)
body += f" • <strong>Uptime:</strong> {html_escape(system['uptime'])}<br>" return
body += f" • <strong>Boot Time:</strong> {html_escape(system['boot_time'])}<br>" await get_system_info(room, bot)
body += f" • <strong>Users:</strong> {system['users']}<br><br>"
# CPU
body += "<strong>⚡ CPU Information</strong><br>"
body += f" • <strong>Cores:</strong> {cpu['physical_cores']} physical, {cpu['total_cores']} logical<br>"
body += f" • <strong>Frequency:</strong> {html_escape(cpu['current_frequency'])} (max: {html_escape(cpu['max_frequency'])})<br>"
body += f" • <strong>Usage:</strong> {cpu['usage_percent']}%<br>"
body += f" • <strong>Load Average:</strong> {html_escape(cpu['load_avg'])}<br><br>"
# Memory
body += "<strong>🧠 Memory Information</strong><br>"
body += f" • <strong>Total:</strong> {html_escape(memory['total'])}<br>"
body += f" • <strong>Used:</strong> {html_escape(memory['used'])} ({memory['usage_percent']}%)<br>"
body += f" • <strong>Available:</strong> {html_escape(memory['available'])}<br>"
body += f" • <strong>Swap:</strong> {html_escape(memory['swap_used'])} / {html_escape(memory['swap_total'])} ({memory['swap_percent']}%)<br><br>"
# Storage
if storage and 'error' not in storage:
body += "<strong>💾 Storage Information</strong><br>"
for p in storage['partitions'][:3]:
body += f" • <strong>{html_escape(p['device'])}:</strong> {p['used']} / {p['total']} ({p['percent']}%)<br>"
# IO stats if wanted
io = storage.get('io_stats')
if io:
body += f" • <strong>Disk I/O:</strong> read {io['read_bytes']}, write {io['write_bytes']}<br>"
body += "<br>"
# GPU
if gpu:
if 'nvidia' in gpu:
body += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
for g in gpu['nvidia']:
body += f" • <strong>{html_escape(g['name'])}:</strong> {g['utilization']} usage, {g['temperature']}<br>"
body += "<br>"
elif 'detected' in gpu:
body += "<strong>🎮 GPU Information</strong><br>"
for line in gpu['detected'][:2]:
body += f"{html_escape(line)}<br>"
body += "<br>"
# Network
if network:
body += "<strong>🌐 Network Information</strong><br>"
for iface in network[:2]:
body += f" • <strong>{html_escape(iface['interface'])}:</strong> {html_escape(iface['ipv4'])}<br>"
body += "<br>"
# Top Processes
if processes:
body += "<strong>🔄 Top Processes (by CPU)</strong><br>"
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" • <strong>{name}:</strong> {cpu_p:.1f}% CPU, {mem_p:.1f}% RAM<br>"
body += f" • <strong>Total Processes:</strong> {processes['total_processes']}<br><br>"
# Docker
if docker and docker.get('available'):
body += "<strong>🐳 Docker Containers</strong><br>"
for c in docker['containers'][:3]:
body += f" • <strong>{html_escape(c['name'])}:</strong> {html_escape(c['status'])}<br>"
body += f" • <strong>Total Running:</strong> {docker['total_running']}<br><br>"
# Sensors
if sensors and 'error' not in sensors:
if sensors.get('temperatures'):
body += "<strong>🌡️ Temperature Sensors</strong><br>"
for sensor, temps in list(sensors['temperatures'].items())[:2]:
body += f" • <strong>{html_escape(sensor)}:</strong> {', '.join(temps[:2])}<br>"
body += "<br>"
if sensors.get('battery'):
bat = sensors['battery']
body += "<strong>🔋 Battery Information</strong><br>"
body += f" • <strong>Charge:</strong> {bat['percent']}%<br>"
body += f" • <strong>Plugged In:</strong> {'Yes' if bat['power_plugged'] else 'No'}<br>"
if bat.get('time_left'):
body += f" • <strong>Time Left:</strong> {bat['time_left']}<br>"
body += "<br>"
# Timestamp
body += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
return collapsible_summary(f"💻 System Information - {hostname}", body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin Metadata # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.0.1" __version__ = "1.3.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Comprehensive system information and monitoring" __description__ = "System information plugin"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!sysinfo</strong> System information</summary> <summary><strong>!sysinfo</strong> System information</summary>
<p>Displays CPU, RAM, storage, network, Docker, GPU, sensors, and top processes.</p> <p>Displays CPU, RAM, storage, network, GPU, sensors, top processes, and more in a clean, aligned code block.</p>
</details> </details>
""" """
+117 -131
View File
@@ -1,210 +1,196 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Time Zone Plugin completely hardcoded-free using Open-Meteo APIs. Time Zone Plugin uses pytz for IANA zones and OpenMeteo for city geocoding.
Outputs a clean code block with emojis and aligned columns via shared code_block.
""" """
import logging import logging
import aiohttp import aiohttp
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
from urllib.parse import quote
from datetime import datetime 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: try:
if '+' in dt_str: tz = pytz.timezone(zone)
dt_str = dt_str.split('+')[0] now = datetime.now(tz)
if '.' in dt_str: return {
dt_str = dt_str.split('.')[0] "datetime": now.isoformat(),
dt_str = dt_str.replace('T', ' ') "timezone": zone,
dt = datetime.fromisoformat(dt_str) "temperature": None # no weather for zone lookups
return dt.strftime("%I:%M:%S %p").lstrip("0") }
except: except pytz.UnknownTimeZoneError:
return dt_str 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). # Online helpers (OpenMeteo)
Returns (latitude, longitude, display_name) or None. # -------------------------------------------------------------------
""" async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
"""Geocode a city name via OpenMeteo. 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" url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
try: try:
async with session.get(url, timeout=10) as resp: async with session.get(url, timeout=10) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json() data = await resp.json()
if data.get("results") and len(data["results"]) > 0: results = data.get("results", [])
result = data["results"][0] if results:
lat = result["latitude"] r = results[0]
lon = result["longitude"] lat = float(r["latitude"])
name = result.get("name", city) lon = float(r["longitude"])
country = result.get("country", "") name = r.get("name", city)
admin1 = result.get("admin1", "") country = r.get("country", "")
admin1 = r.get("admin1", "")
# Build display name: "Lahore, Punjab, Pakistan" display = ", ".join(filter(None, [name, admin1, country]))
display_parts = [name] return lat, lon, display
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}")
except Exception as e: except Exception as e:
logging.warning(f"Geocoding error: {e}") logging.warning(f"Geocoding error: {e}")
return None 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 OpenMeteo (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}&current_weather=true&timezone=auto&timeformat=unixtime" url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true&timezone=auto"
try: try:
async with session.get(url, timeout=10) as resp: async with session.get(url, timeout=10) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json() data = await resp.json()
current = data.get("current_weather", {}) current = data.get("current_weather", {})
timezone = data.get("timezone", "Unknown") time_str = current.get("time") # ISO 8601, local time
unixtime = current.get("time") temp_c = current.get("temperature")
temperature = current.get("temperature") tz = data.get("timezone", "Unknown")
if time_str:
if unixtime:
# Convert UNIX timestamp to datetime
dt = datetime.fromtimestamp(unixtime)
return { return {
"datetime": dt.isoformat(), "datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
"timezone": timezone, "timezone": tz,
"temperature": temperature "temperature": temp_c
} }
except Exception as e: except Exception as e:
logging.warning(f"Time fetch error: {e}") logging.warning(f"Weather fetch error: {e}")
return None 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]: async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
"""Main resolution: geocode any city, then get time.""" """Return (data_dict, display_name) or (None, error_message)."""
query = query.strip().lower() query = query.strip()
# Check if it's an IANA zone (contains '/') # 1. Try as IANA zone (offline, always works)
if '/' in query or query in ("utc", "gmt"): if '/' in query or query.lower() in ("utc", "gmt"):
data = await fetch_time_by_zone(session, query) data = _get_time_for_iana_zone(query)
if data: if data:
return data, query.upper() 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!) # 2. Otherwise geocode as a city name
geocode_result = await geocode_city(session, query) geocode_result = await _geocode_city(session, query)
if not geocode_result: 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 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 # -------------------------------------------------------------------
# Formatting uses shared code_block from common.py
def format_response(data: dict, display_name: str) -> str: # -------------------------------------------------------------------
"""Format time data into HTML.""" 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", "") raw_time = data.get("datetime", "")
local_time = format_ampm(raw_time) if raw_time else "Unknown" # Convert ISO string to AM/PM format
tz = data.get("timezone", "Unknown") 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 = data.get("temperature")
temp_str = f"<br>🌡️ <strong>Temperature:</strong> {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""" rows = [
<details> ("🌐", "Location", display_name),
<summary><strong>🕒 Time in {display_name}</strong></summary> ("🕒", "Local Time", local_time),
<p> ("📅", "Timezone", tz_display),
📍 <strong>Timezone:</strong> {tz}<br> ("🌡️", "Temperature", temp_str),
📅 <strong>Local time:</strong> {local_time}{temp_str} ]
</p> # Wrap rows in a single section with no title (title is part of code_block's main title)
</details> sections = [{"title": "", "rows": rows}]
""" return code_block("🕒 Time Info", sections)
def help_text() -> str:
return """ # -------------------------------------------------------------------
# Help
# -------------------------------------------------------------------
_HELP_MD = """
<details> <details>
<summary><strong>🕒 Time Plugin Help</strong></summary> <summary><strong>🕒 Time Plugin Help</strong></summary>
<p> <p><strong>!time &lt;any city&gt;</strong> Get current time for ANY city worldwide<br>
<strong>!time &lt;any city&gt;</strong> Get current time for ANY city worldwide<br> <strong>!time &lt;IANA zone&gt;</strong> e.g., <code>Europe/London</code>, <code>Asia/Karachi</code><br>
<strong>!time &lt;IANA zone&gt;</strong> e.g., Europe/London, Asia/Karachi<br> <strong>!time help</strong> Show this help<br>
<strong>!time help</strong> Show this help<br><br>
<strong>Examples:</strong><br> <strong>Examples:</strong><br>
<code>!time Lahore</code><br> <code>!time Lahore</code><br>
<code>!time New York</code><br> <code>!time New York</code><br>
<code>!time Paris</code><br> <code>!time Europe/London</code><br>
<code>!time Asia/Karachi</code><br><br> <em>No city names are hardcoded. IANA zones work completely offline.</em>
<em>No city names are hardcoded. The bot uses Open-Meteo's geocoding API.</em>
</p> </p>
</details> </details>
""" """
# -------------------------------------------------------------------
# Plugin lifecycle
# -------------------------------------------------------------------
def setup(bot): def setup(bot):
logging.info("Time plugin (zero hardcoded cities) loaded.") logging.info("Time plugin (offline IANA zones + OpenMeteo cities) loaded.")
async def handle_command(room, message, bot, prefix, config): async def handle_command(room, message, bot, prefix, config):
import simplematrixbotlib as botlib
match = botlib.MessageMatch(room, message, bot, prefix) match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")): if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")):
return return
args = match.args() args = match.args()
if not args or args[0].lower() == "help": 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 return
query = " ".join(args).strip() 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: async with aiohttp.ClientSession() as session:
data, display = await resolve_time(session, query) data, display = await resolve_time(session, query)
if data is None: if data is None:
await bot.api.send_text_message(room.room_id, f"{display}") await bot.api.send_text_message(room.room_id, f"{display}")
return 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}") logging.info(f"Time sent for {query}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0" __version__ = "1.1.2"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "World clock (no hardcoded cities)" __description__ = "World clock (offline IANA zones + free geocoding)"
__help__ = """ __help__ = _HELP_MD
<details>
<summary><strong>!time</strong> Current time for any city</summary>
<ul>
<li><code>!time &lt;city&gt;</code> Geocode any city (free Open-Meteo API)</li>
<li><code>!time &lt;IANA zone&gt;</code> e.g., <code>Europe/London</code></li>
</ul>
<p>Also shows current temperature if available.</p>
</details>
"""
+1 -1
View File
@@ -87,6 +87,6 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.1" __version__ = "1.0.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Urban Dictionary definitions (async)" __description__ = "Urban Dictionary definitions"
__help__ = """<details><summary><strong>!ud</strong> Urban Dictionary</summary> __help__ = """<details><summary><strong>!ud</strong> Urban Dictionary</summary>
<ul><li><code>!ud</code> random, <code>!ud &lt;term&gt;</code> top, <code>!ud &lt;term&gt; &lt;index&gt;</code></li></ul></details>""" <ul><li><code>!ud</code> random, <code>!ud &lt;term&gt;</code> top, <code>!ud &lt;term&gt; &lt;index&gt;</code></li></ul></details>"""
+85 -131
View File
@@ -1,11 +1,6 @@
""" """
Weather plugin primary: OpenWeatherMap, fallback: OpenMeteo. Weather plugin primary: OpenWeatherMap, fallback: OpenMeteo.
Outputs a formatted code block with emojis and perfectly aligned columns.
Uses OpenWeatherMap when a valid API key is present and the request succeeds.
Falls back to OpenMeteo (no key required) otherwise.
Commands:
!weather <location> e.g. !weather London or !weather "New York,US"
""" """
import logging import logging
@@ -14,56 +9,14 @@ import aiohttp
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
from dotenv import load_dotenv from dotenv import load_dotenv
from urllib.parse import quote from urllib.parse import quote
from plugins.common import html_escape, collapsible_summary, code_block
# ---------------------------------------------------------------------------
# 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)
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "") OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# WMO codes → description + emoji (for OpenMeteo) # OpenWeatherMap helpers
# ---------------------------------------------------------------------------
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None: 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: if not OPENWEATHER_API_KEY:
logging.info("OpenWeatherMap key missing, skipping primary") logging.info("OpenWeatherMap key missing, skipping primary")
return None return None
@@ -72,7 +25,7 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
params = { params = {
"q": location, "q": location,
"appid": OPENWEATHER_API_KEY, "appid": OPENWEATHER_API_KEY,
"units": "metric", # Celsius "units": "metric",
} }
try: try:
async with session.get(url, params=params, timeout=10) as resp: 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}") logging.warning(f"OpenWeatherMap request error: {e}")
return None 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"<strong>[{emoji} Weather for {city}, {country}]</strong>: "
f"<strong>Condition:</strong> {description} | "
f"<strong>Temperature:</strong> {temp_c:.1f}°C ({temp_f:.1f}°F) | "
f"<strong>Humidity:</strong> {humidity}% | "
f"<strong>Wind Speed:</strong> {wind} m/s"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fallback: OpenMeteo (no key, free) # OpenMeteo helpers (fallback)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None: async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
"""Geocode a city name via OpenMeteo. Returns location info dict or None."""
url = "https://geocoding-api.open-meteo.com/v1/search" url = "https://geocoding-api.open-meteo.com/v1/search"
params = {"name": location, "count": 1, "language": "en"} params = {"name": location, "count": 1, "language": "en"}
try: try:
@@ -144,10 +61,7 @@ async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict |
logging.warning(f"OpenMeteo geocode error: {e}") logging.warning(f"OpenMeteo geocode error: {e}")
return None return None
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float, timezone: str = "auto") -> dict | None:
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
timezone: str = "auto") -> dict | None:
"""Fetch current weather from OpenMeteo. Returns JSON or None."""
url = "https://api.open-meteo.com/v1/forecast" url = "https://api.open-meteo.com/v1/forecast"
params = { params = {
"latitude": lat, "latitude": lat,
@@ -165,35 +79,82 @@ async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
logging.warning(f"OpenMeteo weather error: {e}") logging.warning(f"OpenMeteo weather error: {e}")
return None 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: def format_meteo(loc_info: dict, weather_data: dict) -> str:
"""Format OpenMeteo result into the same oneline style.""" """Build a code block from OpenMeteo response."""
c = weather_data["current_weather"] c = weather_data["current_weather"]
code = c["weathercode"] 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"] location_parts = [loc_info["name"]]
country = loc_info.get("country", "") if loc_info.get("state") and loc_info["state"] != loc_info["name"]:
state = loc_info.get("state", "") location_parts.append(loc_info["state"])
if loc_info.get("country"):
# Build location string location_parts.append(loc_info["country"])
parts = [city] location = ", ".join(location_parts)
if state and state != city:
parts.append(state)
if country:
parts.append(country)
loc_str = ", ".join(parts)
temp_f = c["temperature"] temp_f = c["temperature"]
temp_c = round((temp_f - 32) * 5 / 9, 1) temp_c = round((temp_f - 32) * 5 / 9, 1)
wind = c["windspeed"] wind = c["windspeed"] # mph
return ( rows = [
f"<strong>[{emoji} Weather for {loc_str}]</strong>: " ("🌍", "Location", location),
f"<strong>Condition:</strong> {desc} | " (emoji, "Condition", desc),
f"<strong>Temperature:</strong> {temp_c}°C ({temp_f}°F) | " ("🌡️", "Temperature", f"{temp_c}°C / {temp_f}°F"),
f"<strong>Wind Speed:</strong> {wind} mph" ("💨", "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: async with aiohttp.ClientSession() as session:
# 1. Try OpenWeatherMap # 1. Try OpenWeatherMap
owm_data = await openweathermap_get(session, location) owm_data = await openweathermap_get(session, location)
if owm_data: if owm_data and owm_data.get("cod") == 200:
if owm_data.get("cod") == 200: block = format_openweathermap(owm_data)
msg = format_openweathermap(owm_data) output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
await bot.api.send_markdown_message(room.room_id, msg) await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent weather via OpenWeatherMap")
return 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"))
# 2. Fallback: OpenMeteo # 2. Fallback: OpenMeteo
logging.info("Falling back to OpenMeteo") logging.info("Falling back to OpenMeteo")
@@ -233,7 +191,7 @@ async def handle_command(room, message, bot, prefix, config):
if not loc_info: if not loc_info:
await bot.api.send_text_message( await bot.api.send_text_message(
room.room_id, room.room_id,
f"Location '{location}' not found." f"Location '{html_escape(location)}' not found."
) )
return return
@@ -247,28 +205,24 @@ async def handle_command(room, message, bot, prefix, config):
) )
return return
msg = format_meteo(loc_info, wdata) block = format_meteo(loc_info, wdata)
await bot.api.send_markdown_message(room.room_id, msg) output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent weather via OpenMeteo (fallback)") logging.info("Sent weather via OpenMeteo (fallback)")
# ---------------------------------------------------------------------------
# Plugin setup
# ---------------------------------------------------------------------------
def setup(bot): def setup(bot):
logging.info("Weather plugin loaded (OpenWeatherMap + OpenMeteo fallback)") logging.info("Weather plugin loaded (OpenWeatherMap + OpenMeteo fallback)")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin Metadata # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "Weather forecast (OWM primary, OpenMeteo fallback)" __description__ = "Weather data plugin"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!weather</strong> Current weather</summary> <summary><strong>!weather</strong> Current weather</summary>
<p><code>!weather &lt;location&gt;</code> Shows temperature, conditions, humidity, wind.<br> <p><code>!weather &lt;location&gt;</code> Shows temperature, conditions, humidity, wind in a clean, aligned table. Uses OpenWeatherMap primary, OpenMeteo fallback.</p>
Uses OpenWeatherMap if a valid API key is present; falls back to free OpenMeteo otherwise.</p>
</details> </details>
""" """
+69 -158
View File
@@ -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 logging
import whois import whois
import ipaddress import ipaddress
import re import re
import asyncio
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, html_escape, code_block
def is_valid_domain(domain): 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}$' 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 return re.match(pattern, domain) is not None
def is_valid_ip(ip): 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: try:
ipaddress.ip_address(ip) ipaddress.ip_address(ip)
return True return True
except ValueError: except ValueError:
return False return False
def _build_rows(data):
"""Build a list of (emoji, label, value) tuples from WHOIS data."""
rows = []
def format_whois_data(domain, data): # Domain
""" domain_name = data.domain_name
Format WHOIS data into a readable format. if isinstance(domain_name, list):
domain_name = ', '.join(domain_name)
rows.append(('🌐', 'Domain', domain_name or 'N/A'))
Args: # Registrar / WHOIS Server
domain (str): The queried domain/IP. if data.registrar:
data (whois domain object): The WHOIS data object. rows.append(('🏢', 'Registrar', data.registrar))
if data.whois_server:
Returns: rows.append(('📡', 'WHOIS Server', data.whois_server))
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"<strong>🔍 Query:</strong> {domain_names}")
# Registrar Information
registrar_items = []
if hasattr(data, 'registrar'):
registrar_items.append(f"<strong>Registrar:</strong> {data.registrar}")
if hasattr(data, 'whois_server'):
registrar_items.append(f"<strong>WHOIS Server:</strong> {data.whois_server}")
if registrar_items:
sections.append('<br>'.join(registrar_items))
# Dates # Dates
date_items = [] creation_date = data.creation_date
if hasattr(data, 'creation_date'): if creation_date:
creation = data.creation_date if isinstance(creation_date, list):
if isinstance(creation, list): creation_date = creation_date[0]
creation = creation[0] rows.append(('📅', 'Created', str(creation_date)))
date_items.append(f"<strong>Created:</strong> {creation}")
if hasattr(data, 'updated_date'): updated_date = data.updated_date
updated = data.updated_date if updated_date:
if isinstance(updated, list): if isinstance(updated_date, list):
updated = updated[0] updated_date = updated_date[0]
date_items.append(f"<strong>Updated:</strong> {updated}") rows.append(('📝', 'Updated', str(updated_date)))
if hasattr(data, 'expiration_date'): expiration_date = data.expiration_date
expiration = data.expiration_date if expiration_date:
if isinstance(expiration, list): if isinstance(expiration_date, list):
expiration = expiration[0] expiration_date = expiration_date[0]
date_items.append(f"<strong>Expires:</strong> {expiration}") rows.append(('', 'Expires', str(expiration_date)))
if date_items: # Name servers
sections.append('<br>'.join(date_items)) 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 # Status
if hasattr(data, 'status'): if data.status:
status = data.status status = data.status
if isinstance(status, list): if isinstance(status, list):
status = '<br>'.join(status[:3]) # Limit to first 3 status entries status = ', '.join(status[:3])
sections.append(f"<strong>Status:</strong><br>{status}") rows.append(('🔒', 'Status', str(status)))
# Name Servers # Contact info
if hasattr(data, 'name_servers'): if data.org:
name_servers = data.name_servers rows.append(('🏛️', 'Organization', data.org))
if isinstance(name_servers, list): if data.country:
if len(name_servers) > 5: rows.append(('🌍', 'Country', data.country))
name_servers_list = '<br>'.join(sorted(name_servers)[:5]) if data.state:
name_servers_list += f"<br><em>...(+{len(name_servers) - 5} more)</em>" rows.append(('🏙️', 'State', data.state))
else: if data.city:
name_servers_list = '<br>'.join(sorted(name_servers)) rows.append(('🏡', 'City', data.city))
else:
name_servers_list = str(name_servers)
sections.append(f"<strong>Name Servers:</strong><br>{name_servers_list}")
# Contact Information
contact_items = []
if hasattr(data, 'org'):
contact_items.append(f"<strong>Organization:</strong> {data.org}")
if hasattr(data, 'country'):
contact_items.append(f"<strong>Country:</strong> {data.country}")
if hasattr(data, 'state'):
contact_items.append(f"<strong>State:</strong> {data.state}")
if hasattr(data, 'city'):
contact_items.append(f"<strong>City:</strong> {data.city}")
if contact_items:
sections.append('<br>'.join(contact_items))
# Build the final message
if sections:
content = f"<strong>🌐 WHOIS Report: {domain}</strong><br><br>"
content += '<br><br>'.join(sections)
else:
content = f"<strong>🌐 WHOIS Information for {domain}</strong><br><br>"
content += "<em>No detailed information available or query returned minimal data.</em>"
# Wrap in collapsible details block for Matrix compatibility
message = f"<details><summary><strong>🌐 WHOIS Report: {domain} (Click to expand)</strong></summary>{content}</details>"
return message
return rows
async def handle_command(room, message, bot, prefix, config): 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) match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("whois"): if match.is_not_from_this_bot() and match.prefix() and match.command("whois"):
args = match.args() args = match.args()
if len(args) < 1: if len(args) < 1:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, "Usage: !whois <domain/ip>\nExample: !whois example.com")
room.room_id,
"Usage: !whois <domain/ip>\nExample: !whois example.com\nExample: !whois 8.8.8.8"
)
return return
query = args[0].strip() 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): if not is_valid_domain(query) and not is_valid_ip(query):
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, f"Invalid input: {html_escape(query)}")
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}")
return return
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {html_escape(query)}...")
try: try:
# Perform WHOIS lookup loop = asyncio.get_running_loop()
logging.info(f"Performing WHOIS lookup for: {query}") data = await loop.run_in_executor(None, whois.whois, query)
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {query}...")
# Use python-whois library rows = _build_rows(data)
whois_data = whois.whois(query) sections = [{"title": "", "rows": rows}] # no section header
block = code_block(f"🌐 WHOIS Report: {html_escape(query)}", sections)
# Format and send the results output = collapsible_summary(f"🌐 WHOIS Report: {html_escape(query)}", block)
result_message = format_whois_data(query, whois_data) await bot.api.send_markdown_message(room.room_id, output)
await bot.api.send_markdown_message(room.room_id, result_message)
logging.info(f"Successfully sent WHOIS results for {query}")
except whois.parser.PywhoisError as e: except whois.parser.PywhoisError as e:
error_msg = f"WHOIS lookup failed for {query}.\n" await bot.api.send_text_message(room.room_id, f"WHOIS lookup failed: {html_escape(str(e))}")
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}")
except Exception as e: except Exception as e:
await bot.api.send_text_message( await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {html_escape(str(e))}")
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin Metadata # Plugin Metadata
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
__version__ = "1.0.0" __version__ = "1.2.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "WHOIS lookup" __description__ = "Domain WHOIS lookup"
__help__ = """ __help__ = """
<details> <details>
<summary><strong>!whois</strong> WHOIS lookup</summary> <summary><strong>!whois</strong> WHOIS lookup</summary>
<p><code>!whois &lt;domain or IP&gt;</code> Shows registrar, creation/expiry dates, nameservers, contacts.</p> <pre>
!whois &lt;domain or IP&gt; Shows registrar, dates, nameservers, etc. in a clean table.
</pre>
</details> </details>
""" """
+1 -1
View File
@@ -44,6 +44,6 @@ def generate_output(results):
__version__ = "1.0.1" __version__ = "1.0.1"
__author__ = "Funguy Bot" __author__ = "Funguy Bot"
__description__ = "YouTube video search (async)" __description__ = "YouTube video search"
__help__ = """<details><summary><strong>!yt</strong> Search YouTube</summary> __help__ = """<details><summary><strong>!yt</strong> Search YouTube</summary>
<p><code>!yt &lt;search terms&gt;</code></p></details>""" <p><code>!yt &lt;search terms&gt;</code></p></details>"""
+1
View File
@@ -31,3 +31,4 @@ yara-python
asn1crypto asn1crypto
PyYAML PyYAML
lxml lxml
wcwidth