various plugin refactors and fixes
This commit is contained in:
+1
-1
@@ -383,7 +383,7 @@ def setup(bot):
|
||||
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "arXiv academic paper search (with rate limiting and error reporting)"
|
||||
__description__ = "arXiv academic paper search"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!arxiv</strong> – Search academic papers on arXiv</summary>
|
||||
|
||||
@@ -5,6 +5,7 @@ import html
|
||||
import ipaddress
|
||||
import socket
|
||||
import logging
|
||||
from wcwidth import wcswidth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,3 +81,58 @@ async def send_html_message(bot, room_id, html_body, markdown_fallback):
|
||||
message_type="m.room.message",
|
||||
content=content
|
||||
)
|
||||
|
||||
|
||||
def code_block(title: str, sections: list) -> str:
|
||||
"""
|
||||
Build a Markdown code block with perfectly aligned columns (emoji‑aware).
|
||||
|
||||
Args:
|
||||
title: header line inside the code block
|
||||
sections: list of dicts with keys 'title' (str) and 'rows'
|
||||
rows is a list of (emoji, label, value) tuples
|
||||
|
||||
Returns:
|
||||
Markdown string with triple backticks and aligned content.
|
||||
"""
|
||||
labelled = []
|
||||
for sec in sections:
|
||||
for emoji, text, value in sec["rows"]:
|
||||
if text.strip() or emoji.strip():
|
||||
labelled.append((emoji, text, value))
|
||||
|
||||
max_label_width = max((len(str(t)) for _, t, _ in labelled), default=0)
|
||||
|
||||
emoji_widths = {}
|
||||
for emoji, _, _ in labelled:
|
||||
if emoji:
|
||||
w = wcswidth(emoji) or 1
|
||||
emoji_widths[emoji] = w
|
||||
else:
|
||||
emoji_widths[emoji] = 0
|
||||
max_emoji_width = max(emoji_widths.values()) if emoji_widths else 0
|
||||
|
||||
prefix_width = max_emoji_width + 1 + max_label_width + 3 # "E label : "
|
||||
separator = "=" * (prefix_width + 30)
|
||||
lines = [title, separator]
|
||||
|
||||
for sec in sections:
|
||||
# Only print a section header if the title is not empty
|
||||
if sec["title"].strip():
|
||||
lines.append("")
|
||||
lines.append(f"── {sec['title']} ──")
|
||||
for emoji, text, value in sec["rows"]:
|
||||
if text.strip() or emoji.strip():
|
||||
if emoji:
|
||||
actual_w = emoji_widths.get(emoji, 0)
|
||||
pad = max_emoji_width - actual_w
|
||||
emoji_field = emoji + " " * pad
|
||||
else:
|
||||
emoji_field = " " * max_emoji_width
|
||||
padded_label = f"{text:<{max_label_width}}"
|
||||
lines.append(f"{emoji_field} {padded_label} : {value}")
|
||||
else:
|
||||
lines.append(f"{' ' * prefix_width}{value}")
|
||||
lines.append("")
|
||||
lines.append(separator)
|
||||
return "```\n" + "\n".join(lines) + "\n```"
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ async def send_help(room, bot):
|
||||
|
||||
__version__ = "2.1.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
|
||||
__description__ = "DuckDuckGo search plugin"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!ddg</strong> – DuckDuckGo search (web, images, news, etc.)</summary>
|
||||
|
||||
+62
-43
@@ -1,14 +1,15 @@
|
||||
"""
|
||||
This plugin provides a command to perform DNS reconnaissance on a domain.
|
||||
DNS reconnaissance plugin – queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.
|
||||
Outputs a formatted code block with emojis and perfectly aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import dns.resolver
|
||||
import dns.reversename
|
||||
import simplematrixbotlib as botlib
|
||||
import re
|
||||
|
||||
from plugins.utils import is_public_destination
|
||||
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||
|
||||
RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV']
|
||||
|
||||
@@ -16,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,}$'
|
||||
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):
|
||||
loop = asyncio.get_running_loop()
|
||||
def _resolve():
|
||||
results = {}
|
||||
resolver = dns.resolver.Resolver()
|
||||
resolver.timeout = 5
|
||||
resolver.lifetime = 5
|
||||
for record_type in RECORD_TYPES:
|
||||
try:
|
||||
logging.info(f"Querying {record_type} records for {domain}")
|
||||
answers = resolver.resolve(domain, record_type)
|
||||
records = []
|
||||
for rdata in answers:
|
||||
@@ -48,11 +42,9 @@ async def query_dns_records(domain):
|
||||
records.append(str(rdata))
|
||||
if records:
|
||||
results[record_type] = records
|
||||
logging.info(f"Found {len(records)} {record_type} record(s)")
|
||||
except dns.resolver.NoAnswer:
|
||||
continue
|
||||
except dns.resolver.NXDOMAIN:
|
||||
logging.warning(f"Domain {domain} does not exist")
|
||||
return None
|
||||
except dns.resolver.Timeout:
|
||||
continue
|
||||
@@ -60,69 +52,96 @@ async def query_dns_records(domain):
|
||||
logging.error(f"Error querying {record_type} for {domain}: {e}")
|
||||
continue
|
||||
return results
|
||||
return await loop.run_in_executor(None, _resolve)
|
||||
|
||||
RECORD_META = {
|
||||
'A': ('🌐', 'A (IPv4)'),
|
||||
'AAAA': ('🌐', 'AAAA (IPv6)'),
|
||||
'MX': ('📧', 'MX (Mail)'),
|
||||
'NS': ('🌐', 'NS (Nameserver)'),
|
||||
'TXT': ('📄', 'TXT'),
|
||||
'CNAME': ('🔀', 'CNAME'),
|
||||
'SOA': ('📋', 'SOA'),
|
||||
'PTR': ('↩️', 'PTR'),
|
||||
'SRV': ('🔌', 'SRV'),
|
||||
}
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("dns"):
|
||||
logging.info("Received !dns command")
|
||||
args = match.args()
|
||||
if len(args) != 1:
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
"Usage: !dns <domain>\nExample: !dns example.com")
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !dns <domain>\nExample: !dns example.com")
|
||||
return
|
||||
domain = args[0].lower().strip()
|
||||
domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
|
||||
|
||||
if not is_valid_domain(domain):
|
||||
await bot.api.send_text_message(room.room_id, f"Invalid domain name: {domain}")
|
||||
await bot.api.send_text_message(room.room_id, f"Invalid domain name: {html_escape(domain)}")
|
||||
return
|
||||
|
||||
if not is_public_destination(domain):
|
||||
await bot.api.send_text_message(room.room_id, "❌ DNS queries for private/internal domains are not allowed.")
|
||||
return
|
||||
|
||||
await bot.api.send_text_message(room.room_id, f"🔍 Performing DNS reconnaissance on {html_escape(domain)}...")
|
||||
|
||||
try:
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
f"🔍 Performing DNS reconnaissance on {domain}...")
|
||||
results = await query_dns_records(domain)
|
||||
if results is None:
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
f"Domain {domain} does not exist (NXDOMAIN)")
|
||||
await bot.api.send_text_message(room.room_id, f"Domain {html_escape(domain)} does not exist (NXDOMAIN)")
|
||||
return
|
||||
if not results:
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
f"No DNS records found for {domain}")
|
||||
await bot.api.send_text_message(room.room_id, f"No DNS records found for {html_escape(domain)}")
|
||||
return
|
||||
# SSRF / privacy check: if all A/AAAA records are private, refuse.
|
||||
|
||||
a_records = results.get('A', [])
|
||||
aaaa_records = results.get('AAAA', [])
|
||||
all_ips = a_records + aaaa_records
|
||||
if all_ips and not any(is_public_destination(ip) for ip in all_ips):
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
"❌ This domain resolves exclusively to private/internal IPs.")
|
||||
await bot.api.send_text_message(room.room_id, "❌ This domain resolves exclusively to private/internal IPs.")
|
||||
return
|
||||
output = f"<strong>🔍 DNS Records for {domain}</strong><br><br>"
|
||||
preferred_order = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
|
||||
for record_type in preferred_order:
|
||||
if record_type in results:
|
||||
output += format_dns_record(record_type, results[record_type])
|
||||
output += "<br>"
|
||||
for record_type in results:
|
||||
if record_type not in preferred_order:
|
||||
output += format_dns_record(record_type, results[record_type])
|
||||
output += "<br>"
|
||||
if output.count('<br>') > 15:
|
||||
output = f"<details><summary><strong>🔍 DNS Records for {domain}</strong></summary>{output}</details>"
|
||||
|
||||
rows = []
|
||||
preferred = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
|
||||
for rtype in preferred:
|
||||
if rtype in results:
|
||||
emoji, label = RECORD_META.get(rtype, ('❓', rtype))
|
||||
for rec in results[rtype]:
|
||||
rows.append((emoji, label, rec))
|
||||
emoji = ""
|
||||
label = ""
|
||||
for rtype in results:
|
||||
if rtype not in preferred:
|
||||
emoji, label = RECORD_META.get(rtype, ('❓', rtype))
|
||||
for rec in results[rtype]:
|
||||
rows.append((emoji, label, rec))
|
||||
emoji = ""
|
||||
label = ""
|
||||
|
||||
if not rows:
|
||||
await bot.api.send_text_message(room.room_id, f"No displayable records for {html_escape(domain)}")
|
||||
return
|
||||
|
||||
sections = [{"title": "", "rows": rows}]
|
||||
block = code_block(f"🔍 DNS Records for {domain}", sections)
|
||||
output = collapsible_summary(f"🔍 DNS: {html_escape(domain)}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Sent DNS records for {domain}")
|
||||
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
f"An error occurred while performing DNS lookup: {str(e)}")
|
||||
await bot.api.send_text_message(room.room_id, f"An error occurred while performing DNS lookup: {str(e)}")
|
||||
logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.1.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "DNS reconnaissance (SSRF‑safe)"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!dns</strong> – DNS reconnaissance</summary>
|
||||
<p><code>!dns <domain></code> – Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.</p>
|
||||
<p><code>!dns <domain></code> – Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records and displays them in a clean, aligned table.</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
+63
-56
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping.
|
||||
DNSDumpster.com integration for domain reconnaissance and DNS mapping.
|
||||
Output uses shared code_block for aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import aiohttp
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import html_escape, collapsible_summary
|
||||
from plugins.common import html_escape, code_block, collapsible_summary
|
||||
|
||||
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
|
||||
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
|
||||
@@ -13,20 +15,13 @@ DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
|
||||
logging.info("Received !dnsdumpster command")
|
||||
|
||||
if not DNSDUMPSTER_API_KEY:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env."
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env.")
|
||||
return
|
||||
|
||||
args = match.args()
|
||||
if len(args) < 1:
|
||||
await show_usage(room, bot)
|
||||
return
|
||||
|
||||
if args[0].lower() == "test":
|
||||
await test_dnsdumpster_connection(room, bot)
|
||||
else:
|
||||
@@ -37,9 +32,6 @@ async def show_usage(room, bot):
|
||||
usage = """<strong>🔍 DNSDumpster Commands:</strong>
|
||||
<strong>!dnsdumpster <domain_name></strong> - Get comprehensive DNS reconnaissance for a domain
|
||||
<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)
|
||||
|
||||
@@ -51,8 +43,7 @@ async def test_dnsdumpster_connection(room, bot):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, timeout=15) as response:
|
||||
status = response.status
|
||||
debug_info = f"<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:
|
||||
data = await response.json()
|
||||
debug_info += "<strong>✅ SUCCESS</strong><br>"
|
||||
@@ -81,50 +72,66 @@ async def dnsdumpster_domain_lookup(room, bot, domain):
|
||||
return
|
||||
data = await response.json()
|
||||
|
||||
output = await format_dnsdumpster_report(domain, data)
|
||||
sections = []
|
||||
|
||||
# A Records
|
||||
if data.get('a'):
|
||||
rows = []
|
||||
for rec in data['a']:
|
||||
host = rec.get('host', 'N/A')
|
||||
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
|
||||
rows.append(("📍", host, ips))
|
||||
sections.append({"title": "A Records (IPv4)", "rows": rows})
|
||||
|
||||
# NS Records
|
||||
if data.get('ns'):
|
||||
rows = []
|
||||
for rec in data['ns']:
|
||||
host = rec.get('host', 'N/A')
|
||||
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
|
||||
rows.append(("🖧", host, ips))
|
||||
sections.append({"title": "NS Records", "rows": rows})
|
||||
|
||||
# MX Records
|
||||
if data.get('mx'):
|
||||
rows = []
|
||||
for rec in data['mx']:
|
||||
host = rec.get('host', 'N/A')
|
||||
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
|
||||
rows.append(("📧", host, ips))
|
||||
sections.append({"title": "MX Records", "rows": rows})
|
||||
|
||||
# CNAME
|
||||
if data.get('cname'):
|
||||
rows = []
|
||||
for rec in data['cname']:
|
||||
host = rec.get('host', 'N/A')
|
||||
target = rec.get('target', 'N/A')
|
||||
rows.append(("🔀", host, target))
|
||||
sections.append({"title": "CNAME Records", "rows": rows})
|
||||
|
||||
# TXT
|
||||
if data.get('txt'):
|
||||
rows = []
|
||||
for txt in data['txt']:
|
||||
rows.append(("📄", "TXT", txt[:150] if len(txt) > 150 else txt))
|
||||
sections.append({"title": "TXT Records", "rows": rows})
|
||||
|
||||
if not sections:
|
||||
await bot.api.send_text_message(room.room_id, "No DNS records found.")
|
||||
return
|
||||
|
||||
block = code_block(f"🔍 DNSDumpster Report: {safe_domain}", sections)
|
||||
output = collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Sent DNSDumpster data for {domain}")
|
||||
except asyncio.TimeoutError:
|
||||
await bot.api.send_text_message(room.room_id, "Request timed out.")
|
||||
except Exception as e:
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||
|
||||
async def format_dnsdumpster_report(domain, data):
|
||||
safe_domain = html_escape(domain)
|
||||
output = f"<strong>🔍 DNSDumpster Report: {safe_domain}</strong><br><br>"
|
||||
if data.get('total_a_recs'):
|
||||
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"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "DNSDumpster domain reconnaissance"
|
||||
__help__ = """
|
||||
|
||||
+103
-40
@@ -1,14 +1,17 @@
|
||||
"""
|
||||
This plugin provides IP geolocation functionality using free APIs.
|
||||
IP geolocation plugin – uses ip-api.com (primary) and ipapi.co (fallback).
|
||||
Outputs a formatted code block with emojis and perfectly aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import aiohttp
|
||||
import simplematrixbotlib as botlib
|
||||
import socket
|
||||
import re
|
||||
from plugins.common import is_public_destination, html_escape, collapsible_summary
|
||||
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||
|
||||
async def is_valid_ip(ip):
|
||||
"""Check if the provided string is a valid IP address."""
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, ip)
|
||||
return True
|
||||
@@ -20,18 +23,21 @@ async def is_valid_ip(ip):
|
||||
return False
|
||||
|
||||
def is_domain(domain):
|
||||
"""Check if the provided string is a domain name."""
|
||||
domain_pattern = re.compile(
|
||||
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
|
||||
)
|
||||
return bool(domain_pattern.match(domain))
|
||||
|
||||
async def resolve_domain(domain):
|
||||
"""Resolve a domain name to an IP address."""
|
||||
try:
|
||||
return socket.gethostbyname(domain)
|
||||
except socket.gaierror:
|
||||
return None
|
||||
|
||||
async def query_ip_api_com(ip):
|
||||
"""Query ip-api.com for geolocation information."""
|
||||
url = f"http://ip-api.com/json/{ip}"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -43,6 +49,7 @@ async def query_ip_api_com(ip):
|
||||
return None
|
||||
|
||||
async def query_ipapi_co(ip):
|
||||
"""Query ipapi.co for geolocation information (fallback)."""
|
||||
url = f"https://ipapi.co/{ip}/json/"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -54,69 +61,125 @@ async def query_ipapi_co(ip):
|
||||
return None
|
||||
|
||||
async def query_geolocation(ip):
|
||||
"""Query geolocation using primary and fallback APIs."""
|
||||
data = await query_ip_api_com(ip)
|
||||
if not data or data.get('status') == 'fail':
|
||||
data = await query_ipapi_co(ip)
|
||||
return data
|
||||
|
||||
async def format_geolocation_results(ip, data):
|
||||
if not data or ('status' in data and data.get('status') == 'fail'):
|
||||
return f"🔍 No geolocation data found for {ip}."
|
||||
country = data.get('country', 'N/A')
|
||||
country_code = data.get('countryCode', 'N/A')
|
||||
region = data.get('regionName', data.get('region', 'N/A'))
|
||||
city = data.get('city', 'N/A')
|
||||
postal = data.get('zip', 'N/A')
|
||||
latitude = data.get('lat', 'N/A')
|
||||
longitude = data.get('lon', 'N/A')
|
||||
timezone = data.get('timezone', 'N/A')
|
||||
isp = data.get('isp', 'N/A')
|
||||
org = data.get('org', 'N/A')
|
||||
asn = data.get('as', 'N/A')
|
||||
|
||||
content = (f"<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):
|
||||
"""Handle the !geo command."""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("geo"):
|
||||
args = match.args()
|
||||
if len(args) < 1:
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !geo <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
|
||||
query = args[0].strip()
|
||||
logging.info(f"Received !geo command for: {query}")
|
||||
|
||||
try:
|
||||
ip = query
|
||||
if is_domain(query):
|
||||
await bot.api.send_text_message(room.room_id, f"🔍 Resolving domain {html_escape(query)}...")
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
f"🔍 Resolving domain {html_escape(query)} to IP address..."
|
||||
)
|
||||
ip = await resolve_domain(query)
|
||||
if not ip:
|
||||
await bot.api.send_text_message(room.room_id, f"Failed to resolve {html_escape(query)}.")
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
f"Failed to resolve domain {html_escape(query)} to IP address.")
|
||||
return
|
||||
if not is_public_destination(ip):
|
||||
await bot.api.send_text_message(room.room_id, "❌ 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
|
||||
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):
|
||||
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
|
||||
else:
|
||||
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
|
||||
|
||||
geo_data = await query_geolocation(ip)
|
||||
result = await format_geolocation_results(ip, geo_data)
|
||||
await bot.api.send_markdown_message(room.room_id, result)
|
||||
await bot.api.send_text_message(room.room_id,
|
||||
f"🔍 Looking up geolocation for {ip}...")
|
||||
|
||||
__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"
|
||||
__description__ = "IP geolocation lookup"
|
||||
__help__ = """<details><summary><strong>!geo</strong> – IP / domain geolocation</summary>
|
||||
<ul><li><code>!geo <ip></code> or <code>!geo <domain></code></li></ul></details>"""
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!geo</strong> – IP / domain geolocation</summary>
|
||||
<p><code>!geo <ip or domain></code> – Locate an IP address or domain. Shows country, city, coordinates, ISP, ASN, etc.</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
+40
-243
@@ -1,22 +1,17 @@
|
||||
"""
|
||||
This plugin provides a command to identify hash types using comprehensive pattern matching.
|
||||
Hash identifier plugin – identifies 100+ hash types with confidence and tool modes.
|
||||
Outputs a clean code block with emojis and perfectly aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import collapsible_summary, html_escape, code_block
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hash identification logic (unchanged from original)
|
||||
# ---------------------------------------------------------------------------
|
||||
def identify_hash(hash_string):
|
||||
"""
|
||||
Identify the hash type based on comprehensive pattern matching.
|
||||
|
||||
Args:
|
||||
hash_string (str): The hash string to identify
|
||||
|
||||
Returns:
|
||||
list: List of tuples (hash_type, hashcat_mode, john_format, confidence)
|
||||
"""
|
||||
|
||||
hash_string = hash_string.strip()
|
||||
hash_lower = hash_string.lower()
|
||||
length = len(hash_string)
|
||||
@@ -25,15 +20,10 @@ def identify_hash(hash_string):
|
||||
|
||||
# Unix crypt and modular crypt formats (most specific first)
|
||||
if hash_string.startswith('$'):
|
||||
# yescrypt (modern Linux /etc/shadow)
|
||||
if re.match(r'^\$y\$', hash_string):
|
||||
possible_types.append(("yescrypt", None, "yescrypt", 95))
|
||||
|
||||
# scrypt
|
||||
elif re.match(r'^\$7\$', hash_string):
|
||||
possible_types.append(("scrypt", "8900", "scrypt", 95))
|
||||
|
||||
# Argon2
|
||||
elif re.match(r'^\$argon2(id?|d)\$', hash_string):
|
||||
if '$argon2i$' in hash_string:
|
||||
possible_types.append(("Argon2i", "10900", "argon2", 95))
|
||||
@@ -41,72 +31,39 @@ def identify_hash(hash_string):
|
||||
possible_types.append(("Argon2d", None, "argon2", 95))
|
||||
elif '$argon2id$' in hash_string:
|
||||
possible_types.append(("Argon2id", "10900", "argon2", 95))
|
||||
|
||||
# bcrypt variants
|
||||
elif re.match(r'^\$(2[abxy]?)\$', hash_string):
|
||||
bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1)
|
||||
possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95))
|
||||
|
||||
# SHA-512 Crypt (common in Linux)
|
||||
elif re.match(r'^\$6\$', hash_string):
|
||||
possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95))
|
||||
|
||||
# SHA-256 Crypt (Unix)
|
||||
elif re.match(r'^\$5\$', hash_string):
|
||||
possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95))
|
||||
|
||||
# MD5 Crypt (Unix)
|
||||
elif re.match(r'^\$1\$', hash_string):
|
||||
possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95))
|
||||
|
||||
# Apache MD5
|
||||
elif re.match(r'^\$apr1\$', hash_string):
|
||||
possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95))
|
||||
|
||||
# AIX SMD5
|
||||
elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE):
|
||||
possible_types.append(("AIX {smd5}", "6300", None, 90))
|
||||
|
||||
# AIX SSHA256
|
||||
elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE):
|
||||
possible_types.append(("AIX {ssha256}", "6700", None, 90))
|
||||
|
||||
# AIX SSHA512
|
||||
elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE):
|
||||
possible_types.append(("AIX {ssha512}", "6800", None, 90))
|
||||
|
||||
# phpBB3
|
||||
elif re.match(r'^\$H\$', hash_string):
|
||||
possible_types.append(("phpBB3", "400", "phpass", 90))
|
||||
|
||||
# Wordpress
|
||||
elif re.match(r'^\$P\$', hash_string):
|
||||
possible_types.append(("Wordpress", "400", "phpass", 90))
|
||||
|
||||
# Drupal 7+
|
||||
elif re.match(r'^\$S\$', hash_string):
|
||||
possible_types.append(("Drupal 7+", "7900", "drupal7", 90))
|
||||
|
||||
# WBB3 (Woltlab Burning Board)
|
||||
elif re.match(r'^\$wbb3\$', hash_string):
|
||||
possible_types.append(("WBB3 (Woltlab)", None, None, 85))
|
||||
|
||||
# PBKDF2-HMAC-SHA256
|
||||
elif re.match(r'^\$pbkdf2-sha256\$', hash_string):
|
||||
possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90))
|
||||
|
||||
# PBKDF2-HMAC-SHA512
|
||||
elif re.match(r'^\$pbkdf2-sha512\$', hash_string):
|
||||
possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90))
|
||||
|
||||
# Django PBKDF2
|
||||
elif re.match(r'^pbkdf2_sha256\$', hash_string):
|
||||
possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90))
|
||||
|
||||
# Unknown modular crypt format
|
||||
else:
|
||||
possible_types.append(("Unknown Modular Crypt Format", None, None, 30))
|
||||
|
||||
return possible_types
|
||||
|
||||
# LDAP formats
|
||||
@@ -123,31 +80,22 @@ def identify_hash(hash_string):
|
||||
possible_types.append(("LDAP CRYPT", None, None, 85))
|
||||
return possible_types
|
||||
|
||||
# Check for colon-separated formats (LM:NTLM, username:hash, etc.)
|
||||
# Colon-separated formats
|
||||
if ':' in hash_string:
|
||||
parts = hash_string.split(':')
|
||||
|
||||
# NetNTLMv1 / NetNTLMv2
|
||||
if len(parts) >= 5:
|
||||
possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85))
|
||||
possible_types.append(("NetNTLMv1", "5500", "netntlm", 75))
|
||||
|
||||
# LM:NTLM format
|
||||
elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32:
|
||||
possible_types.append(("LM:NTLM", "1000", "nt", 90))
|
||||
|
||||
# Username:Hash or similar
|
||||
elif len(parts) == 2:
|
||||
hash_part = parts[1]
|
||||
if len(hash_part) == 32:
|
||||
possible_types.append(("NTLM (with username)", "1000", "nt", 80))
|
||||
elif len(hash_part) == 40:
|
||||
possible_types.append(("SHA-1 (with salt/username)", "110", None, 70))
|
||||
|
||||
# PostgreSQL md5
|
||||
if hash_string.startswith('md5') and len(hash_string) == 35:
|
||||
possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90))
|
||||
|
||||
return possible_types if possible_types else None
|
||||
|
||||
# MySQL formats
|
||||
@@ -159,7 +107,6 @@ def identify_hash(hash_string):
|
||||
if re.match(r'^[A-F0-9]{16}:[A-F0-9]{16}$', hash_string.upper()):
|
||||
possible_types.append(("Oracle 11g", "112", "oracle11", 90))
|
||||
return possible_types
|
||||
|
||||
if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()):
|
||||
possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90))
|
||||
return possible_types
|
||||
@@ -168,234 +115,84 @@ def identify_hash(hash_string):
|
||||
if re.match(r'^0x0100[A-F0-9]{8}[A-F0-9]{40}$', hash_string.upper()):
|
||||
possible_types.append(("MSSQL 2000", "131", "mssql", 90))
|
||||
return possible_types
|
||||
|
||||
if re.match(r'^0x0200[A-F0-9]{8}[A-F0-9]{128}$', hash_string.upper()):
|
||||
possible_types.append(("MSSQL 2012/2014", "1731", "mssql12", 90))
|
||||
return possible_types
|
||||
|
||||
# Base64 pattern check
|
||||
is_base64 = re.match(r'^[A-Za-z0-9+/]+=*$', hash_string) and length % 4 == 0
|
||||
|
||||
# Raw hash identification by length
|
||||
is_hex = re.match(r'^[a-f0-9]+$', hash_lower)
|
||||
|
||||
if is_hex:
|
||||
if length == 16:
|
||||
possible_types.append(("MySQL < 4.1", "200", "mysql", 85))
|
||||
possible_types.append(("Half MD5", None, None, 60))
|
||||
|
||||
elif length == 32:
|
||||
possible_types.append(("MD5", "0", "raw-md5", 80))
|
||||
possible_types.append(("MD4", "900", "raw-md4", 70))
|
||||
possible_types.append(("NTLM", "1000", "nt", 75))
|
||||
possible_types.append(("LM", "3000", "lm", 60))
|
||||
possible_types.append(("RAdmin v2.x", "9900", None, 50))
|
||||
possible_types.append(("Snefru-128", None, None, 40))
|
||||
possible_types.append(("HMAC-MD5 (key = $pass)", "50", None, 50))
|
||||
|
||||
elif length == 40:
|
||||
possible_types.append(("SHA-1", "100", "raw-sha1", 85))
|
||||
possible_types.append(("RIPEMD-160", "6000", "ripemd-160", 65))
|
||||
possible_types.append(("Tiger-160", None, None, 50))
|
||||
possible_types.append(("Haval-160", None, None, 45))
|
||||
possible_types.append(("HMAC-SHA1 (key = $pass)", "150", None, 55))
|
||||
|
||||
elif length == 48:
|
||||
possible_types.append(("Tiger-192", None, None, 70))
|
||||
possible_types.append(("Haval-192", None, None, 65))
|
||||
|
||||
elif length == 56:
|
||||
possible_types.append(("SHA-224", "1300", "raw-sha224", 85))
|
||||
possible_types.append(("Haval-224", None, None, 60))
|
||||
|
||||
elif length == 64:
|
||||
possible_types.append(("SHA-256", "1400", "raw-sha256", 85))
|
||||
possible_types.append(("RIPEMD-256", None, None, 60))
|
||||
possible_types.append(("SHA3-256", "17400", "raw-sha3", 70))
|
||||
possible_types.append(("Keccak-256", "17800", "raw-keccak-256", 70))
|
||||
possible_types.append(("Haval-256", None, None, 50))
|
||||
possible_types.append(("GOST R 34.11-94", "6900", None, 55))
|
||||
possible_types.append(("BLAKE2b-256", None, None, 60))
|
||||
|
||||
elif length == 80:
|
||||
possible_types.append(("RIPEMD-320", None, None, 80))
|
||||
|
||||
elif length == 96:
|
||||
possible_types.append(("SHA-384", "10800", "raw-sha384", 85))
|
||||
possible_types.append(("SHA3-384", "17900", None, 70))
|
||||
possible_types.append(("Keccak-384", None, None, 65))
|
||||
|
||||
elif length == 128:
|
||||
possible_types.append(("SHA-512", "1700", "raw-sha512", 85))
|
||||
possible_types.append(("Whirlpool", "6100", "whirlpool", 75))
|
||||
possible_types.append(("SHA3-512", "17600", None, 70))
|
||||
possible_types.append(("Keccak-512", None, None, 65))
|
||||
possible_types.append(("BLAKE2b-512", None, None, 60))
|
||||
|
||||
# Base64 encoded hashes
|
||||
elif is_base64:
|
||||
if length == 24:
|
||||
possible_types.append(("MD5 (Base64)", None, None, 75))
|
||||
elif length == 28:
|
||||
possible_types.append(("SHA-1 (Base64)", None, None, 75))
|
||||
elif length == 32:
|
||||
possible_types.append(("SHA-224 (Base64)", None, None, 75))
|
||||
elif length == 44:
|
||||
possible_types.append(("SHA-256 (Base64)", None, None, 75))
|
||||
elif length == 64:
|
||||
possible_types.append(("SHA-384 (Base64)", None, None, 75))
|
||||
elif length == 88:
|
||||
possible_types.append(("SHA-512 (Base64)", None, None, 75))
|
||||
|
||||
return possible_types if possible_types else [("Unknown", None, None, 0)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
def _format_results(hash_input, results):
|
||||
"""Build a code block with sections for each possible hash type."""
|
||||
sections = []
|
||||
for idx, (hash_type, hashcat_mode, john_format, confidence) in enumerate(results, 1):
|
||||
emoji = "🟢" if confidence >= 90 else "🟡" if confidence >= 80 else "🟠" if confidence >= 60 else "🔴"
|
||||
title = f"{emoji} Match #{idx}: {hash_type} ({confidence}%)"
|
||||
rows = [
|
||||
("", "Hash Type", hash_type),
|
||||
("", "Confidence", f"{confidence}%"),
|
||||
]
|
||||
if hashcat_mode:
|
||||
rows.append(("", "Hashcat Mode", f"-m {hashcat_mode}"))
|
||||
if john_format:
|
||||
rows.append(("", "John Format", f"--format={john_format}"))
|
||||
sections.append({"title": title, "rows": rows})
|
||||
|
||||
block = code_block(f"🔐 Hash Identification: {hash_input[:30]}...", sections)
|
||||
return collapsible_summary("🔐 Hash Identification Results", block)
|
||||
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""
|
||||
Function to handle the !hashid command.
|
||||
|
||||
Args:
|
||||
room (Room): The Matrix room where the command was invoked.
|
||||
message (RoomMessage): The message object containing the command.
|
||||
bot (Bot): The bot object.
|
||||
prefix (str): The command prefix.
|
||||
config (dict): Configuration parameters.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"):
|
||||
logging.info("Received !hashid command")
|
||||
|
||||
args = match.args()
|
||||
|
||||
if len(args) < 1:
|
||||
usage_msg = """<strong>🔐 Hash Identifier Usage</strong>
|
||||
|
||||
<strong>Usage:</strong> <code>!hashid <hash></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)
|
||||
await bot.api.send_markdown_message(room.room_id, "<strong>Usage:</strong> <code>!hashid <hash></code>")
|
||||
return
|
||||
|
||||
hash_input = ' '.join(args)
|
||||
|
||||
try:
|
||||
# Identify the hash
|
||||
identified = identify_hash(hash_input)
|
||||
|
||||
if not identified:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Could not identify hash type. Please verify the hash format."
|
||||
)
|
||||
results = identify_hash(hash_input)
|
||||
if not results or results[0][0] == "Unknown":
|
||||
await bot.api.send_text_message(room.room_id, "Could not identify the hash type.")
|
||||
return
|
||||
|
||||
# Sort by confidence (highest first)
|
||||
identified = sorted(identified, key=lambda x: x[3], reverse=True)
|
||||
|
||||
# Format the response
|
||||
hash_preview = hash_input[:60] + "..." if len(hash_input) > 60 else hash_input
|
||||
|
||||
# Determine confidence indicator
|
||||
top_confidence = identified[0][3]
|
||||
if top_confidence >= 90:
|
||||
confidence_emoji = "🟢"
|
||||
confidence_label = "Very High"
|
||||
elif top_confidence >= 80:
|
||||
confidence_emoji = "🟡"
|
||||
confidence_label = "High"
|
||||
elif top_confidence >= 60:
|
||||
confidence_emoji = "🟠"
|
||||
confidence_label = "Medium"
|
||||
else:
|
||||
confidence_emoji = "🔴"
|
||||
confidence_label = "Low"
|
||||
|
||||
# Build response inside collapsible details
|
||||
response = "<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)
|
||||
# Sort by confidence descending
|
||||
results.sort(key=lambda x: x[3], reverse=True)
|
||||
output = _format_results(hash_input, results[:6]) # show top 6
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Hash type identifier"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!hashid</strong> – Identify hash type</summary>
|
||||
<p><code>!hashid <hash></code> – Recognises 100+ hash formats (MD5, SHA, bcrypt, etc.).<br>
|
||||
Shows confidence level, Hashcat mode, and John the Ripper format.</p>
|
||||
<p><code>!hashid <hash></code> – Recognises 100+ formats and displays tool modes in a clean table.</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
+139
-282
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
This plugin provides comprehensive HTTP security header analysis.
|
||||
HTTP security header analysis plugin.
|
||||
Outputs a structured code block with perfectly aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -10,133 +11,31 @@ from urllib.parse import urlparse
|
||||
import ssl
|
||||
import socket
|
||||
import datetime
|
||||
from plugins.common import is_public_destination, collapsible_summary, html_escape
|
||||
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""
|
||||
Function to handle !headers command for HTTP security header analysis.
|
||||
"""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("headers"):
|
||||
logging.info("Received !headers command")
|
||||
async def _run_in_thread(func, *args, **kwargs):
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||
|
||||
args = match.args()
|
||||
|
||||
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 <url></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."""
|
||||
async def analyze_http_response(url):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
||||
results['final_url'] = str(response.url)
|
||||
results['status_code'] = response.status
|
||||
results['http_headers'] = dict(response.headers)
|
||||
results['redirects_to_https'] = response.url.scheme == 'https'
|
||||
# aiohttp doesn't give access to redirect history easily, so we'll mark if final URL differs
|
||||
if str(response.url) != url:
|
||||
results['redirect_chain'] = [{'url': url, 'status_code': 301}] # simplified
|
||||
async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
return str(resp.url), resp.status, dict(resp.headers), resp.url.scheme == 'https'
|
||||
except aiohttp.ClientError as e:
|
||||
results['http_error'] = str(e)
|
||||
logging.warning(f"HTTP analysis error: {e}")
|
||||
return url, None, {}, False
|
||||
|
||||
async def analyze_https_response(results, url):
|
||||
"""Analyze HTTPS response headers."""
|
||||
async def analyze_https_response(url):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
||||
results['https_headers'] = dict(response.headers)
|
||||
results['https_status'] = response.status
|
||||
async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
return resp.status, dict(resp.headers)
|
||||
except aiohttp.ClientError as e:
|
||||
results['https_error'] = str(e)
|
||||
logging.warning(f"HTTPS analysis error: {e}")
|
||||
return None, {}
|
||||
|
||||
async def analyze_ssl_certificate(results, domain):
|
||||
"""Analyze SSL certificate information (run in thread to avoid event loop blocking)."""
|
||||
def _get_cert():
|
||||
def _get_cert_info(domain):
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
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_after': cert['notAfter'],
|
||||
'san': cert.get('subjectAltName', []),
|
||||
'version': cert.get('version'),
|
||||
'serial_number': cert.get('serialNumber')
|
||||
}
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
logging.warning(f"SSL cert error: {e}")
|
||||
return None
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
ssl_data = await loop.run_in_executor(None, _get_cert)
|
||||
if isinstance(ssl_data, str):
|
||||
results['ssl_error'] = ssl_data
|
||||
else:
|
||||
results['ssl_info'] = ssl_data
|
||||
|
||||
async def calculate_security_score(results):
|
||||
"""Calculate overall security score based on headers and configuration."""
|
||||
def calculate_score(headers, redirects_to_https, cert_info):
|
||||
score = 100
|
||||
missing_headers = []
|
||||
|
||||
critical_headers = [
|
||||
'Strict-Transport-Security',
|
||||
'Content-Security-Policy',
|
||||
'X-Content-Type-Options',
|
||||
'X-Frame-Options',
|
||||
'X-XSS-Protection'
|
||||
]
|
||||
|
||||
headers = results.get('https_headers') or results.get('http_headers', {})
|
||||
|
||||
for header in critical_headers:
|
||||
if header not in headers:
|
||||
score -= 15
|
||||
missing_headers.append(header)
|
||||
|
||||
# Check HSTS configuration
|
||||
if 'Strict-Transport-Security' not in headers: score -= 15
|
||||
if 'Content-Security-Policy' not in headers: score -= 15
|
||||
if 'X-Content-Type-Options' not in headers: score -= 15
|
||||
if 'X-Frame-Options' not in headers: score -= 15
|
||||
if 'X-XSS-Protection' not in headers: score -= 15
|
||||
hsts = headers.get('Strict-Transport-Security', '')
|
||||
if 'max-age=31536000' not in hsts:
|
||||
score -= 10
|
||||
if 'includeSubDomains' not in hsts:
|
||||
score -= 5
|
||||
if 'preload' not in hsts:
|
||||
score -= 5
|
||||
|
||||
# Check CSP configuration
|
||||
csp = headers.get('Content-Security-Policy', '')
|
||||
if not csp:
|
||||
score -= 10
|
||||
elif "default-src 'none'" not in csp and "default-src 'self'" not in csp:
|
||||
score -= 5
|
||||
|
||||
# Check for insecure headers
|
||||
insecure_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version']
|
||||
for header in insecure_headers:
|
||||
if header in headers:
|
||||
score -= 5
|
||||
|
||||
# Bonus for good practices
|
||||
if headers.get('Referrer-Policy'):
|
||||
score += 5
|
||||
if headers.get('Feature-Policy') or headers.get('Permissions-Policy'):
|
||||
score += 5
|
||||
if headers.get('X-Content-Type-Options') == 'nosniff':
|
||||
score += 5
|
||||
if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']:
|
||||
score += 5
|
||||
|
||||
# HTTPS enforcement bonus
|
||||
if results.get('redirects_to_https'):
|
||||
score += 10
|
||||
|
||||
results['security_score'] = max(0, score)
|
||||
results['missing_headers'] = missing_headers
|
||||
|
||||
async def generate_recommendations(results):
|
||||
"""Generate security recommendations based on analysis."""
|
||||
recommendations = []
|
||||
headers = results.get('https_headers') or results.get('http_headers', {})
|
||||
if 'max-age=31536000' not in hsts: score -= 10
|
||||
if 'includeSubDomains' not in hsts: score -= 5
|
||||
if 'preload' not in hsts: score -= 5
|
||||
if headers.get('Referrer-Policy'): score += 5
|
||||
if headers.get('Feature-Policy') or headers.get('Permissions-Policy'): score += 5
|
||||
if headers.get('X-Content-Type-Options') == 'nosniff': score += 5
|
||||
if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']: score += 5
|
||||
if redirects_to_https: score += 10
|
||||
if cert_info and cert_info.get('not_after'):
|
||||
try:
|
||||
expires = datetime.datetime.strptime(cert_info['not_after'], '%b %d %H:%M:%S %Y %Z')
|
||||
if (expires - datetime.datetime.utcnow()).days < 30: score -= 10
|
||||
except: pass
|
||||
return max(0, score)
|
||||
|
||||
def generate_recommendations(headers, redirects_to_https):
|
||||
recs = []
|
||||
if 'Strict-Transport-Security' not in headers:
|
||||
recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload")
|
||||
else:
|
||||
hsts = headers['Strict-Transport-Security']
|
||||
if 'max-age=31536000' not in hsts:
|
||||
recommendations.append("🔒 Increase HSTS max-age to 31536000 (1 year)")
|
||||
if 'includeSubDomains' not in hsts:
|
||||
recommendations.append("🔒 Add includeSubDomains to HSTS header")
|
||||
if 'preload' not in hsts:
|
||||
recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading")
|
||||
|
||||
recs.append("🔒 Implement HSTS with max-age=31536000, includeSubDomains, preload")
|
||||
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:
|
||||
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:
|
||||
recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing")
|
||||
|
||||
if 'Referrer-Policy' not in headers:
|
||||
recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage")
|
||||
|
||||
recs.append("📄 Add X-Content-Type-Options: nosniff")
|
||||
if not redirects_to_https:
|
||||
recs.append("🔐 Redirect HTTP to HTTPS")
|
||||
if 'Server' in headers or 'X-Powered-By' in headers:
|
||||
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://'):
|
||||
recommendations.append("🔐 Implement HTTP to HTTPS redirects")
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("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 <url></code>")
|
||||
return
|
||||
|
||||
async def format_header_analysis(results):
|
||||
"""Format the header analysis results for display."""
|
||||
safe_url = html_escape(results['url'])
|
||||
output = f"<strong>🔒 Security Headers Analysis: {safe_url}</strong><br><br>"
|
||||
original_input = args[0].strip()
|
||||
url = original_input
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
url = 'https://' + url
|
||||
|
||||
# Security Score
|
||||
score = results['security_score']
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname
|
||||
if not is_public_destination(host):
|
||||
await bot.api.send_text_message(room.room_id, "❌ Private/internal addresses are not allowed.")
|
||||
return
|
||||
|
||||
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 "🔴"
|
||||
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
|
||||
output += "<strong>📊 Basic Information</strong><br>"
|
||||
output += f" • <strong>Final URL:</strong> {html_escape(results.get('final_url', 'N/A'))}<br>"
|
||||
output += f" • <strong>Status Code:</strong> {results.get('status_code', 'N/A')}<br>"
|
||||
if results.get('redirects_to_https'):
|
||||
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>"
|
||||
else:
|
||||
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>"
|
||||
basic_rows = [
|
||||
("🌐", "Final URL", final_url),
|
||||
("📊", "Status Code", str(status_code) if status_code else "N/A"),
|
||||
("🔐", "HTTPS Redirect", "✅ Yes" if redirects_to_https else "❌ No"),
|
||||
]
|
||||
sections.append({"title": "📊 Basic Information", "rows": basic_rows})
|
||||
|
||||
# Security Headers
|
||||
security_headers = {
|
||||
'Strict-Transport-Security': ('🔒', 'HSTS'),
|
||||
'Content-Security-Policy': ('🛡️', 'CSP'),
|
||||
'X-Frame-Options': ('🚫', 'Clickjacking Protection'),
|
||||
'X-Content-Type-Options': ('📄', 'MIME Sniffing'),
|
||||
'X-XSS-Protection': ('❌', 'XSS Protection (Deprecated)'),
|
||||
'X-Frame-Options': ('🚫', 'Frame Options'),
|
||||
'X-Content-Type-Options': ('📄', 'Content Type'),
|
||||
'X-XSS-Protection': ('❌', 'XSS Protection'),
|
||||
'Referrer-Policy': ('🔗', 'Referrer Policy'),
|
||||
'Feature-Policy': ('⚙️', 'Feature Policy'),
|
||||
'Permissions-Policy': ('🔧', 'Permissions Policy'),
|
||||
'Feature-Policy': ('⚙️', 'Feature Policy'),
|
||||
}
|
||||
|
||||
for header, (emoji, description) in security_headers.items():
|
||||
if header in headers:
|
||||
value = html_escape(str(headers[header]))[:100]
|
||||
output += f" • {emoji} <strong>{header}:</strong> ✅ {value}<br>"
|
||||
header_rows = []
|
||||
for hdr, (emoji, label) in security_headers.items():
|
||||
if hdr in headers:
|
||||
val = headers[hdr][:100]
|
||||
header_rows.append((emoji, label, f"✅ {val}"))
|
||||
else:
|
||||
output += f" • {emoji} <strong>{header}:</strong> ❌ Missing<br>"
|
||||
output += "<br>"
|
||||
header_rows.append((emoji, label, "❌ Missing"))
|
||||
sections.append({"title": "🛡️ Security Headers", "rows": header_rows})
|
||||
|
||||
# Other Headers (Information Disclosure)
|
||||
output += "<strong>📋 Other Headers</strong><br>"
|
||||
for header in ['Server', 'X-Powered-By']:
|
||||
if header in headers:
|
||||
output += f" • 🔍 <strong>{header}:</strong> {html_escape(str(headers[header]))}<br>"
|
||||
output += "<br>"
|
||||
# Other Headers
|
||||
other_rows = []
|
||||
for hdr in ['Server', 'X-Powered-By']:
|
||||
if hdr in headers:
|
||||
other_rows.append(("🔍", hdr, headers[hdr]))
|
||||
if other_rows:
|
||||
sections.append({"title": "📋 Other Headers", "rows": other_rows})
|
||||
|
||||
# SSL Certificate Information (if available)
|
||||
if results.get('ssl_info') and 'subject' in results['ssl_info']:
|
||||
output += "<strong>🔐 SSL Certificate</strong><br>"
|
||||
ssl_info = results['ssl_info']
|
||||
if ssl_info.get('subject'):
|
||||
output += f" • <strong>Subject:</strong> {html_escape(ssl_info['subject'].get('commonName', 'N/A'))}<br>"
|
||||
if ssl_info.get('issuer'):
|
||||
output += f" • <strong>Issuer:</strong> {html_escape(ssl_info['issuer'].get('organizationName', 'N/A'))}<br>"
|
||||
if ssl_info.get('not_after'):
|
||||
output += f" • <strong>Expires:</strong> {html_escape(ssl_info['not_after'])}<br>"
|
||||
output += "<br>"
|
||||
# SSL Certificate
|
||||
if cert_info:
|
||||
ssl_rows = [
|
||||
("📜", "Subject", cert_info['subject'].get('commonName', 'N/A')),
|
||||
("🏢", "Issuer", cert_info['issuer'].get('organizationName', 'N/A')),
|
||||
("📅", "Expires", cert_info.get('not_after', 'N/A')),
|
||||
]
|
||||
san = [san[1] for san in cert_info.get('san', []) if san[0] == 'DNS']
|
||||
if san:
|
||||
ssl_rows.append(("🌐", "SANs", ", ".join(san[:5])))
|
||||
sections.append({"title": "🔐 SSL Certificate", "rows": ssl_rows})
|
||||
|
||||
# Recommendations
|
||||
if results.get('recommendations'):
|
||||
output += "<strong>💡 Security Recommendations</strong><br>"
|
||||
for rec in results['recommendations'][:8]:
|
||||
output += f" • {rec}<br>"
|
||||
output += "<br>"
|
||||
if recommendations:
|
||||
rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
|
||||
sections.append({"title": "💡 Recommendations", "rows": rec_rows})
|
||||
|
||||
# Final rating
|
||||
if score >= 80:
|
||||
rating = "🟢 Excellent"
|
||||
elif score >= 60:
|
||||
rating = "🟡 Good"
|
||||
elif score >= 40:
|
||||
rating = "🟠 Fair"
|
||||
else:
|
||||
rating = "🔴 Poor"
|
||||
output += f"<strong>📈 Security Rating:</strong> {rating}<br>"
|
||||
block = code_block(f"🔒 Security Headers: {safe_host}", sections)
|
||||
output = collapsible_summary(f"🔒 Headers: {safe_host}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
|
||||
# Wrap in collapsible details
|
||||
return collapsible_summary(f"🔒 Security Headers Analysis: {safe_url} (Score: {score}/100)", output)
|
||||
|
||||
__version__ = "1.0.2"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.1.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "HTTP security header analysis (SSRF‑safe, async)"
|
||||
__description__ = "HTTP security header analysis"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!headers</strong> – HTTP security header scanner</summary>
|
||||
<p><code>!headers <url></code> – Checks HSTS, CSP, X-Frame-Options, etc.<br>
|
||||
Provides security score (0-100) and recommendations. Also shows SSL certificate info.</p>
|
||||
<summary><strong>!headers</strong> – HTTP security headers analysis</summary>
|
||||
<p><code>!headers <url></code> – Analyzes security headers, SSL cert, gives score and recommendations in a clean, aligned table.</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
@@ -172,7 +172,7 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens):
|
||||
|
||||
__version__ = "1.0.3"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "AI text generation via Infermatic API (async, safe)"
|
||||
__description__ = "AI text generation via Infermatic API"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!text</strong> – AI text generation (Infermatic)</summary>
|
||||
|
||||
+609
-1441
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -51,7 +51,7 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
__version__ = "1.0.4"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "List all loaded plugins with count, collapsible"
|
||||
__description__ = "List all loaded plugins with count"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!plugins</strong> – List active plugins</summary>
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Working SOCKS5 proxy finder (SSRF‑safe, async)"
|
||||
__description__ = "Working SOCKS5 proxy finder"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!proxy</strong> – Random working SOCKS5 proxy</summary>
|
||||
|
||||
+1
-1
@@ -120,6 +120,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Goodreads quotes via Playwright (headless)"
|
||||
__description__ = "Fetch Goodreads quotes"
|
||||
__help__ = """<details><summary><strong>!quote</strong> – Quotes from Goodreads</summary>
|
||||
<p><code>!quote</code> random, <code>!quote <author></code>.</p></details>"""
|
||||
|
||||
+92
-225
@@ -2,79 +2,56 @@
|
||||
"""
|
||||
plugins/roomstats.py — per‑user room statistics (Limnoria‑style).
|
||||
Commands: !roomstats, !rank, !stats
|
||||
Output is a clean code block with emojis and aligned columns.
|
||||
"""
|
||||
|
||||
import time
|
||||
import re
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import nio
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import collapsible_summary, code_block
|
||||
|
||||
logger = logging.getLogger("roomstats")
|
||||
|
||||
DB_PATH = "roomstats.db"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Emoji / smiley regex (Unicode blocks)
|
||||
# ------------------------------------------------------------------
|
||||
# Emoji regex (unchanged)
|
||||
EMOJI_RE = re.compile(
|
||||
"["
|
||||
"\U0001F600-\U0001F64F" # Emoticons
|
||||
"\U0001F300-\U0001F5FF" # Symbols & pictographs
|
||||
"\U0001F680-\U0001F6FF" # Transport & map
|
||||
"\U0001F1E0-\U0001F1FF" # Flags
|
||||
"\U00002702-\U000027B0" # Dingbats
|
||||
"\U000024C2-\U0001F251" # Misc
|
||||
"]+", re.UNICODE)
|
||||
"\U0001F600-\U0001F64F"
|
||||
"\U0001F300-\U0001F5FF"
|
||||
"\U0001F680-\U0001F6FF"
|
||||
"\U0001F1E0-\U0001F1FF"
|
||||
"\U00002702-\U000027B0"
|
||||
"\U000024C2-\U0001F251"
|
||||
"]+", re.UNICODE
|
||||
)
|
||||
|
||||
def count_smileys(text):
|
||||
"""Return number of emoji occurrences."""
|
||||
return len(EMOJI_RE.findall(text))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database init
|
||||
# ------------------------------------------------------------------
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_room_stats (
|
||||
room_id TEXT,
|
||||
user_id TEXT,
|
||||
msgs INTEGER DEFAULT 0,
|
||||
chars INTEGER DEFAULT 0,
|
||||
words INTEGER DEFAULT 0,
|
||||
smileys INTEGER DEFAULT 0,
|
||||
actions INTEGER DEFAULT 0,
|
||||
joins INTEGER DEFAULT 0,
|
||||
parts INTEGER DEFAULT 0,
|
||||
kicks_given INTEGER DEFAULT 0,
|
||||
kicked_received INTEGER DEFAULT 0,
|
||||
topics_set INTEGER DEFAULT 0,
|
||||
last_updated INTEGER,
|
||||
room_id TEXT, user_id TEXT,
|
||||
msgs INTEGER DEFAULT 0, chars INTEGER DEFAULT 0, words INTEGER DEFAULT 0,
|
||||
smileys INTEGER DEFAULT 0, actions INTEGER DEFAULT 0,
|
||||
joins INTEGER DEFAULT 0, parts INTEGER DEFAULT 0,
|
||||
kicks_given INTEGER DEFAULT 0, kicked_received INTEGER DEFAULT 0,
|
||||
topics_set INTEGER DEFAULT 0, last_updated INTEGER,
|
||||
PRIMARY KEY (room_id, user_id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Multi‑word user resolution helper
|
||||
# ------------------------------------------------------------------
|
||||
async def resolve_user_from_tokens(bot, room_id, tokens):
|
||||
"""
|
||||
Given a list of word tokens, find a matching display name.
|
||||
Returns (mxid, display_name) or raises ValueError.
|
||||
"""
|
||||
# Build cache of (lowered display name → user_id) from joined members
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
if resp.members is None:
|
||||
raise ValueError("Could not fetch member list.")
|
||||
|
||||
# Create a dict: lower_display → (mxid, display_name)
|
||||
# If duplicate display name, store None to signal ambiguity.
|
||||
cache = {}
|
||||
for member in resp.members:
|
||||
display = (member.display_name or "").strip()
|
||||
@@ -85,68 +62,31 @@ async def resolve_user_from_tokens(bot, room_id, tokens):
|
||||
cache[key] = None
|
||||
else:
|
||||
cache[key] = (member.user_id, display)
|
||||
|
||||
# Try progressively longer prefixes of the tokens
|
||||
for end in range(len(tokens), 0, -1):
|
||||
candidate = " ".join(tokens[:end]).strip().lower()
|
||||
if candidate in cache:
|
||||
entry = cache[candidate]
|
||||
if entry is not None:
|
||||
return entry # (mxid, display_name)
|
||||
else:
|
||||
# Ambiguous – we need to fetch and check exactly
|
||||
matches = []
|
||||
for member in resp.members:
|
||||
if (member.display_name or "").strip().lower() == candidate:
|
||||
matches.append((member.user_id, member.display_name or member.user_id))
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
elif len(matches) > 1:
|
||||
raise ValueError(
|
||||
f"Multiple users have display name '{candidate}'. Use an MXID instead."
|
||||
)
|
||||
# if none, continue
|
||||
return entry
|
||||
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
|
||||
|
||||
async def resolve_user(bot, room_id, name_or_tokens):
|
||||
"""
|
||||
Accept either a single string (MXID or single-token display name)
|
||||
or a list of tokens. Returns (mxid, display_name).
|
||||
"""
|
||||
if isinstance(name_or_tokens, str):
|
||||
if name_or_tokens.startswith("@"):
|
||||
return name_or_tokens, None
|
||||
# Single token – try direct cache match or fallback to multi‑word
|
||||
tokens = [name_or_tokens]
|
||||
else:
|
||||
tokens = name_or_tokens
|
||||
|
||||
return await resolve_user_from_tokens(bot, room_id, tokens)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Setup: register custom event listeners for membership & topics
|
||||
# ------------------------------------------------------------------
|
||||
def setup(bot):
|
||||
init_db()
|
||||
|
||||
@bot.listener.on_custom_event(nio.RoomMemberEvent)
|
||||
async def member_event(room, event):
|
||||
room_id = room.room_id
|
||||
membership = event.content.get("membership")
|
||||
state_key = event.state_key
|
||||
sender = event.sender
|
||||
|
||||
# Ignore the bot's own membership changes
|
||||
if state_key == bot.async_client.user_id:
|
||||
return
|
||||
|
||||
if membership == "join":
|
||||
_incr(room_id, state_key, "joins")
|
||||
elif membership == "leave":
|
||||
if sender != state_key: # kick
|
||||
if sender != state_key:
|
||||
_incr(room_id, sender, "kicks_given")
|
||||
_incr(room_id, state_key, "kicked_received")
|
||||
else: # part
|
||||
else:
|
||||
_incr(room_id, state_key, "parts")
|
||||
|
||||
@bot.listener.on_custom_event(nio.RoomTopicEvent)
|
||||
@@ -156,53 +96,34 @@ def setup(bot):
|
||||
_incr(room_id, sender, "topics_set")
|
||||
|
||||
def _incr(room_id, user_id, column):
|
||||
"""Increment a stat column by 1, creating row if needed."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)",
|
||||
(room_id, user_id)
|
||||
)
|
||||
c.execute(
|
||||
f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
|
||||
(int(time.time()), room_id, user_id)
|
||||
)
|
||||
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, user_id))
|
||||
c.execute(f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
|
||||
(int(time.time()), room_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message handler – silently records stats, and handles commands
|
||||
# ------------------------------------------------------------------
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
room_id = room.room_id
|
||||
sender = message.sender
|
||||
|
||||
# ----- silently record stats for any non‑bot message -----
|
||||
if sender != bot.async_client.user_id: # <-- FIXED
|
||||
# silently record stats
|
||||
if sender != bot.async_client.user_id:
|
||||
body = message.body or ""
|
||||
words = len(body.split())
|
||||
chars = len(body)
|
||||
smileys = count_smileys(body)
|
||||
is_action = getattr(message, "msgtype", None) == "m.emote"
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
|
||||
c.execute(
|
||||
"""UPDATE user_room_stats
|
||||
SET msgs = msgs + 1,
|
||||
chars = chars + ?,
|
||||
words = words + ?,
|
||||
smileys = smileys + ?,
|
||||
actions = actions + ?,
|
||||
last_updated = ?
|
||||
c.execute("""UPDATE user_room_stats SET msgs=msgs+1, chars=chars+?, words=words+?, smileys=smileys+?, actions=actions+?, last_updated=?
|
||||
WHERE room_id=? AND user_id=?""",
|
||||
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender)
|
||||
)
|
||||
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ----- command matching -----
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if not match.is_not_from_this_bot() or not match.prefix():
|
||||
return
|
||||
@@ -210,33 +131,16 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
cmd = match.command()
|
||||
args = match.args()
|
||||
|
||||
# ===============================
|
||||
# !roomstats
|
||||
# ===============================
|
||||
if cmd == "roomstats":
|
||||
await _handle_roomstats(bot, room_id)
|
||||
|
||||
# ===============================
|
||||
# !rank <expr>
|
||||
# ===============================
|
||||
elif cmd == "rank":
|
||||
if not args:
|
||||
await bot.api.send_text_message(
|
||||
room_id,
|
||||
"Usage: !rank <stat>\n"
|
||||
"Stats: msgs, chars, words, smileys, actions, joins, parts, "
|
||||
"kicks_given, kicked_received, topics_set"
|
||||
)
|
||||
await bot.api.send_text_message(room_id, "Usage: !rank <stat>")
|
||||
return
|
||||
col = args[0].lower()
|
||||
await _handle_rank(bot, room_id, col)
|
||||
|
||||
# ===============================
|
||||
# !stats [<name>]
|
||||
# ===============================
|
||||
elif cmd == "stats":
|
||||
if args:
|
||||
# Use all tokens as the display name (multi‑word)
|
||||
try:
|
||||
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
|
||||
except ValueError as e:
|
||||
@@ -244,44 +148,27 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
return
|
||||
else:
|
||||
target_mxid = sender
|
||||
await _handle_user_stats(bot, room_id, target_mxid, sender)
|
||||
await _handle_user_stats(bot, room_id, target_mxid)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# ------------------------------------------------------------------
|
||||
VALID_STATS = {
|
||||
"msgs": "Messages",
|
||||
"chars": "Characters",
|
||||
"words": "Words",
|
||||
"smileys": "Smileys",
|
||||
"actions": "Actions",
|
||||
"joins": "Joins",
|
||||
"parts": "Parts",
|
||||
"kicks_given": "Kicks given",
|
||||
"kicked_received": "Times kicked",
|
||||
"topics_set": "Topics set",
|
||||
"msgs": "Messages", "chars": "Characters", "words": "Words", "smileys": "Smileys",
|
||||
"actions": "Actions", "joins": "Joins", "parts": "Parts", "kicks_given": "Kicks given",
|
||||
"kicked_received": "Times kicked", "topics_set": "Topics set",
|
||||
}
|
||||
|
||||
async def _get_aggregate(room_id):
|
||||
"""Return dict of aggregate stats for a room."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT
|
||||
COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0),
|
||||
COALESCE(SUM(words),0), COALESCE(SUM(smileys),0),
|
||||
COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
|
||||
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0),
|
||||
COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0)
|
||||
c.execute("""SELECT COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), COALESCE(SUM(words),0),
|
||||
COALESCE(SUM(smileys),0), COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
|
||||
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0), COALESCE(SUM(kicked_received),0),
|
||||
COALESCE(SUM(topics_set),0)
|
||||
FROM user_room_stats WHERE room_id=?""", (room_id,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if not row or all(v == 0 for v in row):
|
||||
return None
|
||||
return {
|
||||
"msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3],
|
||||
"actions": row[4], "joins": row[5], "parts": row[6],
|
||||
"kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9]
|
||||
}
|
||||
return dict(zip(VALID_STATS.keys(), row))
|
||||
|
||||
async def _handle_roomstats(bot, room_id):
|
||||
agg = await _get_aggregate(room_id)
|
||||
@@ -289,17 +176,14 @@ async def _handle_roomstats(bot, room_id):
|
||||
await bot.api.send_text_message(room_id, "No stats collected yet.")
|
||||
return
|
||||
|
||||
# Get top 10 by msgs
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT user_id, msgs FROM user_room_stats
|
||||
WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
|
||||
c.execute("SELECT user_id, msgs FROM user_room_stats WHERE room_id=? ORDER BY msgs DESC LIMIT 10", (room_id,))
|
||||
top = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Resolve display names for top users
|
||||
top_lines = []
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
top_rows = []
|
||||
for uid, cnt in top:
|
||||
disp = uid
|
||||
if resp.members:
|
||||
@@ -307,78 +191,63 @@ async def _handle_roomstats(bot, room_id):
|
||||
if m.user_id == uid:
|
||||
disp = m.display_name or uid
|
||||
break
|
||||
top_lines.append(f"<li><code>{disp}</code> — {cnt} msgs</li>")
|
||||
top_rows.append(("📈", disp, f"{cnt} msgs"))
|
||||
|
||||
msg = f"""<details>
|
||||
<summary><strong>Room Statistics</strong></summary>
|
||||
<ul>
|
||||
<li>📩 Messages: {agg['msgs']}</li>
|
||||
<li>🔤 Characters: {agg['chars']}</li>
|
||||
<li>📝 Words: {agg['words']}</li>
|
||||
<li>😀 Smileys: {agg['smileys']}</li>
|
||||
<li>🎭 Actions: {agg['actions']}</li>
|
||||
<li>🚪 Joins: {agg['joins']}</li>
|
||||
<li>👋 Parts: {agg['parts']}</li>
|
||||
<li>👢 Kicks given: {agg['kicks_given']}</li>
|
||||
<li>🥾 Times kicked: {agg['kicked_received']}</li>
|
||||
<li>📌 Topics set: {agg['topics_set']}</li>
|
||||
</ul>
|
||||
<p><strong>Top 10 by messages:</strong></p>
|
||||
<ol>
|
||||
{''.join(top_lines)}
|
||||
</ol>
|
||||
</details>"""
|
||||
await bot.api.send_markdown_message(room_id, msg)
|
||||
sections = [
|
||||
{"title": "Room Statistics", "rows": [
|
||||
("📩", "Messages", agg["msgs"]),
|
||||
("🔤", "Characters", agg["chars"]),
|
||||
("📝", "Words", agg["words"]),
|
||||
("😀", "Smileys", agg["smileys"]),
|
||||
("🎭", "Actions", agg["actions"]),
|
||||
("🚪", "Joins", agg["joins"]),
|
||||
("👋", "Parts", agg["parts"]),
|
||||
("👢", "Kicks given", agg["kicks_given"]),
|
||||
("🥾", "Times kicked", agg["kicked_received"]),
|
||||
("📌", "Topics set", agg["topics_set"]),
|
||||
]},
|
||||
{"title": "Top 10 by messages", "rows": top_rows},
|
||||
]
|
||||
block = code_block("📊 Room Statistics", sections)
|
||||
output = collapsible_summary("📊 Room Statistics", block)
|
||||
await bot.api.send_markdown_message(room_id, output)
|
||||
|
||||
async def _handle_rank(bot, room_id, col):
|
||||
# Validate column
|
||||
if col not in VALID_STATS:
|
||||
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
# Safe to use f-string because col is validated against a hardcoded set
|
||||
c.execute(f"""SELECT user_id, {col} FROM user_room_stats
|
||||
WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,))
|
||||
c.execute(f"SELECT user_id, {col} FROM user_room_stats WHERE room_id=? AND {col}>0 ORDER BY {col} DESC LIMIT 10", (room_id,))
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
|
||||
return
|
||||
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
items = []
|
||||
for i, (uid, val) in enumerate(rows, 1):
|
||||
rank_rows = []
|
||||
for uid, val in rows:
|
||||
disp = uid
|
||||
if resp.members:
|
||||
for m in resp.members:
|
||||
if m.user_id == uid:
|
||||
disp = m.display_name or uid
|
||||
break
|
||||
items.append(f"<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>
|
||||
<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
|
||||
async def _handle_user_stats(bot, room_id, user_id):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts,
|
||||
kicks_given, kicked_received, topics_set
|
||||
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set
|
||||
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row or all(v == 0 for v in row):
|
||||
# No stats, maybe just joined – get display name for the message
|
||||
disp = user_id
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
if resp.members:
|
||||
@@ -389,46 +258,44 @@ async def _handle_user_stats(bot, room_id, user_id, sender):
|
||||
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
|
||||
return
|
||||
|
||||
# Get display name
|
||||
disp = user_id
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
disp = user_id
|
||||
if resp.members:
|
||||
for m in resp.members:
|
||||
if m.user_id == user_id:
|
||||
disp = m.display_name or user_id
|
||||
break
|
||||
|
||||
msg = f"""<details>
|
||||
<summary><strong>Stats for {disp}</strong></summary>
|
||||
<ul>
|
||||
<li>📩 Messages: {row[0]}</li>
|
||||
<li>🔤 Characters: {row[1]}</li>
|
||||
<li>📝 Words: {row[2]}</li>
|
||||
<li>😀 Smileys: {row[3]}</li>
|
||||
<li>🎭 Actions: {row[4]}</li>
|
||||
<li>🚪 Joins: {row[5]}</li>
|
||||
<li>👋 Parts: {row[6]}</li>
|
||||
<li>👢 Kicks given: {row[7]}</li>
|
||||
<li>🥾 Times kicked: {row[8]}</li>
|
||||
<li>📌 Topics set: {row[9]}</li>
|
||||
</ul>
|
||||
</details>"""
|
||||
await bot.api.send_markdown_message(room_id, msg)
|
||||
rows = [
|
||||
("📩", "Messages", row[0]),
|
||||
("🔤", "Characters", row[1]),
|
||||
("📝", "Words", row[2]),
|
||||
("😀", "Smileys", row[3]),
|
||||
("🎭", "Actions", row[4]),
|
||||
("🚪", "Joins", row[5]),
|
||||
("👋", "Parts", row[6]),
|
||||
("👢", "Kicks given", row[7]),
|
||||
("🥾", "Times kicked", row[8]),
|
||||
("📌", "Topics set", row[9]),
|
||||
]
|
||||
sections = [{"title": f"Stats for {disp}", "rows": rows}]
|
||||
block = code_block(f"📊 Stats for {disp}", sections)
|
||||
output = collapsible_summary(f"📊 Stats: {disp}", block)
|
||||
await bot.api.send_markdown_message(room_id, output)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin metadata
|
||||
# ------------------------------------------------------------------
|
||||
__version__ = "1.0.1"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.1.0"
|
||||
__author__ = "Funguy Roomstats"
|
||||
__description__ = "Per‑user room statistics (Limnoria‑style), with multi‑word name support"
|
||||
__description__ = "Per‑user room statistics"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>Room Statistics Commands</strong></summary>
|
||||
<ul>
|
||||
<li><code>!roomstats</code> – Aggregate room stats + top 10 users</li>
|
||||
<li><code>!rank <stat></code> – Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)</li>
|
||||
<li><code>!stats [name]</code> – Show stats for a user (supports multi‑word names)</li>
|
||||
<li><code>!rank <stat></code> – Top 10 by a specific stat</li>
|
||||
<li><code>!stats [name]</code> – Show stats for a user</li>
|
||||
</ul>
|
||||
<p>All commands work in the current room; display names are automatically resolved.</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
+75
-232
@@ -1,77 +1,43 @@
|
||||
"""
|
||||
This plugin provides Shodan.io integration for security research and reconnaissance.
|
||||
Shodan.io integration for security research and reconnaissance.
|
||||
Output uses shared code_block for aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import aiohttp
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import html_escape, collapsible_summary
|
||||
from plugins.common import html_escape, code_block, collapsible_summary
|
||||
|
||||
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
|
||||
SHODAN_API_BASE = "https://api.shodan.io"
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""
|
||||
Function to handle Shodan commands.
|
||||
"""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"):
|
||||
logging.info("Received !shodan command")
|
||||
|
||||
# Check if API key is configured
|
||||
if not SHODAN_API_KEY:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Shodan API key not configured. Please set SHODAN_KEY environment variable."
|
||||
)
|
||||
logging.error("Shodan API key not configured")
|
||||
await bot.api.send_text_message(room.room_id, "Shodan API key not configured.")
|
||||
return
|
||||
|
||||
args = match.args()
|
||||
|
||||
if len(args) < 1:
|
||||
await show_usage(room, bot)
|
||||
return
|
||||
|
||||
subcommand = args[0].lower()
|
||||
|
||||
if subcommand == "ip":
|
||||
if len(args) < 2:
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !shodan ip <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:])
|
||||
sub = args[0].lower()
|
||||
if sub == "ip" and len(args) >= 2:
|
||||
await shodan_ip_lookup(room, bot, args[1])
|
||||
elif sub == "search" and len(args) >= 2:
|
||||
query = " ".join(args[1:])
|
||||
await shodan_search(room, bot, query)
|
||||
|
||||
elif subcommand == "host":
|
||||
if len(args) < 2:
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !shodan host <domain/ip>")
|
||||
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:])
|
||||
elif sub == "host" and len(args) >= 2:
|
||||
await shodan_host(room, bot, args[1])
|
||||
elif sub == "count" and len(args) >= 2:
|
||||
query = " ".join(args[1:])
|
||||
await shodan_count(room, bot, query)
|
||||
|
||||
else:
|
||||
await show_usage(room, bot)
|
||||
|
||||
async def show_usage(room, bot):
|
||||
"""Display Shodan command usage."""
|
||||
usage = """
|
||||
<strong>🔍 Shodan Commands:</strong>
|
||||
|
||||
usage = """<strong>🔍 Shodan Commands:</strong>
|
||||
<strong>!shodan ip <ip_address></strong> - Get detailed information about an IP
|
||||
<strong>!shodan search <query></strong> - Search Shodan database
|
||||
<strong>!shodan host <domain/ip></strong> - Get host information
|
||||
@@ -86,228 +52,112 @@ async def show_usage(room, bot):
|
||||
await bot.api.send_markdown_message(room.room_id, usage)
|
||||
|
||||
async def shodan_ip_lookup(room, bot, ip):
|
||||
"""Look up information about a specific IP address."""
|
||||
safe_ip = html_escape(ip)
|
||||
try:
|
||||
url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}"
|
||||
logging.info(f"Fetching Shodan IP info for: {ip}")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=15) as response:
|
||||
if response.status == 404:
|
||||
await bot.api.send_text_message(room.room_id, f"No information found for IP: {html_escape(ip)}")
|
||||
return
|
||||
elif response.status == 401:
|
||||
await bot.api.send_text_message(room.room_id, "Invalid Shodan API key")
|
||||
return
|
||||
elif response.status != 200:
|
||||
await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status}")
|
||||
async with session.get(url, timeout=15) as resp:
|
||||
if resp.status == 404:
|
||||
await bot.api.send_text_message(room.room_id, f"No information found for IP: {safe_ip}")
|
||||
return
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
|
||||
data = await response.json()
|
||||
|
||||
# Format the response
|
||||
output = f"<strong>🔍 Shodan IP Lookup: {html_escape(ip)}</strong><br><br>"
|
||||
|
||||
if data.get('country_name'):
|
||||
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
|
||||
rows = [
|
||||
("🌐", "IP", safe_ip),
|
||||
("📍", "Location", f"{data.get('city','N/A')}, {data.get('country_name','N/A')}"),
|
||||
("🏢", "Organization", data.get('org', 'N/A')),
|
||||
("💻", "OS", data.get('os', 'N/A')),
|
||||
("🔌", "Open Ports", ', '.join(map(str, data.get('ports', []))) or 'None'),
|
||||
]
|
||||
if data.get('data'):
|
||||
output += "<strong>📡 Services:</strong><br>"
|
||||
for service in data['data'][:5]: # Limit to first 5 services
|
||||
port = service.get('port', 'N/A')
|
||||
product = service.get('product', 'Unknown')
|
||||
version = service.get('version', '')
|
||||
banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '')
|
||||
|
||||
output += f" • <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)
|
||||
|
||||
for svc in data['data'][:5]:
|
||||
rows.append(("📡", f"Port {svc.get('port')}", svc.get('product','Unknown')))
|
||||
sections = [{"title": f"Shodan IP Lookup: {safe_ip}", "rows": rows}]
|
||||
block = code_block(f"🔍 Shodan IP Lookup: {safe_ip}", sections)
|
||||
output = collapsible_summary(f"🔍 Shodan: {safe_ip}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Sent Shodan IP info for {ip}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {e}")
|
||||
logging.error(f"Shodan API error: {e}")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
|
||||
logging.error(f"Error in shodan_ip_lookup: {e}")
|
||||
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||
|
||||
async def shodan_search(room, bot, query):
|
||||
"""Search the Shodan database."""
|
||||
safe_query = html_escape(query)
|
||||
try:
|
||||
url = f"{SHODAN_API_BASE}/shodan/host/search"
|
||||
params = {
|
||||
"key": SHODAN_API_KEY,
|
||||
"query": query,
|
||||
"minify": "true",
|
||||
"limit": 5
|
||||
}
|
||||
logging.info(f"Searching Shodan for: {query}")
|
||||
url = f"{SHODAN_API_BASE}/shodan/host/search?key={SHODAN_API_KEY}&query={query}&minify=true&limit=5"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
await handle_shodan_error(room, bot, response.status)
|
||||
return
|
||||
data = await response.json()
|
||||
|
||||
async with session.get(url, timeout=15) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
if not data.get('matches'):
|
||||
await bot.api.send_text_message(room.room_id, f"No results found for: {html_escape(query)}")
|
||||
await bot.api.send_text_message(room.room_id, f"No results for '{safe_query}'.")
|
||||
return
|
||||
|
||||
output = f"<strong>🔍 Shodan Search: '{html_escape(query)}'</strong><br>"
|
||||
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br><br>"
|
||||
|
||||
for match in data['matches'][:5]: # Show first 5 results
|
||||
rows = []
|
||||
for match in data['matches'][:5]:
|
||||
ip = match.get('ip_str', 'N/A')
|
||||
port = match.get('port', 'N/A')
|
||||
port = match.get('port', '')
|
||||
org = match.get('org', 'Unknown')
|
||||
product = match.get('product', 'Unknown')
|
||||
|
||||
output += f"<strong>🌐 {html_escape(ip)}:{port}</strong><br>"
|
||||
output += f" • <strong>Organization:</strong> {html_escape(org)}<br>"
|
||||
output += f" • <strong>Service:</strong> {html_escape(product)}<br>"
|
||||
|
||||
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>"
|
||||
|
||||
rows.append(("🌐", f"{ip}:{port}", f"{product} – {org}"))
|
||||
sections = [{"title": f"Search: {safe_query}", "rows": rows}]
|
||||
block = code_block(f"🔍 Shodan Search: {safe_query}", sections)
|
||||
output = collapsible_summary(f"Shodan Search: {safe_query}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Sent Shodan search results for: {query}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {e}")
|
||||
logging.error(f"Shodan API error: {e}")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
|
||||
logging.error(f"Error in shodan_search: {e}")
|
||||
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||
|
||||
async def shodan_host(room, bot, host):
|
||||
"""Get host information (domain or IP)."""
|
||||
safe_host = html_escape(host)
|
||||
try:
|
||||
url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}"
|
||||
logging.info(f"Fetching Shodan host info for: {host}")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=15) as response:
|
||||
if response.status == 404:
|
||||
# Try IP lookup instead
|
||||
async with session.get(url, timeout=15) as resp:
|
||||
if resp.status == 404:
|
||||
await shodan_ip_lookup(room, bot, host)
|
||||
return
|
||||
elif response.status != 200:
|
||||
await handle_shodan_error(room, bot, response.status)
|
||||
return
|
||||
data = await response.json()
|
||||
|
||||
output = f"<strong>🔍 Shodan Host: {html_escape(host)}</strong><br><br>"
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
rows = [("🌐", "Domain", safe_host)]
|
||||
if data.get('subdomains'):
|
||||
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>"
|
||||
for subdomain in sorted(data['subdomains'])[:10]: # Show first 10
|
||||
output += f" • {html_escape(subdomain)}.{html_escape(host)}<br>"
|
||||
|
||||
for sub in sorted(data['subdomains'])[:10]:
|
||||
rows.append(("", "Subdomain", f"{sub}.{safe_host}"))
|
||||
if len(data['subdomains']) > 10:
|
||||
output += f" • ... and {len(data['subdomains']) - 10} more<br>"
|
||||
|
||||
if data.get('tags'):
|
||||
output += f"<br><strong>🏷️ Tags:</strong> {', '.join(html_escape(t) for t in data['tags'])}<br>"
|
||||
|
||||
if data.get('data'):
|
||||
output += f"<br><strong>📊 Records Found:</strong> {len(data['data'])}<br>"
|
||||
|
||||
rows.append(("", "", f"... and {len(data['subdomains']) - 10} more"))
|
||||
sections = [{"title": f"Host: {safe_host}", "rows": rows}]
|
||||
block = code_block(f"🔍 Shodan Host: {safe_host}", sections)
|
||||
output = collapsible_summary(f"Shodan Host: {safe_host}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Sent Shodan host info for: {host}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error fetching host info: {e}")
|
||||
logging.error(f"Shodan API error: {e}")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
|
||||
logging.error(f"Error in shodan_host: {e}")
|
||||
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||
|
||||
async def shodan_count(room, bot, query):
|
||||
"""Count results for a search query."""
|
||||
safe_query = html_escape(query)
|
||||
try:
|
||||
url = f"{SHODAN_API_BASE}/shodan/host/count"
|
||||
params = {
|
||||
"key": SHODAN_API_KEY,
|
||||
"query": query
|
||||
}
|
||||
logging.info(f"Counting Shodan results for: {query}")
|
||||
url = f"{SHODAN_API_BASE}/shodan/host/count?key={SHODAN_API_KEY}&query={query}"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
await handle_shodan_error(room, bot, response.status)
|
||||
return
|
||||
data = await response.json()
|
||||
|
||||
output = f"<strong>🔍 Shodan Count: '{html_escape(query)}'</strong><br><br>"
|
||||
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br>"
|
||||
|
||||
# Show top countries if available
|
||||
if data.get('facets') and 'country' in data['facets']:
|
||||
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>"
|
||||
|
||||
async with session.get(url, timeout=15) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
rows = [("🔢", "Total Results", f"{data.get('total', 0):,}")]
|
||||
if data.get('facets'):
|
||||
for facet_name, facet_data in data['facets'].items():
|
||||
for item in facet_data[:5]:
|
||||
rows.append(("", facet_name.capitalize(), f"{item['value']}: {item['count']:,}"))
|
||||
sections = [{"title": f"Count: {safe_query}", "rows": rows}]
|
||||
block = code_block(f"🔍 Shodan Count: {safe_query}", sections)
|
||||
output = collapsible_summary(f"Shodan Count: {safe_query}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Sent Shodan count for: {query}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {e}")
|
||||
logging.error(f"Shodan API error: {e}")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
|
||||
logging.error(f"Error in shodan_count: {e}")
|
||||
|
||||
async def handle_shodan_error(room, bot, status_code):
|
||||
"""Handle Shodan API errors."""
|
||||
error_messages = {
|
||||
401: "Invalid Shodan API key",
|
||||
403: "Access denied - check API key permissions",
|
||||
404: "No results found",
|
||||
429: "Rate limit exceeded - try again later",
|
||||
500: "Shodan API server error",
|
||||
503: "Shodan API temporarily unavailable"
|
||||
}
|
||||
message = error_messages.get(status_code, f"Shodan API error: {status_code}")
|
||||
await bot.api.send_text_message(room.room_id, message)
|
||||
logging.error(f"Shodan API error: {status_code}")
|
||||
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Shodan.io reconnaissance"
|
||||
__help__ = """
|
||||
@@ -319,13 +169,6 @@ __help__ = """
|
||||
<li><code>!shodan host <domain></code> – Host & subdomain enumeration</li>
|
||||
<li><code>!shodan count <query></code> – Result counts</li>
|
||||
</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>
|
||||
</details>
|
||||
"""
|
||||
|
||||
+65
-174
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Comprehensive SSL/TLS security scanning and analysis.
|
||||
All blocking socket calls run in a thread pool; user input is sanitised.
|
||||
Output is a clean code block with aligned columns.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -10,7 +11,7 @@ import ssl
|
||||
import OpenSSL
|
||||
import datetime
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import is_public_destination, html_escape, collapsible_summary
|
||||
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||
|
||||
# SSL/TLS configuration – handle missing protocols in modern Python
|
||||
TLS_VERSIONS = {
|
||||
@@ -37,9 +38,6 @@ CIPHER_CATEGORIES = {
|
||||
}
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""
|
||||
Handle !sslscan command for comprehensive SSL/TLS analysis.
|
||||
"""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
|
||||
args = match.args()
|
||||
@@ -49,7 +47,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
target = args[0].strip()
|
||||
port = 443
|
||||
|
||||
if ':' in target:
|
||||
parts = target.split(':')
|
||||
target = parts[0]
|
||||
@@ -65,12 +62,8 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
await perform_ssl_scan(room, bot, target, port)
|
||||
|
||||
|
||||
async def show_usage(room, bot):
|
||||
"""Display sslscan command usage."""
|
||||
usage = """
|
||||
<strong>🔐 SSL/TLS Security Scanner</strong>
|
||||
|
||||
usage = """<strong>🔐 SSL/TLS Security Scanner</strong>
|
||||
<strong>!sslscan <domain[:port]></strong> - Comprehensive SSL/TLS security analysis
|
||||
|
||||
<strong>Examples:</strong>
|
||||
@@ -88,28 +81,21 @@ async def show_usage(room, bot):
|
||||
"""
|
||||
await bot.api.send_markdown_message(room.room_id, usage)
|
||||
|
||||
|
||||
# ----- async wrappers for blocking socket calls -----
|
||||
async def _run_blocking(func, *args, **kwargs):
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||
|
||||
|
||||
def _test_connectivity(target, port):
|
||||
"""Test basic connectivity."""
|
||||
try:
|
||||
with socket.create_connection((target, port), timeout=10):
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def _get_certificate_info(target, port):
|
||||
"""Retrieve detailed certificate info."""
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
with socket.create_connection((target, port), timeout=10) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
||||
cert_bin = ssock.getpeercert(binary_form=True)
|
||||
@@ -117,15 +103,12 @@ def _get_certificate_info(target, port):
|
||||
|
||||
subject = cert.get_subject()
|
||||
issuer = cert.get_issuer()
|
||||
|
||||
not_before = cert.get_notBefore().decode('utf-8')
|
||||
not_after = cert.get_notAfter().decode('utf-8')
|
||||
sig_alg = cert.get_signature_algorithm().decode('utf-8')
|
||||
|
||||
not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
|
||||
days_remaining = (not_after_dt - datetime.datetime.utcnow()).days
|
||||
|
||||
# Extensions summary
|
||||
extensions = []
|
||||
for i in range(cert.get_extension_count()):
|
||||
ext = cert.get_extension(i)
|
||||
@@ -158,9 +141,7 @@ def _get_certificate_info(target, port):
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _test_protocols(target, port):
|
||||
"""Test support for various SSL/TLS protocols."""
|
||||
protocols = {}
|
||||
for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
|
||||
if proto_name not in TLS_VERSIONS:
|
||||
@@ -177,9 +158,7 @@ def _test_protocols(target, port):
|
||||
protocols[proto_name] = False
|
||||
return protocols
|
||||
|
||||
|
||||
def _test_cipher_suites(target, port):
|
||||
"""Return list of supported cipher suite names."""
|
||||
test_ciphers = [
|
||||
'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384',
|
||||
'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384',
|
||||
@@ -207,130 +186,63 @@ def _test_cipher_suites(target, port):
|
||||
pass
|
||||
return supported
|
||||
|
||||
|
||||
# ----- analysis helpers (same logic as original) -----
|
||||
def _check_vulnerabilities(protocols, cert_info, supported_ciphers):
|
||||
vulns = []
|
||||
|
||||
if protocols.get('SSLv2'):
|
||||
vulns.append({
|
||||
'name': 'SSLv2 Support',
|
||||
'severity': 'CRITICAL',
|
||||
'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
|
||||
'cve': 'Multiple CVEs'
|
||||
})
|
||||
|
||||
vulns.append(('SSLv2 Support', 'CRITICAL'))
|
||||
if protocols.get('SSLv3'):
|
||||
vulns.append({
|
||||
'name': 'SSLv3 Support',
|
||||
'severity': 'HIGH',
|
||||
'description': 'SSLv3 is vulnerable to POODLE attack',
|
||||
'cve': 'CVE-2014-3566'
|
||||
})
|
||||
|
||||
vulns.append(('SSLv3 Support', 'HIGH'))
|
||||
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
|
||||
vulns.append({
|
||||
'name': 'Certificate Expiring Soon',
|
||||
'severity': 'MEDIUM',
|
||||
'description': f"Certificate expires in {cert_info['days_until_expiry']} days",
|
||||
'cve': 'N/A'
|
||||
})
|
||||
|
||||
weak_ciphers = [c for c in supported_ciphers
|
||||
if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])]
|
||||
vulns.append(('Certificate Expiring Soon', 'MEDIUM'))
|
||||
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||
if weak_ciphers:
|
||||
vulns.append({
|
||||
'name': 'Weak Cipher Suites',
|
||||
'severity': 'HIGH',
|
||||
'description': f'Weak ciphers supported: {", ".join(weak_ciphers[:3])}',
|
||||
'cve': 'Multiple CVEs'
|
||||
})
|
||||
|
||||
vulns.append(('Weak Cipher Suites', 'HIGH'))
|
||||
if not protocols.get('TLSv1.2', False):
|
||||
vulns.append({
|
||||
'name': 'TLS 1.2 Not Supported',
|
||||
'severity': 'HIGH',
|
||||
'description': 'TLS 1.2 is required for modern security',
|
||||
'cve': 'N/A'
|
||||
})
|
||||
|
||||
vulns.append(('TLS 1.2 Not Supported', 'HIGH'))
|
||||
if not protocols.get('TLSv1.3', False):
|
||||
vulns.append({
|
||||
'name': 'TLS 1.3 Not Supported',
|
||||
'severity': 'MEDIUM',
|
||||
'description': 'TLS 1.3 provides improved security and performance',
|
||||
'cve': 'N/A'
|
||||
})
|
||||
|
||||
vulns.append(('TLS 1.3 Not Supported', 'MEDIUM'))
|
||||
return vulns
|
||||
|
||||
|
||||
def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities):
|
||||
score = 100
|
||||
|
||||
if protocols.get('SSLv2'): score -= 30
|
||||
if protocols.get('SSLv3'): score -= 20
|
||||
if not protocols.get('TLSv1.2'): score -= 15
|
||||
if not protocols.get('TLSv1.3'): score -= 10
|
||||
|
||||
if cert_info and cert_info.get('days_until_expiry', 0) < 30: score -= 10
|
||||
if cert_info and cert_info.get('days_until_expiry', 0) < 7: score -= 20
|
||||
|
||||
weak_cipher_count = sum(1 for c in supported_ciphers
|
||||
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
|
||||
weak_cipher_count = sum(1 for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
|
||||
score -= min(weak_cipher_count * 5, 25)
|
||||
|
||||
for vuln in vulnerabilities:
|
||||
if vuln['severity'] == 'CRITICAL': score -= 20
|
||||
elif vuln['severity'] == 'HIGH': score -= 15
|
||||
elif vuln['severity'] == 'MEDIUM': score -= 10
|
||||
elif vuln['severity'] == 'LOW': score -= 5
|
||||
|
||||
for name, severity in vulnerabilities:
|
||||
if severity == 'CRITICAL': score -= 20
|
||||
elif severity == 'HIGH': score -= 15
|
||||
elif severity == 'MEDIUM': score -= 10
|
||||
return max(0, score)
|
||||
|
||||
|
||||
def _generate_recommendations(protocols, cert_info, supported_ciphers, score):
|
||||
recs = []
|
||||
if protocols.get('SSLv2'): recs.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable")
|
||||
if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3 - vulnerable to POODLE attack")
|
||||
if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3 for best security and performance")
|
||||
|
||||
if protocols.get('SSLv2'): recs.append("🔴 Disable SSLv2")
|
||||
if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3")
|
||||
if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3")
|
||||
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
|
||||
recs.append("🟡 Renew SSL certificate - expiring soon")
|
||||
|
||||
weak_ciphers = [c for c in supported_ciphers
|
||||
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||
recs.append("🟡 Renew certificate")
|
||||
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||
if weak_ciphers:
|
||||
recs.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)")
|
||||
|
||||
recs.append("🔴 Remove weak ciphers")
|
||||
if score < 80:
|
||||
recs.append("🛡️ Implement modern TLS configuration following Mozilla guidelines")
|
||||
|
||||
recs.append("🛡️ Improve TLS configuration")
|
||||
if not any('ECDHE' in c for c in supported_ciphers):
|
||||
recs.append("🟡 Enable Forward Secrecy with ECDHE cipher suites")
|
||||
|
||||
recs.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features")
|
||||
recs.append("🟡 Enable Forward Secrecy")
|
||||
return recs
|
||||
|
||||
|
||||
def _format_cert_date(date_str):
|
||||
try:
|
||||
dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ')
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||
except:
|
||||
return date_str
|
||||
|
||||
|
||||
# ----- main scan orchestration -----
|
||||
async def perform_ssl_scan(room, bot, target, port):
|
||||
safe_target = html_escape(target)
|
||||
await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {safe_target}:{port}...")
|
||||
await bot.api.send_text_message(room.room_id, f"🔍 Starting SSL/TLS scan for {safe_target}:{port}...")
|
||||
|
||||
if not await _run_blocking(_test_connectivity, target, port):
|
||||
await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {safe_target}:{port}")
|
||||
return
|
||||
|
||||
# Run blocking checks in parallel
|
||||
cert_task = _run_blocking(_get_certificate_info, target, port)
|
||||
proto_task = _run_blocking(_test_protocols, target, port)
|
||||
cipher_task = _run_blocking(_test_cipher_suites, target, port)
|
||||
@@ -341,36 +253,25 @@ async def perform_ssl_scan(room, bot, target, port):
|
||||
score = _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities)
|
||||
recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score)
|
||||
|
||||
# Build output (using safe domain/port)
|
||||
output = await _format_results(target, port, cert_info, protocols, supported_ciphers,
|
||||
vulnerabilities, score, recommendations)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Completed SSL scan for {target}:{port}")
|
||||
sections = []
|
||||
|
||||
|
||||
async def _format_results(target, port, cert_info, protocols, supported_ciphers,
|
||||
vulnerabilities, score, recommendations):
|
||||
safe_target = html_escape(target)
|
||||
# Score
|
||||
score_emoji = "🟢" if score >= 90 else "🟡" if score >= 80 else "🟠" if score >= 60 else "🔴"
|
||||
rating = "Excellent" if score >= 90 else "Good" if score >= 80 else "Fair" if score >= 60 else "Poor"
|
||||
sections.append({"title": f"{score_emoji} Security Score", "rows": [("", "Score", f"{score}/100 ({rating})")]})
|
||||
|
||||
body = f"<strong>🔐 SSL/TLS Security Scan: {safe_target}:{port}</strong><br><br>"
|
||||
body += f"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
|
||||
|
||||
# Certificate Information
|
||||
# Certificate
|
||||
if cert_info:
|
||||
body += "<strong>📜 Certificate Information</strong><br>"
|
||||
body += f" • <strong>Subject:</strong> {html_escape(cert_info['subject'].get('common_name', 'N/A'))}<br>"
|
||||
body += f" • <strong>Issuer:</strong> {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}<br>"
|
||||
body += f" • <strong>Valid From:</strong> {_format_cert_date(cert_info['not_before'])}<br>"
|
||||
body += f" • <strong>Valid Until:</strong> {_format_cert_date(cert_info['not_after'])}<br>"
|
||||
days = cert_info.get('days_until_expiry', 'N/A')
|
||||
body += f" • <strong>Expires In:</strong> {days} days<br>"
|
||||
body += f" • <strong>Signature Algorithm:</strong> {html_escape(cert_info['signature_algorithm'])}<br>"
|
||||
body += "<br>"
|
||||
cert_rows = [
|
||||
("📜", "Subject", cert_info['subject'].get('common_name', 'N/A')),
|
||||
("🏢", "Issuer", cert_info['issuer'].get('common_name', 'N/A')),
|
||||
("📅", "Valid Until", cert_info['not_after']),
|
||||
("⏳", "Expires In", f"{cert_info['days_until_expiry']} days"),
|
||||
]
|
||||
sections.append({"title": "📜 Certificate", "rows": cert_rows})
|
||||
|
||||
# Protocol Support
|
||||
body += "<strong>🔌 Protocol Support</strong><br>"
|
||||
# Protocols
|
||||
proto_rows = []
|
||||
for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
|
||||
supported = protocols.get(proto, False)
|
||||
if proto in ['SSLv2', 'SSLv3'] and supported:
|
||||
@@ -381,67 +282,57 @@ async def _format_results(target, port, cert_info, protocols, supported_ciphers,
|
||||
emoji = "✅" if supported else "❌"
|
||||
status = "Supported" if supported else "Not Supported"
|
||||
if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS:
|
||||
status = "Cannot test (Python security)"
|
||||
status = "Cannot test"
|
||||
emoji = "⚫"
|
||||
body += f" • {emoji} <strong>{proto}:</strong> {status}<br>"
|
||||
body += "<br>"
|
||||
proto_rows.append((emoji, proto, status))
|
||||
sections.append({"title": "🔌 Protocols", "rows": proto_rows})
|
||||
|
||||
# Cipher Suites
|
||||
body += "<strong>🔐 Cipher Suites</strong><br>"
|
||||
body += f" • <strong>Total Supported:</strong> {len(supported_ciphers)}<br>"
|
||||
|
||||
weak_ciphers = [c for c in supported_ciphers
|
||||
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||
strong_ciphers = [c for c in supported_ciphers if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
|
||||
cipher_rows = [("🔢", "Total Supported", str(len(supported_ciphers)))]
|
||||
if weak_ciphers:
|
||||
body += f" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>"
|
||||
for cipher in weak_ciphers[:3]:
|
||||
body += f" └─ 🔴 {html_escape(cipher)}<br>"
|
||||
strong_ciphers = [c for c in supported_ciphers
|
||||
if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
|
||||
cipher_rows.append(("🔴", "Weak Ciphers", str(len(weak_ciphers))))
|
||||
for c in weak_ciphers[:3]:
|
||||
cipher_rows.append(("", "", c))
|
||||
if strong_ciphers:
|
||||
body += f" • <strong>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>"
|
||||
body += "<br>"
|
||||
cipher_rows.append(("🟢", "Strong Ciphers", str(len(strong_ciphers))))
|
||||
sections.append({"title": "🔐 Cipher Suites", "rows": cipher_rows})
|
||||
|
||||
# Vulnerabilities
|
||||
if vulnerabilities:
|
||||
body += "<strong>⚠️ Security Vulnerabilities</strong><br>"
|
||||
for vuln in vulnerabilities[:5]:
|
||||
sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
|
||||
body += f" • {sev_emoji} <strong>{html_escape(vuln['name'])}</strong> ({vuln['severity']})<br>"
|
||||
body += f" └─ {html_escape(vuln['description'])}<br>"
|
||||
body += "<br>"
|
||||
vuln_rows = []
|
||||
for name, sev in vulnerabilities:
|
||||
sev_emoji = "🔴" if sev == 'CRITICAL' else "🟠" if sev == 'HIGH' else "🟡"
|
||||
vuln_rows.append((sev_emoji, name, sev))
|
||||
sections.append({"title": "⚠️ Vulnerabilities", "rows": vuln_rows})
|
||||
|
||||
# Recommendations
|
||||
if recommendations:
|
||||
body += "<strong>💡 Security Recommendations</strong><br>"
|
||||
for rec in recommendations[:8]:
|
||||
body += f" • {rec}<br>"
|
||||
body += "<br>"
|
||||
rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
|
||||
sections.append({"title": "💡 Recommendations", "rows": rec_rows})
|
||||
|
||||
# Quick Assessment
|
||||
body += "<strong>📊 Quick Assessment</strong><br>"
|
||||
assessment_rows = []
|
||||
if score >= 90:
|
||||
body += " • ✅ Excellent TLS configuration<br>"
|
||||
body += " • ✅ Modern protocols and ciphers<br>"
|
||||
body += " • ✅ Good certificate management<br>"
|
||||
assessment_rows = [("", "Assessment", "✅ Excellent configuration")]
|
||||
elif score >= 70:
|
||||
body += " • ⚠️ Good configuration with minor issues<br>"
|
||||
body += " • 🔧 Some improvements recommended<br>"
|
||||
assessment_rows = [("", "Assessment", "⚠️ Good, minor improvements possible")]
|
||||
else:
|
||||
body += " • 🚨 Significant security issues found<br>"
|
||||
body += " • 🔴 Immediate action required<br>"
|
||||
|
||||
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)
|
||||
assessment_rows = [("", "Assessment", "🚨 Significant issues found")]
|
||||
sections.append({"title": "📊 Quick Assessment", "rows": assessment_rows})
|
||||
|
||||
block = code_block(f"🔐 SSL/TLS Scan: {safe_target}:{port}", sections)
|
||||
output = collapsible_summary(f"🔐 SSL/TLS: {safe_target} (Score: {score}/100)", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Completed SSL scan for {target}:{port}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.0.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "SSL/TLS security scanner (SSRF‑safe, async)"
|
||||
__description__ = "SSL/TLS security scanner"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!sslscan</strong> – SSL/TLS analysis</summary>
|
||||
|
||||
@@ -137,7 +137,7 @@ def print_help():
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.1.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Stable Diffusion image generation (async, LORA support)"
|
||||
__description__ = "Stable Diffusion image generation (LORA support)"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!sd</strong> – Generate images via Stable Diffusion</summary>
|
||||
|
||||
+131
-140
@@ -2,28 +2,26 @@
|
||||
"""
|
||||
plugins/subnet.py – Subnet calculator and network splitting plugin for Funguy Bot.
|
||||
|
||||
Provides the following commands:
|
||||
!subnet info <CIDR> – Show detailed info about a network
|
||||
!subnet split <CIDR> --prefix <N> – Split network into smaller subnets (new prefix length)
|
||||
!subnet split <CIDR> --diff <N> – Split network into equal subnets (prefixlen delta)
|
||||
!subnet adjacent <CIDR> <count> – Show given network and next <count> adjacent ones
|
||||
!subnet help – Display this help
|
||||
Commands:
|
||||
!subnet info <CIDR>
|
||||
!subnet split <CIDR> --prefix <N>
|
||||
!subnet split <CIDR> --diff <N>
|
||||
!subnet adjacent <CIDR> <count>
|
||||
!subnet help
|
||||
|
||||
Examples:
|
||||
!subnet info 192.168.4.0/26
|
||||
!subnet split 192.168.4.0/24 --prefix 26
|
||||
!subnet split 10.0.0.0/16 --diff 2
|
||||
!subnet adjacent 192.168.4.0/26 3
|
||||
Output is a clean code block with emojis and perfectly aligned columns.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import sys
|
||||
from typing import Union
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import collapsible_summary, html_escape, code_block
|
||||
|
||||
# ------------------------------- helper functions --------------------------------
|
||||
# -------------------------------------------------------------------
|
||||
# Helper functions (synchronous)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> str:
|
||||
"""Return a human‑readable string with all relevant subnet details."""
|
||||
def _fmt_subnet_info_rows(net):
|
||||
"""Return list of (emoji, label, value) tuples."""
|
||||
nw = net.network_address
|
||||
bc = net.broadcast_address if hasattr(net, "broadcast_address") else None
|
||||
total = net.num_addresses
|
||||
@@ -50,102 +48,124 @@ def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -
|
||||
first = last = None
|
||||
usable_count = 0
|
||||
|
||||
lines = [
|
||||
f"CIDR: {net.with_prefixlen}",
|
||||
f"Network: {nw}",
|
||||
f"Broadcast: {bc if bc is not None else 'N/A'}",
|
||||
f"Netmask: {net.netmask if hasattr(net, 'netmask') else 'N/A'}",
|
||||
f"Wildcard Mask: {net.hostmask if hasattr(net, 'hostmask') else 'N/A'}",
|
||||
f"Total IPs: {total}",
|
||||
f"Usable Hosts: {usable_count}",
|
||||
rows = [
|
||||
("🌐", "CIDR", str(net.with_prefixlen)),
|
||||
("📡", "Network", str(nw)),
|
||||
("📢", "Broadcast", str(bc) if bc is not None else "N/A"),
|
||||
("🧱", "Netmask", str(net.netmask) if hasattr(net, "netmask") else "N/A"),
|
||||
("🕳️", "Wildcard Mask", str(net.hostmask) if hasattr(net, "hostmask") else "N/A"),
|
||||
("🔢", "Total IPs", str(total)),
|
||||
("👥", "Usable Hosts", str(usable_count)),
|
||||
]
|
||||
if first is not None and last is not None:
|
||||
lines.append(f"First Usable: {first}")
|
||||
lines.append(f"Last Usable: {last}")
|
||||
lines.append(f"Usable Range: {first} - {last}")
|
||||
return "\n".join(lines)
|
||||
rows.append(("🏁", "First Usable", str(first)))
|
||||
rows.append(("🏁", "Last Usable", str(last)))
|
||||
rows.append(("↔️", "Usable Range", f"{first} - {last}"))
|
||||
return rows
|
||||
|
||||
|
||||
def _split_by_prefix(net, new_prefix: int) -> str:
|
||||
def _split_by_prefix(net, new_prefix):
|
||||
if new_prefix < net.prefixlen:
|
||||
return f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split."
|
||||
out = [f"# Splitting {net.with_prefixlen} into /{new_prefix} subnets:"]
|
||||
for i, sub in enumerate(net.subnets(new_prefix=new_prefix)):
|
||||
out.append(f"\n-- Subnet #{i+1} --")
|
||||
out.append(_fmt_subnet_info(sub))
|
||||
return "\n".join(out)
|
||||
return None
|
||||
return list(net.subnets(new_prefix=new_prefix))
|
||||
|
||||
|
||||
def _split_by_diff(net, diff: int) -> str:
|
||||
new_prefix = net.prefixlen + diff
|
||||
return _split_by_prefix(net, new_prefix)
|
||||
def _split_by_diff(net, diff):
|
||||
return _split_by_prefix(net, net.prefixlen + diff)
|
||||
|
||||
|
||||
def _adjacent_networks(net, count: int) -> str:
|
||||
out = [f"# Adjacent networks of size /{net.prefixlen} (starting at {net.with_prefixlen}):"]
|
||||
def _adjacent_networks(net, count):
|
||||
nets = [net]
|
||||
current = net
|
||||
for i in range(count + 1):
|
||||
out.append(f"\n-- Adjacent #{i} --")
|
||||
out.append(_fmt_subnet_info(current))
|
||||
for _ in range(count):
|
||||
try:
|
||||
next_net_addr = current.network_address + current.num_addresses
|
||||
current = ipaddress.ip_network(f"{next_net_addr}/{current.prefixlen}", strict=True)
|
||||
except ValueError:
|
||||
out.append("[!] Reached address space limit.")
|
||||
next_addr = current.network_address + current.num_addresses
|
||||
current = ipaddress.ip_network(f"{next_addr}/{current.prefixlen}", strict=True)
|
||||
nets.append(current)
|
||||
except (ValueError, ipaddress.AddressValueError):
|
||||
break
|
||||
return "\n".join(out)
|
||||
return nets
|
||||
|
||||
|
||||
# ------------------------------- bot plugin entry -------------------------------
|
||||
# -------------------------------------------------------------------
|
||||
# Output builders (each returns a collapsible Markdown message)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _info_output(net):
|
||||
"""Build a collapsible message for a single subnet."""
|
||||
title = f"🔍 Subnet {net.with_prefixlen}"
|
||||
rows = _fmt_subnet_info_rows(net)
|
||||
block = code_block(title, [{"title": "", "rows": rows}])
|
||||
return collapsible_summary(title, block)
|
||||
|
||||
|
||||
def _split_output(networks):
|
||||
"""Build a collapsible message for a split operation."""
|
||||
total = len(networks)
|
||||
title = f"🔀 Split into {total} subnets"
|
||||
sections = []
|
||||
for i, sub in enumerate(networks, 1):
|
||||
rows = _fmt_subnet_info_rows(sub)
|
||||
sections.append({"title": f"Subnet {sub.with_prefixlen}", "rows": rows})
|
||||
block = code_block(title, sections)
|
||||
return collapsible_summary(title, block)
|
||||
|
||||
|
||||
def _adjacent_output(networks):
|
||||
"""Build a collapsible message for adjacent networks."""
|
||||
base = networks[0]
|
||||
title = f"📐 Adjacent networks (base {base.with_prefixlen})"
|
||||
sections = []
|
||||
for i, net in enumerate(networks):
|
||||
label = "Base network" if i == 0 else f"Adjacent #{i}"
|
||||
rows = _fmt_subnet_info_rows(net)
|
||||
sections.append({"title": label, "rows": rows})
|
||||
block = code_block(title, sections)
|
||||
return collapsible_summary(title, block)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Help
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
_HELP_MD = """
|
||||
<details>
|
||||
<summary><strong>!subnet</strong> – Subnet calculator and exploration</summary>
|
||||
<pre>
|
||||
!subnet info <CIDR> Show detailed info for a network
|
||||
!subnet split <CIDR> --prefix <N> Split into smaller subnets (new prefix)
|
||||
!subnet split <CIDR> --diff <N> Split by prefix delta
|
||||
!subnet adjacent <CIDR> <count> Show current and adjacent networks
|
||||
</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):
|
||||
import simplematrixbotlib as botlib
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
|
||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("subnet")):
|
||||
return
|
||||
|
||||
args = match.args()
|
||||
if not args:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Usage: !subnet <info|split|adjacent> ...\n"
|
||||
" !subnet help – show full help"
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !subnet <info|split|adjacent> ...\n !subnet help")
|
||||
return
|
||||
|
||||
subcmd = args[0].lower()
|
||||
|
||||
# --- help ---
|
||||
if subcmd in ("help", "-h", "--help"):
|
||||
# Send nicely formatted HTML in a details tag via markdown
|
||||
html = "<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 <CIDR></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 <CIDR> --prefix <new_prefix></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 <CIDR> --diff <delta></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 <CIDR> <count></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)
|
||||
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
|
||||
return
|
||||
|
||||
# --- info (or a CIDR passed directly) ---
|
||||
if subcmd == "info" or "/" in subcmd:
|
||||
cidr = args[1] if subcmd == "info" else subcmd
|
||||
try:
|
||||
@@ -153,16 +173,13 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
except ValueError as e:
|
||||
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||
return
|
||||
await bot.api.send_text_message(room.room_id, _fmt_subnet_info(net))
|
||||
output = _info_output(net)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
return
|
||||
|
||||
# --- split ---
|
||||
if subcmd == "split":
|
||||
if len(args) < 2:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Usage: !subnet split <CIDR> --prefix <new_prefix> OR --diff <delta>"
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --prefix <N> OR !subnet split <CIDR> --diff <delta>")
|
||||
return
|
||||
cidr = args[1]
|
||||
try:
|
||||
@@ -176,39 +193,31 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
idx = args.index("--prefix")
|
||||
new_prefix = int(args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Usage: !subnet split <CIDR> --prefix <number>"
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --prefix <number>")
|
||||
return
|
||||
result = _split_by_prefix(net, new_prefix)
|
||||
subnets = _split_by_prefix(net, new_prefix)
|
||||
elif "--diff" in args:
|
||||
try:
|
||||
idx = args.index("--diff")
|
||||
diff = int(args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Usage: !subnet split <CIDR> --diff <delta>"
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --diff <delta>")
|
||||
return
|
||||
result = _split_by_diff(net, diff)
|
||||
subnets = _split_by_diff(net, diff)
|
||||
else:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"You must provide either --prefix <N> or --diff <N> for split."
|
||||
)
|
||||
return
|
||||
await bot.api.send_text_message(room.room_id, result)
|
||||
await bot.api.send_text_message(room.room_id, "You must provide --prefix <N> or --diff <N> for split.")
|
||||
return
|
||||
|
||||
if subnets is None:
|
||||
await bot.api.send_text_message(room.room_id, f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split.")
|
||||
return
|
||||
output = _split_output(subnets)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
return
|
||||
|
||||
# --- adjacent ---
|
||||
if subcmd == "adjacent":
|
||||
if len(args) < 3:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Usage: !subnet adjacent <CIDR> <count>"
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !subnet adjacent <CIDR> <count>")
|
||||
return
|
||||
cidr = args[1]
|
||||
try:
|
||||
@@ -219,39 +228,21 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
try:
|
||||
count = int(args[2])
|
||||
except ValueError:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Count must be an integer."
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Count must be an integer.")
|
||||
return
|
||||
result = _adjacent_networks(net, count)
|
||||
await bot.api.send_text_message(room.room_id, result)
|
||||
networks = _adjacent_networks(net, count)
|
||||
output = _adjacent_output(networks)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
return
|
||||
|
||||
# Unknown subcommand
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
f"Unknown subcommand '{subcmd}'. Use !subnet help to see available commands."
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{subcmd}'. Use !subnet help.")
|
||||
|
||||
|
||||
# Plugin metadata
|
||||
__version__ = "1.0.1"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__version__ = "1.3.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Subnet calculator, splitter, and adjacent network enumerator"
|
||||
__help__ = """
|
||||
<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 <CIDR></code> – Show detailed info for a network<br>
|
||||
Example: <code>!subnet info 192.168.1.0/24</code></li>
|
||||
<li><code>!subnet split <CIDR> --prefix <new_prefix></code> – Split into smaller subnets<br>
|
||||
Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code></li>
|
||||
<li><code>!subnet split <CIDR> --diff <delta></code> – Split by prefix delta<br>
|
||||
Example: <code>!subnet split 10.0.0.0/16 --diff 2</code></li>
|
||||
<li><code>!subnet adjacent <CIDR> <count></code> – Show adjacent networks<br>
|
||||
Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>
|
||||
</ul>
|
||||
</details>
|
||||
"""
|
||||
__description__ = "Subnet calculator"
|
||||
__help__ = _HELP_MD
|
||||
|
||||
+236
-277
@@ -1,354 +1,313 @@
|
||||
"""
|
||||
Comprehensive system information and resource monitoring.
|
||||
All blocking calls (psutil, subprocess) run in a thread pool.
|
||||
Comprehensive system information – code block with emoji + aligned columns.
|
||||
All blocking calls run in thread pool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import os
|
||||
import asyncio
|
||||
import psutil
|
||||
import socket
|
||||
import datetime
|
||||
import subprocess
|
||||
import logging, platform, os, asyncio, psutil, socket, datetime, subprocess
|
||||
import simplematrixbotlib as botlib
|
||||
from plugins.common import collapsible_summary, html_escape
|
||||
from plugins.common import collapsible_summary, html_escape, code_block
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""
|
||||
Handle !sysinfo command for system information.
|
||||
"""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
|
||||
args = match.args()
|
||||
if args and args[0].lower() == 'help':
|
||||
await show_usage(room, bot)
|
||||
return
|
||||
await get_system_info(room, bot)
|
||||
|
||||
async def show_usage(room, bot):
|
||||
"""Display sysinfo command usage."""
|
||||
usage = """
|
||||
<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):
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||
|
||||
# ----- Individual data collectors (all sync, run in thread) -----
|
||||
# ---------- Data collectors (unchanged) ----------
|
||||
def _system_overview():
|
||||
boot = datetime.datetime.fromtimestamp(psutil.boot_time())
|
||||
uptime_delta = datetime.datetime.now() - boot
|
||||
uptime_str = str(datetime.timedelta(seconds=int(uptime_delta.total_seconds())))
|
||||
return {
|
||||
'hostname': socket.gethostname(),
|
||||
'os': platform.system(),
|
||||
'os_release': platform.release(),
|
||||
'os_version': platform.version(),
|
||||
'architecture': platform.architecture()[0],
|
||||
'machine': platform.machine(),
|
||||
'processor': platform.processor(),
|
||||
'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))),
|
||||
'users': len(psutil.users())
|
||||
"hostname": socket.gethostname(),
|
||||
"os": f"{platform.system()} {platform.release()}",
|
||||
"architecture": platform.architecture()[0],
|
||||
"machine": platform.machine(),
|
||||
"processor": platform.processor(),
|
||||
"boot_time": boot.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"uptime": uptime_str,
|
||||
"users": len(psutil.users())
|
||||
}
|
||||
|
||||
def _cpu_info():
|
||||
cpu_times = psutil.cpu_times_percent(interval=1)
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else (0,0,0)
|
||||
load = os.getloadavg() if hasattr(os, "getloadavg") else (0,0,0)
|
||||
return {
|
||||
'physical_cores': psutil.cpu_count(logical=False),
|
||||
'total_cores': psutil.cpu_count(logical=True),
|
||||
'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A",
|
||||
'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A",
|
||||
'usage_percent': psutil.cpu_percent(interval=1),
|
||||
'user_time': cpu_times.user,
|
||||
'system_time': cpu_times.system,
|
||||
'idle_time': cpu_times.idle,
|
||||
'load_avg': ", ".join(f"{l:.2f}" for l in load_avg)
|
||||
"physical_cores": psutil.cpu_count(logical=False),
|
||||
"logical_cores": psutil.cpu_count(logical=True),
|
||||
"max_freq": f"{cpu_freq.max:.0f} MHz" if cpu_freq else "N/A",
|
||||
"current_freq": f"{cpu_freq.current:.0f} MHz" if cpu_freq else "N/A",
|
||||
"usage": f"{psutil.cpu_percent(interval=1)}%",
|
||||
"load_avg": f"{load[0]:.2f} {load[1]:.2f} {load[2]:.2f}"
|
||||
}
|
||||
|
||||
def _memory_info():
|
||||
mem = psutil.virtual_memory()
|
||||
swap = psutil.swap_memory()
|
||||
return {
|
||||
'total': f"{mem.total / (1024**3):.2f} GB",
|
||||
'available': f"{mem.available / (1024**3):.2f} GB",
|
||||
'used': f"{mem.used / (1024**3):.2f} GB",
|
||||
'usage_percent': mem.percent,
|
||||
'swap_total': f"{swap.total / (1024**3):.2f} GB",
|
||||
'swap_used': f"{swap.used / (1024**3):.2f} GB",
|
||||
'swap_free': f"{swap.free / (1024**3):.2f} GB",
|
||||
'swap_percent': swap.percent
|
||||
"total_ram": f"{mem.total / (1024**3):.1f} GB",
|
||||
"used_ram": f"{mem.used / (1024**3):.1f} GB",
|
||||
"ram_percent": f"{mem.percent}%",
|
||||
"available_ram": f"{mem.available / (1024**3):.1f} GB",
|
||||
"total_swap": f"{swap.total / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
|
||||
"used_swap": f"{swap.used / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
|
||||
"swap_percent": f"{swap.percent}%" if swap.total > 0 else "N/A"
|
||||
}
|
||||
|
||||
def _storage_info():
|
||||
def _disk_info():
|
||||
partitions = psutil.disk_partitions()
|
||||
storage_list = []
|
||||
for part in partitions:
|
||||
mounted = []
|
||||
for p in partitions:
|
||||
try:
|
||||
usage = psutil.disk_usage(part.mountpoint)
|
||||
storage_list.append({
|
||||
'device': part.device,
|
||||
'mountpoint': part.mountpoint,
|
||||
'fstype': part.fstype,
|
||||
'total': f"{usage.total / (1024**3):.2f} GB",
|
||||
'used': f"{usage.used / (1024**3):.2f} GB",
|
||||
'free': f"{usage.free / (1024**3):.2f} GB",
|
||||
'percent': usage.percent
|
||||
usage = psutil.disk_usage(p.mountpoint)
|
||||
mounted.append({
|
||||
"mount": p.mountpoint,
|
||||
"used": f"{usage.used / (1024**3):.1f} GB",
|
||||
"total": f"{usage.total / (1024**3):.1f} GB",
|
||||
"percent": usage.percent
|
||||
})
|
||||
except:
|
||||
pass
|
||||
disk_io = psutil.disk_io_counters()
|
||||
io_info = {
|
||||
'read_count': disk_io.read_count if disk_io else 0,
|
||||
'write_count': disk_io.write_count if disk_io else 0,
|
||||
'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB",
|
||||
'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB"
|
||||
}
|
||||
return {'partitions': storage_list, 'io_stats': io_info}
|
||||
io = psutil.disk_io_counters()
|
||||
io_read = f"{io.read_bytes / (1024**3):.2f} GB" if io else "0 GB"
|
||||
io_write = f"{io.write_bytes / (1024**3):.2f} GB" if io else "0 GB"
|
||||
return mounted, io_read, io_write
|
||||
|
||||
def _network_info():
|
||||
interfaces = psutil.net_if_addrs()
|
||||
ifaces = psutil.net_if_addrs()
|
||||
io_counters = psutil.net_io_counters(pernic=True)
|
||||
net_list = []
|
||||
for iface, addrs in interfaces.items():
|
||||
if iface == 'lo':
|
||||
net = []
|
||||
for name, addrs in ifaces.items():
|
||||
if name == "lo":
|
||||
continue
|
||||
info = {
|
||||
'interface': iface,
|
||||
'ipv4': next((a.address for a in addrs if a.family == socket.AF_INET), 'N/A'),
|
||||
'ipv6': next((a.address for a in addrs if a.family == socket.AF_INET6), 'N/A'),
|
||||
'mac': next((a.address for a in addrs if a.family == psutil.AF_LINK), 'N/A'),
|
||||
}
|
||||
io = io_counters.get(iface)
|
||||
if io:
|
||||
info['bytes_sent'] = f"{io.bytes_sent / (1024**2):.2f} MB"
|
||||
info['bytes_recv'] = f"{io.bytes_recv / (1024**2):.2f} MB"
|
||||
else:
|
||||
info['bytes_sent'] = 'N/A'
|
||||
info['bytes_recv'] = 'N/A'
|
||||
net_list.append(info)
|
||||
return net_list
|
||||
ip4 = next((a.address for a in addrs if a.family == socket.AF_INET), None)
|
||||
if ip4:
|
||||
stats = io_counters.get(name)
|
||||
sent = f"{stats.bytes_sent / (1024**2):.1f} MB" if stats else "0 MB"
|
||||
recv = f"{stats.bytes_recv / (1024**2):.1f} MB" if stats else "0 MB"
|
||||
net.append((name, ip4, sent, recv))
|
||||
return net
|
||||
|
||||
def _process_info():
|
||||
def _top_processes():
|
||||
procs = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
|
||||
for p in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
|
||||
try:
|
||||
procs.append(proc.info)
|
||||
procs.append(p.info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
top_cpu = sorted(procs, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5]
|
||||
return {'total_processes': len(procs), 'top_cpu': top_cpu}
|
||||
return top_cpu, len(procs)
|
||||
|
||||
def _gpu_info():
|
||||
info = {}
|
||||
try:
|
||||
res = subprocess.run(
|
||||
['nvidia-smi', '--query-gpu=name,memory.used,memory.total,temperature.gpu,utilization.gpu',
|
||||
'--format=csv,noheader,nounits'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if res.returncode == 0:
|
||||
gpus = []
|
||||
for line in res.stdout.strip().split('\n'):
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
if len(parts) >= 5:
|
||||
gpus.append({
|
||||
"name": parts[0],
|
||||
"mem_used": f"{parts[1]} MB",
|
||||
"mem_total": f"{parts[2]} MB",
|
||||
"temp": f"{parts[3]}°C",
|
||||
"usage": f"{parts[4]}%"
|
||||
})
|
||||
if gpus:
|
||||
info["nvidia"] = gpus
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
res = subprocess.run(['lspci'], capture_output=True, text=True)
|
||||
if res.returncode == 0:
|
||||
lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
|
||||
if lines:
|
||||
info["detected"] = lines[:2]
|
||||
except:
|
||||
pass
|
||||
return info
|
||||
|
||||
def _docker_info():
|
||||
try:
|
||||
result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return {'available': False}
|
||||
result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'],
|
||||
capture_output=True, text=True)
|
||||
ver = subprocess.run(['docker', '--version'], capture_output=True, text=True)
|
||||
if ver.returncode != 0:
|
||||
return None
|
||||
ps_res = subprocess.run(
|
||||
['docker', 'ps', '--format', '{{.Names}}|{{.Status}}'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
containers = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
for line in ps_res.stdout.strip().split('\n'):
|
||||
if line:
|
||||
parts = line.split('|')
|
||||
if len(parts) >= 2:
|
||||
containers.append({'name': parts[0], 'status': parts[1], 'ports': parts[2] if len(parts)>2 else 'N/A'})
|
||||
return {'available': True, 'containers': containers, 'total_running': len(containers)}
|
||||
containers.append({"name": parts[0], "status": parts[1]})
|
||||
return containers
|
||||
except:
|
||||
return {'available': False}
|
||||
return None
|
||||
|
||||
def _sensor_info():
|
||||
temps = psutil.sensors_temperatures()
|
||||
fans = psutil.sensors_fans()
|
||||
battery = psutil.sensors_battery()
|
||||
sensor = {'temperatures': {}, 'fans': {}, 'battery': {}}
|
||||
data = {"temps": [], "fans": [], "battery": None}
|
||||
if temps:
|
||||
for name, entries in temps.items():
|
||||
sensor['temperatures'][name] = [f"{e.current}°C" for e in entries[:2]]
|
||||
for chip, entries in temps.items():
|
||||
for e in entries[:2]:
|
||||
data["temps"].append(f"{e.label or chip}: {e.current}°C")
|
||||
if fans:
|
||||
for name, entries in fans.items():
|
||||
sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]]
|
||||
for chip, entries in fans.items():
|
||||
for e in entries[:2]:
|
||||
data["fans"].append(f"{e.label or chip}: {e.current} RPM")
|
||||
if battery:
|
||||
sensor['battery'] = {
|
||||
'percent': battery.percent,
|
||||
'power_plugged': battery.power_plugged,
|
||||
'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown"
|
||||
}
|
||||
return sensor
|
||||
rem = ""
|
||||
if battery.secsleft != psutil.POWER_TIME_UNLIMITED and battery.secsleft > 0:
|
||||
h = battery.secsleft // 3600
|
||||
m = (battery.secsleft % 3600) // 60
|
||||
rem = f" ({h}h {m}m left)"
|
||||
plugged = " 🔌" if battery.power_plugged else ""
|
||||
data["battery"] = f"{battery.percent}%{plugged}{rem}"
|
||||
return data
|
||||
|
||||
def _gpu_info():
|
||||
gpu_data = {}
|
||||
# NVIDIA
|
||||
try:
|
||||
res = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu',
|
||||
'--format=csv,noheader,nounits'], capture_output=True, text=True)
|
||||
if res.returncode == 0:
|
||||
nvidia = []
|
||||
for line in res.stdout.strip().split('\n'):
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
if len(parts) >= 6:
|
||||
nvidia.append({
|
||||
'name': parts[0],
|
||||
'memory_total': f"{parts[1]} MB",
|
||||
'memory_used': f"{parts[2]} MB",
|
||||
'memory_free': f"{parts[3]} MB",
|
||||
'temperature': f"{parts[4]}°C",
|
||||
'utilization': f"{parts[5]}%"
|
||||
})
|
||||
if nvidia:
|
||||
gpu_data['nvidia'] = nvidia
|
||||
except:
|
||||
pass
|
||||
# lspci fallback
|
||||
try:
|
||||
res = subprocess.run(['lspci'], capture_output=True, text=True)
|
||||
if res.returncode == 0:
|
||||
gpu_lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
|
||||
if gpu_lines:
|
||||
gpu_data['detected'] = gpu_lines[:3]
|
||||
except:
|
||||
pass
|
||||
return gpu_data
|
||||
|
||||
# ----- Main info gatherer -----
|
||||
# -------------------------------------------------------------------
|
||||
# Main builder
|
||||
# -------------------------------------------------------------------
|
||||
async def get_system_info(room, bot):
|
||||
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
|
||||
|
||||
# Run all blocking collectors concurrently
|
||||
system = await _run_blocking(_system_overview)
|
||||
cpu = await _run_blocking(_cpu_info)
|
||||
memory = await _run_blocking(_memory_info)
|
||||
storage = await _run_blocking(_storage_info)
|
||||
network = await _run_blocking(_network_info)
|
||||
processes = await _run_blocking(_process_info)
|
||||
mem = await _run_blocking(_memory_info)
|
||||
disks, io_read, io_write = await _run_blocking(_disk_info)
|
||||
net = await _run_blocking(_network_info)
|
||||
top_procs, total_procs = await _run_blocking(_top_processes)
|
||||
gpu = await _run_blocking(_gpu_info)
|
||||
docker = await _run_blocking(_docker_info)
|
||||
sensors = await _run_blocking(_sensor_info)
|
||||
gpu = await _run_blocking(_gpu_info)
|
||||
|
||||
# Build output HTML
|
||||
output = await format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu)
|
||||
sections = []
|
||||
|
||||
# System Overview
|
||||
sys_rows = [
|
||||
("💻", "Hostname", system["hostname"]),
|
||||
("🖥️", "OS", system["os"]),
|
||||
("📐", "Architecture", system["architecture"]),
|
||||
("⚙️", "Machine", system["machine"]),
|
||||
("🔧", "Processor", system["processor"]),
|
||||
("⏰", "Uptime", system["uptime"]),
|
||||
("📅", "Boot Time", system["boot_time"]),
|
||||
("👥", "Users", str(system["users"]))
|
||||
]
|
||||
sections.append({"title": "🖥️ System Overview", "rows": sys_rows})
|
||||
|
||||
# CPU
|
||||
cpu_rows = [
|
||||
("⚡", "CPU Cores", f"{cpu['physical_cores']} physical, {cpu['logical_cores']} logical"),
|
||||
("📈", "Freq (Max/Cur)", f"{cpu['max_freq']} / {cpu['current_freq']}"),
|
||||
("📊", "CPU Usage", cpu["usage"]),
|
||||
("⚖️", "Load Avg", cpu["load_avg"])
|
||||
]
|
||||
sections.append({"title": "⚡ CPU", "rows": cpu_rows})
|
||||
|
||||
# Memory
|
||||
mem_rows = [
|
||||
("🧠", "RAM", f"{mem['used_ram']} / {mem['total_ram']} ({mem['ram_percent']})")
|
||||
]
|
||||
if mem["total_swap"] != "N/A":
|
||||
mem_rows.append(("💾", "Swap", f"{mem['used_swap']} / {mem['total_swap']} ({mem['swap_percent']})"))
|
||||
sections.append({"title": "🧠 Memory", "rows": mem_rows})
|
||||
|
||||
# Storage
|
||||
disk_rows = []
|
||||
for d in disks[:5]:
|
||||
disk_rows.append(("💽", d['mount'], f"{d['used']} / {d['total']} ({d['percent']}%)"))
|
||||
disk_rows.append(("📀", "Disk I/O", f"Read {io_read} / Write {io_write}"))
|
||||
sections.append({"title": "💾 Storage", "rows": disk_rows})
|
||||
|
||||
# Network
|
||||
net_rows = []
|
||||
if net:
|
||||
for idx, (name, ip, sent, recv) in enumerate(net[:3]):
|
||||
emoji = "🌐" if idx == 0 else ""
|
||||
label = "Network" if idx == 0 else ""
|
||||
net_rows.append((emoji, label, f"{name} - {ip} | ↓{recv} ↑{sent}"))
|
||||
else:
|
||||
net_rows.append(("🌐", "Network", "No active interfaces"))
|
||||
sections.append({"title": "🌐 Network", "rows": net_rows})
|
||||
|
||||
# GPU
|
||||
gpu_rows = []
|
||||
if "nvidia" in gpu:
|
||||
for g in gpu["nvidia"]:
|
||||
gpu_rows.append(("🎮", "GPU", f"{g['name']} | {g['mem_used']}/{g['mem_total']} | {g['temp']} | {g['usage']} util"))
|
||||
elif "detected" in gpu:
|
||||
for line in gpu["detected"]:
|
||||
gpu_rows.append(("🎮", "GPU", line))
|
||||
else:
|
||||
gpu_rows.append(("🎮", "GPU", "No dedicated GPU detected"))
|
||||
sections.append({"title": "🎮 GPU", "rows": gpu_rows})
|
||||
|
||||
# Processes
|
||||
proc_rows = [("🔄", "Processes", f"Total: {total_procs}")]
|
||||
for p in top_procs:
|
||||
name = p.get('name', '?')
|
||||
cpu_p = p.get('cpu_percent') or 0
|
||||
mem_p = p.get('memory_percent') or 0
|
||||
proc_rows.append(("", "", f"{name} - CPU {cpu_p:.1f}% / RAM {mem_p:.1f}%"))
|
||||
sections.append({"title": "🔄 Top Processes", "rows": proc_rows})
|
||||
|
||||
# Docker
|
||||
docker_rows = []
|
||||
if docker is not None:
|
||||
if docker:
|
||||
for c in docker[:5]:
|
||||
docker_rows.append(("🐳", "Docker", f"{c['name']} - {c['status']}"))
|
||||
else:
|
||||
docker_rows.append(("🐳", "Docker", "No containers running"))
|
||||
else:
|
||||
docker_rows.append(("🐳", "Docker", "Docker not available"))
|
||||
sections.append({"title": "🐳 Docker", "rows": docker_rows})
|
||||
|
||||
# Sensors
|
||||
sensor_rows = []
|
||||
if sensors["temps"]:
|
||||
sensor_rows.append(("🌡️", "Temperature", ", ".join(sensors["temps"])))
|
||||
if sensors["fans"]:
|
||||
sensor_rows.append(("🌀", "Fans", ", ".join(sensors["fans"])))
|
||||
if sensors["battery"]:
|
||||
sensor_rows.append(("🔋", "Battery", sensors["battery"]))
|
||||
if sensor_rows:
|
||||
sections.append({"title": "🌡️ Sensors", "rows": sensor_rows})
|
||||
|
||||
block = code_block(f"💻 System Info: {system['hostname']}", sections)
|
||||
output = collapsible_summary(f"💻 System Info – {html_escape(system['hostname'])}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info("Sent system information")
|
||||
|
||||
async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu):
|
||||
hostname = html_escape(system.get('hostname', 'Unknown'))
|
||||
body = "<strong>💻 System Information</strong><br><br>"
|
||||
|
||||
# System Overview
|
||||
body += "<strong>🖥️ System Overview</strong><br>"
|
||||
body += f" • <strong>Hostname:</strong> {hostname}<br>"
|
||||
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>"
|
||||
body += f" • <strong>Uptime:</strong> {html_escape(system['uptime'])}<br>"
|
||||
body += f" • <strong>Boot Time:</strong> {html_escape(system['boot_time'])}<br>"
|
||||
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)
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
|
||||
if match.args() and match.args()[0].lower() == 'help':
|
||||
usage = """
|
||||
<strong>💻 System Information</strong>
|
||||
<code>!sysinfo</code> – display comprehensive system info in a clean code block.
|
||||
"""
|
||||
await bot.api.send_markdown_message(room.room_id, usage)
|
||||
return
|
||||
await get_system_info(room, bot)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.3.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Comprehensive system information and monitoring"
|
||||
__description__ = "System information plugin"
|
||||
__help__ = """
|
||||
<details>
|
||||
<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>
|
||||
"""
|
||||
|
||||
+117
-131
@@ -1,210 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Time Zone Plugin – completely hardcoded-free using Open-Meteo APIs.
|
||||
Time Zone Plugin – uses pytz for IANA zones and Open‑Meteo for city geocoding.
|
||||
Outputs a clean code block with emojis and aligned columns via shared code_block.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import aiohttp
|
||||
import simplematrixbotlib as botlib
|
||||
from urllib.parse import quote
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from plugins.common import collapsible_summary, html_escape, code_block
|
||||
|
||||
def format_ampm(dt_str: str) -> str:
|
||||
"""Convert ISO datetime to AM/PM format."""
|
||||
# -------------------------------------------------------------------
|
||||
# Offline helper for IANA timezone names
|
||||
# -------------------------------------------------------------------
|
||||
def _get_time_for_iana_zone(zone: str) -> dict | None:
|
||||
"""Return a dict with datetime, timezone, and optional temperature using pytz."""
|
||||
try:
|
||||
if '+' in dt_str:
|
||||
dt_str = dt_str.split('+')[0]
|
||||
if '.' in dt_str:
|
||||
dt_str = dt_str.split('.')[0]
|
||||
dt_str = dt_str.replace('T', ' ')
|
||||
dt = datetime.fromisoformat(dt_str)
|
||||
return dt.strftime("%I:%M:%S %p").lstrip("0")
|
||||
except:
|
||||
return dt_str
|
||||
tz = pytz.timezone(zone)
|
||||
now = datetime.now(tz)
|
||||
return {
|
||||
"datetime": now.isoformat(),
|
||||
"timezone": zone,
|
||||
"temperature": None # no weather for zone lookups
|
||||
}
|
||||
except pytz.UnknownTimeZoneError:
|
||||
return None
|
||||
|
||||
async def geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
|
||||
"""
|
||||
Open-Meteo Geocoding API (free, no key, no hardcoding).
|
||||
Returns (latitude, longitude, display_name) or None.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Online helpers (Open‑Meteo)
|
||||
# -------------------------------------------------------------------
|
||||
async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
|
||||
"""Geocode a city name via Open‑Meteo. Returns (lat, lon, display_name) or None."""
|
||||
from urllib.parse import quote
|
||||
url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
|
||||
|
||||
try:
|
||||
async with session.get(url, timeout=10) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
if data.get("results") and len(data["results"]) > 0:
|
||||
result = data["results"][0]
|
||||
lat = result["latitude"]
|
||||
lon = result["longitude"]
|
||||
name = result.get("name", city)
|
||||
country = result.get("country", "")
|
||||
admin1 = result.get("admin1", "")
|
||||
|
||||
# Build display name: "Lahore, Punjab, Pakistan"
|
||||
display_parts = [name]
|
||||
if admin1 and admin1 != name:
|
||||
display_parts.append(admin1)
|
||||
if country:
|
||||
display_parts.append(country)
|
||||
display_name = ", ".join(display_parts)
|
||||
|
||||
logging.info(f"Geocoded: {city} → {display_name} ({lat}, {lon})")
|
||||
return lat, lon, display_name
|
||||
else:
|
||||
logging.warning(f"Geocoding API HTTP {resp.status} for {city}")
|
||||
results = data.get("results", [])
|
||||
if results:
|
||||
r = results[0]
|
||||
lat = float(r["latitude"])
|
||||
lon = float(r["longitude"])
|
||||
name = r.get("name", city)
|
||||
country = r.get("country", "")
|
||||
admin1 = r.get("admin1", "")
|
||||
display = ", ".join(filter(None, [name, admin1, country]))
|
||||
return lat, lon, display
|
||||
except Exception as e:
|
||||
logging.warning(f"Geocoding error: {e}")
|
||||
return None
|
||||
|
||||
async def get_timezone(lat: float, lon: float) -> str | None:
|
||||
"""
|
||||
Get timezone name from coordinates using timezonedb (free tier, no key).
|
||||
Alternative: use Open-Meteo's time API directly.
|
||||
"""
|
||||
# Open-Meteo's time API accepts coordinates directly
|
||||
# We'll use this instead of timezonedb
|
||||
return None # Will be handled in fetch_time_by_coords
|
||||
|
||||
async def fetch_time_by_coords(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
||||
async def _fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
||||
"""
|
||||
Get current time using Open-Meteo (no key required).
|
||||
Fetch current time and temperature from Open‑Meteo (free, no key).
|
||||
The API returns an ISO 8601 string for the current time.
|
||||
"""
|
||||
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto&timeformat=unixtime"
|
||||
|
||||
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto"
|
||||
try:
|
||||
async with session.get(url, timeout=10) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
current = data.get("current_weather", {})
|
||||
timezone = data.get("timezone", "Unknown")
|
||||
unixtime = current.get("time")
|
||||
temperature = current.get("temperature")
|
||||
|
||||
if unixtime:
|
||||
# Convert UNIX timestamp to datetime
|
||||
dt = datetime.fromtimestamp(unixtime)
|
||||
time_str = current.get("time") # ISO 8601, local time
|
||||
temp_c = current.get("temperature")
|
||||
tz = data.get("timezone", "Unknown")
|
||||
if time_str:
|
||||
return {
|
||||
"datetime": dt.isoformat(),
|
||||
"timezone": timezone,
|
||||
"temperature": temperature
|
||||
"datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
|
||||
"timezone": tz,
|
||||
"temperature": temp_c
|
||||
}
|
||||
except Exception as e:
|
||||
logging.warning(f"Time fetch error: {e}")
|
||||
logging.warning(f"Weather fetch error: {e}")
|
||||
return None
|
||||
|
||||
async def fetch_time_by_zone(session: aiohttp.ClientSession, zone: str) -> dict | None:
|
||||
"""Get current time for a named timezone using Open-Meteo."""
|
||||
# Open-Meteo doesn't have named timezone endpoint, need to geocode a representative city
|
||||
# Fallback to worldtimeapi.org for IANA zones
|
||||
url = f"http://worldtimeapi.org/api/timezone/{zone}"
|
||||
try:
|
||||
async with session.get(url, timeout=10) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
logging.warning(f"Timezone API error: {e}")
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Main resolver
|
||||
# -------------------------------------------------------------------
|
||||
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
|
||||
"""Main resolution: geocode any city, then get time."""
|
||||
query = query.strip().lower()
|
||||
"""Return (data_dict, display_name) or (None, error_message)."""
|
||||
query = query.strip()
|
||||
|
||||
# Check if it's an IANA zone (contains '/')
|
||||
if '/' in query or query in ("utc", "gmt"):
|
||||
data = await fetch_time_by_zone(session, query)
|
||||
# 1. Try as IANA zone (offline, always works)
|
||||
if '/' in query or query.lower() in ("utc", "gmt"):
|
||||
data = _get_time_for_iana_zone(query)
|
||||
if data:
|
||||
return data, query.upper()
|
||||
return None, f"Timezone '{query}' not found"
|
||||
else:
|
||||
return None, f"Timezone '{html_escape(query)}' not recognised."
|
||||
|
||||
# Geocode the city (no hardcoding!)
|
||||
geocode_result = await geocode_city(session, query)
|
||||
# 2. Otherwise geocode as a city name
|
||||
geocode_result = await _geocode_city(session, query)
|
||||
if not geocode_result:
|
||||
return None, f"Could not find city '{query}'. Try being more specific."
|
||||
return None, f"Could not find city '{html_escape(query)}'. Try a more specific name or use an IANA zone."
|
||||
|
||||
lat, lon, display_name = geocode_result
|
||||
weather_data = await _fetch_weather(session, lat, lon)
|
||||
if weather_data:
|
||||
return weather_data, display_name
|
||||
return None, f"Could not fetch time/weather for '{html_escape(display_name)}'."
|
||||
|
||||
# Get time from coordinates
|
||||
data = await fetch_time_by_coords(session, lat, lon)
|
||||
if not data:
|
||||
return None, f"Could not get time for '{display_name}'"
|
||||
|
||||
return data, display_name
|
||||
|
||||
def format_response(data: dict, display_name: str) -> str:
|
||||
"""Format time data into HTML."""
|
||||
# -------------------------------------------------------------------
|
||||
# Formatting – uses shared code_block from common.py
|
||||
# -------------------------------------------------------------------
|
||||
def _format_time_output(data: dict, display_name: str) -> str:
|
||||
"""Convert time data into a code block via the shared formatter."""
|
||||
raw_time = data.get("datetime", "")
|
||||
local_time = format_ampm(raw_time) if raw_time else "Unknown"
|
||||
tz = data.get("timezone", "Unknown")
|
||||
# Convert ISO string to AM/PM format
|
||||
try:
|
||||
if '+' in raw_time:
|
||||
raw_time = raw_time.split('+')[0]
|
||||
dt = datetime.fromisoformat(raw_time)
|
||||
local_time = dt.strftime("%I:%M:%S %p").lstrip("0")
|
||||
except Exception:
|
||||
local_time = raw_time
|
||||
|
||||
tz_display = data.get("timezone", "Unknown")
|
||||
temp = data.get("temperature")
|
||||
temp_str = f"<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"""
|
||||
<details>
|
||||
<summary><strong>🕒 Time in {display_name}</strong></summary>
|
||||
<p>
|
||||
📍 <strong>Timezone:</strong> {tz}<br>
|
||||
📅 <strong>Local time:</strong> {local_time}{temp_str}
|
||||
</p>
|
||||
</details>
|
||||
"""
|
||||
rows = [
|
||||
("🌐", "Location", display_name),
|
||||
("🕒", "Local Time", local_time),
|
||||
("📅", "Timezone", tz_display),
|
||||
("🌡️", "Temperature", temp_str),
|
||||
]
|
||||
# Wrap rows in a single section with no title (title is part of code_block's main title)
|
||||
sections = [{"title": "", "rows": rows}]
|
||||
return code_block("🕒 Time Info", sections)
|
||||
|
||||
def help_text() -> str:
|
||||
return """
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Help
|
||||
# -------------------------------------------------------------------
|
||||
_HELP_MD = """
|
||||
<details>
|
||||
<summary><strong>🕒 Time Plugin Help</strong></summary>
|
||||
<p>
|
||||
<strong>!time <any city></strong> – Get current time for ANY city worldwide<br>
|
||||
<strong>!time <IANA zone></strong> – e.g., Europe/London, Asia/Karachi<br>
|
||||
<strong>!time help</strong> – Show this help<br><br>
|
||||
<p><strong>!time <any city></strong> – Get current time for ANY city worldwide<br>
|
||||
<strong>!time <IANA zone></strong> – e.g., <code>Europe/London</code>, <code>Asia/Karachi</code><br>
|
||||
<strong>!time help</strong> – Show this help<br>
|
||||
<strong>Examples:</strong><br>
|
||||
<code>!time Lahore</code><br>
|
||||
<code>!time New York</code><br>
|
||||
<code>!time Paris</code><br>
|
||||
<code>!time Asia/Karachi</code><br><br>
|
||||
<em>No city names are hardcoded. The bot uses Open-Meteo's geocoding API.</em>
|
||||
<code>!time Europe/London</code><br>
|
||||
<em>No city names are hardcoded. IANA zones work completely offline.</em>
|
||||
</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Plugin lifecycle
|
||||
# -------------------------------------------------------------------
|
||||
def setup(bot):
|
||||
logging.info("Time plugin (zero hardcoded cities) loaded.")
|
||||
logging.info("Time plugin (offline IANA zones + Open‑Meteo cities) loaded.")
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
import simplematrixbotlib as botlib
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")):
|
||||
return
|
||||
|
||||
args = match.args()
|
||||
if not args or args[0].lower() == "help":
|
||||
await bot.api.send_markdown_message(room.room_id, help_text())
|
||||
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
|
||||
return
|
||||
|
||||
query = " ".join(args).strip()
|
||||
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {query}...")
|
||||
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {html_escape(query)}...")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data, display = await resolve_time(session, query)
|
||||
if data is None:
|
||||
await bot.api.send_text_message(room.room_id, f"❌ {display}")
|
||||
return
|
||||
await bot.api.send_markdown_message(room.room_id, format_response(data, display))
|
||||
block = _format_time_output(data, display)
|
||||
output = collapsible_summary(f"🕒 Time in {html_escape(display)}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info(f"Time sent for {query}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.2"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "World clock (no hardcoded cities)"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!time</strong> – Current time for any city</summary>
|
||||
<ul>
|
||||
<li><code>!time <city></code> – Geocode any city (free Open-Meteo API)</li>
|
||||
<li><code>!time <IANA zone></code> – e.g., <code>Europe/London</code></li>
|
||||
</ul>
|
||||
<p>Also shows current temperature if available.</p>
|
||||
</details>
|
||||
"""
|
||||
__description__ = "World clock (offline IANA zones + free geocoding)"
|
||||
__help__ = _HELP_MD
|
||||
|
||||
@@ -87,6 +87,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Urban Dictionary definitions (async)"
|
||||
__description__ = "Urban Dictionary definitions"
|
||||
__help__ = """<details><summary><strong>!ud</strong> – Urban Dictionary</summary>
|
||||
<ul><li><code>!ud</code> random, <code>!ud <term></code> top, <code>!ud <term> <index></code></li></ul></details>"""
|
||||
|
||||
+85
-131
@@ -1,11 +1,6 @@
|
||||
"""
|
||||
Weather plugin – primary: OpenWeatherMap, fallback: Open‑Meteo.
|
||||
|
||||
Uses OpenWeatherMap when a valid API key is present and the request succeeds.
|
||||
Falls back to Open‑Meteo (no key required) otherwise.
|
||||
|
||||
Commands:
|
||||
!weather <location> e.g. !weather London or !weather "New York,US"
|
||||
Outputs a formatted code block with emojis and perfectly aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,56 +9,14 @@ import aiohttp
|
||||
import simplematrixbotlib as botlib
|
||||
from dotenv import load_dotenv
|
||||
from urllib.parse import quote
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load .env (for OPENWEATHER_API_KEY)
|
||||
# ---------------------------------------------------------------------------
|
||||
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(plugin_dir)
|
||||
dotenv_path = os.path.join(parent_dir, ".env")
|
||||
load_dotenv(dotenv_path)
|
||||
from plugins.common import html_escape, collapsible_summary, code_block
|
||||
|
||||
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WMO codes → description + emoji (for Open‑Meteo)
|
||||
# ---------------------------------------------------------------------------
|
||||
WMO_CODES = {
|
||||
0: ("Clear sky", "☀️"),
|
||||
1: ("Mainly clear", "🌤️"),
|
||||
2: ("Partly cloudy", "⛅"),
|
||||
3: ("Overcast", "☁️"),
|
||||
45: ("Fog", "🌫️"),
|
||||
48: ("Depositing rime fog", "🌫️"),
|
||||
51: ("Light drizzle", "🌦️"),
|
||||
53: ("Moderate drizzle", "🌦️"),
|
||||
55: ("Dense drizzle", "🌧️"),
|
||||
56: ("Light freezing drizzle", "🌧️"),
|
||||
57: ("Dense freezing drizzle", "🌧️"),
|
||||
61: ("Slight rain", "🌧️"),
|
||||
63: ("Moderate rain", "🌧️"),
|
||||
65: ("Heavy rain", "🌧️"),
|
||||
66: ("Light freezing rain", "🌧️"),
|
||||
67: ("Heavy freezing rain", "🌧️"),
|
||||
71: ("Slight snow fall", "❄️"),
|
||||
73: ("Moderate snow fall", "❄️"),
|
||||
75: ("Heavy snow fall", "❄️"),
|
||||
77: ("Snow grains", "❄️"),
|
||||
80: ("Slight rain showers", "🌦️"),
|
||||
81: ("Moderate rain showers", "🌧️"),
|
||||
82: ("Violent rain showers", "🌧️"),
|
||||
85: ("Slight snow showers", "🌨️"),
|
||||
86: ("Heavy snow showers", "🌨️"),
|
||||
95: ("Thunderstorm", "⛈️"),
|
||||
96: ("Thunderstorm with slight hail", "⛈️"),
|
||||
99: ("Thunderstorm with heavy hail", "⛈️"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primary: OpenWeatherMap
|
||||
# OpenWeatherMap helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None:
|
||||
"""Fetch current weather from OpenWeatherMap. Returns None on failure."""
|
||||
if not OPENWEATHER_API_KEY:
|
||||
logging.info("OpenWeatherMap key missing, skipping primary")
|
||||
return None
|
||||
@@ -72,7 +25,7 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
|
||||
params = {
|
||||
"q": location,
|
||||
"appid": OPENWEATHER_API_KEY,
|
||||
"units": "metric", # Celsius
|
||||
"units": "metric",
|
||||
}
|
||||
try:
|
||||
async with session.get(url, params=params, timeout=10) as resp:
|
||||
@@ -83,46 +36,10 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
|
||||
logging.warning(f"OpenWeatherMap request error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def format_openweathermap(data: dict) -> str:
|
||||
"""Build the one-line weather message from OpenWeatherMap data."""
|
||||
city = data.get("name", "Unknown")
|
||||
sys_data = data.get("sys", {})
|
||||
country = sys_data.get("country", "")
|
||||
|
||||
main_data = data.get("main", {})
|
||||
temp_c = main_data.get("temp", 0)
|
||||
temp_f = round(temp_c * 9 / 5 + 32, 1)
|
||||
humidity = main_data.get("humidity", 0)
|
||||
|
||||
weather_list = data.get("weather", [])
|
||||
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
|
||||
emoji = "🌡️"
|
||||
if weather_list:
|
||||
wmain = weather_list[0].get("main", "")
|
||||
emoji = {
|
||||
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
|
||||
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
|
||||
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
|
||||
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
|
||||
}.get(wmain, "🌡️")
|
||||
|
||||
wind = data.get("wind", {}).get("speed", 0)
|
||||
|
||||
return (
|
||||
f"<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: Open‑Meteo (no key, free)
|
||||
# Open‑Meteo helpers (fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
|
||||
"""Geocode a city name via Open‑Meteo. Returns location info dict or None."""
|
||||
url = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
params = {"name": location, "count": 1, "language": "en"}
|
||||
try:
|
||||
@@ -144,10 +61,7 @@ async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict |
|
||||
logging.warning(f"Open‑Meteo geocode error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
|
||||
timezone: str = "auto") -> dict | None:
|
||||
"""Fetch current weather from Open‑Meteo. Returns JSON or None."""
|
||||
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float, timezone: str = "auto") -> dict | None:
|
||||
url = "https://api.open-meteo.com/v1/forecast"
|
||||
params = {
|
||||
"latitude": lat,
|
||||
@@ -165,35 +79,82 @@ async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
|
||||
logging.warning(f"Open‑Meteo weather error: {e}")
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_openweathermap(data: dict) -> str:
|
||||
"""Build a code block from OpenWeatherMap response."""
|
||||
city = data.get("name", "Unknown")
|
||||
sys_data = data.get("sys", {})
|
||||
country = sys_data.get("country", "")
|
||||
main = data.get("main", {})
|
||||
temp_c = main.get("temp", 0)
|
||||
temp_f = round(temp_c * 9 / 5 + 32, 1)
|
||||
humidity = main.get("humidity", 0)
|
||||
wind_speed = data.get("wind", {}).get("speed", 0)
|
||||
weather_list = data.get("weather", [])
|
||||
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
|
||||
|
||||
emoji_map = {
|
||||
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
|
||||
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
|
||||
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
|
||||
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
|
||||
}
|
||||
main_weather = weather_list[0].get("main", "") if weather_list else ""
|
||||
weather_emoji = emoji_map.get(main_weather, "🌡️")
|
||||
|
||||
location = f"{city}, {country}" if country else city
|
||||
|
||||
rows = [
|
||||
("🌍", "Location", location),
|
||||
(weather_emoji, "Condition", description),
|
||||
("🌡️", "Temperature", f"{temp_c:.1f}°C / {temp_f:.1f}°F"),
|
||||
("💧", "Humidity", f"{humidity}%"),
|
||||
("💨", "Wind Speed", f"{wind_speed} m/s"),
|
||||
]
|
||||
sections = [{"title": "", "rows": rows}]
|
||||
return code_block(f"🌤️ Weather for {location}", sections)
|
||||
|
||||
|
||||
def format_meteo(loc_info: dict, weather_data: dict) -> str:
|
||||
"""Format Open‑Meteo result into the same one‑line style."""
|
||||
"""Build a code block from Open‑Meteo response."""
|
||||
c = weather_data["current_weather"]
|
||||
code = c["weathercode"]
|
||||
desc, emoji = WMO_CODES.get(code, ("Unknown", "🌡️"))
|
||||
wmo_emoji = {
|
||||
0: ("Clear sky", "☀️"),
|
||||
1: ("Mainly clear", "🌤️"),
|
||||
2: ("Partly cloudy", "⛅"),
|
||||
3: ("Overcast", "☁️"),
|
||||
45: ("Fog", "🌫️"),
|
||||
51: ("Light drizzle", "🌦️"),
|
||||
61: ("Slight rain", "🌧️"),
|
||||
63: ("Moderate rain", "🌧️"),
|
||||
71: ("Slight snow", "❄️"),
|
||||
95: ("Thunderstorm", "⛈️"),
|
||||
}
|
||||
desc, emoji = wmo_emoji.get(code, ("Unknown", "🌡️"))
|
||||
|
||||
city = loc_info["name"]
|
||||
country = loc_info.get("country", "")
|
||||
state = loc_info.get("state", "")
|
||||
|
||||
# Build location string
|
||||
parts = [city]
|
||||
if state and state != city:
|
||||
parts.append(state)
|
||||
if country:
|
||||
parts.append(country)
|
||||
loc_str = ", ".join(parts)
|
||||
location_parts = [loc_info["name"]]
|
||||
if loc_info.get("state") and loc_info["state"] != loc_info["name"]:
|
||||
location_parts.append(loc_info["state"])
|
||||
if loc_info.get("country"):
|
||||
location_parts.append(loc_info["country"])
|
||||
location = ", ".join(location_parts)
|
||||
|
||||
temp_f = c["temperature"]
|
||||
temp_c = round((temp_f - 32) * 5 / 9, 1)
|
||||
wind = c["windspeed"]
|
||||
wind = c["windspeed"] # mph
|
||||
|
||||
return (
|
||||
f"<strong>[{emoji} Weather for {loc_str}]</strong>: "
|
||||
f"<strong>Condition:</strong> {desc} | "
|
||||
f"<strong>Temperature:</strong> {temp_c}°C ({temp_f}°F) | "
|
||||
f"<strong>Wind Speed:</strong> {wind} mph"
|
||||
)
|
||||
rows = [
|
||||
("🌍", "Location", location),
|
||||
(emoji, "Condition", desc),
|
||||
("🌡️", "Temperature", f"{temp_c}°C / {temp_f}°F"),
|
||||
("💨", "Wind Speed", f"{wind} mph"),
|
||||
]
|
||||
sections = [{"title": "", "rows": rows}]
|
||||
return code_block(f"🌤️ Weather for {location}", sections)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -218,14 +179,11 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 1. Try OpenWeatherMap
|
||||
owm_data = await openweathermap_get(session, location)
|
||||
if owm_data:
|
||||
if owm_data.get("cod") == 200:
|
||||
msg = format_openweathermap(owm_data)
|
||||
await bot.api.send_markdown_message(room.room_id, msg)
|
||||
logging.info("Sent weather via OpenWeatherMap")
|
||||
if owm_data and owm_data.get("cod") == 200:
|
||||
block = format_openweathermap(owm_data)
|
||||
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
return
|
||||
# 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: Open‑Meteo
|
||||
logging.info("Falling back to Open‑Meteo")
|
||||
@@ -233,7 +191,7 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
if not loc_info:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
f"Location '{location}' not found."
|
||||
f"Location '{html_escape(location)}' not found."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -247,28 +205,24 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
)
|
||||
return
|
||||
|
||||
msg = format_meteo(loc_info, wdata)
|
||||
await bot.api.send_markdown_message(room.room_id, msg)
|
||||
block = format_meteo(loc_info, wdata)
|
||||
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
logging.info("Sent weather via Open‑Meteo (fallback)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin setup
|
||||
# ---------------------------------------------------------------------------
|
||||
def setup(bot):
|
||||
logging.info("Weather plugin loaded (OpenWeatherMap + Open‑Meteo fallback)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__version__ = "1.1.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Weather forecast (OWM primary, Open‑Meteo fallback)"
|
||||
__description__ = "Weather data plugin"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!weather</strong> – Current weather</summary>
|
||||
<p><code>!weather <location></code> – Shows temperature, conditions, humidity, wind.<br>
|
||||
Uses OpenWeatherMap if a valid API key is present; falls back to free Open‑Meteo otherwise.</p>
|
||||
<p><code>!weather <location></code> – Shows temperature, conditions, humidity, wind in a clean, aligned table. Uses OpenWeatherMap primary, Open‑Meteo fallback.</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
+69
-158
@@ -1,219 +1,130 @@
|
||||
"""
|
||||
This plugin provides WHOIS lookup functionality for domains, IPs, and related network information.
|
||||
WHOIS lookup plugin – outputs a formatted code block with emojis and aligned columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import whois
|
||||
import ipaddress
|
||||
import re
|
||||
import asyncio
|
||||
import simplematrixbotlib as botlib
|
||||
|
||||
from plugins.common import collapsible_summary, html_escape, code_block
|
||||
|
||||
def is_valid_domain(domain):
|
||||
"""
|
||||
Validate if the provided string is a valid domain name.
|
||||
|
||||
Args:
|
||||
domain (str): The domain to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if valid, False otherwise.
|
||||
"""
|
||||
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9-]{1,63}$'
|
||||
return re.match(pattern, domain) is not None
|
||||
|
||||
|
||||
def is_valid_ip(ip):
|
||||
"""
|
||||
Validate if the provided string is a valid IPv4 or IPv6 address.
|
||||
|
||||
Args:
|
||||
ip (str): The IP address to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if valid, False otherwise.
|
||||
"""
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _build_rows(data):
|
||||
"""Build a list of (emoji, label, value) tuples from WHOIS data."""
|
||||
rows = []
|
||||
|
||||
def format_whois_data(domain, data):
|
||||
"""
|
||||
Format WHOIS data into a readable format.
|
||||
# Domain
|
||||
domain_name = data.domain_name
|
||||
if isinstance(domain_name, list):
|
||||
domain_name = ', '.join(domain_name)
|
||||
rows.append(('🌐', 'Domain', domain_name or 'N/A'))
|
||||
|
||||
Args:
|
||||
domain (str): The queried domain/IP.
|
||||
data (whois domain object): The WHOIS data object.
|
||||
|
||||
Returns:
|
||||
str: Formatted HTML message.
|
||||
"""
|
||||
sections = []
|
||||
|
||||
# Domain/Query Information
|
||||
if hasattr(data, 'domain_name') or hasattr(data, 'query'):
|
||||
domain_names = getattr(data, 'domain_name', domain)
|
||||
if isinstance(domain_names, list):
|
||||
domain_names = ', '.join(domain_names)
|
||||
sections.append(f"<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))
|
||||
# Registrar / WHOIS Server
|
||||
if data.registrar:
|
||||
rows.append(('🏢', 'Registrar', data.registrar))
|
||||
if data.whois_server:
|
||||
rows.append(('📡', 'WHOIS Server', data.whois_server))
|
||||
|
||||
# Dates
|
||||
date_items = []
|
||||
if hasattr(data, 'creation_date'):
|
||||
creation = data.creation_date
|
||||
if isinstance(creation, list):
|
||||
creation = creation[0]
|
||||
date_items.append(f"<strong>Created:</strong> {creation}")
|
||||
creation_date = data.creation_date
|
||||
if creation_date:
|
||||
if isinstance(creation_date, list):
|
||||
creation_date = creation_date[0]
|
||||
rows.append(('📅', 'Created', str(creation_date)))
|
||||
|
||||
if hasattr(data, 'updated_date'):
|
||||
updated = data.updated_date
|
||||
if isinstance(updated, list):
|
||||
updated = updated[0]
|
||||
date_items.append(f"<strong>Updated:</strong> {updated}")
|
||||
updated_date = data.updated_date
|
||||
if updated_date:
|
||||
if isinstance(updated_date, list):
|
||||
updated_date = updated_date[0]
|
||||
rows.append(('📝', 'Updated', str(updated_date)))
|
||||
|
||||
if hasattr(data, 'expiration_date'):
|
||||
expiration = data.expiration_date
|
||||
if isinstance(expiration, list):
|
||||
expiration = expiration[0]
|
||||
date_items.append(f"<strong>Expires:</strong> {expiration}")
|
||||
expiration_date = data.expiration_date
|
||||
if expiration_date:
|
||||
if isinstance(expiration_date, list):
|
||||
expiration_date = expiration_date[0]
|
||||
rows.append(('⏰', 'Expires', str(expiration_date)))
|
||||
|
||||
if date_items:
|
||||
sections.append('<br>'.join(date_items))
|
||||
# Name servers
|
||||
if data.name_servers:
|
||||
ns_sorted = sorted(data.name_servers)
|
||||
ns_text = ', '.join(ns_sorted[:5])
|
||||
if len(ns_sorted) > 5:
|
||||
ns_text += f' (+{len(ns_sorted)-5} more)'
|
||||
rows.append(('🌍', 'Name Servers', ns_text))
|
||||
|
||||
# Status
|
||||
if hasattr(data, 'status'):
|
||||
if data.status:
|
||||
status = data.status
|
||||
if isinstance(status, list):
|
||||
status = '<br>'.join(status[:3]) # Limit to first 3 status entries
|
||||
sections.append(f"<strong>Status:</strong><br>{status}")
|
||||
status = ', '.join(status[:3])
|
||||
rows.append(('🔒', 'Status', str(status)))
|
||||
|
||||
# Name Servers
|
||||
if hasattr(data, 'name_servers'):
|
||||
name_servers = data.name_servers
|
||||
if isinstance(name_servers, list):
|
||||
if len(name_servers) > 5:
|
||||
name_servers_list = '<br>'.join(sorted(name_servers)[:5])
|
||||
name_servers_list += f"<br><em>...(+{len(name_servers) - 5} more)</em>"
|
||||
else:
|
||||
name_servers_list = '<br>'.join(sorted(name_servers))
|
||||
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
|
||||
# Contact info
|
||||
if data.org:
|
||||
rows.append(('🏛️', 'Organization', data.org))
|
||||
if data.country:
|
||||
rows.append(('🌍', 'Country', data.country))
|
||||
if data.state:
|
||||
rows.append(('🏙️', 'State', data.state))
|
||||
if data.city:
|
||||
rows.append(('🏡', 'City', data.city))
|
||||
|
||||
return rows
|
||||
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""
|
||||
Function to handle the !whois command.
|
||||
|
||||
Args:
|
||||
room (Room): The Matrix room where the command was invoked.
|
||||
message (RoomMessage): The message object containing the command.
|
||||
bot (Bot): The bot object.
|
||||
prefix (str): The command prefix.
|
||||
config (dict): Configuration parameters.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
|
||||
if match.is_not_from_this_bot() and match.prefix() and match.command("whois"):
|
||||
args = match.args()
|
||||
|
||||
if len(args) < 1:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
"Usage: !whois <domain/ip>\nExample: !whois example.com\nExample: !whois 8.8.8.8"
|
||||
)
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !whois <domain/ip>\nExample: !whois example.com")
|
||||
return
|
||||
|
||||
query = args[0].strip()
|
||||
logging.info(f"Received !whois command for: {query}")
|
||||
|
||||
# Validate the query
|
||||
if not is_valid_domain(query) and not is_valid_ip(query):
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
f"Invalid domain or IP address format: {query}\nPlease provide a valid domain (e.g., example.com) or IP address."
|
||||
)
|
||||
logging.warning(f"Invalid WHOIS query format: {query}")
|
||||
await bot.api.send_text_message(room.room_id, f"Invalid input: {html_escape(query)}")
|
||||
return
|
||||
|
||||
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {html_escape(query)}...")
|
||||
|
||||
try:
|
||||
# Perform WHOIS lookup
|
||||
logging.info(f"Performing WHOIS lookup for: {query}")
|
||||
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {query}...")
|
||||
loop = asyncio.get_running_loop()
|
||||
data = await loop.run_in_executor(None, whois.whois, query)
|
||||
|
||||
# Use python-whois library
|
||||
whois_data = whois.whois(query)
|
||||
|
||||
# Format and send the results
|
||||
result_message = format_whois_data(query, whois_data)
|
||||
await bot.api.send_markdown_message(room.room_id, result_message)
|
||||
logging.info(f"Successfully sent WHOIS results for {query}")
|
||||
rows = _build_rows(data)
|
||||
sections = [{"title": "", "rows": rows}] # no section header
|
||||
block = code_block(f"🌐 WHOIS Report: {html_escape(query)}", sections)
|
||||
output = collapsible_summary(f"🌐 WHOIS Report: {html_escape(query)}", block)
|
||||
await bot.api.send_markdown_message(room.room_id, output)
|
||||
|
||||
except whois.parser.PywhoisError as e:
|
||||
error_msg = f"WHOIS lookup failed for {query}.\n"
|
||||
error_msg += "Possible reasons:\n- Domain/IP not found\n- WHOIS server unavailable\n- Rate limited by registrar"
|
||||
await bot.api.send_text_message(room.room_id, error_msg)
|
||||
logging.error(f"WHOIS lookup error for {query}: {e}")
|
||||
|
||||
await bot.api.send_text_message(room.room_id, f"❌ WHOIS lookup failed: {html_escape(str(e))}")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(
|
||||
room.room_id,
|
||||
f"An unexpected error occurred during WHOIS lookup for {query}. Please try again later."
|
||||
)
|
||||
logging.error(f"Unexpected error in WHOIS plugin for {query}: {e}", exc_info=True)
|
||||
await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {html_escape(str(e))}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.2.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "WHOIS lookup"
|
||||
__description__ = "Domain WHOIS lookup"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>!whois</strong> – WHOIS lookup</summary>
|
||||
<p><code>!whois <domain or IP></code> – Shows registrar, creation/expiry dates, nameservers, contacts.</p>
|
||||
<pre>
|
||||
!whois <domain or IP> Shows registrar, dates, nameservers, etc. in a clean table.
|
||||
</pre>
|
||||
</details>
|
||||
"""
|
||||
|
||||
@@ -44,6 +44,6 @@ def generate_output(results):
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "YouTube video search (async)"
|
||||
__description__ = "YouTube video search"
|
||||
__help__ = """<details><summary><strong>!yt</strong> – Search YouTube</summary>
|
||||
<p><code>!yt <search terms></code></p></details>"""
|
||||
|
||||
@@ -31,3 +31,4 @@ yara-python
|
||||
asn1crypto
|
||||
PyYAML
|
||||
lxml
|
||||
wcwidth
|
||||
Reference in New Issue
Block a user