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