Compare commits

...

7 Commits

39 changed files with 5586 additions and 4264 deletions
+817 -137
View File
@@ -1,76 +1,147 @@
I wrote this bot in one night, while I'm recovering from two level cervical spinal surgery, CDA Cervical Discectomy and Disc Arthroplasty. Expect a lot of bugs.
# 🍄 Funguy Bot
# Matrix Bot
A modular, security-focused Matrix chat bot built with [`simplematrixbotlib`](https://simple-matrix-bot-lib.readthedocs.io/), written in Python. Features a plugin architecture, in-process cron scheduling, per-room plugin management, rate limiting, and a full suite of recon, encoding, and utility commands.
Matrix Bot is a Python-based chat bot designed to work with Matrix, an open network for secure, decentralized communication. This bot is built using the `simplematrixbotlib` library and provides various commands and functionalities for interacting with Matrix rooms.
> Written during recovery from cervical spinal surgery (CDA Cervical Discectomy and Disc Arthroplasty). Expect bugs — please report them!
## Features
---
- Modular architecture: Commands are implemented as separate plugins, making it easy to add or modify functionality.
- Command handling: The bot listens for specific commands prefixed with `!` and responds accordingly.
- Plugin system: Each command is implemented as a separate plugin module, allowing for easy customization and extension.
- Extensible: Users can add new commands by creating additional plugin modules.
## 📋 Table of Contents
## Automatic Installation
Run the installation script
1. `./install-funguy.sh`
- [Features](#-features)
- [Requirements](#-requirements)
- [Installation](#-installation)
- [Configuration](#-configuration)
- [Running the Bot](#-running-the-bot)
- [Core Admin Commands](#-core-admin-commands)
- [Plugin Reference](#-plugin-reference)
- [Rate Limiting](#-rate-limiting)
- [Security Notes](#-security-notes)
- [Support](#-support)
- [Credits](#-credits)
2. Launch the bot:
`sudo systemctl start funguybot`
---
## Manual Installation
## ✨ Features
1. Create python venv
`python3 -m venv venv`
`source venv/bin/activate`
- **Modular plugin system** each command lives in its own `plugins/*.py` file; add or remove features without touching core code
- **Runtime plugin management** load, unload, enable, or disable plugins per-room with no restart required
- **In-process cron scheduler** schedule any bot command to fire automatically (APScheduler + SQLite, no system crontab needed)
- **Rate limiting** non-admin users are capped at 3 commands per 5-second window; admins are always exempt
- **SSRF protection** all outbound network plugins validate that targets resolve to public IPs only
- **Admin/moderator access control** a dedicated `admin_user` is set in config; moderation commands require power level ≥ 50
- **Live configuration** settings can be changed at runtime with `!set`/`!saveconf` without restarting
2. Clone the repository:
`git clone https://gitlab.com/Eggzy/funguybot.git`
---
3. Apply the patch
`cp api.py.patch simplematrixbotlib`
`git apply api.py.patch`
## 📦 Requirements
4. Install dependencies:
`cd simplematrixbotlib && pip install .`
`cd ../ && pip install -r requirements.txt`
- Python 3.9+
- A Matrix homeserver account for the bot
- `fortune` package installed on the host (`sudo apt install fortune`) if using `fortune.py`
- `dict` / WordNet installed on the host (`sudo apt install dict dict-wn`) if using `dictionary.py`
- Optional API keys for certain plugins (see [Configuration](#-configuration))
5. If you use the Goodreads quote plugin (quote.py):
---
`pip install playwright beautifulsoup4 lxml`
`playwright install chromium`
## 🚀 Installation
6. Set up environment variables:
Create/Edit `.env` file in the root directory of the bot and add the following variables:
**1. Clone the repository**
```
MATRIX_URL="https://matrix.org" (or another homeserver)
MATRIX_USER=""
MATRIX_PASS=""
OPENWEATHER_API_KEY="" # Optional: For weather plugin
SMTP_SERVER = "example.com`"
SMTP_PORT = 465
SMTP_USER = "name@domain.tld"
SMTP_PASSWORD = "somepassword"
OPENWEATHER_API_KEY=
SHODAN_KEY=
DNSDUMPSTER_KEY=
LASTFM_API_KEY=
YOUTUBE_API_KEY=
INFERMATIC_API=
INFERMATIC_MODEL=
OMDB_API_KEY=
GNEWS_API_KEY=
```bash
git clone https://gitlab.com/Eggzy/funguybot.git
cd funguybot
```
7. Create systemd.service
Create `/etc/systemd/system/funguybot.service`
Replace `$working_directory` with your bot install path
**2. Create and activate a Python virtual environment**
```bash
python3 -m venv venv
source venv/bin/activate
```
**3. Install dependencies**
```bash
pip3 install -r requirements.txt
```
**4. (Optional) Install Playwright for the Goodreads quote plugin (`quote.py`)**
```bash
pip3 install playwright beautifulsoup4 lxml
playwright install chromium
```
**5. Configure the bot** — create your `.env` and `funguy.conf` files (see [Configuration](#-configuration))
**6. Set up and start the systemd service** (see [Running the Bot](#-running-the-bot))
---
## ⚙️ Configuration
### Environment Variables (`.env`)
Create a `.env` file in the bot's root directory. Only the three Matrix variables are required; all API keys are optional.
```env
# ── Required ──────────────────────────────────────────────────────────
MATRIX_URL="https://matrix.org" # Your homeserver URL
MATRIX_USER="@yourbot:matrix.org" # Bot's Matrix ID
MATRIX_PASS="your_password" # Bot's password
# ── Logging (optional, default: INFO) ─────────────────────────────────
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR | CRITICAL
# ── Plugin API Keys (all optional) ────────────────────────────────────
OPENWEATHER_API_KEY= # weather.py
SHODAN_KEY= # shodan.py
DNSDUMPSTER_KEY= # dnsdumpster.py
LASTFM_API_KEY= # lastfm.py
YOUTUBE_API_KEY= # youtube-search.py
INFERMATIC_API= # infermatic-text.py
INFERMATIC_MODEL= # infermatic-text.py model name string
OMDB_API_KEY= # imdb.py
GNEWS_API_KEY= # news.py
# ── SMTP (optional, for notification plugins) ─────────────────────────
SMTP_SERVER=mail.example.com
SMTP_PORT=465
SMTP_USER=bot@example.com
SMTP_PASSWORD=yourpassword
```
### Bot Configuration (`funguy.conf`)
Create `funguy.conf` in the bot's root directory. This is a TOML file consumed by `simplematrixbotlib` and the `config.py` plugin.
```toml
[simplematrixbotlib.config]
admin_user = "@youradmin:matrix.org" # Full Matrix ID of the admin
prefix = "!" # Single-character command prefix
join_on_invite = true
encryption_enabled = false
emoji_verify = false
ignore_unverified_devices = true
store_path = "./store/"
# Pre-disable plugins per room (optional)
[plugins.disabled]
# "!yourroom:matrix.org" = ["pluginname1", "pluginname2"]
```
All writable options can also be changed live by the admin using `!set` (see [config.py](#configpy--live-configuration-admin-only)).
---
## 🤖 Running the Bot
### Systemd Service (Recommended)
Create `/etc/systemd/system/funguybot.service`, replacing the `$variables` with your actual paths and user:
```ini
[Unit]
Description=Funguy Bot Service
After=network.target
@@ -88,97 +159,706 @@ SyslogIdentifier=funguybot
[Install]
WantedBy=multi-user.target
EOF
```
8. Launch Fungy
```
```bash
systemctl daemon-reload
systemctl enable funguybot
systemctl start funguybot
```
# 🍄 Funguy Bot Commands 🍄
### Direct Launch
## Available Plugins
The bot includes the following plugins:
- **admin.py**: Full room moderation multiword name support
- **arxiv.py**: arXiv academic paper search (with rate limiting and error reporting)
- **bitcoin.py**: Current Bitcoin price
- **config.py**: Admin-only configuration commands (preserves disabled plugins)
- **cron.py**: Inprocess cron scheduler (roomaware, no system crontab)
- **date.py**: Show current date and time
- **ddg.py**: DuckDuckGo search collapsible results (ddgs library, no API key)
- **dictionary.py** Dictionary plugin to fetch word definitions from dict
- **dns.py**: DNS reconnaissance
- **dnsdumpster.py**: DNSDumpster domain reconnaissance
- **exploitdb.py**: Exploit-DB search
- **fortune.py**: Random fortune message
- **geo.py**: IP geolocation lookup
- **hackernews.py**: Hacker News integration
- **hashid.py**: Hash type identifier
- **headers.py**: HTTP security header analysis
- **help.py**: Plugin for dynamically aggregating help from all loaded plugins.
- **imdb.py**: IMDb lookup via OMDb API
- **infermatic-text.py**: AI text generation via Infermatic API
- **isup.py**: Check if a site is up
- **joke.py**: Get a random joke from the joke APIs
- **karma.py**: Room karma tracking system (display names only, no Matrix IDs)
- **lastfm.py**: Last.fm integration
- **loadplugin.py**: Load/unload plugins at runtime
- **news.py**: News headlines via GNews API
- **plugins.py**: List all loaded plugins
- **proxy.py**: Working SOCKS5 proxy finder
- **quote.py**: Goodreads quotes via headless browser (Playwright)
- **roomstats.py**: Peruser room statistics (Limnoriastyle), with multiword name support
- **shodan.py**: Shodan.io reconnaissance
- **sslscan.py**: SSL/TLS security scanner
- **stable-diffusion.py**: Stable Diffusion image generation
- **subdomains.py**: Subdomain enumeration via CertSpotter
- **sysinfo.py**: System information and monitoring
- **timezone.py**: World clock (no hardcoded cities)
- **urbandictionary.py**: Urban Dictionary definitions
- **weather.py**: Weather forecast (OWM primary, OpenMeteo fallback)
- **welcome.py**: Room welcome message
- **whois.py**: WHOIS lookup
- **wikipedia.py**: Wikipedia article summary
- **xkcd.py**: Random XKCD comic
- **youtube-search.py**: YouTube video search
## Configuration
The bot uses a TOML configuration file (`funguy.conf`) for settings:
- `admin_user` - Matrix user ID with admin privileges
- `prefix` - Command prefix (default: "!")
- Plugin-specific settings in `plugins/ai.json` for AI commands
## Dependencies
- Python 3.7+
- simplematrixbotlib
- Various AI/ML services (Stable Diffusion, Ollama, etc.)
- Database support (SQLite)
- External APIs (OpenWeatherMap, Urban Dictionary, YouTube)
## Dependencies
- Ensure all environment variables are set correctly
- Check that required services are running (Stable Diffusion API, Ollama, etc.)
- Verify plugin permissions and whitelist settings
- Check logs for detailed error information
## Support
Join our Matrix room for support and community:
[Self-hosting | Security | Sysadmin | Homelab | Programming](https://matrix.to/#/#selfhosting:mozilla.org)
## Credits
**🧙‍♂️ Creator & Developer**: HB (@hashborgir:mozilla.org)
**🍄 Funguy Bot** - Created during recovery from cervical spinal surgery
```bash
source venv/bin/activate
python3 funguy.py
```
---
*Note: This bot was created rapidly and may contain bugs. Please report issues and contribute improvements!*
## 🔑 Core Admin Commands
These commands are handled by `funguy.py` itself and require `admin_user` privileges.
| Command | Description |
|---------|-------------|
| `!reload` | Reload all plugins from disk (no restart needed) |
| `!load <plugin>` | Dynamically load a single plugin by filename (without `.py`) |
| `!unload <plugin>` | Dynamically unload a single plugin |
| `!enable <plugin>` | Re-enable a plugin in the current room |
| `!disable <plugin>` | Disable a plugin in the current room (persisted to `funguy.conf`) |
| `!restart` | Gracefully stop the bot process (systemd will restart it automatically) |
| `!rehash` | Reload `funguy.conf` and the disabled-plugin list without restarting |
---
## 🧩 Plugin Reference
All commands use the prefix defined in `funguy.conf` (default `!`). Plugin filenames correspond to the names used with `!load` / `!unload` / `!disable` / `!enable`.
---
### 🛡️ admin.py Room Moderation
Full moderator toolkit with multi-word display name resolution. Requires power level ≥ 50 or `admin_user`. The bot automatically resolves multi-word display names and prompts with a numbered disambiguation list when names are ambiguous.
| Command | Description |
|---------|-------------|
| `!kick <name\|@user> [reason]` | Kick a user from the room |
| `!ban <name\|@user> [reason]` | Ban a user |
| `!unban <@user:domain>` | Unban a user (full MXID required) |
| `!invite <name\|@user>` | Invite a user to the room |
| `!op <name\|@user> [power_level]` | Promote a user (max 50 / moderator level) |
| `!deop <name\|@user>` | Demote a user to power level 0 |
| `!userinfo <name\|@user>` | Show display name and power level |
| `!topic [new topic]` | View or set the room topic |
| `!roomname [new name]` | View or set the room name |
| `!avatar [mxc://…]` | View or set the room avatar (must be an `mxc://` URL) |
| `!members` | List all joined members with power levels |
| `!bans` | List all banned users |
| `!modhelp` | Show moderator command reference |
`!admin <action>` also works as an explicit parent command for all of the above.
---
### 📄 arxiv.py arXiv Academic Search
| Command | Description |
|---------|-------------|
| `!arxiv <query>` | Search papers with abstracts |
| `!arxiv list <query>` | Search titles only (no abstracts) |
| `!arxiv category <cat>` | Browse recent papers by category |
| `!arxiv recent [category]` | Papers from the last 7 days |
| `!arxiv random` | Random paper |
| `!arxiv <id>` | Fetch paper by arXiv ID (e.g. `2101.00101`) |
**Categories:** `ai`, `ml`, `security`, `crypto`, `cv`, `nlp`, `math`, `physics`, `quantum`, `bio`, `software`
---
### ₿ bitcoin.py Bitcoin Price
```
!btc
```
Fetches the latest BTC/USD price from `bitcointicker.co` (Bitstamp feed, 60-second intervals). No API key required.
---
### ⚙️ config.py Live Configuration *(Admin Only)*
Manage bot settings at runtime. Changes are in-memory until you run `!saveconf`.
| Command | Description |
|---------|-------------|
| `!set <option> <value>` | Change a configuration option |
| `!get <option>` | View the current value of one option |
| `!show` | Display all current configuration values |
| `!saveconf` | Write current settings to `funguy.conf` |
| `!loadconf` | Reload settings from `funguy.conf` |
| `!reset` | Reset all options to defaults (preserves `admin_user`) |
| `!config help` | Show config command help |
**Configurable options:** `prefix`, `timeout`, `join_on_invite`, `encryption_enabled`, `emoji_verify`, `ignore_unverified_devices`, `store_path`, `allowlist`, `blocklist`
> `admin_user` is read-only via `!set` — edit `funguy.conf` directly to change it.
---
### 🕐 cron.py Scheduled Commands *(Admin Only)*
In-process cron scheduler backed by APScheduler and SQLite (`cron_jobs.db`). Room context is automatically derived from where the command is issued — no room ID needed.
| Command | Description |
|---------|-------------|
| `!cron add <cron_expr> <command> [tz=IANA]` | Schedule a command in the current room |
| `!cron remove <job_id>` | Delete a job |
| `!cron list` | List jobs in the current room |
| `!cron list *` | List all jobs across all rooms |
| `!cron enable <job_id>` | Re-enable a paused job |
| `!cron disable <job_id>` | Pause a job without deleting it |
| `!cron clear` | Remove all jobs from the current room |
**Cron expression format:** 5 fields — `minute hour day-of-month month day-of-week`
```
!cron add 0 8 * * * !weather London tz=Europe/London
```
Timezone defaults to UTC. Use any IANA zone name (e.g. `tz=America/New_York`).
---
### 📅 date.py Date & Time
```
!date
```
Displays the current day of the week, ordinal date (e.g. "the 9th"), and 12-hour time. No API key required.
---
### 🔍 ddg.py DuckDuckGo Search
No API key required. Uses the `ddgs` library.
| Command | Description |
|---------|-------------|
| `!ddg <query>` | Top web result snippet (collapsible) |
| `!ddg search <query>` | 5 web results |
| `!ddg image <query>` | 3 image results |
| `!ddg news <query>` | 3 news articles |
| `!ddg video <query>` | 3 video results |
| `!ddg bang <!bang query>` | DuckDuckGo bang redirect |
| `!ddg define <word>` | Word definition |
| `!ddg calc <expression>` | Calculator |
| `!ddg weather [location]` | Weather snippet |
---
### 📖 dictionary.py Word Definitions
```
!define <word>
```
Looks up definitions using the system `dict` command against WordNet (with fallback to all installed dictionaries). Requires the `dict` and `dict-wn` packages on the host (`sudo apt install dict dict-wn`).
---
### 🌐 dns.py DNS Reconnaissance *(SSRF-safe)*
```
!dns <domain>
```
Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, and PTR records and outputs a clean, emoji-aligned table. Private/internal IP targets are blocked.
---
### 🗺️ dnsdumpster.py DNSDumpster Recon
Requires `DNSDUMPSTER_KEY` in `.env`.
| Command | Description |
|---------|-------------|
| `!dnsdumpster <domain>` | Full DNS mapping via DNSDumpster API |
| `!dnsdumpster test` | Test API connection against `google.com` |
---
### 🔧 encode.py CyberChef-Style Toolkit
A fully offline, CyberChef-like data manipulation plugin with dozens of operations across encoding, cryptography, compression, data processing, forensics, and networking.
```
!encode <operation> [arguments] <data>
!encode help <op> # Detailed help for a specific operation
```
**Encoding**
| Operation | Example |
|-----------|---------|
| `base64 encode\|decode` | `!encode base64 encode Hello World` |
| `base32 encode\|decode` | `!encode base32 encode Hello` |
| `hex encode\|decode` | `!encode hex encode Secret` |
| `url encode\|decode` | `!encode url encode https://example.com/a b` |
| `html encode\|decode` | `!encode html encode "<script>"` |
| `unicode encode\|decode` | `!encode unicode encode café` |
| `binary encode\|decode` | `!encode binary encode Hi` |
| `rot13` | `!encode rot13 Uryyb Jbeyq` |
| `morse encode\|decode` | `!encode morse encode SOS` |
**Cryptography**
| Operation | Example |
|-----------|---------|
| `xor <key_hex>` | `!encode xor 41 Hello` |
| `aes encrypt\|decrypt <key_hex> <iv_hex>` | AES-CBC |
| `chacha20 encrypt\|decrypt <key_hex> <nonce_hex>` | ChaCha20 |
| `rsa encrypt\|decrypt <PEM_key>` | RSA-OAEP |
| `md5` / `sha1` / `sha256` / `sha512` | `!encode sha256 hello` |
| `sha3-256` / `sha3-512` | `!encode sha3-256 hello` |
| `hmac <algo> <key_hex>` | `!encode hmac sha256 6b6579 message` |
| `bcrypt hash <rounds>` / `bcrypt verify` | `!encode bcrypt hash 12 mypassword` |
| `argon2 hash <params>` / `argon2 verify` | PHC format |
| `pbkdf2 <salt_hex> <iters> <keylen> <algo>` | `!encode pbkdf2 aabbccdd 100000 32 sha256 pass` |
**Compression**
| Operation | Example |
|-----------|---------|
| `gzip compress\|decompress` | `!encode gzip compress "Hello World"` |
| `zlib compress\|decompress` | `!encode zlib compress "Hello World"` |
| `bzip2 compress\|decompress` | `!encode bzip2 compress "Hello World"` |
| `lzma compress\|decompress` | `!encode lzma compress "Hello World"` |
| `deflate compress\|decompress` | `!encode deflate compress "Hello World"` |
| `zstd compress\|decompress` | `!encode zstd compress "Hello World"` |
**Data Processing**
| Operation | Example |
|-----------|---------|
| `json format\|validate` | `!encode json format '{"key":"value"}'` |
| `xml format` | `!encode xml format "<root><a>1</a></root>"` |
| `yaml format\|tojson` | `!encode yaml format "key: value"` |
| `csv` | Parse CSV (first row as header) |
| `asn1` | Parse ASN.1 DER (base64 input) |
| `pemder topem\|toder` | PEM ↔ DER conversion |
**Forensics**
| Operation | Description |
|-----------|-------------|
| `entropy` | Shannon entropy of input |
| `ioc` | Extract IPs, domains, URLs, emails, hashes |
| `strings` | Extract ASCII strings (≥4 chars) from hex data |
| `filemagic` | Detect file type by magic bytes |
| `base64blob` | Find embedded Base64 blobs in text |
| `xbrute` | XOR single-byte brute force |
| `yara` | Scan with a YARA rule (rule as base64) |
| `peinfo` | PE header analysis (hex input) |
**Networking**
| Operation | Example |
|-----------|---------|
| `cidr` | `!encode cidr 192.168.1.0/24` |
| `ipconv hex\|dec\|binary` | `!encode ipconv hex 192.168.1.1` |
| `urlparse` | `!encode urlparse https://user:pass@example.com:8080/path?q=1` |
| `dns` | `!encode dns example.com` |
**Recipes** — chain multiple operations:
```
!encode recipe list
!encode recipe run '{"steps":[{"op":"base64","args":["encode"]},{"op":"hex","args":["encode"]}]}' "hello world"
!encode recipe run 'base64 encode | hex encode :: hello world'
```
**Recipe Commands Details:**
- `!encode recipe list` - Lists all available operations that can be chained together
- `!encode recipe run '<json>' <data>` - Execute a JSON recipe on input data
- `!encode recipe run '<op> arg | <op> arg :: <data>'` - Execute a pipe-style recipe with data
JSON recipes allow complex operation chaining with the format:
```json
{
"steps": [
{"op": "base64", "args": ["encode"]},
{"op": "hex", "args": ["encode"]}
]
}
```
Pipe-style recipes provide a simpler syntax:
```
base64 encode | hex encode :: hello world
```
---
### 💣 exploitdb.py Exploit-DB Search
```
!exploitdb <search term> [max_results]
```
Searches the Exploit-DB CSV export from GitLab. Returns up to 10 results (default 5) with title, EDB-ID, type, platform, author, and a direct link. No API key required.
---
### 🃏 fortune.py Random Fortune
```
!fortune
```
Runs `/usr/games/fortune` on the host and sends the result to the room. Requires the `fortune` package (`sudo apt install fortune`).
---
### 📍 geo.py IP / Domain Geolocation
```
!geo <ip_address or domain>
```
Geolocates an IP or domain using `ip-api.com` (primary) with `ipapi.co` as fallback. Shows country, city, region, postal code, coordinates, timezone, ISP, organization, and ASN. Private IP targets are blocked.
---
### 📰 hackernews.py Hacker News
No API key required. Uses the official Firebase HN API and Algolia search.
| Command | Description |
|---------|-------------|
| `!hn` | Top 5 stories (default) |
| `!hn top\|new\|best\|ask\|show\|job` | Stories by type |
| `!hn story <id>` | Full story details |
| `!hn comments <id>` | Top comments for a story |
| `!hn search <query>` | Full-text search via Algolia |
Append a number to set the result count: `!hn new 10`
---
### 🔐 hashid.py Hash Type Identifier
```
!hashid <hash>
```
Identifies 100+ hash formats including MD5, SHA family, bcrypt, Argon2, yescrypt, scrypt, PBKDF2, NTLM, NetNTLMv2, LM, LDAP, Oracle, MSSQL, MySQL, phpBB3, WordPress, Drupal, and more. Displays Hashcat mode (`-m`) and John the Ripper format (`--format`) for each match, sorted by confidence.
---
### 🔒 headers.py HTTP Security Header Analysis
```
!headers <url>
```
Fetches a URL and analyzes its HTTP security headers. Outputs a security score (0100) plus a structured breakdown covering HSTS, CSP, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, SSL certificate details, and actionable recommendations. Private/internal addresses are blocked.
---
### ❓ help.py Dynamic Help
```
!help
```
Aggregates and displays the `__help__` metadata from every currently loaded plugin in a single collapsible message.
---
### 🎬 imdb.py IMDb / OMDb Lookup
Requires `OMDB_API_KEY` in `.env`.
| Command | Description |
|---------|-------------|
| `!imdb <title>` | Full details + poster image |
| `!imdb id <tt1234567>` | Lookup by IMDb ID |
| `!imdb search <query>` | Search titles |
| `!imdb episode <series> -s N -e N` | Episode info |
Optional flags: `-y <year>`, `-t movie|series|episode`, `--short-plot`
---
### 🤖 infermatic-text.py AI Text Generation
Requires `INFERMATIC_API` (and optionally `INFERMATIC_MODEL`) in `.env`.
| Command | Description |
|---------|-------------|
| `!text <prompt>` | Generate text with the default model |
| `!text --list-models` | List available models |
| `!text --use-model <model> <prompt>` | Use a specific model |
Optional flags: `--temperature <0.01.0>` (default 0.9), `--max-tokens <N>` (default 512)
---
### 🌐 isup.py Site Availability Check
```
!isup <domain or IP>
```
Performs DNS resolution then checks HTTP/HTTPS availability, reporting the status code and response time.
---
### 😂 joke.py Random Jokes
```
!joke # General joke
!joke programming # Programming joke
```
Fetches jokes from the Official Joke API. No API key required.
---
### ⭐ karma.py Room Karma Tracking
Karma is tracked by display name — Matrix IDs (`@user:server`) are **not** accepted.
| Command | Description |
|---------|-------------|
| `!karma <user>` | Show karma points for a user |
| `!karma++ <user>` / `!-- <user>` | Give or remove karma |
| `!karma top [n]` / `!karma bottom [n]` | Leaderboard (top or bottom N) |
| `!karma rank <user>` | Show a user's rank |
| `!karma stats` | Room-wide statistics |
| `!karma history <user>` | Recent votes for a user |
Shortcuts: `!++ user`, `!-- user`, or inline `username++` / `username--` anywhere in a message.
---
### 🎵 lastfm.py Last.fm Music Stats
Requires `LASTFM_API_KEY` in `.env`. Run `!lastfm` for the full command list. Outputs neatly aligned code blocks.
---
### 📡 news.py News Headlines
Requires `GNEWS_API_KEY` in `.env`.
| Command | Description |
|---------|-------------|
| `!news` | Top headlines |
| `!news top\|world\|tech\|business\|science\|health\|sports\|crypto` | Category headlines |
| `!news search <query>` | Search news |
Append a number to set result count: `!news tech 8`
---
### 🔌 plugins.py List Loaded Plugins
```
!plugins
```
Displays the total count of loaded plugins and a collapsible list with each plugin's name and description.
---
### 🔗 proxy.py SOCKS5 Proxy Finder
```
!proxy
```
Fetches a public proxy list, tests each candidate for connectivity, and returns a random working SOCKS5 proxy with its measured latency.
---
### 💬 quote.py Goodreads Quotes
```
!quote random
!quote <author>
```
Scrapes Goodreads using Playwright (headless Chromium). Requires `playwright`, `beautifulsoup4`, and `lxml` to be installed (see [Installation](#-installation)).
---
### 📊 roomstats.py Per-User Room Statistics
| Command | Description |
|---------|-------------|
| `!roomstats` | Aggregate room stats + top 10 users |
| `!rank <stat>` | Top 10 users by a specific statistic |
| `!stats [name]` | Show statistics for a specific user |
---
### 🔎 shodan.py Shodan Reconnaissance
Requires `SHODAN_KEY` in `.env`.
| Command | Description |
|---------|-------------|
| `!shodan ip <ip>` | IP info with open ports and services |
| `!shodan search <query>` | Search internet-connected devices |
| `!shodan host <domain>` | Host and subdomain enumeration |
| `!shodan count <query>` | Result count for a query |
---
### 🔐 sslscan.py SSL/TLS Security Scanner
```
!sslscan <domain[:port]>
```
Tests SSL/TLS protocol support, cipher suites, certificate validity, and known vulnerabilities. Provides a security score (0100) and actionable recommendations. Defaults to port 443.
---
### 🎨 stable-diffusion.py Image Generation
Requires a locally running Stable Diffusion API (e.g. AUTOMATIC1111 or ComfyUI).
```
!sd [options] <prompt>
```
| Flag | Description |
|------|-------------|
| `--steps N` | Sampling steps (default 4) |
| `--cfg <scale>` | CFG scale (default 2) |
| `--h H --w W` | Image height / width in pixels (default 512×512) |
| `--neg <prompt>` | Negative prompt |
| `--sampler <name>` | Sampler name (default `DPM++ SDE`) |
| `--seed <N>` | Deterministic seed |
LORAs can be embedded directly in the prompt: `<lora:filename:weight>`
---
### 🌍 subdomains.py Subdomain Enumeration
```
!subdomains <domain>
```
Discovers subdomains using SSL certificate transparency logs via the CertSpotter API. No API key required.
---
### 🧮 subnet.py Subnet Calculator
| Command | Description |
|---------|-------------|
| `!subnet info <CIDR>` | Detailed network info (network, broadcast, hosts, mask, etc.) |
| `!subnet split <CIDR> --prefix <N>` | Split into smaller subnets by new prefix length |
| `!subnet split <CIDR> --diff <N>` | Split by prefix delta |
| `!subnet adjacent <CIDR> <count>` | Show current and adjacent networks |
Supports both IPv4 and IPv6. RFC 3021 `/31` and `/32` networks are handled correctly (both addresses listed as usable).
---
### 💻 sysinfo.py System Information
```
!sysinfo
```
Displays CPU usage and model, RAM, disk, network I/O, GPU info, temperature sensors, and top processes in a clean, emoji-aligned code block.
---
### 🕒 timezone.py World Clock
```
!time <city> # Any city worldwide (geocoded)
!time <IANA zone> # e.g. Europe/London, Asia/Karachi
!time help # Show help
```
Examples: `!time Lahore`, `!time New York`, `!time America/Chicago`
No city names are hardcoded. IANA zones resolve completely offline; city name lookup uses free geocoding.
---
### 📚 urbandictionary.py Urban Dictionary
```
!ud <term>
```
Fetches top definitions from Urban Dictionary. No API key required.
---
### ☁️ weather.py Current Weather
```
!weather <location>
```
Shows temperature, feels-like, conditions, humidity, wind speed, and more in a clean aligned table. Uses OpenWeatherMap as the primary source (requires `OPENWEATHER_API_KEY`) with Open-Meteo as a free fallback.
---
### 👋 welcome.py Room Welcome Message
```
!welcome
```
Manually triggers the room's configured welcome message for the requesting user.
---
### 🔍 whois.py WHOIS Lookup
```
!whois <domain or IP>
```
Returns registrar, creation/expiry dates, nameservers, and registrant info in a clean aligned table.
---
### 📖 wikipedia.py Wikipedia Summary
```
!wp <search term>
```
Returns the lead section and main image of a Wikipedia article using the MediaWiki API. No scraping, no API key required.
---
### 🗂️ xkcd.py xkcd Comics
```
!xkcd # Random comic
!xkcd <number> # Specific comic (e.g. !xkcd 538)
```
Fetches comics directly from the xkcd JSON API. No API key required.
---
### 🎥 youtube-search.py YouTube Search
Requires `YOUTUBE_API_KEY` in `.env`.
```
!yt <search query>
```
Returns the top video results from YouTube with titles, channel names, and direct links.
---
## ⚡ Rate Limiting
Non-admin users are limited to **3 commands per 5 seconds**. Exceeding this limit returns:
> ⛔ You're sending commands too quickly. Please wait a few seconds.
The `admin_user` defined in `funguy.conf` is always exempt from rate limiting.
---
## 🔐 Security Notes
- All plugins that make outbound HTTP requests (`geo`, `dns`, `headers`, `sslscan`, etc.) validate that the target resolves to a **public IP address** before connecting. Private, loopback, link-local, and reserved ranges are blocked.
- The `admin_user` config field is **read-only via `!set`** — it can only be changed by editing `funguy.conf` directly, preventing privilege escalation through chat commands.
- Moderator commands (`!kick`, `!ban`, `!op`, etc.) require Matrix power level ≥ 50 in the room, independent of the global admin setting.
- The `encode.py` plugin operates entirely **offline** — no data is ever sent to external services.
---
## 💬 Support
Join the community Matrix room for help, bug reports, and discussion:
**[Self-hosting | Security | Sysadmin | Homelab | Programming](https://matrix.to/#/#selfhosting:mozilla.org)**
---
## 🧙 Credits
**Creator & Developer:** HB ([@hashborgir:mozilla.org](https://matrix.to/#/@hashborgir:mozilla.org))
🍄 *Funguy Bot — built during recovery from cervical spinal surgery. Bugs are features in disguise.*
-32
View File
@@ -1,32 +0,0 @@
From 7b3421cf893ef8ea36978ae1343f7c8d5d353412 Mon Sep 17 00:00:00 2001
From: Hash Borgir <hash@stoned.io>
Date: Tue, 13 Feb 2024 15:48:35 -0700
Subject: [PATCH] api.py patch
---
simplematrixbotlib/api.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/simplematrixbotlib/api.py b/simplematrixbotlib/api.py
index 6d51b38..3af7e7e 100644
--- a/simplematrixbotlib/api.py
+++ b/simplematrixbotlib/api.py
@@ -347,6 +347,7 @@ class Api:
pass # Successful upload
else:
print(f"Failed Upload Response: {resp}")
+ return
content = {
"body": os.path.basename(image_filepath),
@@ -394,6 +395,7 @@ class Api:
pass # Successful upload
else:
print(f"Failed Upload Response: {resp}")
+ return
content = {
"body": os.path.basename(video_filepath),
--
2.34.1
+123 -35
View File
@@ -19,8 +19,8 @@ from collections import defaultdict
from plugins.config import FunguyConfig
# Rate limiter settings
RATE_LIMIT_WINDOW = 5.0 # seconds
MAX_COMMANDS_PER_WINDOW = 5
RATE_LIMIT_WINDOW = 15.0 # seconds
MAX_COMMANDS_PER_WINDOW = 3
class FunguyBot:
@@ -118,22 +118,78 @@ class FunguyBot:
toml.dump(existing_config, f)
def _check_rate_limit(self, sender: str) -> bool:
"""Return True if the sender is allowed to proceed, False if rate limited."""
"""Return True if the sender is allowed to proceed.
Admin is always allowed."""
# Admin bypass
if sender == self.config.admin_user:
return True
now = time.monotonic()
bucket = self._rate_limit_buckets[sender]
# Prune old entries
bucket = [t for t in bucket if now - t < RATE_LIMIT_WINDOW]
self._rate_limit_buckets[sender] = bucket
if len(bucket) >= MAX_COMMANDS_PER_WINDOW:
logging.debug("Rate limit hit for %s", sender)
return False
bucket.append(now)
return True
# ------------------------------------------------------------------
# New: load/unload a single plugin at runtime
# ------------------------------------------------------------------
async def load_plugin(self, plugin_name: str) -> bool:
"""Dynamically load a plugin module, add to PLUGINS, and call its setup()."""
if plugin_name in self.PLUGINS:
logging.info(f"Plugin '{plugin_name}' is already loaded.")
return False
try:
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
self.PLUGINS[plugin_name] = module
logging.info(f"Loaded plugin: {plugin_name}")
# Call setup if the bot is already running
if self.bot is not None and hasattr(module, "setup") and callable(module.setup):
module.setup(self.bot)
logging.info(f"Setup called for newly loaded plugin: {plugin_name}")
return True
except Exception as e:
logging.error(f"Error loading plugin {plugin_name}: {e}")
return False
async def unload_plugin(self, plugin_name: str) -> bool:
"""Remove a plugin from PLUGINS and unload its module."""
if plugin_name not in self.PLUGINS:
logging.info(f"Plugin '{plugin_name}' is not loaded.")
return False
try:
del self.PLUGINS[plugin_name]
module_path = f"{self.PLUGINS_DIR}.{plugin_name}"
if module_path in sys.modules:
del sys.modules[module_path]
logging.info(f"Unloaded plugin: {plugin_name}")
return True
except Exception as e:
logging.error(f"Error unloading plugin {plugin_name}: {e}")
return False
# ------------------------------------------------------------------
# New: restart the bot process
# ------------------------------------------------------------------
async def restart_bot(self, room_id):
await self.bot.api.send_text_message(room_id, "🔄 Restarting bot...")
await asyncio.sleep(1)
logging.info("Restart command received exiting.")
sys.exit(0)
async def handle_commands(self, room, message):
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix)
# Rate limit check (applies to all commands)
sender = str(message.sender)
is_admin = (sender == self.config.admin_user)
# Rate limit check (applies to all nonadmin commands)
if not self._check_rate_limit(sender):
await self.bot.api.send_text_message(
room.room_id,
@@ -143,45 +199,81 @@ class FunguyBot:
# Admin commands
if match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
if sender == self.config.admin_user:
if is_admin:
self.reload_plugins()
await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully")
await self.bot.api.send_text_message(room.room_id, "All plugins reloaded successfully.")
else:
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
return
if match.is_not_from_this_bot() and match.prefix() and match.command("load"):
if not is_admin:
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
return
args = match.args()
if len(args) != 1:
await self.bot.api.send_text_message(room.room_id, "Usage: !load <plugin>")
return
success = await self.load_plugin(args[0])
msg = f"✅ Plugin '{args[0]}' loaded." if success else f"❌ Could not load '{args[0]}'. See logs for details."
await self.bot.api.send_text_message(room.room_id, msg)
return
if match.is_not_from_this_bot() and match.prefix() and match.command("unload"):
if not is_admin:
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
return
args = match.args()
if len(args) != 1:
await self.bot.api.send_text_message(room.room_id, "Usage: !unload <plugin>")
return
success = await self.unload_plugin(args[0])
msg = f"✅ Plugin '{args[0]}' unloaded." if success else f"❌ Could not unload '{args[0]}'. See logs for details."
await self.bot.api.send_text_message(room.room_id, msg)
return
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
if sender == self.config.admin_user:
args = match.args()
if len(args) != 2:
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>")
else:
plugin_name, room_id = args
await self.disable_plugin(room_id, plugin_name)
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'")
else:
await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.")
if not is_admin:
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
return
args = match.args()
if len(args) != 1:
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin>")
return
plugin_name = args[0]
room_id = room.room_id
await self.disable_plugin(room_id, plugin_name)
await self.bot.api.send_text_message(room.room_id, f"🚫 Plugin '{plugin_name}' disabled in this room.")
return
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
if sender == self.config.admin_user:
args = match.args()
if len(args) != 2:
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>")
else:
plugin_name, room_id = args
await self.enable_plugin(room_id, plugin_name)
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'")
else:
await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.")
if not is_admin:
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
return
args = match.args()
if len(args) != 1:
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin>")
return
plugin_name = args[0]
room_id = room.room_id
await self.enable_plugin(room_id, plugin_name)
await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' enabled in this room.")
return
if match.is_not_from_this_bot() and match.prefix() and match.command("restart"):
if not is_admin:
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
return
await self.restart_bot(room.room_id)
return
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
if sender == self.config.admin_user:
self.rehash_config()
await self.bot.api.send_text_message(room.room_id, "Config rehashed")
else:
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
if not is_admin:
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload config.")
return
self.load_config()
self.load_disabled_plugins()
await self.bot.api.send_text_message(room.room_id, "🔄 Configuration rehashed.")
return
# Dispatch to active plugins
@@ -192,10 +284,6 @@ class FunguyBot:
except Exception as e:
logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True)
def rehash_config(self):
del self.config
self.config = FunguyConfig()
async def disable_plugin(self, room_id, plugin_name):
if room_id not in self.disabled_plugins:
self.disabled_plugins[room_id] = []
+183 -46
View File
@@ -2,10 +2,15 @@
"""
plugins/admin.py Full room moderation commands.
Supports multiword display names, standalone commands (!op, !kick, etc.)
Automatic flood detection:
message flood (5 msgs in 3s) → autoban + kick
join flood (5 joins in 3s, any domain) → room locked to inviteonly
"""
import time
import logging
import re
from collections import defaultdict, deque
import simplematrixbotlib as botlib
logger = logging.getLogger("admin")
@@ -17,6 +22,17 @@ _pending_resolution = {} # room_id → {"matches": [...], "expires": timestamp}
_name_cache = {} # room_id → {display_name.lower(): mxid}
RESOLUTION_TIMEOUT = 60
# Flood detection settings
FLOOD_MAX_MESSAGES = 15
FLOOD_TIME_WINDOW = 3.0 # seconds
JOIN_FLOOD_MAX = 5
JOIN_FLOOD_WINDOW = 3.0 # seconds
# Per-room per-user message timestamps
_flood_tracker: dict[str, dict[str, deque[float]]] = defaultdict(lambda: defaultdict(deque))
# Per-room join event timestamps (any domain)
_join_flood_tracker: dict[str, deque[float]] = defaultdict(deque)
def _cleanup_resolutions():
now = time.time()
expired = [r for r, v in _pending_resolution.items() if v["expires"] < now]
@@ -28,7 +44,6 @@ class UserResolutionError(Exception):
self.matches = matches # list of {"mxid": ..., "display_name": ...}
async def _populate_name_cache(bot, room_id):
"""Fetch the full member list and cache display names."""
if room_id in _name_cache:
return
try:
@@ -39,7 +54,6 @@ async def _populate_name_cache(bot, room_id):
for member in resp.members:
display = (member.display_name or "").strip().lower()
if display:
# If duplicate display name, store None to indicate ambiguity
if display in cache:
cache[display] = None
else:
@@ -50,23 +64,17 @@ async def _populate_name_cache(bot, room_id):
logger.error(f"Could not cache members: {e}")
async def _resolve_multiword(bot, room_id, tokens):
"""
Given a list of word tokens, try to find a matching display name
by testing progressively longer prefixes of the token list.
Returns (mxid, display_name) or raises ValueError if no match.
"""
clean_tokens = [re.sub(r'<[^>]+>', '', t).strip() for t in tokens]
await _populate_name_cache(bot, room_id)
cache = _name_cache.get(room_id, {})
# Build candidates from 1 token up to all tokens
for end in range(len(tokens), 0, -1):
candidate = " ".join(tokens[:end]).strip().lower()
for end in range(len(clean_tokens), 0, -1):
candidate = " ".join(clean_tokens[:end]).strip().lower()
if candidate in cache:
mxid = cache[candidate]
if mxid is not None:
return mxid, candidate
else:
# Duplicate display name → fall through to ambiguity handling
resp = await bot.async_client.joined_members(room_id)
matches = []
for member in resp.members:
@@ -76,23 +84,15 @@ async def _resolve_multiword(bot, room_id, tokens):
return matches[0]["mxid"], matches[0]["display_name"]
elif len(matches) > 1:
raise UserResolutionError(matches)
# else: not found (unlikely) → continue
raise ValueError(f"No member with display name '{' '.join(tokens)}' found.")
raise ValueError(f"No member with display name '{' '.join(clean_tokens)}' found.")
async def resolve_user_from_target(bot, room_id, target):
"""
Resolve a target string to a Matrix user ID.
Accepts: full MXID (@user:domain), display name (multiword), or number
(referring to a previous ambiguous resolution).
Returns (mxid, display_name_or_None).
Raises ValueError or UserResolutionError.
"""
target = re.sub(r'<[^>]+>', '', target).strip()
if target.startswith("@"):
return target, None
_cleanup_resolutions()
# Check for number reference to a previous ambiguous match
if target.isdigit():
idx = int(target) - 1
if room_id in _pending_resolution:
@@ -106,16 +106,12 @@ async def resolve_user_from_target(bot, room_id, target):
else:
raise ValueError("No pending resolution. Use @user:domain or display name.")
# If we reached here, `target` is a single token, but might be part of a longer name.
# That case is handled by calling _resolve_multiword from handle_command.
# But for completeness, we still attempt a direct cache match.
await _populate_name_cache(bot, room_id)
cache = _name_cache.get(room_id, {})
mxid = cache.get(target.strip().lower())
if mxid:
return mxid, target.strip().lower()
elif mxid is None:
# Ambiguous: fetch and raise
resp = await bot.async_client.joined_members(room_id)
matches = []
for member in resp.members:
@@ -183,11 +179,61 @@ async def get_banned_users(bot, room_id):
logger.error(f"Failed to fetch bans: {e}")
return []
# ------------------------------------------------------------------
# Flood detection (message + global join)
# ------------------------------------------------------------------
def _check_flood(room_id, user_id) -> bool:
now = time.monotonic()
q = _flood_tracker[room_id][user_id]
while q and q[0] < now - FLOOD_TIME_WINDOW:
q.popleft()
q.append(now)
if len(q) >= FLOOD_MAX_MESSAGES:
q.clear()
return True
return False
def _check_join_flood(room_id) -> bool:
now = time.monotonic()
q = _join_flood_tracker[room_id]
while q and q[0] < now - JOIN_FLOOD_WINDOW:
q.popleft()
q.append(now)
if len(q) >= JOIN_FLOOD_MAX:
q.clear()
return True
return False
async def _kick_user(bot, room_id, user_id, reason):
try:
await bot.async_client.room_kick(room_id, user_id, reason)
logger.info(f"Kicked {user_id} from {room_id}: {reason}")
except Exception as e:
logger.error(f"Failed to kick {user_id}: {e}")
async def _ban_user(bot, room_id, user_id, reason):
try:
await bot.async_client.room_ban(room_id, user_id, reason)
logger.info(f"Banned {user_id} from {room_id}: {reason}")
except Exception as e:
logger.error(f"Failed to ban {user_id}: {e}")
async def _lock_room(bot, room_id):
"""Set room join rule to 'invite'."""
try:
await bot.async_client.room_put_state(
room_id,
"m.room.join_rules",
{"join_rule": "invite"}
)
logger.info(f"Room {room_id} locked to inviteonly (join flood detected).")
except Exception as e:
logger.error(f"Failed to lock room {room_id}: {e}")
# ------------------------------------------------------------------
# Main command handler
# ------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
"""Dispatches !admin or standalone moderation commands."""
match = botlib.MessageMatch(room, message, bot, prefix)
if not match.is_not_from_this_bot() or not match.prefix():
return
@@ -200,7 +246,7 @@ async def handle_command(room, message, bot, prefix, config):
"ban": "ban",
"unban": "unban",
"invite": "invite",
"userinfo": "whois", # <-- renamed from "whois" to "userinfo"
"userinfo": "whois",
"op": "op",
"deop": "deop",
"topic": "topic",
@@ -208,6 +254,8 @@ async def handle_command(room, message, bot, prefix, config):
"avatar": "avatar",
"members": "members",
"bans": "bans",
"mkick": "mkick",
"joinrule": "joinrule",
"modhelp": "help",
"admin": "admin",
}
@@ -216,7 +264,6 @@ async def handle_command(room, message, bot, prefix, config):
if cmd not in standalone_actions:
return
# Permission gate (skip for help)
if cmd not in ("modhelp", "help"):
if not await has_mod_permission(bot, room_id, sender, config):
await bot.api.send_text_message(
@@ -226,7 +273,6 @@ async def handle_command(room, message, bot, prefix, config):
args = match.args()
# Determine action and sub_args
if cmd == "admin":
if not args:
await bot.api.send_text_message(room_id, "Usage: !admin <action> [args...]")
@@ -238,36 +284,76 @@ async def handle_command(room, message, bot, prefix, config):
sub_args = args
# ------------------------------------------------------------
# User-targeting actions (kick, ban, invite, userinfo, op, deop)
# Masskick by domain
# ------------------------------------------------------------
if action in ("kick", "ban", "invite", "userinfo", "op", "deop"):
if action == "mkick":
if not sub_args:
await bot.api.send_text_message(room_id, "Usage: !mkick <domain>\nExample: !mkick evilbots.net")
return
domain = sub_args[0].strip().lower()
if ':' in domain:
domain = domain.split(':')[-1]
try:
resp = await bot.async_client.joined_members(room_id)
if not resp.members:
await bot.api.send_text_message(room_id, "Could not fetch member list.")
return
targets = [m for m in resp.members if m.user_id.endswith(f":{domain}")]
if not targets:
await bot.api.send_text_message(room_id, f"No users found from domain '{domain}'.")
return
reason = f"Masskick of domain {domain}"
count = 0
for member in targets:
await _kick_user(bot, room_id, member.user_id, reason)
count += 1
await bot.api.send_text_message(room_id, f"👢 Kicked {count} user(s) from {domain}.")
except Exception as e:
await bot.api.send_text_message(room_id, f"❌ Masskick failed: {e}")
# ------------------------------------------------------------
# Join rule toggle
# ------------------------------------------------------------
elif action == "joinrule":
if not sub_args or sub_args[0] not in ("public", "invite"):
await bot.api.send_text_message(room_id, "Usage: !joinrule <public|invite>")
return
new_rule = sub_args[0].lower()
try:
await bot.async_client.room_put_state(
room_id,
"m.room.join_rules",
{"join_rule": new_rule}
)
await bot.api.send_text_message(room_id, f"🔐 Join rule set to **{new_rule}**.")
except Exception as e:
await bot.api.send_text_message(room_id, f"❌ Failed to set join rule: {e}")
# ------------------------------------------------------------
# User-targeting actions
# ------------------------------------------------------------
elif action in ("kick", "ban", "invite", "userinfo", "op", "deop"):
if not sub_args:
await bot.api.send_text_message(
room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]"
)
return
# For op/deop, the last token might be a power level (number)
if action in ("op", "deop"):
# Try to parse last token as power level
potential_pl = sub_args[-1]
try:
power = int(potential_pl)
# Success: power level found, name is sub_args[:-1]
name_tokens = sub_args[:-1]
if not name_tokens:
await bot.api.send_text_message(room_id, "Missing user name.")
return
except ValueError:
# No numeric power, whole sub_args is the name
name_tokens = sub_args
power = None
else:
# kick, ban, invite, userinfo
name_tokens = sub_args # entire args is the name
name_tokens = sub_args
power = None
# Resolve the multi-word name
try:
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
except UserResolutionError as e:
@@ -279,7 +365,6 @@ async def handle_command(room, message, bot, prefix, config):
await bot.api.send_text_message(room_id, "\n".join(lines))
return
except ValueError as e:
# Fallback: also try the old way with just the first token (maybe they used @user)
target_str = sub_args[0]
try:
target_mxid, target_display = await resolve_user_from_target(bot, room_id, target_str)
@@ -287,7 +372,6 @@ async def handle_command(room, message, bot, prefix, config):
await bot.api.send_text_message(room_id, str(e2))
return
# Determine reason and power level for op/deop
if action in ("op", "deop"):
if action == "op":
requested_pl = power if power is not None else 50
@@ -321,7 +405,6 @@ async def handle_command(room, message, bot, prefix, config):
await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}")
else:
# For kick/ban/invite/userinfo: reason is everything after the name tokens
reason = " ".join(sub_args[len(name_tokens):]) if len(sub_args) > len(name_tokens) else ""
if action == "kick":
@@ -345,7 +428,7 @@ async def handle_command(room, message, bot, prefix, config):
except Exception as e:
await bot.api.send_text_message(room_id, f"❌ Failed to invite: {e}")
elif action == "userinfo": # <-- was "whois", now "userinfo"
elif action == "userinfo":
try:
resp = await bot.async_client.joined_members(room_id)
member_info = None
@@ -385,7 +468,6 @@ async def handle_command(room, message, bot, prefix, config):
# ------------------------------------------------------------
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
# (unchanged)
# ------------------------------------------------------------
elif action == "topic":
if not sub_args:
@@ -477,6 +559,8 @@ async def handle_command(room, message, bot, prefix, config):
- `!ban <@user|name> [reason]` Ban a user
- `!unban <@user:domain>` Unban (full MXID required)
- `!invite <@user|name>` Invite a user
- `!mkick <domain>` Kick all users from the given domain
- `!joinrule <public|invite>` Manually set the room join rule
- `!userinfo <@user|name>` Show user details & power level (was `!whois`)
- `!op <@user|name> [pl=50]` Promote user (max 50, moderator)
- `!deop <@user|name>` Demote user to power level 0
@@ -497,18 +581,65 @@ If the name is ambiguous you'll be asked to choose from a numbered list.
room_id, f"Unknown action: {action}. Use `!modhelp`."
)
# ------------------------------------------------------------------
# Plugin setup register flood detectors
# ------------------------------------------------------------------
def setup(bot):
"""Initialize the admin plugin and register flood detectors."""
# Message flood detector (bans + kicks)
@bot.listener.on_message_event
async def _message_flood(room, message):
room_id = room.room_id
sender = message.sender
if sender == bot.async_client.user_id:
return
if _check_flood(room_id, sender):
disp = sender
try:
resp = await bot.async_client.joined_members(room_id)
if resp.members:
for m in resp.members:
if m.user_id == sender:
disp = m.display_name or sender
break
except:
pass
reason = f"Autoban for flooding ({FLOOD_MAX_MESSAGES} messages in {FLOOD_TIME_WINDOW}s)"
await _ban_user(bot, room_id, sender, reason)
await _kick_user(bot, room_id, sender, reason)
# Join flood detector (any domain)
@bot.listener.on_custom_event(botlib.nio.RoomMemberEvent)
async def _join_flood(room, event):
room_id = room.room_id
if event.membership != "join":
return
sender = event.state_key
if sender == bot.async_client.user_id:
return
if _check_join_flood(room_id):
await _lock_room(bot, room_id)
await bot.api.send_text_message(
room_id,
"🔐 Join flood detected room locked to inviteonly. Use `!joinrule public` to reopen."
)
logger.info("Admin plugin flood detectors registered")
# ------------------------------------------------------------------
# Plugin metadata
# ------------------------------------------------------------------
__version__ = "1.1.1"
__version__ = "1.2.3"
__author__ = "Funguy Admin"
__description__ = "Full room moderation multiword name support"
__description__ = "Full room moderation multiword name support + flood detection + mass domain kick"
__help__ = """
<details>
<summary><strong>Admin / Moderator Commands</strong></summary>
<ul>
<li><code>!kick</code>, <code>!ban</code>, <code>!unban</code>, <code>!invite</code></li>
<li><code>!userinfo</code> Show user details & power level (was !whois)</li>
<li><code>!mkick &lt;domain&gt;</code> Kick all users from a domain</li>
<li><code>!joinrule &lt;public|invite&gt;</code> Change room join rule</li>
<li><code>!userinfo</code> Show user details & power level</li>
<li><code>!op</code> (max PL 50), <code>!deop</code></li>
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
<li><code>!members</code>, <code>!bans</code></li>
@@ -516,5 +647,11 @@ __help__ = """
</ul>
<p>Power level ≥ 50 required (or global admin).</p>
<p>Multiword display names are automatically recognized.</p>
<p><strong>Flood detection:</strong>
<ul>
<li>Message flood: 5 messages in 3 seconds → autoban + kick</li>
<li>Join flood: 5 users in 3 seconds (any domain) → room locked to inviteonly</li>
</ul>
</p>
</details>
"""
+1194
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
"""
This plugin provides commands to interact with different AI models.
"""
import logging
import requests
import json
import simplematrixbotlib as botlib
import re
import markdown2
async def handle_command(room, message, bot, prefix, config):
"""
Function to handle AI commands.
Args:
room (Room): The Matrix room where the command was invoked.
message (RoomMessage): The message object containing the command.
bot (Bot): The bot object.
prefix (str): The command prefix.
config (dict): Configuration parameters.
Returns:
None
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix():
logging.info(f"Received command: {match.command()}")
command = match.command()
conf = load_config()
if command in conf:
await handle_ai_command(room, bot, command, match.args(), conf)
async def handle_ai_command(room, bot, command, args, config):
"""
Function to handle AI commands.
Args:
room (Room): The Matrix room where the command was invoked.
bot (Bot): The bot object.
command (str): The name of the AI model command.
args (list): List of arguments provided with the command.
config (dict): Configuration parameters.
Returns:
None
"""
if len(args) < 1:
await bot.api.send_text_message(room.room_id, f"Usage: !{command} [prompt]")
logging.info("Sent usage message to the room")
return
prompt = ' '.join(args)
# Prepare data for the API request
url = "http://127.0.0.1:5000/v1/completions"
headers = {
"Content-Type": "application/json"
}
data = {
"prompt": f"<s>[INST]{config[command]['prompt']}{prompt}[/INST]",
"max_tokens": 4096,
"temperature": config[command]["temperature"],
"top_p": config[command]["top_p"],
"top_k": config[command]["top_k"],
"repetition_penalty": config[command]["repetition_penalty"],
"seed": -1,
"stream": False
}
# Make HTTP request to the API endpoint
try:
response = requests.post(url, headers=headers, json=data, verify=False, timeout=300)
response.raise_for_status() # Raise HTTPError for bad responses
payload = response.json()
new_text = payload['choices'][0]['text']
new_text = markdown_to_html(new_text)
if new_text.count('<p>') > 1 or new_text.count('<li>') > 1: # Check if new_text has more than one paragraph
new_text = f"<details><summary><strong>{config[command]['summary']}</strong></summary>{new_text}</details>"
await bot.api.send_markdown_message(room.room_id, new_text)
else:
await bot.api.send_markdown_message(room.room_id, new_text)
logging.info("Sent generated text to the room")
except requests.exceptions.RequestException as e:
logging.error(f"HTTP request failed for '{prompt}': {e}")
await bot.api.send_text_message(room.room_id, f"Error generating text: {e}")
def markdown_to_html(markdown_text):
"""
Convert Markdown text to HTML.
Args:
markdown_text (str): Markdown formatted text.
Returns:
str: HTML formatted text.
"""
html_content = markdown2.markdown(markdown_text)
return html_content
def load_config():
"""
Load configuration from ai.json file.
Returns:
dict: Configuration parameters.
"""
with open("plugins/ai.json", "r") as f:
config = json.load(f)
return config
CONFIG = load_config()
+1 -1
View File
@@ -383,7 +383,7 @@ def setup(bot):
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "arXiv academic paper search (with rate limiting and error reporting)"
__description__ = "arXiv academic paper search"
__help__ = """
<details>
<summary><strong>!arxiv</strong> Search academic papers on arXiv</summary>
+56
View File
@@ -5,6 +5,7 @@ import html
import ipaddress
import socket
import logging
from wcwidth import wcswidth
logger = logging.getLogger(__name__)
@@ -80,3 +81,58 @@ async def send_html_message(bot, room_id, html_body, markdown_fallback):
message_type="m.room.message",
content=content
)
def code_block(title: str, sections: list) -> str:
"""
Build a Markdown code block with perfectly aligned columns (emojiaware).
Args:
title: header line inside the code block
sections: list of dicts with keys 'title' (str) and 'rows'
rows is a list of (emoji, label, value) tuples
Returns:
Markdown string with triple backticks and aligned content.
"""
labelled = []
for sec in sections:
for emoji, text, value in sec["rows"]:
if text.strip() or emoji.strip():
labelled.append((emoji, text, value))
max_label_width = max((len(str(t)) for _, t, _ in labelled), default=0)
emoji_widths = {}
for emoji, _, _ in labelled:
if emoji:
w = wcswidth(emoji) or 1
emoji_widths[emoji] = w
else:
emoji_widths[emoji] = 0
max_emoji_width = max(emoji_widths.values()) if emoji_widths else 0
prefix_width = max_emoji_width + 1 + max_label_width + 3 # "E label : "
separator = "=" * (prefix_width + 30)
lines = [title, separator]
for sec in sections:
# Only print a section header if the title is not empty
if sec["title"].strip():
lines.append("")
lines.append(f"── {sec['title']} ──")
for emoji, text, value in sec["rows"]:
if text.strip() or emoji.strip():
if emoji:
actual_w = emoji_widths.get(emoji, 0)
pad = max_emoji_width - actual_w
emoji_field = emoji + " " * pad
else:
emoji_field = " " * max_emoji_width
padded_label = f"{text:<{max_label_width}}"
lines.append(f"{emoji_field} {padded_label} : {value}")
else:
lines.append(f"{' ' * prefix_width}{value}")
lines.append("")
lines.append(separator)
return "```\n" + "\n".join(lines) + "\n```"
+258 -94
View File
@@ -2,6 +2,14 @@
Custom configuration class for the Funguy bot.
Securityhardened: only the configured admin user can read or change settings.
Save operation preserves extra sections (plugins.disabled, etc.).
Commands:
!set <option> <value>
!get <option>
!show
!saveconf
!loadconf
!reset
!config help
"""
# plugins/config.py
@@ -10,6 +18,75 @@ import logging
import toml
import simplematrixbotlib as botlib
from dataclasses import dataclass
from plugins.common import code_block, collapsible_summary, html_escape
# ----------------------------------------------------------------------
# Allowed configuration keys and metadata
# ----------------------------------------------------------------------
OPTIONS = {
"prefix": {
"description": "Command prefix (e.g., !)",
"validator": lambda v: isinstance(v, str) and len(v) == 1,
"type": "string",
"default": "!",
},
"timeout": {
"description": "HTTP timeout (seconds)",
"validator": lambda v: isinstance(v, (int, float)) and v > 0,
"type": "integer",
"default": 30,
},
"join_on_invite": {
"description": "Autojoin rooms on invite (true/false)",
"validator": lambda v: isinstance(v, bool),
"type": "boolean",
"default": True,
},
"encryption_enabled": {
"description": "Enable message encryption (true/false)",
"validator": lambda v: isinstance(v, bool),
"type": "boolean",
"default": False,
},
"emoji_verify": {
"description": "Use emoji verification (true/false)",
"validator": lambda v: isinstance(v, bool),
"type": "boolean",
"default": False,
},
"ignore_unverified_devices": {
"description": "Ignore unverified devices (true/false)",
"validator": lambda v: isinstance(v, bool),
"type": "boolean",
"default": True,
},
"store_path": {
"description": "Path for device store",
"validator": lambda v: isinstance(v, str),
"type": "string",
"default": "./store/",
},
"allowlist": {
"description": "Allowed users (comma separated)",
"validator": lambda v: isinstance(v, list),
"type": "list",
"default": [],
},
"blocklist": {
"description": "Blocked users (comma separated)",
"validator": lambda v: isinstance(v, list),
"type": "list",
"default": [],
},
"admin_user": {
"description": "Admin Matrix user ID",
"validator": lambda v: isinstance(v, str) and v.startswith("@"),
"type": "string (readonly via !set)",
"default": "", # must be set via config file
"readonly": True,
},
}
@dataclass
@@ -17,16 +94,24 @@ class FunguyConfig(botlib.Config):
"""
Custom configuration class for the Funguy bot.
Extends the base Config class to provide additional configuration options.
Args:
config_file (str): Path to the configuration file.
"""
def __init__(self, config_file="funguy.conf"):
super().__init__()
# Load configuration from file
# Load the TOML file *first* so we can easily extract admin_user and prefix
raw = {}
if os.path.exists(config_file):
with open(config_file, 'r') as f:
raw = toml.load(f)
bot_section = raw.get('simplematrixbotlib', {}).get('config', {})
self._admin_user = bot_section.get('admin_user', '')
self._prefix = bot_section.get('prefix', '!')
# Now let the base class parse the file and fill in other attributes
self.load_toml(config_file)
# Store the actual file path used (so save_config can find it)
# Store the file path for later saves
self._config_file = config_file
logging.info(f"Loaded configuration from {config_file}")
@@ -59,7 +144,16 @@ class FunguyConfig(botlib.Config):
self._config_file = value
def load_config(self, config_file):
"""Load configuration from a TOML file."""
"""Load configuration from a TOML file (reread admin_user and prefix)."""
raw = {}
if os.path.exists(config_file):
with open(config_file, 'r') as f:
raw = toml.load(f)
bot_section = raw.get('simplematrixbotlib', {}).get('config', {})
self._admin_user = bot_section.get('admin_user', '')
self._prefix = bot_section.get('prefix', '!')
self.load_toml(config_file)
self._config_file = config_file
logging.info(f"Loaded configuration from {config_file}")
@@ -68,37 +162,28 @@ class FunguyConfig(botlib.Config):
"""
Save configuration to a TOML file, **preserving** any extra sections
(e.g., plugins.disabled) not managed by the base library.
If config_file is not provided, the instance's stored config_file is used.
"""
if config_file is None:
config_file = self._config_file
if not config_file:
raise ValueError("No config file path set for saving.")
# 1. Let the library write its portion to a temporary file
tmp_file = config_file + ".tmp"
try:
self.save_toml(tmp_file)
# 2. Read the temporary file (library's view of the config)
with open(tmp_file, 'r') as f:
new_config = toml.load(f)
# 3. Read the current config file (if it exists) to preserve extra sections
original = {}
if os.path.exists(config_file):
with open(config_file, 'r') as f:
original = toml.load(f)
# 4. Merge: keep everything from the original, then overlay
# the library's config table(s). This leaves any top-level
# sections not produced by the library exactly as they were.
merged = original.copy()
for key, value in new_config.items():
merged[key] = value
# 5. Write back the merged result
with open(config_file, 'w') as f:
toml.dump(merged, f)
@@ -107,31 +192,95 @@ class FunguyConfig(botlib.Config):
logging.error(f"Error saving config to {config_file}: {e}")
raise
finally:
# Always remove the temp file
if os.path.exists(tmp_file):
os.remove(tmp_file)
async def handle_command(room, message, bot, prefix, config):
"""
Handle commands related to bot configuration.
All subcommands require the sender to be the configured admin_user.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
# ----------------------------------------------------------------------
# Helpers for formatting
# ----------------------------------------------------------------------
def _bool_to_str(v):
return "true" if v else "false"
if not match.is_not_from_this_bot() or not match.prefix():
def _str_to_bool(s: str):
return s.lower() in ("true", "yes", "1")
def _format_value(key, value):
if isinstance(value, bool):
return _bool_to_str(value)
if isinstance(value, list):
return ", ".join(value) if value else "(empty)"
return str(value)
def _set_config_option(config, key, value_str):
meta = OPTIONS.get(key)
if not meta:
return False, f"Unknown option '{html_escape(key)}'."
if meta.get("readonly"):
return False, f"'{html_escape(key)}' cannot be changed via !set for safety."
try:
if meta["type"] == "boolean":
value = _str_to_bool(value_str)
elif meta["type"] == "integer":
value = int(value_str)
elif meta["type"] == "list":
if value_str.strip() == "":
value = []
else:
value = [item.strip() for item in value_str.split(",") if item.strip()]
else:
value = value_str
if not meta["validator"](value):
return False, f"Invalid value for '{html_escape(key)}'. Expected {meta['type']}."
# Apply to config object
if key == "prefix":
config.prefix = value
elif key == "timeout":
config.timeout = value
elif key == "join_on_invite":
config.join_on_invite = value
elif key == "encryption_enabled":
config.encryption_enabled = value
elif key == "emoji_verify":
config.emoji_verify = value
elif key == "ignore_unverified_devices":
config.ignore_unverified_devices = value
elif key == "store_path":
config.store_path = value
elif key == "allowlist":
config.allowlist = value
elif key == "blocklist":
config.blocklist = value
# admin_user is readonly, not set here
return True, f"{html_escape(key)} set to {_format_value(key, value)}."
except (ValueError, TypeError) as e:
return False, f"Invalid value: {html_escape(str(e))}"
# ----------------------------------------------------------------------
# Command handler
# ----------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix()):
return
cmd = match.command()
if cmd not in ("set", "get", "saveconf", "loadconf", "show", "reset"):
if cmd not in ("config", "set", "get", "show", "saveconf", "loadconf", "reset"):
return
sender = str(message.sender)
if sender != config.admin_user:
logging.warning(
"Unauthorized config command attempt by %s in room %s: %s",
sender, room.room_id, cmd
)
logging.warning("Unauthorized config command attempt by %s (%s vs %s)", sender, config.admin_user)
await bot.api.send_text_message(
room.room_id,
"⛔ You are not authorized to use configuration commands."
@@ -139,94 +288,109 @@ async def handle_command(room, message, bot, prefix, config):
return
args = match.args()
if cmd == "config":
if not args:
await _send_help(room, bot)
return
subcmd = args[0].lower()
args = args[1:]
else:
subcmd = cmd
if cmd == "set":
if subcmd == "set":
if len(args) != 2:
await bot.api.send_text_message(room.room_id,
"Usage: !set <config_option> <value>")
await bot.api.send_text_message(room.room_id, "Usage: !set <option> <value>\nUse !config show for options.")
return
option, value = args
if option == "admin_user":
await bot.api.send_text_message(
room.room_id,
"❌ Changing 'admin_user' via !set is not allowed for security reasons."
)
return
elif option == "prefix":
config.prefix = value
await bot.api.send_text_message(room.room_id,
f"Prefix set to `{value}`")
else:
await bot.api.send_text_message(room.room_id,
"Invalid configuration option.")
success, msg = _set_config_option(config, option, value)
await bot.api.send_text_message(room.room_id, msg)
elif cmd == "get":
elif subcmd == "get":
if len(args) != 1:
await bot.api.send_text_message(room.room_id,
"Usage: !get <config_option>")
await bot.api.send_text_message(room.room_id, "Usage: !get <option>")
return
option = args[0]
if option == "admin_user":
await bot.api.send_text_message(room.room_id,
f"Admin user: {config.admin_user}")
elif option == "prefix":
await bot.api.send_text_message(room.room_id,
f"Prefix: {config.prefix}")
else:
await bot.api.send_text_message(room.room_id,
"Invalid configuration option.")
meta = OPTIONS.get(option)
if not meta:
await bot.api.send_text_message(room.room_id, f"Unknown option '{html_escape(option)}'.")
return
val = getattr(config, option, meta["default"])
await bot.api.send_text_message(room.room_id, f"{html_escape(option)}: {_format_value(option, val)}")
elif cmd == "show":
await bot.api.send_text_message(
room.room_id,
f"Admin user: {config.admin_user}\nPrefix: {config.prefix}"
)
elif subcmd == "show":
rows = []
for key, meta in OPTIONS.items():
val = getattr(config, key, meta["default"])
rows.append(("⚙️", key, _format_value(key, val)))
block = code_block("📋 Current Configuration", [{"title": "", "rows": rows}])
output = collapsible_summary("📋 Current Configuration", block)
await bot.api.send_markdown_message(room.room_id, output)
elif cmd == "saveconf":
elif subcmd == "saveconf":
try:
config.save_config() # uses the stored config_file by default
await bot.api.send_text_message(
room.room_id,
"Configuration saved (including disabled plugins)."
)
config.save_config()
await bot.api.send_text_message(room.room_id, "💾 Configuration saved to file.")
except Exception as e:
await bot.api.send_text_message(
room.room_id,
f"❌ Failed to save configuration: {e}"
)
await bot.api.send_text_message(room.room_id, f"❌ Failed to save: {html_escape(str(e))}")
elif cmd == "loadconf":
config.load_config(config.config_file)
await bot.api.send_text_message(
room.room_id,
"Configuration reloaded from file."
)
elif subcmd == "loadconf":
try:
config.load_config(config.config_file)
await bot.api.send_text_message(room.room_id, "🔄 Configuration reloaded from file.")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"❌ Failed to load: {html_escape(str(e))}")
elif cmd == "reset":
config.prefix = "!"
await bot.api.send_text_message(
room.room_id,
"Configuration reset to defaults (admin_user unchanged)."
)
elif subcmd == "reset":
for key, meta in OPTIONS.items():
if key == "admin_user":
continue
setattr(config, key, meta["default"])
if key == "prefix":
config.prefix = meta["default"]
await bot.api.send_text_message(room.room_id, "♻️ Configuration reset to defaults (admin_user preserved).")
elif subcmd == "help":
await _send_help(room, bot)
else:
await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{html_escape(subcmd)}'. Use !config help.")
async def _send_help(room, bot):
help_text = """
<details>
<summary><strong>🔧 Config Plugin Commands</strong></summary>
<p><code>!set &lt;option&gt; &lt;value&gt;</code> Change a configuration option</p>
<p><code>!get &lt;option&gt;</code> Display a single option</p>
<p><code>!show</code> Show all current settings</p>
<p><code>!saveconf</code> Save configuration to file</p>
<p><code>!loadconf</code> Reload from file</p>
<p><code>!reset</code> Reset to defaults (keeps admin_user)</p>
<p><code>!config help</code> This help</p>
<p><strong>Available options:</strong><br>
<code>prefix, timeout, join_on_invite, encryption_enabled, emoji_verify, ignore_unverified_devices, store_path, allowlist, blocklist</code></p>
<p><em>Note: <code>admin_user</code> can only be changed by editing funguy.conf directly.</em></p>
</details>
"""
await bot.api.send_markdown_message(room.room_id, help_text)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.2"
__author__ = "Funguy Bot (hardened)"
__description__ = "Admin-only configuration commands (preserves disabled plugins)"
__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "Adminonly configuration management"
__help__ = """
<details>
<summary><strong>Admin Config</strong> (!set, !get, !saveconf, …)</summary>
<summary><strong>!config</strong> Manage bot settings</summary>
<ul>
<li><code>!set prefix &lt;value&gt;</code> Change command prefix (admin only)</li>
<li><code>!get &lt;option&gt;</code> Display config value (admin only)</li>
<li><code>!show</code> Show current settings (admin only)</li>
<li><code>!saveconf</code> / <code>!loadconf</code> Save/load config (admin only)</li>
<li><code>!reset</code> Reset to defaults, preserving admin_user (admin only)</li>
<li><code>!set &lt;option&gt; &lt;value&gt;</code> Change a setting</li>
<li><code>!get &lt;option&gt;</code> View a setting</li>
<li><code>!show</code> All settings</li>
<li><code>!saveconf</code> Save to file</li>
<li><code>!loadconf</code> Reload from file</li>
<li><code>!reset</code> Reset defaults</li>
<li><code>!config help</code> This help</li>
</ul>
<p>Changing <code>admin_user</code> via bot commands is blocked for safety.</p>
<p>The <code>plugins.disabled</code> section is now preserved when saving.</p>
</details>
"""
+1 -1
View File
@@ -265,7 +265,7 @@ async def send_help(room, bot):
__version__ = "2.1.1"
__author__ = "Funguy Bot"
__description__ = "DuckDuckGo search collapsible results (ddgs library, no API key)"
__description__ = "DuckDuckGo search plugin"
__help__ = """
<details>
<summary><strong>!ddg</strong> DuckDuckGo search (web, images, news, etc.)</summary>
+94 -75
View File
@@ -1,14 +1,15 @@
"""
This plugin provides a command to perform DNS reconnaissance on a domain.
DNS reconnaissance plugin queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.
Outputs a formatted code block with emojis and perfectly aligned columns.
"""
import logging
import asyncio
import dns.resolver
import dns.reversename
import simplematrixbotlib as botlib
import re
from plugins.utils import is_public_destination
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV']
@@ -16,113 +17,131 @@ def is_valid_domain(domain):
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
return re.match(pattern, domain) is not None
def format_dns_record(record_type, records):
if not records:
return ""
output = f"<strong>{record_type} Records:</strong><br>"
for record in records:
output += f"{record}<br>"
return output
async def query_dns_records(domain):
results = {}
resolver = dns.resolver.Resolver()
resolver.timeout = 5
resolver.lifetime = 5
for record_type in RECORD_TYPES:
try:
logging.info(f"Querying {record_type} records for {domain}")
answers = resolver.resolve(domain, record_type)
records = []
for rdata in answers:
if record_type == 'MX':
records.append(f"{rdata.preference} {rdata.exchange}")
elif record_type == 'SOA':
records.append(f"{rdata.mname} {rdata.rname}")
elif record_type == 'SRV':
records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}")
elif record_type == 'TXT':
txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
records.append(txt_data)
else:
records.append(str(rdata))
if records:
results[record_type] = records
logging.info(f"Found {len(records)} {record_type} record(s)")
except dns.resolver.NoAnswer:
continue
except dns.resolver.NXDOMAIN:
logging.warning(f"Domain {domain} does not exist")
return None
except dns.resolver.Timeout:
continue
except Exception as e:
logging.error(f"Error querying {record_type} for {domain}: {e}")
continue
return results
loop = asyncio.get_running_loop()
def _resolve():
results = {}
resolver = dns.resolver.Resolver()
resolver.timeout = 5
resolver.lifetime = 5
for record_type in RECORD_TYPES:
try:
answers = resolver.resolve(domain, record_type)
records = []
for rdata in answers:
if record_type == 'MX':
records.append(f"{rdata.preference} {rdata.exchange}")
elif record_type == 'SOA':
records.append(f"{rdata.mname} {rdata.rname}")
elif record_type == 'SRV':
records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}")
elif record_type == 'TXT':
txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
records.append(txt_data)
else:
records.append(str(rdata))
if records:
results[record_type] = records
except dns.resolver.NoAnswer:
continue
except dns.resolver.NXDOMAIN:
return None
except dns.resolver.Timeout:
continue
except Exception as e:
logging.error(f"Error querying {record_type} for {domain}: {e}")
continue
return results
return await loop.run_in_executor(None, _resolve)
RECORD_META = {
'A': ('🌐', 'A (IPv4)'),
'AAAA': ('🌐', 'AAAA (IPv6)'),
'MX': ('📧', 'MX (Mail)'),
'NS': ('🌐', 'NS (Nameserver)'),
'TXT': ('📄', 'TXT'),
'CNAME': ('🔀', 'CNAME'),
'SOA': ('📋', 'SOA'),
'PTR': ('↩️', 'PTR'),
'SRV': ('🔌', 'SRV'),
}
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("dns"):
logging.info("Received !dns command")
args = match.args()
if len(args) != 1:
await bot.api.send_text_message(room.room_id,
"Usage: !dns <domain>\nExample: !dns example.com")
await bot.api.send_text_message(room.room_id, "Usage: !dns <domain>\nExample: !dns example.com")
return
domain = args[0].lower().strip()
domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
if not is_valid_domain(domain):
await bot.api.send_text_message(room.room_id, f"Invalid domain name: {domain}")
await bot.api.send_text_message(room.room_id, f"Invalid domain name: {html_escape(domain)}")
return
if not is_public_destination(domain):
await bot.api.send_text_message(room.room_id, "❌ DNS queries for private/internal domains are not allowed.")
return
await bot.api.send_text_message(room.room_id, f"🔍 Performing DNS reconnaissance on {html_escape(domain)}...")
try:
await bot.api.send_text_message(room.room_id,
f"🔍 Performing DNS reconnaissance on {domain}...")
results = await query_dns_records(domain)
if results is None:
await bot.api.send_text_message(room.room_id,
f"Domain {domain} does not exist (NXDOMAIN)")
await bot.api.send_text_message(room.room_id, f"Domain {html_escape(domain)} does not exist (NXDOMAIN)")
return
if not results:
await bot.api.send_text_message(room.room_id,
f"No DNS records found for {domain}")
await bot.api.send_text_message(room.room_id, f"No DNS records found for {html_escape(domain)}")
return
# SSRF / privacy check: if all A/AAAA records are private, refuse.
a_records = results.get('A', [])
aaaa_records = results.get('AAAA', [])
all_ips = a_records + aaaa_records
if all_ips and not any(is_public_destination(ip) for ip in all_ips):
await bot.api.send_text_message(room.room_id,
"❌ This domain resolves exclusively to private/internal IPs.")
await bot.api.send_text_message(room.room_id, "❌ This domain resolves exclusively to private/internal IPs.")
return
output = f"<strong>🔍 DNS Records for {domain}</strong><br><br>"
preferred_order = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
for record_type in preferred_order:
if record_type in results:
output += format_dns_record(record_type, results[record_type])
output += "<br>"
for record_type in results:
if record_type not in preferred_order:
output += format_dns_record(record_type, results[record_type])
output += "<br>"
if output.count('<br>') > 15:
output = f"<details><summary><strong>🔍 DNS Records for {domain}</strong></summary>{output}</details>"
rows = []
preferred = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
for rtype in preferred:
if rtype in results:
emoji, label = RECORD_META.get(rtype, ('', rtype))
for rec in results[rtype]:
rows.append((emoji, label, rec))
emoji = ""
label = ""
for rtype in results:
if rtype not in preferred:
emoji, label = RECORD_META.get(rtype, ('', rtype))
for rec in results[rtype]:
rows.append((emoji, label, rec))
emoji = ""
label = ""
if not rows:
await bot.api.send_text_message(room.room_id, f"No displayable records for {html_escape(domain)}")
return
sections = [{"title": "", "rows": rows}]
block = code_block(f"🔍 DNS Records for {domain}", sections)
output = collapsible_summary(f"🔍 DNS: {html_escape(domain)}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent DNS records for {domain}")
except Exception as e:
await bot.api.send_text_message(room.room_id,
f"An error occurred while performing DNS lookup: {str(e)}")
await bot.api.send_text_message(room.room_id, f"An error occurred while performing DNS lookup: {str(e)}")
logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.1"
__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "DNS reconnaissance (SSRFsafe)"
__help__ = """
<details>
<summary><strong>!dns</strong> DNS reconnaissance</summary>
<p><code>!dns &lt;domain&gt;</code> Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.</p>
<p><code>!dns &lt;domain&gt;</code> Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records and displays them in a clean, aligned table.</p>
</details>
"""
+63 -56
View File
@@ -1,11 +1,13 @@
"""
This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping.
DNSDumpster.com integration for domain reconnaissance and DNS mapping.
Output uses shared code_block for aligned columns.
"""
import logging
import os
import aiohttp
import simplematrixbotlib as botlib
from plugins.common import html_escape, collapsible_summary
from plugins.common import html_escape, code_block, collapsible_summary
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
@@ -13,20 +15,13 @@ DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
logging.info("Received !dnsdumpster command")
if not DNSDUMPSTER_API_KEY:
await bot.api.send_text_message(
room.room_id,
"DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env."
)
await bot.api.send_text_message(room.room_id, "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env.")
return
args = match.args()
if len(args) < 1:
await show_usage(room, bot)
return
if args[0].lower() == "test":
await test_dnsdumpster_connection(room, bot)
else:
@@ -37,9 +32,6 @@ async def show_usage(room, bot):
usage = """<strong>🔍 DNSDumpster Commands:</strong>
<strong>!dnsdumpster &lt;domain_name&gt;</strong> - Get comprehensive DNS reconnaissance for a domain
<strong>!dnsdumpster test</strong> - Test API connection
<strong>Examples:</strong>
• <code>!dnsdumpster google.com</code>
• <code>!dnsdumpster github.com</code>
"""
await bot.api.send_markdown_message(room.room_id, usage)
@@ -51,8 +43,7 @@ async def test_dnsdumpster_connection(room, bot):
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=15) as response:
status = response.status
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>Test Domain: {test_domain}<br>"
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>"
if status == 200:
data = await response.json()
debug_info += "<strong>✅ SUCCESS</strong><br>"
@@ -81,50 +72,66 @@ async def dnsdumpster_domain_lookup(room, bot, domain):
return
data = await response.json()
output = await format_dnsdumpster_report(domain, data)
sections = []
# A Records
if data.get('a'):
rows = []
for rec in data['a']:
host = rec.get('host', 'N/A')
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
rows.append(("📍", host, ips))
sections.append({"title": "A Records (IPv4)", "rows": rows})
# NS Records
if data.get('ns'):
rows = []
for rec in data['ns']:
host = rec.get('host', 'N/A')
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
rows.append(("🖧", host, ips))
sections.append({"title": "NS Records", "rows": rows})
# MX Records
if data.get('mx'):
rows = []
for rec in data['mx']:
host = rec.get('host', 'N/A')
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
rows.append(("📧", host, ips))
sections.append({"title": "MX Records", "rows": rows})
# CNAME
if data.get('cname'):
rows = []
for rec in data['cname']:
host = rec.get('host', 'N/A')
target = rec.get('target', 'N/A')
rows.append(("🔀", host, target))
sections.append({"title": "CNAME Records", "rows": rows})
# TXT
if data.get('txt'):
rows = []
for txt in data['txt']:
rows.append(("📄", "TXT", txt[:150] if len(txt) > 150 else txt))
sections.append({"title": "TXT Records", "rows": rows})
if not sections:
await bot.api.send_text_message(room.room_id, "No DNS records found.")
return
block = code_block(f"🔍 DNSDumpster Report: {safe_domain}", sections)
output = collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent DNSDumpster data for {domain}")
except asyncio.TimeoutError:
await bot.api.send_text_message(room.room_id, "Request timed out.")
except Exception as e:
except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error: {e}")
async def format_dnsdumpster_report(domain, data):
safe_domain = html_escape(domain)
output = f"<strong>🔍 DNSDumpster Report: {safe_domain}</strong><br><br>"
if data.get('total_a_recs'):
output += f"<strong>📊 Summary</strong><br>Total A Records: {data['total_a_recs']}<br>"
for record_type, label in [('a','A Records'),('ns','NS Records'),('mx','MX Records'),('cname','CNAME'),('txt','TXT')]:
if data.get(record_type) and data[record_type]:
output += f"<br><strong>{label} ({len(data[record_type])} found)</strong><br>"
for rec in data[record_type]:
if record_type == 'txt':
txt = html_escape(str(rec))
if len(txt) > 200:
txt = txt[:200] + "..."
output += f"{txt}<br>"
elif record_type == 'a':
host = html_escape(rec.get('host','N/A'))
ips = rec.get('ips',[])
output += f" • <strong>{host}</strong><br>"
for ip_info in ips:
ip = html_escape(ip_info.get('ip','N/A'))
country = html_escape(ip_info.get('country','Unknown'))
output += f" └─ {ip} ({country})<br>"
else:
host = html_escape(rec.get('host','N/A'))
ips = rec.get('ips',[])
output += f" • <strong>{host}</strong><br>"
for ip_info in ips:
ip = html_escape(ip_info.get('ip','N/A'))
country = html_escape(ip_info.get('country','Unknown'))
output += f" └─ {ip} ({country})<br>"
output += "<br><em>💡 Rate Limit: 1 request per 2 seconds</em>"
return collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain} (Click to expand)", output)
__version__ = "1.0.1"
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "DNSDumpster domain reconnaissance"
__help__ = """
+124 -12
View File
@@ -55,6 +55,12 @@ try:
except ImportError:
HAS_CRYPTOGRAPHY = False
try:
import zstandard
HAS_ZSTD = True
except ImportError:
HAS_ZSTD = False
try:
import bcrypt
HAS_BCRYPT = True
@@ -855,6 +861,46 @@ async def op_dns(text: str) -> str:
except Exception as e:
return f"DNS error: {e}"
@register_op("deflate", "Raw DEFLATE compress / decompress", "Compression", arg_names=["subcmd"])
async def op_deflate(subcmd: str, text: str) -> str:
sub = subcmd.lower()
raw = validate_input(text)
if sub in ("compress", "comp", "c"):
# Use zlib in rawdeflate mode (wbits=-15)
compressor = zlib.compressobj(level=6, wbits=-15)
compressed = compressor.compress(raw) + compressor.flush()
return base64.b64encode(compressed).decode()
elif sub in ("decompress", "decomp", "d"):
try:
decompressed = zlib.decompress(base64.b64decode(raw), wbits=-15)
return decompressed.decode("utf-8", errors="replace")
except Exception as e:
return f"DEFLATE decompress error: {e}"
else:
raise ValueError("Use 'compress' or 'decompress'")
@register_op("zstd", "Zstandard compress / decompress", "Compression", arg_names=["subcmd"])
async def op_zstd(subcmd: str, text: str) -> str:
if not HAS_ZSTD:
return "Error: zstandard library not installed"
import zstandard as zstd
sub = subcmd.lower()
raw = validate_input(text)
if sub in ("compress", "comp", "c"):
cctx = zstd.ZstdCompressor()
compressed = cctx.compress(raw)
return base64.b64encode(compressed).decode()
elif sub in ("decompress", "decomp", "d"):
try:
dctx = zstd.ZstdDecompressor()
decompressed = dctx.decompress(base64.b64decode(raw))
return decompressed.decode("utf-8", errors="replace")
except Exception as e:
return f"Zstd decompress error: {e}"
else:
raise ValueError("Use 'compress' or 'decompress'")
# ---------- Recipe system ----------
class Recipe:
"""A sequence of operations to apply."""
@@ -896,9 +942,9 @@ class Recipe:
break
return data
@register_op("recipe", "Run a recipe (provide JSON and data)", "Recipes",
@register_op("recipe", "Run a recipe (JSON or pipe syntax)", "Recipes",
arg_names=["subcmd"])
async def op_recipe(subcmd: str, json_or_data: str, *extra: str) -> str:
async def op_recipe(subcmd: str, json_or_data: str) -> str:
sub = subcmd.lower()
if sub == "list":
cats = collections.defaultdict(list)
@@ -908,20 +954,79 @@ async def op_recipe(subcmd: str, json_or_data: str, *extra: str) -> str:
for cat in sorted(cats):
out.append(f"{cat}: {', '.join(cats[cat])}")
return "\n".join(out)
elif sub == "run":
# Usage: !encode recipe run '<json>' <data>
# Here json_or_data is the JSON string, extra[0] is the data
if not extra:
raise ValueError("Provide data after JSON recipe")
recipe_json = json_or_data
data = " ".join(extra) # extra is tuple
s = json_or_data.strip()
# Strip a single pair of matching outer quotes if present
if (s.startswith("'") and s.endswith("'")) or (s.startswith('"') and s.endswith('"')):
s = s[1:-1].strip()
# ---- try JSON first ----
if s.startswith("{"):
# original JSON parsing
depth = 0
end = 0
for i, ch in enumerate(s):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break
if depth != 0:
raise ValueError("Unbalanced braces in JSON recipe")
recipe_json = s[:end+1]
data_part = s[end+1:].strip()
if not data_part:
raise ValueError("No data provided after JSON recipe")
try:
recipe = Recipe.from_json(recipe_json)
return await recipe.run(data_part, OPERATIONS)
except Exception as e:
return f"Recipe error: {e}"
# ---- try pipe syntax: op args ... | op args ... :: data ----
# Split into operations and data at the last occurrence of " :: "
if " :: " in s:
ops_section, data_part = s.rsplit(" :: ", 1)
data_part = data_part.strip()
else:
raise ValueError(
"Pipe syntax: !encode recipe run <op> [args] | <op> [args] :: <data>\n"
"Or JSON: !encode recipe run '{...}' <data>"
)
ops_section = ops_section.strip()
if not ops_section:
raise ValueError("At least one operation is required before ::")
if not data_part:
raise ValueError("No data provided after ::")
# Split operations by " | "
ops_parts = [p.strip() for p in ops_section.split(" | ")]
steps = []
for part in ops_parts:
tokens = part.split()
if not tokens:
continue
op_name = tokens[0].lower()
if op_name not in OPERATIONS:
raise ValueError(f"Unknown operation '{op_name}' in pipe recipe")
args = tokens[1:] # remaining tokens are args
steps.append({"op": op_name, "args": args})
try:
recipe = Recipe.from_json(recipe_json)
return await recipe.run(data, OPERATIONS)
recipe = Recipe(steps)
return await recipe.run(data_part, OPERATIONS)
except Exception as e:
return f"Recipe error: {e}"
else:
raise ValueError("Use 'list' or 'run <json> <data>'")
raise ValueError("Use 'list' or 'run'")
# ---------- Main handler (interface to the bot) ----------
async def handle_command(room, message, bot, prefix, config):
@@ -1137,6 +1242,10 @@ compression, data processing, forensics, and networking. Fully offline.
<code>!encode bzip2 compress data</code></li>
<li><b>lzma</b> LZMA compress/decompress<br>
<code>!encode lzma compress data</code></li>
<li><b>deflate</b> Raw DEFLATE compress/decompress<br>
<code>!encode deflate compress data</code></li>
<li><b>zstd</b> Zstandard compress/decompress (requires zstandard)<br>
<code>!encode zstd compress data</code></li>
</ul>
<h3>Data Processing</h3>
@@ -1195,7 +1304,10 @@ compression, data processing, forensics, and networking. Fully offline.
<li><b>recipe list</b> List all available operations<br>
<code>!encode recipe list</code></li>
<li><b>recipe run</b> Execute a JSON recipe on data<br>
<code>!encode recipe run '{"steps":[{"op":"base64","args":["encode"]},{"op":"hex","args":["encode"]}]}' "hello world"</code></li>
<code>!encode recipe run '{"steps":[{"op":"base64", args ["encode"]}, {"op":"hex", args ["encode"]}]}' "hello world"</code></li>
<li><b>recipe run (pipe syntax)</b> Chain operations with <code>|</code> and separate data with <code>::</code><br>
<code>!encode recipe run base64 encode | hex encode :: hello world</code><br>
<code>!encode recipe run base64 encode | hex encode | gzip compress :: my secret data</code></li>
</ul>
<p>Type <code>!encode help &lt;op&gt;</code> for detailed argument info on any operation.</p>
+547
View File
@@ -0,0 +1,547 @@
"""
Factoids plugin a clone of the classic infobot / supybot Factoids plugin.
Stores and retrieves factoids via Matrix chat.
Commands:
!fact <key> retrieve a factoid
!fact search <query> search factoids by key or value
!fact info <key> show metadata for a factoid
!fact random show a random factoid
!fact stats show database statistics
!fact list [glob] list factoid keys matching a glob pattern
!fact lock <key> lock a factoid (admin only)
!fact unlock <key> unlock a factoid (admin only)
!fact change <key> is <val> change an existing factoid
!learn <key> is <value> teach the bot a new factoid
!forget <key> delete a factoid
!also <key> is <value> append to an existing factoid
!no, <key> is <value> replace a factoid (same as change)
Inline query (no prefix needed):
<key>? ask for a factoid
Special value tags:
<reply> text replies with "text" (not "key is text")
<action> text replies as an emote (/me)
a | b | c picks one option at random
"""
import sqlite3
import logging
import random
import re
import time
import asyncio
import simplematrixbotlib as botlib
from plugins.common import code_block, collapsible_summary, html_escape
DB_PATH = "factoids.db"
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def init_db():
"""Ensure the factoids table exists."""
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS factoids (
factoid_key VARCHAR(64) NOT NULL DEFAULT '' PRIMARY KEY,
requested_by VARCHAR(80),
requested_time INTEGER,
requested_count SMALLINT,
created_by VARCHAR(80),
created_time INTEGER DEFAULT 0,
modified_by VARCHAR(80),
modified_time INTEGER,
locked_by VARCHAR(80),
locked_time INTEGER,
factoid_value TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def _conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ---------------------------------------------------------------------------
# Core operations
# ---------------------------------------------------------------------------
def _normalise_key(raw: str) -> str:
"""Lower-case, strip punctuation, collapse whitespace."""
key = raw.strip().lower()
key = re.sub(r'[?.,!]+$', '', key)
key = ' '.join(key.split())
return key
def get_factoid(key: str) -> dict | None:
"""Return a factoid row (or None) and bump its request count."""
conn = _conn()
row = conn.execute(
"SELECT * FROM factoids WHERE factoid_key = ?", (key,)
).fetchone()
if row:
conn.execute(
"UPDATE factoids SET requested_count = COALESCE(requested_count,0)+1, "
"requested_time = ? WHERE factoid_key = ?",
(int(time.time()), key)
)
conn.commit()
result = dict(row)
else:
result = None
conn.close()
return result
def set_factoid(key: str, value: str, created_by: str, locked_by: str = None):
"""Insert or replace a factoid."""
now = int(time.time())
conn = _conn()
conn.execute(
"""INSERT OR REPLACE INTO factoids
(factoid_key, factoid_value, created_by, created_time, modified_by, modified_time, locked_by, locked_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(key, value, created_by, now, created_by, now, locked_by, now if locked_by else None)
)
conn.commit()
conn.close()
def append_factoid(key: str, addition: str, modified_by: str) -> bool:
"""Append text to an existing factoid. Returns True if it existed."""
conn = _conn()
row = conn.execute("SELECT factoid_value FROM factoids WHERE factoid_key = ?", (key,)).fetchone()
if not row:
conn.close()
return False
new_value = row["factoid_value"] + " or " + addition
conn.execute(
"UPDATE factoids SET factoid_value = ?, modified_by = ?, modified_time = ? WHERE factoid_key = ?",
(new_value, modified_by, int(time.time()), key)
)
conn.commit()
conn.close()
return True
def delete_factoid(key: str) -> bool:
"""Delete a factoid. Returns True if it existed."""
conn = _conn()
cur = conn.execute("DELETE FROM factoids WHERE factoid_key = ?", (key,))
existed = cur.rowcount > 0
conn.commit()
conn.close()
return existed
def search_factoids(query: str, limit: int = 20) -> list[dict]:
"""Search factoids by key or value."""
conn = _conn()
like = f"%{query}%"
rows = conn.execute(
"SELECT * FROM factoids WHERE factoid_key LIKE ? OR factoid_value LIKE ? LIMIT ?",
(like, like, limit)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_keys(glob_pattern: str = None, limit: int = 50) -> list[str]:
"""List factoid keys, optionally matching a glob pattern."""
conn = _conn()
if glob_pattern:
like = glob_pattern.replace("*", "%").replace("?", "_")
rows = conn.execute(
"SELECT factoid_key FROM factoids WHERE factoid_key LIKE ? ORDER BY factoid_key LIMIT ?",
(like, limit)
).fetchall()
else:
rows = conn.execute(
"SELECT factoid_key FROM factoids ORDER BY factoid_key LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [r["factoid_key"] for r in rows]
def random_factoid() -> dict | None:
"""Return a random factoid."""
conn = _conn()
row = conn.execute(
"SELECT * FROM factoids ORDER BY RANDOM() LIMIT 1"
).fetchone()
conn.close()
return dict(row) if row else None
def get_stats() -> dict:
"""Return aggregate statistics."""
conn = _conn()
total = conn.execute("SELECT COUNT(*) AS n FROM factoids").fetchone()["n"]
top = conn.execute(
"SELECT factoid_key, requested_count FROM factoids ORDER BY COALESCE(requested_count,0) DESC LIMIT 10"
).fetchall()
conn.close()
return {"total": total, "top": [dict(r) for r in top]}
def lock_factoid(key: str, locked_by: str) -> bool:
"""Lock a factoid. Returns True if it existed."""
conn = _conn()
cur = conn.execute(
"UPDATE factoids SET locked_by = ?, locked_time = ? WHERE factoid_key = ?",
(locked_by, int(time.time()), key)
)
existed = cur.rowcount > 0
conn.commit()
conn.close()
return existed
def unlock_factoid(key: str) -> bool:
"""Unlock a factoid. Returns True if it existed."""
conn = _conn()
cur = conn.execute(
"UPDATE factoids SET locked_by = NULL, locked_time = NULL WHERE factoid_key = ?",
(key,)
)
existed = cur.rowcount > 0
conn.commit()
conn.close()
return existed
# ---------------------------------------------------------------------------
# Value formatting
# ---------------------------------------------------------------------------
def _format_response(key: str, raw_value: str) -> str:
"""Format a factoid value for display, handling <reply>, <action>, and |."""
value = raw_value.strip()
if value.startswith("<reply>"):
return value[len("<reply>"):].strip()
if value.startswith("<action>"):
action = value[len("<action>"):].strip()
return f"* {key} {action}"
if "|" in value:
parts = [p.strip() for p in value.split("|")]
return f"{key} is {random.choice(parts)}"
return f"{key} is {value}"
def _format_info(fact: dict) -> str:
"""Format factoid metadata as code-block rows."""
rows = [
("🔑", "Key", fact["factoid_key"]),
("📝", "Value", fact["factoid_value"][:200] + ("" if len(fact.get("factoid_value",""))>200 else "")),
]
if fact.get("created_by"):
rows.append(("👤", "Created by", fact["created_by"]))
if fact.get("created_time"):
rows.append(("📅", "Created", time.strftime("%Y-%m-%d", time.localtime(fact["created_time"]))))
if fact.get("modified_by") and fact["modified_by"] != fact.get("created_by"):
rows.append(("✏️", "Modified by", fact["modified_by"]))
if fact.get("requested_count"):
rows.append(("🔢", "Requested", f"{fact['requested_count']} times"))
if fact.get("locked_by"):
rows.append(("🔒", "Locked by", fact["locked_by"]))
sections = [{"title": "", "rows": rows}]
return code_block(f"️ Factoid Info: {fact['factoid_key']}", sections)
# ---------------------------------------------------------------------------
# Command handler
# ---------------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
init_db()
match = botlib.MessageMatch(room, message, bot, prefix)
room_id = room.room_id
sender = str(message.sender)
body = (message.body or "").strip()
is_admin = (sender == config.admin_user)
# ---- In-line factoid query: X? (no prefix needed) ----
# Only retrieval is allowed without prefix; learning requires !learn etc.
if match.is_not_from_this_bot() and not match.prefix():
stripped = body.strip()
if stripped.endswith("?") and not stripped.startswith("!"):
key = _normalise_key(stripped[:-1])
if key:
fact = get_factoid(key)
if fact:
resp = _format_response(key, fact["factoid_value"])
await bot.api.send_markdown_message(room_id, resp)
return
# All learning now requires a ! prefix, so we ignore unprefixed messages
return
# ---- Prefixed commands ----
if not (match.is_not_from_this_bot() and match.prefix()):
return
cmd = match.command()
args = match.args()
# !fact
if cmd == "fact":
if not args:
await _send_help(room, bot)
return
sub = args[0].lower()
# !fact search <query>
if sub == "search" and len(args) >= 2:
query = " ".join(args[1:])
results = search_factoids(query)
if not results:
await bot.api.send_text_message(room_id, f"🔍 No factoids matching '{html_escape(query)}'.")
return
rows = []
for f in results:
val = f["factoid_value"][:80] + ("" if len(f["factoid_value"]) > 80 else "")
rows.append(("📌", f["factoid_key"], val))
sections = [{"title": f"Search: {html_escape(query)}", "rows": rows}]
block = code_block(f"🔍 Factoid Search: {html_escape(query)}", sections)
output = collapsible_summary(f"🔍 Factoids matching '{html_escape(query)}'", block)
await bot.api.send_markdown_message(room_id, output)
return
# !fact info <key>
if sub == "info" and len(args) >= 2:
key = _normalise_key(" ".join(args[1:]))
fact = get_factoid(key)
if not fact:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
return
await bot.api.send_markdown_message(room_id, _format_info(fact))
return
# !fact random
if sub == "random":
fact = random_factoid()
if not fact:
await bot.api.send_text_message(room_id, "📭 No factoids in the database yet.")
return
resp = _format_response(fact["factoid_key"], fact["factoid_value"])
await bot.api.send_markdown_message(room_id, resp)
return
# !fact stats
if sub == "stats":
stats = get_stats()
rows = [("📊", "Total factoids", str(stats["total"]))]
for i, t in enumerate(stats["top"], 1):
count = t.get("requested_count") or 0
rows.append(("🏅", f"#{i} {t['factoid_key']}", f"{count} requests"))
sections = [{"title": "Factoid Statistics", "rows": rows}]
block = code_block("📊 Factoid Stats", sections)
output = collapsible_summary("📊 Factoid Statistics", block)
await bot.api.send_markdown_message(room_id, output)
return
# !fact list [glob]
if sub == "list":
pattern = " ".join(args[1:]) if len(args) > 1 else None
keys = list_keys(pattern)
if not keys:
await bot.api.send_text_message(room_id, "📭 No factoids found.")
return
rows = [(f"{i}.", k, "") for i, k in enumerate(keys, 1)]
title = f"Factoid Keys ({len(keys)} total)"
sections = [{"title": title, "rows": rows}]
block = code_block(f"📋 {title}", sections)
output = collapsible_summary(title, block)
await bot.api.send_markdown_message(room_id, output)
return
# !fact lock <key>
if sub == "lock" and len(args) >= 2:
if not is_admin:
await bot.api.send_text_message(room_id, "⛔ Admin only.")
return
key = _normalise_key(" ".join(args[1:]))
if lock_factoid(key, sender):
await bot.api.send_text_message(room_id, f"🔒 Locked '{html_escape(key)}'.")
else:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
return
# !fact unlock <key>
if sub == "unlock" and len(args) >= 2:
if not is_admin:
await bot.api.send_text_message(room_id, "⛔ Admin only.")
return
key = _normalise_key(" ".join(args[1:]))
if unlock_factoid(key):
await bot.api.send_text_message(room_id, f"🔓 Unlocked '{html_escape(key)}'.")
else:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
return
# !fact change <key> is <value>
if sub == "change" and len(args) >= 2:
rest = " ".join(args[1:])
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
if not m:
await bot.api.send_text_message(room_id, "Usage: !fact change <key> is <value>")
return
key = _normalise_key(m.group(1).strip())
value = m.group(2).strip()
existing = get_factoid(key)
if not existing:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Use !learn to create one.")
return
if existing.get("locked_by") and not is_admin:
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.")
return
set_factoid(key, value, sender)
await bot.api.send_text_message(room_id, f"✏️ Changed '{html_escape(key)}'.")
return
# !fact <key> (bare retrieval)
key = _normalise_key(" ".join(args))
fact = get_factoid(key)
if not fact:
keys = list_keys(f"*{key}*")
if keys:
suggestions = ", ".join(keys[:10])
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Did you mean: {suggestions}?")
else:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
return
resp = _format_response(key, fact["factoid_value"])
await bot.api.send_markdown_message(room_id, resp)
return
# !learn <key> is <value>
if cmd == "learn":
if not args:
await bot.api.send_text_message(room_id, "Usage: !learn <key> is <value>")
return
rest = " ".join(args)
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
if not m:
await bot.api.send_text_message(room_id, "Usage: !learn <key> is <value>")
return
key = _normalise_key(m.group(1).strip())
value = m.group(2).strip()
existing = get_factoid(key)
if existing and existing.get("locked_by") and not is_admin:
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.")
return
set_factoid(key, value, sender)
await bot.api.send_text_message(room_id, f"💡 Learned '{html_escape(key)}'.")
return
# !forget <key>
if cmd == "forget":
if not args:
await bot.api.send_text_message(room_id, "Usage: !forget <key>")
return
key = _normalise_key(" ".join(args))
fact = get_factoid(key)
if fact and fact.get("locked_by") and not is_admin:
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {fact['locked_by']}.")
return
if delete_factoid(key):
await bot.api.send_text_message(room_id, f"🗑️ Forgot '{html_escape(key)}'.")
else:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
return
# !also <key> is <value>
if cmd == "also":
if not args:
await bot.api.send_text_message(room_id, "Usage: !also <key> is <value>")
return
rest = " ".join(args)
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
if not m:
await bot.api.send_text_message(room_id, "Usage: !also <key> is <value>")
return
key = _normalise_key(m.group(1).strip())
value = m.group(2).strip()
fact = get_factoid(key)
if fact and fact.get("locked_by") and not is_admin:
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {fact['locked_by']}.")
return
if append_factoid(key, value, sender):
await bot.api.send_text_message(room_id, f"📎 Appended to '{html_escape(key)}'.")
else:
set_factoid(key, value, sender)
await bot.api.send_text_message(room_id, f"💡 Learned '{html_escape(key)}'.")
return
# !no, <key> is <value> (same as change)
if cmd == "no":
if not args:
await bot.api.send_text_message(room_id, "Usage: !no, <key> is <value>")
return
rest = " ".join(args).lstrip(",").strip()
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
if not m:
await bot.api.send_text_message(room_id, "Usage: !no, <key> is <value>")
return
key = _normalise_key(m.group(1).strip())
value = m.group(2).strip()
existing = get_factoid(key)
if not existing:
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Use !learn to create one.")
return
if existing.get("locked_by") and not is_admin:
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.")
return
set_factoid(key, value, sender)
await bot.api.send_text_message(room_id, f"✏️ Changed '{html_escape(key)}'.")
return
async def _send_help(room, bot):
help_text = """
<details>
<summary><strong>📚 Factoids Plugin Help</strong></summary>
<p>
<strong>Commands:</strong><br>
<code>!fact &lt;key&gt;</code> retrieve a factoid<br>
<code>&lt;key&gt;?</code> ask for a factoid inline<br>
<code>!learn &lt;key&gt; is &lt;value&gt;</code> teach the bot<br>
<code>!forget &lt;key&gt;</code> delete a factoid<br>
<code>!also &lt;key&gt; is &lt;value&gt;</code> append to a factoid<br>
<code>!no, &lt;key&gt; is &lt;value&gt;</code> replace a factoid<br>
<code>!fact change &lt;key&gt; is &lt;value&gt;</code> change a factoid<br>
<code>!fact search &lt;query&gt;</code> search factoids<br>
<code>!fact info &lt;key&gt;</code> show metadata<br>
<code>!fact random</code> random factoid<br>
<code>!fact stats</code> statistics<br>
<code>!fact list [glob]</code> list keys<br>
<code>!fact lock|unlock &lt;key&gt;</code> admin only<br>
<br>
<strong>Special values:</strong><br>
<code>&lt;reply&gt; text</code> replies with just "text"<br>
<code>&lt;action&gt; text</code> replies as /me<br>
<code>a | b | c</code> picks one at random
</p>
</details>
"""
await bot.api.send_markdown_message(room.room_id, help_text)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__author__ = "Funguy Bot"
__description__ = "Factoids infobot/supybot-style factoid storage and retrieval"
__help__ = """
<details>
<summary><strong>!fact</strong> Factoids (infobot/supybot clone)</summary>
<ul>
<li><code>!fact &lt;key&gt;</code> retrieve a factoid</li>
<li><code>&lt;key&gt;?</code> ask for a factoid inline</li>
<li><code>!learn &lt;key&gt; is &lt;value&gt;</code> teach</li>
<li><code>!forget &lt;key&gt;</code> delete</li>
<li><code>!also &lt;key&gt; is &lt;value&gt;</code> append</li>
<li><code>!no, &lt;key&gt; is &lt;value&gt;</code> replace</li>
<li><code>!fact search &lt;query&gt;</code> search</li>
<li><code>!fact random</code> / <code>!fact stats</code> / <code>!fact list</code></li>
<li>Special tags: <code>&lt;reply&gt;</code>, <code>&lt;action&gt;</code>, pipe (<code>|</code>) for random</li>
</ul>
</details>
"""
+113 -50
View File
@@ -1,14 +1,17 @@
"""
This plugin provides IP geolocation functionality using free APIs.
IP geolocation plugin uses ip-api.com (primary) and ipapi.co (fallback).
Outputs a formatted code block with emojis and perfectly aligned columns.
"""
import logging
import aiohttp
import simplematrixbotlib as botlib
import socket
import re
from plugins.common import is_public_destination, html_escape, collapsible_summary
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
async def is_valid_ip(ip):
"""Check if the provided string is a valid IP address."""
try:
socket.inet_pton(socket.AF_INET, ip)
return True
@@ -20,18 +23,21 @@ async def is_valid_ip(ip):
return False
def is_domain(domain):
"""Check if the provided string is a domain name."""
domain_pattern = re.compile(
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
)
return bool(domain_pattern.match(domain))
async def resolve_domain(domain):
"""Resolve a domain name to an IP address."""
try:
return socket.gethostbyname(domain)
except socket.gaierror:
return None
async def query_ip_api_com(ip):
"""Query ip-api.com for geolocation information."""
url = f"http://ip-api.com/json/{ip}"
try:
async with aiohttp.ClientSession() as session:
@@ -43,6 +49,7 @@ async def query_ip_api_com(ip):
return None
async def query_ipapi_co(ip):
"""Query ipapi.co for geolocation information (fallback)."""
url = f"https://ipapi.co/{ip}/json/"
try:
async with aiohttp.ClientSession() as session:
@@ -54,69 +61,125 @@ async def query_ipapi_co(ip):
return None
async def query_geolocation(ip):
"""Query geolocation using primary and fallback APIs."""
data = await query_ip_api_com(ip)
if not data or data.get('status') == 'fail':
data = await query_ipapi_co(ip)
return data
async def format_geolocation_results(ip, data):
if not data or ('status' in data and data.get('status') == 'fail'):
return f"🔍 No geolocation data found for {ip}."
country = data.get('country', 'N/A')
country_code = data.get('countryCode', 'N/A')
region = data.get('regionName', data.get('region', 'N/A'))
city = data.get('city', 'N/A')
postal = data.get('zip', 'N/A')
latitude = data.get('lat', 'N/A')
longitude = data.get('lon', 'N/A')
timezone = data.get('timezone', 'N/A')
isp = data.get('isp', 'N/A')
org = data.get('org', 'N/A')
asn = data.get('as', 'N/A')
content = (f"<strong>Country:</strong> {country} ({country_code})<br>"
f"<strong>Region:</strong> {region}<br>"
f"<strong>City:</strong> {city}<br>"
f"<strong>Postal Code:</strong> {postal}<br>"
f"<strong>Coordinates:</strong> {latitude}, {longitude}<br>"
f"<strong>Timezone:</strong> {timezone}<br>"
f"<strong>ISP/Organization:</strong> {isp}<br>"
f"<strong>ASN:</strong> {asn}<br>")
return collapsible_summary(f"🔍 Geolocation: {ip}", content)
async def handle_command(room, message, bot, prefix, config):
"""Handle the !geo command."""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("geo"):
args = match.args()
if len(args) < 1:
await bot.api.send_text_message(room.room_id, "Usage: !geo <ip/domain>")
await bot.api.send_text_message(
room.room_id,
"Usage: !geo <ip_address/domain>\nExample: !geo 8.8.8.8\nExample: !geo example.com"
)
return
query = args[0].strip()
ip = query
if is_domain(query):
await bot.api.send_text_message(room.room_id, f"🔍 Resolving domain {html_escape(query)}...")
ip = await resolve_domain(query)
if not ip:
await bot.api.send_text_message(room.room_id, f"Failed to resolve {html_escape(query)}.")
logging.info(f"Received !geo command for: {query}")
try:
ip = query
if is_domain(query):
await bot.api.send_text_message(
room.room_id,
f"🔍 Resolving domain {html_escape(query)} to IP address..."
)
ip = await resolve_domain(query)
if not ip:
await bot.api.send_text_message(room.room_id,
f"Failed to resolve domain {html_escape(query)} to IP address.")
return
if not is_public_destination(ip):
await bot.api.send_text_message(room.room_id,
"❌ That domain resolves to a private/internal IP, geo not allowed.")
return
await bot.api.send_text_message(room.room_id,
f"Domain {html_escape(query)} resolved to IP {ip}")
elif not await is_valid_ip(query):
await bot.api.send_text_message(room.room_id,
f"Invalid IP address or domain format: {html_escape(query)}")
return
if not is_public_destination(ip):
await bot.api.send_text_message(room.room_id, "❌ Domain resolves to private IP.")
return
await bot.api.send_text_message(room.room_id, f"Resolved to {ip}")
elif not await is_valid_ip(query):
await bot.api.send_text_message(room.room_id, f"Invalid IP/domain: {html_escape(query)}")
return
else:
if not is_public_destination(ip):
await bot.api.send_text_message(room.room_id, "❌ Private IP not allowed.")
else:
if not is_public_destination(ip):
await bot.api.send_text_message(room.room_id,
"❌ Geolocation of private IP addresses is not allowed.")
return
await bot.api.send_text_message(room.room_id,
f"🔍 Looking up geolocation for {ip}...")
geo_data = await query_geolocation(ip)
if not geo_data or ('status' in geo_data and geo_data.get('status') == 'fail'):
await bot.api.send_text_message(room.room_id, f"No geolocation data found for {ip}.")
return
geo_data = await query_geolocation(ip)
result = await format_geolocation_results(ip, geo_data)
await bot.api.send_markdown_message(room.room_id, result)
# Build rows
rows = []
if 'country' in geo_data: # ip-api.com format
country = geo_data.get('country', 'N/A')
country_code = geo_data.get('countryCode', 'N/A')
region = geo_data.get('regionName', geo_data.get('region', 'N/A'))
city = geo_data.get('city', 'N/A')
postal = geo_data.get('zip', 'N/A')
latitude = geo_data.get('lat', 'N/A')
longitude = geo_data.get('lon', 'N/A')
timezone = geo_data.get('timezone', 'N/A')
isp = geo_data.get('isp', 'N/A')
org = geo_data.get('org', 'N/A')
asn = geo_data.get('as', 'N/A')
else: # ipapi.co format
country = geo_data.get('country_name', geo_data.get('country', 'N/A'))
country_code = geo_data.get('country_code', geo_data.get('countryCode', 'N/A'))
region = geo_data.get('region', 'N/A')
city = geo_data.get('city', 'N/A')
postal = geo_data.get('postal', 'N/A')
latitude = geo_data.get('latitude', 'N/A')
longitude = geo_data.get('longitude', 'N/A')
timezone = geo_data.get('timezone', 'N/A')
isp = geo_data.get('org', 'N/A')
org = geo_data.get('org', 'N/A')
asn = geo_data.get('asn', 'N/A')
__version__ = "1.0.2"
rows.append(("🌍", "Country", f"{country} ({country_code})"))
rows.append(("🏙️", "City", city))
if region and region != city:
rows.append(("🏷️", "Region", region))
if postal and postal != 'N/A':
rows.append(("📮", "Postal Code", postal))
rows.append(("📍", "Coordinates", f"{latitude}, {longitude}"))
rows.append(("🕒", "Timezone", timezone))
rows.append(("📡", "ISP", isp))
if org and org != isp:
rows.append(("🏢", "Organization", org))
if asn and asn != 'N/A':
rows.append(("🔢", "ASN", asn))
sections = [{"title": "", "rows": rows}]
block = code_block(f"🔍 IP Geolocation for {ip}", sections)
output = collapsible_summary(f"🔍 Geolocation: {ip}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Successfully sent geolocation results for {ip}")
except Exception as e:
await bot.api.send_text_message(room.room_id,
f"An error occurred during geolocation lookup for {html_escape(query)}.")
logging.error(f"Error in geo plugin for {query}: {e}", exc_info=True)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "IP geolocation lookup"
__help__ = """<details><summary><strong>!geo</strong> IP / domain geolocation</summary>
<ul><li><code>!geo &lt;ip&gt;</code> or <code>!geo &lt;domain&gt;</code></li></ul></details>"""
__help__ = """
<details>
<summary><strong>!geo</strong> IP / domain geolocation</summary>
<p><code>!geo &lt;ip or domain&gt;</code> Locate an IP address or domain. Shows country, city, coordinates, ISP, ASN, etc.</p>
</details>
"""
+41 -244
View File
@@ -1,22 +1,17 @@
"""
This plugin provides a command to identify hash types using comprehensive pattern matching.
Hash identifier plugin identifies 100+ hash types with confidence and tool modes.
Outputs a clean code block with emojis and perfectly aligned columns.
"""
import logging
import re
import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, html_escape, code_block
# ---------------------------------------------------------------------------
# Hash identification logic (unchanged from original)
# ---------------------------------------------------------------------------
def identify_hash(hash_string):
"""
Identify the hash type based on comprehensive pattern matching.
Args:
hash_string (str): The hash string to identify
Returns:
list: List of tuples (hash_type, hashcat_mode, john_format, confidence)
"""
hash_string = hash_string.strip()
hash_lower = hash_string.lower()
length = len(hash_string)
@@ -25,15 +20,10 @@ def identify_hash(hash_string):
# Unix crypt and modular crypt formats (most specific first)
if hash_string.startswith('$'):
# yescrypt (modern Linux /etc/shadow)
if re.match(r'^\$y\$', hash_string):
possible_types.append(("yescrypt", None, "yescrypt", 95))
# scrypt
elif re.match(r'^\$7\$', hash_string):
possible_types.append(("scrypt", "8900", "scrypt", 95))
# Argon2
elif re.match(r'^\$argon2(id?|d)\$', hash_string):
if '$argon2i$' in hash_string:
possible_types.append(("Argon2i", "10900", "argon2", 95))
@@ -41,72 +31,39 @@ def identify_hash(hash_string):
possible_types.append(("Argon2d", None, "argon2", 95))
elif '$argon2id$' in hash_string:
possible_types.append(("Argon2id", "10900", "argon2", 95))
# bcrypt variants
elif re.match(r'^\$(2[abxy]?)\$', hash_string):
bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1)
possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95))
# SHA-512 Crypt (common in Linux)
elif re.match(r'^\$6\$', hash_string):
possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95))
# SHA-256 Crypt (Unix)
elif re.match(r'^\$5\$', hash_string):
possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95))
# MD5 Crypt (Unix)
elif re.match(r'^\$1\$', hash_string):
possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95))
# Apache MD5
elif re.match(r'^\$apr1\$', hash_string):
possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95))
# AIX SMD5
elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {smd5}", "6300", None, 90))
# AIX SSHA256
elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {ssha256}", "6700", None, 90))
# AIX SSHA512
elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE):
possible_types.append(("AIX {ssha512}", "6800", None, 90))
# phpBB3
elif re.match(r'^\$H\$', hash_string):
possible_types.append(("phpBB3", "400", "phpass", 90))
# Wordpress
elif re.match(r'^\$P\$', hash_string):
possible_types.append(("Wordpress", "400", "phpass", 90))
# Drupal 7+
elif re.match(r'^\$S\$', hash_string):
possible_types.append(("Drupal 7+", "7900", "drupal7", 90))
# WBB3 (Woltlab Burning Board)
elif re.match(r'^\$wbb3\$', hash_string):
possible_types.append(("WBB3 (Woltlab)", None, None, 85))
# PBKDF2-HMAC-SHA256
elif re.match(r'^\$pbkdf2-sha256\$', hash_string):
possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90))
# PBKDF2-HMAC-SHA512
elif re.match(r'^\$pbkdf2-sha512\$', hash_string):
possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90))
# Django PBKDF2
elif re.match(r'^pbkdf2_sha256\$', hash_string):
possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90))
# Unknown modular crypt format
else:
possible_types.append(("Unknown Modular Crypt Format", None, None, 30))
return possible_types
# LDAP formats
@@ -123,31 +80,22 @@ def identify_hash(hash_string):
possible_types.append(("LDAP CRYPT", None, None, 85))
return possible_types
# Check for colon-separated formats (LM:NTLM, username:hash, etc.)
# Colon-separated formats
if ':' in hash_string:
parts = hash_string.split(':')
# NetNTLMv1 / NetNTLMv2
if len(parts) >= 5:
possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85))
possible_types.append(("NetNTLMv1", "5500", "netntlm", 75))
# LM:NTLM format
elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32:
possible_types.append(("LM:NTLM", "1000", "nt", 90))
# Username:Hash or similar
elif len(parts) == 2:
hash_part = parts[1]
if len(hash_part) == 32:
possible_types.append(("NTLM (with username)", "1000", "nt", 80))
elif len(hash_part) == 40:
possible_types.append(("SHA-1 (with salt/username)", "110", None, 70))
# PostgreSQL md5
if hash_string.startswith('md5') and len(hash_string) == 35:
possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90))
return possible_types if possible_types else None
# MySQL formats
@@ -159,7 +107,6 @@ def identify_hash(hash_string):
if re.match(r'^[A-F0-9]{16}:[A-F0-9]{16}$', hash_string.upper()):
possible_types.append(("Oracle 11g", "112", "oracle11", 90))
return possible_types
if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()):
possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90))
return possible_types
@@ -168,234 +115,84 @@ def identify_hash(hash_string):
if re.match(r'^0x0100[A-F0-9]{8}[A-F0-9]{40}$', hash_string.upper()):
possible_types.append(("MSSQL 2000", "131", "mssql", 90))
return possible_types
if re.match(r'^0x0200[A-F0-9]{8}[A-F0-9]{128}$', hash_string.upper()):
possible_types.append(("MSSQL 2012/2014", "1731", "mssql12", 90))
return possible_types
# Base64 pattern check
is_base64 = re.match(r'^[A-Za-z0-9+/]+=*$', hash_string) and length % 4 == 0
# Raw hash identification by length
is_hex = re.match(r'^[a-f0-9]+$', hash_lower)
if is_hex:
if length == 16:
possible_types.append(("MySQL < 4.1", "200", "mysql", 85))
possible_types.append(("Half MD5", None, None, 60))
elif length == 32:
possible_types.append(("MD5", "0", "raw-md5", 80))
possible_types.append(("MD4", "900", "raw-md4", 70))
possible_types.append(("NTLM", "1000", "nt", 75))
possible_types.append(("LM", "3000", "lm", 60))
possible_types.append(("RAdmin v2.x", "9900", None, 50))
possible_types.append(("Snefru-128", None, None, 40))
possible_types.append(("HMAC-MD5 (key = $pass)", "50", None, 50))
elif length == 40:
possible_types.append(("SHA-1", "100", "raw-sha1", 85))
possible_types.append(("RIPEMD-160", "6000", "ripemd-160", 65))
possible_types.append(("Tiger-160", None, None, 50))
possible_types.append(("Haval-160", None, None, 45))
possible_types.append(("HMAC-SHA1 (key = $pass)", "150", None, 55))
elif length == 48:
possible_types.append(("Tiger-192", None, None, 70))
possible_types.append(("Haval-192", None, None, 65))
elif length == 56:
possible_types.append(("SHA-224", "1300", "raw-sha224", 85))
possible_types.append(("Haval-224", None, None, 60))
elif length == 64:
possible_types.append(("SHA-256", "1400", "raw-sha256", 85))
possible_types.append(("RIPEMD-256", None, None, 60))
possible_types.append(("SHA3-256", "17400", "raw-sha3", 70))
possible_types.append(("Keccak-256", "17800", "raw-keccak-256", 70))
possible_types.append(("Haval-256", None, None, 50))
possible_types.append(("GOST R 34.11-94", "6900", None, 55))
possible_types.append(("BLAKE2b-256", None, None, 60))
elif length == 80:
possible_types.append(("RIPEMD-320", None, None, 80))
elif length == 96:
possible_types.append(("SHA-384", "10800", "raw-sha384", 85))
possible_types.append(("SHA3-384", "17900", None, 70))
possible_types.append(("Keccak-384", None, None, 65))
elif length == 128:
possible_types.append(("SHA-512", "1700", "raw-sha512", 85))
possible_types.append(("Whirlpool", "6100", "whirlpool", 75))
possible_types.append(("SHA3-512", "17600", None, 70))
possible_types.append(("Keccak-512", None, None, 65))
possible_types.append(("BLAKE2b-512", None, None, 60))
# Base64 encoded hashes
elif is_base64:
if length == 24:
possible_types.append(("MD5 (Base64)", None, None, 75))
elif length == 28:
possible_types.append(("SHA-1 (Base64)", None, None, 75))
elif length == 32:
possible_types.append(("SHA-224 (Base64)", None, None, 75))
elif length == 44:
possible_types.append(("SHA-256 (Base64)", None, None, 75))
elif length == 64:
possible_types.append(("SHA-384 (Base64)", None, None, 75))
elif length == 88:
possible_types.append(("SHA-512 (Base64)", None, None, 75))
return possible_types if possible_types else [("Unknown", None, None, 0)]
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def _format_results(hash_input, results):
"""Build a code block with sections for each possible hash type."""
sections = []
for idx, (hash_type, hashcat_mode, john_format, confidence) in enumerate(results, 1):
emoji = "🟢" if confidence >= 90 else "🟡" if confidence >= 80 else "🟠" if confidence >= 60 else "🔴"
title = f"{emoji} Match #{idx}: {hash_type} ({confidence}%)"
rows = [
("", "Hash Type", hash_type),
("", "Confidence", f"{confidence}%"),
]
if hashcat_mode:
rows.append(("", "Hashcat Mode", f"-m {hashcat_mode}"))
if john_format:
rows.append(("", "John Format", f"--format={john_format}"))
sections.append({"title": title, "rows": rows})
block = code_block(f"🔐 Hash Identification: {hash_input[:30]}...", sections)
return collapsible_summary("🔐 Hash Identification Results", block)
async def handle_command(room, message, bot, prefix, config):
"""
Function to handle the !hashid command.
Args:
room (Room): The Matrix room where the command was invoked.
message (RoomMessage): The message object containing the command.
bot (Bot): The bot object.
prefix (str): The command prefix.
config (dict): Configuration parameters.
Returns:
None
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"):
logging.info("Received !hashid command")
args = match.args()
if len(args) < 1:
usage_msg = """<strong>🔐 Hash Identifier Usage</strong>
<strong>Usage:</strong> <code>!hashid &lt;hash&gt;</code>
<strong>Examples:</strong>
• <code>!hashid 5f4dcc3b5aa765d61d8327deb882cf99</code>
• <code>!hashid 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8</code>
• <code>!hashid $6$rounds=5000$salt$hash...</code>
• <code>!hashid $y$j9T$...</code> (yescrypt from /etc/shadow)
<strong>Supported Hash Types:</strong>
• <strong>Modern:</strong> yescrypt, scrypt, Argon2, bcrypt
• <strong>Unix Crypt:</strong> SHA-512 Crypt, SHA-256 Crypt, MD5 Crypt
• <strong>Raw Hashes:</strong> MD5, SHA-1/224/256/384/512, SHA-3, NTLM, LM
• <strong>Database:</strong> MySQL, PostgreSQL, Oracle, MSSQL
• <strong>CMS:</strong> Wordpress, phpBB3, Drupal, Django
• <strong>LDAP:</strong> SSHA, SMD5, and various LDAP formats
• <strong>Network:</strong> NetNTLMv1/v2, Kerberos
• <strong>Exotic:</strong> Whirlpool, RIPEMD, BLAKE2, Keccak, GOST
"""
await bot.api.send_markdown_message(room.room_id, usage_msg)
await bot.api.send_markdown_message(room.room_id, "<strong>Usage:</strong> <code>!hashid &lt;hash&gt;</code>")
return
hash_input = ' '.join(args)
try:
# Identify the hash
identified = identify_hash(hash_input)
if not identified:
await bot.api.send_text_message(
room.room_id,
"Could not identify hash type. Please verify the hash format."
)
return
# Sort by confidence (highest first)
identified = sorted(identified, key=lambda x: x[3], reverse=True)
# Format the response
hash_preview = hash_input[:60] + "..." if len(hash_input) > 60 else hash_input
# Determine confidence indicator
top_confidence = identified[0][3]
if top_confidence >= 90:
confidence_emoji = "🟢"
confidence_label = "Very High"
elif top_confidence >= 80:
confidence_emoji = "🟡"
confidence_label = "High"
elif top_confidence >= 60:
confidence_emoji = "🟠"
confidence_label = "Medium"
else:
confidence_emoji = "🔴"
confidence_label = "Low"
# Build response inside collapsible details
response = "<details><summary><strong>🔐 Hash Identification Results</strong></summary>\n"
response += "<br>\n"
response += f"<strong>Input:</strong> <code>{hash_preview}</code><br>\n"
response += f"<strong>Length:</strong> {len(hash_input)} characters<br>\n"
response += f"<strong>Overall Confidence:</strong> {confidence_emoji} {confidence_label} ({top_confidence}%)<br>\n"
response += "<br>\n"
response += f"<strong>Possible Hash Types ({len(identified)}):</strong><br>\n"
for idx, (hash_type, hashcat_mode, john_format, confidence) in enumerate(identified, 1):
# Confidence indicator per hash
if confidence >= 90:
conf_emoji = "🟢"
elif confidence >= 80:
conf_emoji = "🟡"
elif confidence >= 60:
conf_emoji = "🟠"
else:
conf_emoji = "🔴"
response += f" <strong>{idx}. {hash_type}</strong> {conf_emoji} {confidence}%<br>\n"
tools = []
if hashcat_mode:
tools.append(f"Hashcat: <code>-m {hashcat_mode}</code>")
if john_format:
tools.append(f"John: <code>--format={john_format}</code>")
if tools:
response += f" {' | '.join(tools)}<br>\n"
response += "<br>\n"
# Add useful tips
if len(identified) == 1 and identified[0][0] not in ["Unknown", "Unknown Modular Crypt Format"]:
response += "<br><strong>💡 Single match with high confidence</strong><br>\n"
elif len(identified) > 5:
response += "<br><em>️ Multiple possibilities - context may help narrow it down</em><br>\n"
# Add legend
response += "<br>\n"
response += "<strong>Confidence Legend:</strong><br>\n"
response += "🟢 Very High (90-100%) | 🟡 High (80-89%) | 🟠 Medium (60-79%) | 🔴 Low (0-59%)<br>\n"
response += "</details>"
await bot.api.send_markdown_message(room.room_id, response)
logging.info(f"Identified hash types: {', '.join([f'{h[0]} ({h[3]}%)' for h in identified])}")
except Exception as e:
await bot.api.send_text_message(
room.room_id,
f"Error identifying hash: {str(e)}"
)
logging.error(f"Error in hashid command: {e}", exc_info=True)
results = identify_hash(hash_input)
if not results or results[0][0] == "Unknown":
await bot.api.send_text_message(room.room_id, "Could not identify the hash type.")
return
# Sort by confidence descending
results.sort(key=lambda x: x[3], reverse=True)
output = _format_results(hash_input, results[:6]) # show top 6
await bot.api.send_markdown_message(room.room_id, output)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.0"
__author__ = "Funguy Bot"
__description__ = "Hash type identifier"
__help__ = """
<details>
<summary><strong>!hashid</strong> Identify hash type</summary>
<p><code>!hashid &lt;hash&gt;</code> Recognises 100+ hash formats (MD5, SHA, bcrypt, etc.).<br>
Shows confidence level, Hashcat mode, and John the Ripper format.</p>
<p><code>!hashid &lt;hash&gt;</code> Recognises 100+ formats and displays tool modes in a clean table.</p>
</details>
"""
+166 -309
View File
@@ -1,5 +1,6 @@
"""
This plugin provides comprehensive HTTP security header analysis.
HTTP security header analysis plugin.
Outputs a structured code block with perfectly aligned columns.
"""
import logging
@@ -10,342 +11,198 @@ from urllib.parse import urlparse
import ssl
import socket
import datetime
from plugins.common import is_public_destination, collapsible_summary, html_escape
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
async def _run_in_thread(func, *args, **kwargs):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
async def analyze_http_response(url):
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as resp:
return str(resp.url), resp.status, dict(resp.headers), resp.url.scheme == 'https'
except aiohttp.ClientError as e:
logging.warning(f"HTTP analysis error: {e}")
return url, None, {}, False
async def analyze_https_response(url):
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as resp:
return resp.status, dict(resp.headers)
except aiohttp.ClientError as e:
logging.warning(f"HTTPS analysis error: {e}")
return None, {}
def _get_cert_info(domain):
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
return {
'subject': dict(x[0] for x in cert['subject']),
'issuer': dict(x[0] for x in cert['issuer']),
'not_before': cert['notBefore'],
'not_after': cert['notAfter'],
'san': cert.get('subjectAltName', []),
}
except Exception as e:
logging.warning(f"SSL cert error: {e}")
return None
def calculate_score(headers, redirects_to_https, cert_info):
score = 100
if 'Strict-Transport-Security' not in headers: score -= 15
if 'Content-Security-Policy' not in headers: score -= 15
if 'X-Content-Type-Options' not in headers: score -= 15
if 'X-Frame-Options' not in headers: score -= 15
if 'X-XSS-Protection' not in headers: score -= 15
hsts = headers.get('Strict-Transport-Security', '')
if 'max-age=31536000' not in hsts: score -= 10
if 'includeSubDomains' not in hsts: score -= 5
if 'preload' not in hsts: score -= 5
if headers.get('Referrer-Policy'): score += 5
if headers.get('Feature-Policy') or headers.get('Permissions-Policy'): score += 5
if headers.get('X-Content-Type-Options') == 'nosniff': score += 5
if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']: score += 5
if redirects_to_https: score += 10
if cert_info and cert_info.get('not_after'):
try:
expires = datetime.datetime.strptime(cert_info['not_after'], '%b %d %H:%M:%S %Y %Z')
if (expires - datetime.datetime.utcnow()).days < 30: score -= 10
except: pass
return max(0, score)
def generate_recommendations(headers, redirects_to_https):
recs = []
if 'Strict-Transport-Security' not in headers:
recs.append("🔒 Implement HSTS with max-age=31536000, includeSubDomains, preload")
if 'Content-Security-Policy' not in headers:
recs.append("🛡️ Add Content-Security-Policy")
if 'X-Frame-Options' not in headers:
recs.append("🚫 Add X-Frame-Options (DENY or SAMEORIGIN)")
if 'X-Content-Type-Options' not in headers:
recs.append("📄 Add X-Content-Type-Options: nosniff")
if not redirects_to_https:
recs.append("🔐 Redirect HTTP to HTTPS")
if 'Server' in headers or 'X-Powered-By' in headers:
recs.append("🕵️ Remove info disclosure headers (Server, X-Powered-By)")
return recs
async def handle_command(room, message, bot, prefix, config):
"""
Function to handle !headers command for HTTP security header analysis.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("headers"):
logging.info("Received !headers command")
if not (match.is_not_from_this_bot() and match.prefix() and match.command("headers")):
return
args = match.args()
args = match.args()
if len(args) < 1:
await bot.api.send_markdown_message(room.room_id,
"<strong>🔒 HTTP Security Headers Analysis</strong>\n<code>!headers &lt;url&gt;</code>")
return
if len(args) < 1:
await show_usage(room, bot)
return
original_input = args[0].strip()
url = original_input
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
url = args[0].strip()
parsed = urlparse(url)
host = parsed.hostname
if not is_public_destination(host):
await bot.api.send_text_message(room.room_id, "❌ Private/internal addresses are not allowed.")
return
# Add protocol if missing
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
safe_input = html_escape(original_input)
safe_host = html_escape(host)
await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {safe_input}...")
# SSRF protection: refuse internal hosts
parsed = urlparse(url)
host = parsed.hostname
if not is_public_destination(host):
await bot.api.send_text_message(room.room_id,
"❌ Scanning of private/internal addresses is not allowed.")
return
final_url, status_code, http_headers, redirects_to_https = await analyze_http_response(url)
_, https_headers = await analyze_https_response(url) if url.startswith('https://') else (None, {})
await analyze_headers(room, bot, url)
headers = https_headers or http_headers
cert_info = None
if url.startswith('https://'):
cert_info = await _run_in_thread(_get_cert_info, host)
async def show_usage(room, bot):
"""Display headers command usage."""
usage = """
<strong>🔒 HTTP Security Headers Analysis</strong>
score = calculate_score(headers, redirects_to_https, cert_info)
recommendations = generate_recommendations(headers, redirects_to_https)
<strong>!headers &lt;url&gt;</strong> - Comprehensive HTTP security header analysis
sections = []
<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:
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response:
results['final_url'] = str(response.url)
results['status_code'] = response.status
results['http_headers'] = dict(response.headers)
results['redirects_to_https'] = response.url.scheme == 'https'
# aiohttp doesn't give access to redirect history easily, so we'll mark if final URL differs
if str(response.url) != url:
results['redirect_chain'] = [{'url': url, 'status_code': 301}] # simplified
except aiohttp.ClientError as e:
results['http_error'] = str(e)
async def analyze_https_response(results, url):
"""Analyze HTTPS response headers."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as response:
results['https_headers'] = dict(response.headers)
results['https_status'] = response.status
except aiohttp.ClientError as e:
results['https_error'] = str(e)
async def analyze_ssl_certificate(results, domain):
"""Analyze SSL certificate information (run in thread to avoid event loop blocking)."""
def _get_cert():
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
return {
'subject': dict(x[0] for x in cert['subject']),
'issuer': dict(x[0] for x in cert['issuer']),
'not_before': cert['notBefore'],
'not_after': cert['notAfter'],
'san': cert.get('subjectAltName', []),
'version': cert.get('version'),
'serial_number': cert.get('serialNumber')
}
except Exception as e:
return f"Error: {e}"
loop = asyncio.get_running_loop()
ssl_data = await loop.run_in_executor(None, _get_cert)
if isinstance(ssl_data, str):
results['ssl_error'] = ssl_data
else:
results['ssl_info'] = ssl_data
async def calculate_security_score(results):
"""Calculate overall security score based on headers and configuration."""
score = 100
missing_headers = []
critical_headers = [
'Strict-Transport-Security',
'Content-Security-Policy',
'X-Content-Type-Options',
'X-Frame-Options',
'X-XSS-Protection'
]
headers = results.get('https_headers') or results.get('http_headers', {})
for header in critical_headers:
if header not in headers:
score -= 15
missing_headers.append(header)
# Check HSTS configuration
hsts = headers.get('Strict-Transport-Security', '')
if 'max-age=31536000' not in hsts:
score -= 10
if 'includeSubDomains' not in hsts:
score -= 5
if 'preload' not in hsts:
score -= 5
# Check CSP configuration
csp = headers.get('Content-Security-Policy', '')
if not csp:
score -= 10
elif "default-src 'none'" not in csp and "default-src 'self'" not in csp:
score -= 5
# Check for insecure headers
insecure_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version']
for header in insecure_headers:
if header in headers:
score -= 5
# Bonus for good practices
if headers.get('Referrer-Policy'):
score += 5
if headers.get('Feature-Policy') or headers.get('Permissions-Policy'):
score += 5
if headers.get('X-Content-Type-Options') == 'nosniff':
score += 5
if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']:
score += 5
# HTTPS enforcement bonus
if results.get('redirects_to_https'):
score += 10
results['security_score'] = max(0, score)
results['missing_headers'] = missing_headers
async def generate_recommendations(results):
"""Generate security recommendations based on analysis."""
recommendations = []
headers = results.get('https_headers') or results.get('http_headers', {})
if 'Strict-Transport-Security' not in headers:
recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload")
else:
hsts = headers['Strict-Transport-Security']
if 'max-age=31536000' not in hsts:
recommendations.append("🔒 Increase HSTS max-age to 31536000 (1 year)")
if 'includeSubDomains' not in hsts:
recommendations.append("🔒 Add includeSubDomains to HSTS header")
if 'preload' not in hsts:
recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading")
if 'Content-Security-Policy' not in headers:
recommendations.append("🛡️ Implement Content Security Policy to prevent XSS attacks")
if 'X-Frame-Options' not in headers:
recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)")
if 'X-Content-Type-Options' not in headers:
recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing")
if 'Referrer-Policy' not in headers:
recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage")
if 'Server' in headers or 'X-Powered-By' in headers:
recommendations.append("🕵️ Remove Server and X-Powered-By headers to avoid information disclosure")
if not results.get('redirects_to_https') and not results['url'].startswith('https://'):
recommendations.append("🔐 Implement HTTP to HTTPS redirects")
results['recommendations'] = recommendations
async def format_header_analysis(results):
"""Format the header analysis results for display."""
safe_url = html_escape(results['url'])
output = f"<strong>🔒 Security Headers Analysis: {safe_url}</strong><br><br>"
# Security Score
score = results['security_score']
# Score
score_emoji = "🟢" if score >= 80 else "🟡" if score >= 60 else "🔴"
output += f"<strong>{score_emoji} Security Score: {score}/100</strong><br><br>"
sections.append({
"title": f"{score_emoji} Security Score",
"rows": [("", "Score", f"{score}/100")]
})
# Basic Information
output += "<strong>📊 Basic Information</strong><br>"
output += f" • <strong>Final URL:</strong> {html_escape(results.get('final_url', 'N/A'))}<br>"
output += f" • <strong>Status Code:</strong> {results.get('status_code', 'N/A')}<br>"
if results.get('redirects_to_https'):
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>"
else:
output += f" • <strong>HTTPS Redirect:</strong> ❌ Not enforced<br>"
output += "<br>"
# Security Headers Analysis
headers = results.get('https_headers') or results.get('http_headers', {})
output += "<strong>🛡️ Security Headers Analysis</strong><br>"
basic_rows = [
("🌐", "Final URL", final_url),
("📊", "Status Code", str(status_code) if status_code else "N/A"),
("🔐", "HTTPS Redirect", "✅ Yes" if redirects_to_https else "❌ No"),
]
sections.append({"title": "📊 Basic Information", "rows": basic_rows})
# Security Headers
security_headers = {
'Strict-Transport-Security': ('🔒', 'HSTS'),
'Content-Security-Policy': ('🛡️', 'CSP'),
'X-Frame-Options': ('🚫', 'Clickjacking Protection'),
'X-Content-Type-Options': ('📄', 'MIME Sniffing'),
'X-XSS-Protection': ('', 'XSS Protection (Deprecated)'),
'Referrer-Policy': ('🔗', 'Referrer Policy'),
'Feature-Policy': ('⚙️', 'Feature Policy'),
'Permissions-Policy': ('🔧', 'Permissions Policy'),
'Content-Security-Policy': ('🛡️', 'CSP'),
'X-Frame-Options': ('🚫', 'Frame Options'),
'X-Content-Type-Options': ('📄', 'Content Type'),
'X-XSS-Protection': ('', 'XSS Protection'),
'Referrer-Policy': ('🔗', 'Referrer Policy'),
'Permissions-Policy': ('🔧', 'Permissions Policy'),
'Feature-Policy': ('⚙️', 'Feature Policy'),
}
for header, (emoji, description) in security_headers.items():
if header in headers:
value = html_escape(str(headers[header]))[:100]
output += f"{emoji} <strong>{header}:</strong> {value}<br>"
header_rows = []
for hdr, (emoji, label) in security_headers.items():
if hdr in headers:
val = headers[hdr][:100]
header_rows.append((emoji, label, f"{val}"))
else:
output += f"{emoji} <strong>{header}:</strong> ❌ Missing<br>"
output += "<br>"
header_rows.append((emoji, label, "❌ Missing"))
sections.append({"title": "🛡️ Security Headers", "rows": header_rows})
# Other Headers (Information Disclosure)
output += "<strong>📋 Other Headers</strong><br>"
for header in ['Server', 'X-Powered-By']:
if header in headers:
output += f" • 🔍 <strong>{header}:</strong> {html_escape(str(headers[header]))}<br>"
output += "<br>"
# Other Headers
other_rows = []
for hdr in ['Server', 'X-Powered-By']:
if hdr in headers:
other_rows.append(("🔍", hdr, headers[hdr]))
if other_rows:
sections.append({"title": "📋 Other Headers", "rows": other_rows})
# SSL Certificate Information (if available)
if results.get('ssl_info') and 'subject' in results['ssl_info']:
output += "<strong>🔐 SSL Certificate</strong><br>"
ssl_info = results['ssl_info']
if ssl_info.get('subject'):
output += f" • <strong>Subject:</strong> {html_escape(ssl_info['subject'].get('commonName', 'N/A'))}<br>"
if ssl_info.get('issuer'):
output += f" • <strong>Issuer:</strong> {html_escape(ssl_info['issuer'].get('organizationName', 'N/A'))}<br>"
if ssl_info.get('not_after'):
output += f" • <strong>Expires:</strong> {html_escape(ssl_info['not_after'])}<br>"
output += "<br>"
# SSL Certificate
if cert_info:
ssl_rows = [
("📜", "Subject", cert_info['subject'].get('commonName', 'N/A')),
("🏢", "Issuer", cert_info['issuer'].get('organizationName', 'N/A')),
("📅", "Expires", cert_info.get('not_after', 'N/A')),
]
san = [san[1] for san in cert_info.get('san', []) if san[0] == 'DNS']
if san:
ssl_rows.append(("🌐", "SANs", ", ".join(san[:5])))
sections.append({"title": "🔐 SSL Certificate", "rows": ssl_rows})
# Recommendations
if results.get('recommendations'):
output += "<strong>💡 Security Recommendations</strong><br>"
for rec in results['recommendations'][:8]:
output += f"{rec}<br>"
output += "<br>"
if recommendations:
rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
sections.append({"title": "💡 Recommendations", "rows": rec_rows})
# Final rating
if score >= 80:
rating = "🟢 Excellent"
elif score >= 60:
rating = "🟡 Good"
elif score >= 40:
rating = "🟠 Fair"
else:
rating = "🔴 Poor"
output += f"<strong>📈 Security Rating:</strong> {rating}<br>"
block = code_block(f"🔒 Security Headers: {safe_host}", sections)
output = collapsible_summary(f"🔒 Headers: {safe_host}", block)
await bot.api.send_markdown_message(room.room_id, output)
# Wrap in collapsible details
return collapsible_summary(f"🔒 Security Headers Analysis: {safe_url} (Score: {score}/100)", output)
__version__ = "1.0.2"
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.1.2"
__author__ = "Funguy Bot"
__description__ = "HTTP security header analysis (SSRFsafe, async)"
__description__ = "HTTP security header analysis"
__help__ = """
<details>
<summary><strong>!headers</strong> HTTP security header scanner</summary>
<p><code>!headers &lt;url&gt;</code> Checks HSTS, CSP, X-Frame-Options, etc.<br>
Provides security score (0-100) and recommendations. Also shows SSL certificate info.</p>
<summary><strong>!headers</strong> HTTP security headers analysis</summary>
<p><code>!headers &lt;url&gt;</code> Analyzes security headers, SSL cert, gives score and recommendations in a clean, aligned table.</p>
</details>
"""
+1 -1
View File
@@ -172,7 +172,7 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens):
__version__ = "1.0.3"
__author__ = "Funguy Bot"
__description__ = "AI text generation via Infermatic API (async, safe)"
__description__ = "AI text generation via Infermatic API"
__help__ = """
<details>
<summary><strong>!text</strong> AI text generation (Infermatic)</summary>
+1 -2
View File
@@ -6,8 +6,7 @@ import logging
import aiohttp
import socket
import simplematrixbotlib as botlib
from plugins.utils import is_public_destination
from plugins.common import is_public_destination
async def check_http(domain):
"""Check if HTTP service is up for the given domain."""
+77 -31
View File
@@ -9,6 +9,7 @@ Features:
* View karma leaderboards (top/bottom)
* Rate limiting to prevent spam
* Room-specific karma tracking
* Pertarget throttle (max votes per target per minute)
Commands:
!karma - Show this help
@@ -37,13 +38,18 @@ from datetime import datetime, timedelta
import re
import asyncio
import traceback
import time
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# Prevent spam: minimum seconds between karma changes to same target by same user
COOLDOWN_SECONDS = 5
# Pertarget cooldown: one karma point per hour per user
COOLDOWN_SECONDS = 3600
# Pertarget throttle: max votes a target can receive per minute
PER_TARGET_THROTTLE_COUNT = 5
PER_TARGET_THROTTLE_SECONDS = 3600
# Database file
DB_FILE = "karma.db"
@@ -55,6 +61,8 @@ display_name_cache = {}
# Last time we refreshed the cache (per room)
cache_timestamp = {}
# Pertarget throttle tracker: (room_id, user_id) -> list of monotonic timestamps
_target_vote_times: dict[tuple[str, str], list[float]] = {}
# ---------------------------------------------------------------------------
# Helper: pluralize "point" vs "points"
@@ -130,33 +138,23 @@ async def refresh_display_name_cache(bot, room_id):
logging.info(f"Refreshing display name cache for room {room_id}")
try:
# Try to get room members from the bot's state
if hasattr(bot, 'async_client') and bot.async_client:
# Get the room state
room = bot.async_client.rooms.get(room_id)
if room and hasattr(room, 'users'):
# Build mapping of display names to user IDs
resp = await bot.async_client.joined_members(room_id)
if resp.members:
name_map = {}
for user_id, user_info in room.users.items():
# Get display name - try different attributes
display_name = None
if hasattr(user_info, 'display_name') and user_info.display_name:
display_name = user_info.display_name
elif hasattr(user_info, 'name') and user_info.name:
display_name = user_info.name
for member in resp.members:
display_name = (member.display_name or "").strip()
if display_name:
name_map[display_name.lower()] = user_id
name_map[display_name.lower()] = member.user_id
# Also store without emojis for easier matching
clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower()
if clean_name and clean_name != display_name.lower():
name_map[clean_name] = user_id
name_map[clean_name] = member.user_id
display_name_cache[room_id] = name_map
cache_timestamp[room_id] = now
logging.info(f"Cached {len(name_map)} display names for room {room_id}")
return
except Exception as e:
logging.warning(f"Could not refresh display name cache: {e}")
@@ -176,6 +174,9 @@ def resolve_display_name(room_id, display_name, bot=None):
Returns None if the input is a Matrix ID (rejected) or if the name
cannot be resolved.
"""
# Strip HTML tags (Matrix mention pills)
clean = re.sub(r'<[^>]+>', '', display_name).strip()
# Reject Matrix IDs outright
if is_matrix_id(display_name):
return None
@@ -185,20 +186,15 @@ def resolve_display_name(room_id, display_name, bot=None):
name_map = display_name_cache[room_id]
# Try exact match (case-insensitive)
key = display_name.lower()
key = clean.lower()
if key in name_map:
return name_map[key]
# Try without emojis/special characters
clean_key = re.sub(r'[^\w\s]', '', display_name).strip().lower()
clean_key = re.sub(r'[^\w\s]', '', clean).strip().lower()
if clean_key and clean_key in name_map:
return name_map[clean_key]
# Try partial match (if display name is contained in a cached name)
for cached_name, user_id in name_map.items():
if key in cached_name or cached_name in key:
return user_id
return None
@@ -459,6 +455,28 @@ def format_karma_display(display_name, points):
return f"⚖️ **{display_name}** has neutral karma (0)"
# ---------------------------------------------------------------------------
# Pertarget throttle helpers
# ---------------------------------------------------------------------------
def _is_target_throttled(room_id: str, user_id: str) -> bool:
"""Return True if the target user has received too many votes recently."""
key = (room_id, user_id)
now = time.monotonic()
times = _target_vote_times.get(key, [])
# Remove old entries
times = [t for t in times if now - t < PER_TARGET_THROTTLE_SECONDS]
_target_vote_times[key] = times
return len(times) >= PER_TARGET_THROTTLE_COUNT
def _record_target_vote(room_id: str, user_id: str):
"""Record that a vote was just cast for the target user."""
key = (room_id, user_id)
times = _target_vote_times.get(key, [])
times.append(time.monotonic())
_target_vote_times[key] = times
# ---------------------------------------------------------------------------
# Command Handlers
# ---------------------------------------------------------------------------
@@ -473,10 +491,13 @@ async def handle_command(room, message, bot, prefix, config):
# Debug logging
message_body = message.body if hasattr(message, 'body') else str(message)
logging.info(f"Karma plugin received message: '{message_body}' from {message.sender}")
logging.debug(f"Karma plugin received message: '{message_body}' from {message.sender}")
# Get the full command (including what might be karma++ etc.)
full_cmd = match.command() if hasattr(match, 'command') else ''
try:
full_cmd = match.command()
except IndexError:
full_cmd = ''
logging.debug(f"Full command: '{full_cmd}'")
@@ -561,10 +582,24 @@ async def process_karma_vote(room, display_name, action, voter, bot):
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma!")
return
# Pertarget throttle: limit how many votes a target can receive per minute
if _is_target_throttled(room_id, user_id):
await bot.api.send_markdown_message(
room.room_id,
f"{display_name} is receiving too many votes right now. Please try again later."
)
return
# Check cooldown
if is_on_cooldown(room_id, user_id, voter_str):
remaining = get_cooldown_remaining(room_id, user_id, voter_str)
await bot.api.send_markdown_message(room.room_id, f"⏳ You're doing that too fast! Wait {remaining} seconds.")
hours = remaining // 3600
minutes = (remaining % 3600) // 60
if hours > 0:
time_str = f"{hours}h {minutes}m"
else:
time_str = f"{minutes}m"
await bot.api.send_markdown_message(room.room_id, f"⏳ Slow down! You can give karma to that user again in {time_str}.")
return
# Update karma
@@ -572,6 +607,9 @@ async def process_karma_vote(room, display_name, action, voter, bot):
new_points = update_karma(room_id, user_id, change, voter_str)
update_cooldown(room_id, user_id, voter_str)
# Record target vote for throttle
_record_target_vote(room_id, user_id)
# Get display name for response
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
response = format_karma_display(display_name_resolved, new_points)
@@ -632,7 +670,7 @@ async def handle_karma_command(room, message, bot, config):
<strong>Notes:</strong>
<ul>
<li>You cannot modify your own karma</li>
<li>There is a {COOLDOWN_SECONDS} second cooldown between votes</li>
<li>There is a 1hour cooldown per user you give karma to</li>
<li>Karma is tracked separately per room</li>
<li>Display names with emojis are supported</li>
</ul>
@@ -781,7 +819,7 @@ async def handle_inline_karma(room, message, bot):
if not matches:
return
logging.info(f"Found inline karma matches: {matches}")
logging.debug(f"Found inline karma matches: {matches}")
responses = []
for display_name, operator in matches:
@@ -806,6 +844,11 @@ async def handle_inline_karma(room, message, bot):
logging.debug(f"Skipping self-modification: {sender} -> {display_name}")
continue
# Pertarget throttle for inline votes
if _is_target_throttled(room_id, user_id):
logging.debug(f"Inline target throttle active for {user_id}")
continue
# Check cooldown
if is_on_cooldown(room_id, user_id, sender):
logging.debug(f"Cooldown active for {sender} -> {user_id}")
@@ -816,6 +859,9 @@ async def handle_inline_karma(room, message, bot):
new_points = update_karma(room_id, user_id, change, sender)
update_cooldown(room_id, user_id, sender)
# Record target vote for throttle
_record_target_vote(room_id, user_id)
# Format response
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
arrow = "⬆️" if change > 0 else "⬇️"
@@ -854,7 +900,7 @@ def setup(bot):
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.1"
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Room karma tracking system (display names only, no Matrix IDs)"
__help__ = """
+630 -1462
View File
File diff suppressed because it is too large Load Diff
-128
View File
@@ -1,128 +0,0 @@
"""
Plugin for providing a command for the admin to load a plugin.
"""
import os
import logging
import importlib
import simplematrixbotlib as botlib
import sys # Import sys module for unloading plugins
# Dictionary to store loaded plugins
PLUGINS = {}
async def load_plugin(plugin_name):
"""
Asynchronously loads a plugin.
Args:
plugin_name (str): The name of the plugin to load.
Returns:
bool: True if the plugin is loaded successfully, False otherwise.
"""
try:
# Import the plugin module
module = importlib.import_module(f"plugins.{plugin_name}")
# Add the plugin module to the PLUGINS dictionary
PLUGINS[plugin_name] = module
logging.info(f"Loaded plugin: {plugin_name}")
return True
except Exception as e:
# Log an error if the plugin fails to load
logging.error(f"Error loading plugin {plugin_name}: {e}")
return False
async def unload_plugin(plugin_name):
"""
Asynchronously unloads a plugin.
Args:
plugin_name (str): The name of the plugin to unload.
Returns:
bool: True if the plugin is unloaded successfully, False otherwise.
"""
try:
if plugin_name in PLUGINS:
del PLUGINS[plugin_name] # Remove the plugin from the PLUGINS dictionary
del sys.modules[f"plugins.{plugin_name}"] # Unload the plugin module from sys.modules
logging.info(f"Unloaded plugin: {plugin_name}")
return True
else:
logging.warning(f"Plugin '{plugin_name}' is not loaded")
return False
except Exception as e:
# Log an error if the plugin fails to unload
logging.error(f"Error unloading plugin {plugin_name}: {e}")
return False
async def handle_command(room, message, bot, prefix, config):
"""
Asynchronously handles the command to load or unload a plugin.
Args:
room (Room): The Matrix room where the command was invoked.
message (RoomMessage): The message object containing the command.
bot (MatrixBot): The Matrix bot instance.
prefix (str): The command prefix.
config (dict): The bot's configuration.
Returns:
None
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix():
command = match.command()
if command == "load":
if str(message.sender) == config.admin_user:
args = match.args()
if len(args) != 1:
# Send usage message if the command format is incorrect
await bot.api.send_text_message(room.room_id, "Usage: !load <plugin>")
else:
plugin_name = args[0]
# Check if the plugin is not already loaded
if plugin_name not in PLUGINS:
# Load the plugin
success = await load_plugin(plugin_name)
if success:
await bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' loaded successfully")
else:
await bot.api.send_text_message(room.room_id, f"Error loading plugin '{plugin_name}'")
else:
await bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' is already loaded")
else:
# Send unauthorized message if the sender is not the admin
await bot.api.send_text_message(room.room_id, "You are not authorized to load plugins.")
elif command == "unload":
if str(message.sender) == config.admin_user:
args = match.args()
if len(args) != 1:
# Send usage message if the command format is incorrect
await bot.api.send_text_message(room.room_id, "Usage: !unload <plugin>")
else:
plugin_name = args[0]
# Unload the plugin
success = await unload_plugin(plugin_name)
if success:
await bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' unloaded successfully")
else:
await bot.api.send_text_message(room.room_id, f"Error unloading plugin '{plugin_name}'")
else:
# Send unauthorized message if the sender is not the admin
await bot.api.send_text_message(room.room_id, "You are not authorized to unload plugins.")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__author__ = "Funguy Bot"
__description__ = "Load/unload plugins at runtime"
__help__ = """
<details>
<summary><strong>Admin: !load / !unload</strong></summary>
<p><code>!load &lt;plugin&gt;</code> / <code>!unload &lt;plugin&gt;</code> Dynamically load or unload a plugin module. Admin only.</p>
</details>
"""
+1 -1
View File
@@ -51,7 +51,7 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.4"
__author__ = "Funguy Bot"
__description__ = "List all loaded plugins with count, collapsible"
__description__ = "List all loaded plugins with count"
__help__ = """
<details>
<summary><strong>!plugins</strong> List active plugins</summary>
+1 -1
View File
@@ -138,7 +138,7 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Working SOCKS5 proxy finder (SSRFsafe, async)"
__description__ = "Working SOCKS5 proxy finder"
__help__ = """
<details>
<summary><strong>!proxy</strong> Random working SOCKS5 proxy</summary>
+1 -1
View File
@@ -120,6 +120,6 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Goodreads quotes via Playwright (headless)"
__description__ = "Fetch Goodreads quotes"
__help__ = """<details><summary><strong>!quote</strong> Quotes from Goodreads</summary>
<p><code>!quote</code> random, <code>!quote &lt;author&gt;</code>.</p></details>"""
+93 -226
View File
@@ -2,79 +2,56 @@
"""
plugins/roomstats.py — peruser room statistics (Limnoriastyle).
Commands: !roomstats, !rank, !stats
Output is a clean code block with emojis and aligned columns.
"""
import time
import re
import sqlite3
import logging
import nio
import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, code_block
logger = logging.getLogger("roomstats")
DB_PATH = "roomstats.db"
# ------------------------------------------------------------------
# Emoji / smiley regex (Unicode blocks)
# ------------------------------------------------------------------
# Emoji regex (unchanged)
EMOJI_RE = re.compile(
"["
"\U0001F600-\U0001F64F" # Emoticons
"\U0001F300-\U0001F5FF" # Symbols & pictographs
"\U0001F680-\U0001F6FF" # Transport & map
"\U0001F1E0-\U0001F1FF" # Flags
"\U00002702-\U000027B0" # Dingbats
"\U000024C2-\U0001F251" # Misc
"]+", re.UNICODE)
"\U0001F600-\U0001F64F"
"\U0001F300-\U0001F5FF"
"\U0001F680-\U0001F6FF"
"\U0001F1E0-\U0001F1FF"
"\U00002702-\U000027B0"
"\U000024C2-\U0001F251"
"]+", re.UNICODE
)
def count_smileys(text):
"""Return number of emoji occurrences."""
return len(EMOJI_RE.findall(text))
# ------------------------------------------------------------------
# Database init
# ------------------------------------------------------------------
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS user_room_stats (
room_id TEXT,
user_id TEXT,
msgs INTEGER DEFAULT 0,
chars INTEGER DEFAULT 0,
words INTEGER DEFAULT 0,
smileys INTEGER DEFAULT 0,
actions INTEGER DEFAULT 0,
joins INTEGER DEFAULT 0,
parts INTEGER DEFAULT 0,
kicks_given INTEGER DEFAULT 0,
kicked_received INTEGER DEFAULT 0,
topics_set INTEGER DEFAULT 0,
last_updated INTEGER,
room_id TEXT, user_id TEXT,
msgs INTEGER DEFAULT 0, chars INTEGER DEFAULT 0, words INTEGER DEFAULT 0,
smileys INTEGER DEFAULT 0, actions INTEGER DEFAULT 0,
joins INTEGER DEFAULT 0, parts INTEGER DEFAULT 0,
kicks_given INTEGER DEFAULT 0, kicked_received INTEGER DEFAULT 0,
topics_set INTEGER DEFAULT 0, last_updated INTEGER,
PRIMARY KEY (room_id, user_id)
)
""")
conn.commit()
conn.close()
# ------------------------------------------------------------------
# Multiword user resolution helper
# ------------------------------------------------------------------
async def resolve_user_from_tokens(bot, room_id, tokens):
"""
Given a list of word tokens, find a matching display name.
Returns (mxid, display_name) or raises ValueError.
"""
# Build cache of (lowered display name → user_id) from joined members
resp = await bot.async_client.joined_members(room_id)
if resp.members is None:
raise ValueError("Could not fetch member list.")
# Create a dict: lower_display → (mxid, display_name)
# If duplicate display name, store None to signal ambiguity.
cache = {}
for member in resp.members:
display = (member.display_name or "").strip()
@@ -85,68 +62,31 @@ async def resolve_user_from_tokens(bot, room_id, tokens):
cache[key] = None
else:
cache[key] = (member.user_id, display)
# Try progressively longer prefixes of the tokens
for end in range(len(tokens), 0, -1):
candidate = " ".join(tokens[:end]).strip().lower()
if candidate in cache:
entry = cache[candidate]
if entry is not None:
return entry # (mxid, display_name)
else:
# Ambiguous we need to fetch and check exactly
matches = []
for member in resp.members:
if (member.display_name or "").strip().lower() == candidate:
matches.append((member.user_id, member.display_name or member.user_id))
if len(matches) == 1:
return matches[0]
elif len(matches) > 1:
raise ValueError(
f"Multiple users have display name '{candidate}'. Use an MXID instead."
)
# if none, continue
return entry
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
async def resolve_user(bot, room_id, name_or_tokens):
"""
Accept either a single string (MXID or single-token display name)
or a list of tokens. Returns (mxid, display_name).
"""
if isinstance(name_or_tokens, str):
if name_or_tokens.startswith("@"):
return name_or_tokens, None
# Single token try direct cache match or fallback to multiword
tokens = [name_or_tokens]
else:
tokens = name_or_tokens
return await resolve_user_from_tokens(bot, room_id, tokens)
# ------------------------------------------------------------------
# Setup: register custom event listeners for membership & topics
# ------------------------------------------------------------------
def setup(bot):
init_db()
@bot.listener.on_custom_event(nio.RoomMemberEvent)
async def member_event(room, event):
room_id = room.room_id
membership = event.content.get("membership")
state_key = event.state_key
sender = event.sender
# Ignore the bot's own membership changes
if state_key == bot.async_client.user_id:
return
if membership == "join":
_incr(room_id, state_key, "joins")
elif membership == "leave":
if sender != state_key: # kick
if sender != state_key:
_incr(room_id, sender, "kicks_given")
_incr(room_id, state_key, "kicked_received")
else: # part
else:
_incr(room_id, state_key, "parts")
@bot.listener.on_custom_event(nio.RoomTopicEvent)
@@ -156,53 +96,34 @@ def setup(bot):
_incr(room_id, sender, "topics_set")
def _incr(room_id, user_id, column):
"""Increment a stat column by 1, creating row if needed."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(
"INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)",
(room_id, user_id)
)
c.execute(
f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
(int(time.time()), room_id, user_id)
)
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, user_id))
c.execute(f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
(int(time.time()), room_id, user_id))
conn.commit()
conn.close()
# ------------------------------------------------------------------
# Message handler silently records stats, and handles commands
# ------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
room_id = room.room_id
sender = message.sender
# ----- silently record stats for any nonbot message -----
if sender != bot.async_client.user_id: # <-- FIXED
# silently record stats
if sender != bot.async_client.user_id:
body = message.body or ""
words = len(body.split())
chars = len(body)
smileys = count_smileys(body)
is_action = getattr(message, "msgtype", None) == "m.emote"
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
c.execute(
"""UPDATE user_room_stats
SET msgs = msgs + 1,
chars = chars + ?,
words = words + ?,
smileys = smileys + ?,
actions = actions + ?,
last_updated = ?
WHERE room_id = ? AND user_id = ?""",
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender)
)
c.execute("""UPDATE user_room_stats SET msgs=msgs+1, chars=chars+?, words=words+?, smileys=smileys+?, actions=actions+?, last_updated=?
WHERE room_id=? AND user_id=?""",
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender))
conn.commit()
conn.close()
# ----- command matching -----
match = botlib.MessageMatch(room, message, bot, prefix)
if not match.is_not_from_this_bot() or not match.prefix():
return
@@ -210,33 +131,16 @@ async def handle_command(room, message, bot, prefix, config):
cmd = match.command()
args = match.args()
# ===============================
# !roomstats
# ===============================
if cmd == "roomstats":
await _handle_roomstats(bot, room_id)
# ===============================
# !rank <expr>
# ===============================
elif cmd == "rank":
if not args:
await bot.api.send_text_message(
room_id,
"Usage: !rank <stat>\n"
"Stats: msgs, chars, words, smileys, actions, joins, parts, "
"kicks_given, kicked_received, topics_set"
)
await bot.api.send_text_message(room_id, "Usage: !rank <stat>")
return
col = args[0].lower()
await _handle_rank(bot, room_id, col)
# ===============================
# !stats [<name>]
# ===============================
elif cmd == "stats":
if args:
# Use all tokens as the display name (multiword)
try:
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
except ValueError as e:
@@ -244,44 +148,27 @@ async def handle_command(room, message, bot, prefix, config):
return
else:
target_mxid = sender
await _handle_user_stats(bot, room_id, target_mxid, sender)
await _handle_user_stats(bot, room_id, target_mxid)
# ------------------------------------------------------------------
# Command implementations
# ------------------------------------------------------------------
VALID_STATS = {
"msgs": "Messages",
"chars": "Characters",
"words": "Words",
"smileys": "Smileys",
"actions": "Actions",
"joins": "Joins",
"parts": "Parts",
"kicks_given": "Kicks given",
"kicked_received": "Times kicked",
"topics_set": "Topics set",
"msgs": "Messages", "chars": "Characters", "words": "Words", "smileys": "Smileys",
"actions": "Actions", "joins": "Joins", "parts": "Parts", "kicks_given": "Kicks given",
"kicked_received": "Times kicked", "topics_set": "Topics set",
}
async def _get_aggregate(room_id):
"""Return dict of aggregate stats for a room."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT
COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0),
COALESCE(SUM(words),0), COALESCE(SUM(smileys),0),
COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0),
COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0)
c.execute("""SELECT COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), COALESCE(SUM(words),0),
COALESCE(SUM(smileys),0), COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0), COALESCE(SUM(kicked_received),0),
COALESCE(SUM(topics_set),0)
FROM user_room_stats WHERE room_id=?""", (room_id,))
row = c.fetchone()
conn.close()
if not row or all(v == 0 for v in row):
return None
return {
"msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3],
"actions": row[4], "joins": row[5], "parts": row[6],
"kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9]
}
return dict(zip(VALID_STATS.keys(), row))
async def _handle_roomstats(bot, room_id):
agg = await _get_aggregate(room_id)
@@ -289,17 +176,14 @@ async def _handle_roomstats(bot, room_id):
await bot.api.send_text_message(room_id, "No stats collected yet.")
return
# Get top 10 by msgs
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT user_id, msgs FROM user_room_stats
WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
c.execute("SELECT user_id, msgs FROM user_room_stats WHERE room_id=? ORDER BY msgs DESC LIMIT 10", (room_id,))
top = c.fetchall()
conn.close()
# Resolve display names for top users
top_lines = []
resp = await bot.async_client.joined_members(room_id)
top_rows = []
for uid, cnt in top:
disp = uid
if resp.members:
@@ -307,78 +191,63 @@ async def _handle_roomstats(bot, room_id):
if m.user_id == uid:
disp = m.display_name or uid
break
top_lines.append(f"<li><code>{disp}</code> — {cnt} msgs</li>")
top_rows.append(("📈", disp, f"{cnt} msgs"))
msg = f"""<details>
<summary><strong>Room Statistics</strong></summary>
<ul>
<li>📩 Messages: {agg['msgs']}</li>
<li>🔤 Characters: {agg['chars']}</li>
<li>📝 Words: {agg['words']}</li>
<li>😀 Smileys: {agg['smileys']}</li>
<li>🎭 Actions: {agg['actions']}</li>
<li>🚪 Joins: {agg['joins']}</li>
<li>👋 Parts: {agg['parts']}</li>
<li>👢 Kicks given: {agg['kicks_given']}</li>
<li>🥾 Times kicked: {agg['kicked_received']}</li>
<li>📌 Topics set: {agg['topics_set']}</li>
</ul>
<p><strong>Top 10 by messages:</strong></p>
<ol>
{''.join(top_lines)}
</ol>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
sections = [
{"title": "Room Statistics", "rows": [
("📩", "Messages", agg["msgs"]),
("🔤", "Characters", agg["chars"]),
("📝", "Words", agg["words"]),
("😀", "Smileys", agg["smileys"]),
("🎭", "Actions", agg["actions"]),
("🚪", "Joins", agg["joins"]),
("👋", "Parts", agg["parts"]),
("👢", "Kicks given", agg["kicks_given"]),
("🥾", "Times kicked", agg["kicked_received"]),
("📌", "Topics set", agg["topics_set"]),
]},
{"title": "Top 10 by messages", "rows": top_rows},
]
block = code_block("📊 Room Statistics", sections)
output = collapsible_summary("📊 Room Statistics", block)
await bot.api.send_markdown_message(room_id, output)
async def _handle_rank(bot, room_id, col):
# Validate column
if col not in VALID_STATS:
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
return
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Safe to use f-string because col is validated against a hardcoded set
c.execute(f"""SELECT user_id, {col} FROM user_room_stats
WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,))
c.execute(f"SELECT user_id, {col} FROM user_room_stats WHERE room_id=? AND {col}>0 ORDER BY {col} DESC LIMIT 10", (room_id,))
rows = c.fetchall()
conn.close()
if not rows:
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
return
resp = await bot.async_client.joined_members(room_id)
items = []
for i, (uid, val) in enumerate(rows, 1):
rank_rows = []
for uid, val in rows:
disp = uid
if resp.members:
for m in resp.members:
if m.user_id == uid:
disp = m.display_name or uid
break
items.append(f"<li>{i}. <code>{disp}</code> — {val}</li>")
rank_rows.append(("🏅", disp, str(val)))
sections = [{"title": f"Ranking by {VALID_STATS[col]}", "rows": rank_rows}]
block = code_block(f"🏆 Top {VALID_STATS[col]}", sections)
output = collapsible_summary(f"🏆 {VALID_STATS[col]} Ranking", block)
await bot.api.send_markdown_message(room_id, output)
msg = f"""<details>
<summary><strong>Ranking by {VALID_STATS[col]}</strong></summary>
<ol>
{''.join(items)}
</ol>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
async def _handle_user_stats(bot, room_id, user_id, sender):
# Fetch stats
async def _handle_user_stats(bot, room_id, user_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts,
kicks_given, kicked_received, topics_set
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
row = c.fetchone()
conn.close()
if not row or all(v == 0 for v in row):
# No stats, maybe just joined get display name for the message
disp = user_id
resp = await bot.async_client.joined_members(room_id)
if resp.members:
@@ -389,46 +258,44 @@ async def _handle_user_stats(bot, room_id, user_id, sender):
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
return
# Get display name
disp = user_id
resp = await bot.async_client.joined_members(room_id)
disp = user_id
if resp.members:
for m in resp.members:
if m.user_id == user_id:
disp = m.display_name or user_id
break
msg = f"""<details>
<summary><strong>Stats for {disp}</strong></summary>
<ul>
<li>📩 Messages: {row[0]}</li>
<li>🔤 Characters: {row[1]}</li>
<li>📝 Words: {row[2]}</li>
<li>😀 Smileys: {row[3]}</li>
<li>🎭 Actions: {row[4]}</li>
<li>🚪 Joins: {row[5]}</li>
<li>👋 Parts: {row[6]}</li>
<li>👢 Kicks given: {row[7]}</li>
<li>🥾 Times kicked: {row[8]}</li>
<li>📌 Topics set: {row[9]}</li>
</ul>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
rows = [
("📩", "Messages", row[0]),
("🔤", "Characters", row[1]),
("📝", "Words", row[2]),
("😀", "Smileys", row[3]),
("🎭", "Actions", row[4]),
("🚪", "Joins", row[5]),
("👋", "Parts", row[6]),
("👢", "Kicks given", row[7]),
("🥾", "Times kicked", row[8]),
("📌", "Topics set", row[9]),
]
sections = [{"title": f"Stats for {disp}", "rows": rows}]
block = code_block(f"📊 Stats for {disp}", sections)
output = collapsible_summary(f"📊 Stats: {disp}", block)
await bot.api.send_markdown_message(room_id, output)
# ------------------------------------------------------------------
# Plugin metadata
# ------------------------------------------------------------------
__version__ = "1.0.1"
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.1.0"
__author__ = "Funguy Roomstats"
__description__ = "Peruser room statistics (Limnoriastyle), with multiword name support"
__description__ = "Peruser room statistics"
__help__ = """
<details>
<summary><strong>Room Statistics Commands</strong></summary>
<ul>
<li><code>!roomstats</code> Aggregate room stats + top 10 users</li>
<li><code>!rank &lt;stat&gt;</code> Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)</li>
<li><code>!stats [name]</code> Show stats for a user (supports multiword names)</li>
<li><code>!rank &lt;stat&gt;</code> Top 10 by a specific stat</li>
<li><code>!stats [name]</code> Show stats for a user</li>
</ul>
<p>All commands work in the current room; display names are automatically resolved.</p>
</details>
"""
+95
View File
@@ -0,0 +1,95 @@
"""
Plugin for generating text using Ollama's Mistral 7B Instruct model and sending it to a Matrix chat room.
"""
import requests
from asyncio import Queue
import simplematrixbotlib as botlib
import argparse
# Queue to store pending commands
command_queue = Queue()
API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "mistral:7b-instruct"
async def process_command(room, message, bot, prefix, config):
"""
Queue and process !text commands sequentially.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.prefix() and match.command("text"):
if command_queue.empty():
await handle_command(room, message, bot, prefix, config)
else:
await command_queue.put((room, message, bot, prefix, config))
async def handle_command(room, message, bot, prefix, config):
"""
Send the prompt to Ollama API and return the generated text.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.prefix() and match.command("text")):
return
# Parse optional arguments
parser = argparse.ArgumentParser(description='Generate text using Ollama API')
parser.add_argument('--max_tokens', type=int, default=512, help='Maximum tokens to generate')
parser.add_argument('--temperature', type=float, default=0.7, help='Temperature for generation')
parser.add_argument('prompt', nargs='+', help='Prompt for the model')
try:
args = parser.parse_args(message.body.split()[1:]) # Skip command itself
prompt = ' '.join(args.prompt).strip()
if not prompt:
await bot.api.send_text_message(room.room_id, "Usage: !text <your prompt here>")
return
payload = {
"model": MODEL_NAME,
"prompt": prompt,
"max_tokens": args.max_tokens,
"temperature": args.temperature,
"stream": False
}
response = requests.post(API_URL, json=payload, timeout=60)
response.raise_for_status()
r = response.json()
generated_text = r.get("response", "").strip()
if not generated_text:
generated_text = "(No response from model)"
await bot.api.send_text_message(room.room_id, generated_text)
except argparse.ArgumentError as e:
await bot.api.send_text_message(room.room_id, f"Argument error: {e}")
except requests.exceptions.RequestException as e:
await bot.api.send_text_message(room.room_id, f"Error connecting to Ollama API: {e}")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Unexpected error: {e}")
finally:
# Process next command from the queue, if any
if not command_queue.empty():
next_command = await command_queue.get()
await handle_command(*next_command)
def print_help():
"""
Generates help text for the !text command.
"""
return """
<p>Generate text using Ollama's Mistral 7B Instruct model</p>
<p>Usage:</p>
<ul>
<li>!text <prompt> - Basic prompt for the model</li>
<li>Optional arguments:</li>
<ul>
<li>--max_tokens MAX_TOKENS - Maximum tokens to generate (default 512)</li>
<li>--temperature TEMPERATURE - Sampling temperature (default 0.7)</li>
</ul>
</ul>
"""
+75 -232
View File
@@ -1,77 +1,43 @@
"""
This plugin provides Shodan.io integration for security research and reconnaissance.
Shodan.io integration for security research and reconnaissance.
Output uses shared code_block for aligned columns.
"""
import logging
import os
import aiohttp
import simplematrixbotlib as botlib
from plugins.common import html_escape, collapsible_summary
from plugins.common import html_escape, code_block, collapsible_summary
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
SHODAN_API_BASE = "https://api.shodan.io"
async def handle_command(room, message, bot, prefix, config):
"""
Function to handle Shodan commands.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"):
logging.info("Received !shodan command")
# Check if API key is configured
if not SHODAN_API_KEY:
await bot.api.send_text_message(
room.room_id,
"Shodan API key not configured. Please set SHODAN_KEY environment variable."
)
logging.error("Shodan API key not configured")
await bot.api.send_text_message(room.room_id, "Shodan API key not configured.")
return
args = match.args()
if len(args) < 1:
await show_usage(room, bot)
return
subcommand = args[0].lower()
if subcommand == "ip":
if len(args) < 2:
await bot.api.send_text_message(room.room_id, "Usage: !shodan ip <ip_address>")
return
ip = args[1]
await shodan_ip_lookup(room, bot, ip)
elif subcommand == "search":
if len(args) < 2:
await bot.api.send_text_message(room.room_id, "Usage: !shodan search <query>")
return
query = ' '.join(args[1:])
sub = args[0].lower()
if sub == "ip" and len(args) >= 2:
await shodan_ip_lookup(room, bot, args[1])
elif sub == "search" and len(args) >= 2:
query = " ".join(args[1:])
await shodan_search(room, bot, query)
elif subcommand == "host":
if len(args) < 2:
await bot.api.send_text_message(room.room_id, "Usage: !shodan host <domain/ip>")
return
host = args[1]
await shodan_host(room, bot, host)
elif subcommand == "count":
if len(args) < 2:
await bot.api.send_text_message(room.room_id, "Usage: !shodan count <query>")
return
query = ' '.join(args[1:])
elif sub == "host" and len(args) >= 2:
await shodan_host(room, bot, args[1])
elif sub == "count" and len(args) >= 2:
query = " ".join(args[1:])
await shodan_count(room, bot, query)
else:
await show_usage(room, bot)
async def show_usage(room, bot):
"""Display Shodan command usage."""
usage = """
<strong>🔍 Shodan Commands:</strong>
usage = """<strong>🔍 Shodan Commands:</strong>
<strong>!shodan ip &lt;ip_address&gt;</strong> - Get detailed information about an IP
<strong>!shodan search &lt;query&gt;</strong> - Search Shodan database
<strong>!shodan host &lt;domain/ip&gt;</strong> - Get host information
@@ -86,228 +52,112 @@ async def show_usage(room, bot):
await bot.api.send_markdown_message(room.room_id, usage)
async def shodan_ip_lookup(room, bot, ip):
"""Look up information about a specific IP address."""
safe_ip = html_escape(ip)
try:
url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}"
logging.info(f"Fetching Shodan IP info for: {ip}")
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=15) as response:
if response.status == 404:
await bot.api.send_text_message(room.room_id, f"No information found for IP: {html_escape(ip)}")
return
elif response.status == 401:
await bot.api.send_text_message(room.room_id, "Invalid Shodan API key")
return
elif response.status != 200:
await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status}")
async with session.get(url, timeout=15) as resp:
if resp.status == 404:
await bot.api.send_text_message(room.room_id, f"No information found for IP: {safe_ip}")
return
resp.raise_for_status()
data = await resp.json()
data = await response.json()
# Format the response
output = f"<strong>🔍 Shodan IP Lookup: {html_escape(ip)}</strong><br><br>"
if data.get('country_name'):
output += f"<strong>📍 Location:</strong> {html_escape(data.get('city', 'N/A'))}, {html_escape(data.get('country_name', 'N/A'))}<br>"
if data.get('org'):
output += f"<strong>🏢 Organization:</strong> {html_escape(data['org'])}<br>"
if data.get('os'):
output += f"<strong>💻 Operating System:</strong> {html_escape(data['os'])}<br>"
if data.get('ports'):
output += f"<strong>🔌 Open Ports:</strong> {', '.join(map(str, data['ports']))}<br>"
output += f"<strong>🕒 Last Update:</strong> {data.get('last_update', 'N/A')}<br><br>"
# Show services
rows = [
("🌐", "IP", safe_ip),
("📍", "Location", f"{data.get('city','N/A')}, {data.get('country_name','N/A')}"),
("🏢", "Organization", data.get('org', 'N/A')),
("💻", "OS", data.get('os', 'N/A')),
("🔌", "Open Ports", ', '.join(map(str, data.get('ports', []))) or 'None'),
]
if data.get('data'):
output += "<strong>📡 Services:</strong><br>"
for service in data['data'][:5]: # Limit to first 5 services
port = service.get('port', 'N/A')
product = service.get('product', 'Unknown')
version = service.get('version', '')
banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '')
output += f" • <strong>Port {port}:</strong> {html_escape(product)} {html_escape(version)}<br>"
if banner:
output += f" <em>{html_escape(banner)}</em><br>"
if len(data['data']) > 5:
output += f" • ... and {len(data['data']) - 5} more services<br>"
# Wrap in collapsible if output is large
if len(output) > 500:
output = collapsible_summary(f"🔍 Shodan IP Lookup: {html_escape(ip)}", output)
for svc in data['data'][:5]:
rows.append(("📡", f"Port {svc.get('port')}", svc.get('product','Unknown')))
sections = [{"title": f"Shodan IP Lookup: {safe_ip}", "rows": rows}]
block = code_block(f"🔍 Shodan IP Lookup: {safe_ip}", sections)
output = collapsible_summary(f"🔍 Shodan: {safe_ip}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan IP info for {ip}")
except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {e}")
logging.error(f"Shodan API error: {e}")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
logging.error(f"Error in shodan_ip_lookup: {e}")
await bot.api.send_text_message(room.room_id, f"API error: {e}")
async def shodan_search(room, bot, query):
"""Search the Shodan database."""
safe_query = html_escape(query)
try:
url = f"{SHODAN_API_BASE}/shodan/host/search"
params = {
"key": SHODAN_API_KEY,
"query": query,
"minify": "true",
"limit": 5
}
logging.info(f"Searching Shodan for: {query}")
url = f"{SHODAN_API_BASE}/shodan/host/search?key={SHODAN_API_KEY}&query={query}&minify=true&limit=5"
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=15) as response:
if response.status != 200:
await handle_shodan_error(room, bot, response.status)
return
data = await response.json()
async with session.get(url, timeout=15) as resp:
resp.raise_for_status()
data = await resp.json()
if not data.get('matches'):
await bot.api.send_text_message(room.room_id, f"No results found for: {html_escape(query)}")
await bot.api.send_text_message(room.room_id, f"No results for '{safe_query}'.")
return
output = f"<strong>🔍 Shodan Search: '{html_escape(query)}'</strong><br>"
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br><br>"
for match in data['matches'][:5]: # Show first 5 results
rows = []
for match in data['matches'][:5]:
ip = match.get('ip_str', 'N/A')
port = match.get('port', 'N/A')
port = match.get('port', '')
org = match.get('org', 'Unknown')
product = match.get('product', 'Unknown')
output += f"<strong>🌐 {html_escape(ip)}:{port}</strong><br>"
output += f" • <strong>Organization:</strong> {html_escape(org)}<br>"
output += f" • <strong>Service:</strong> {html_escape(product)}<br>"
if match.get('location'):
loc = match['location']
if loc.get('city') and loc.get('country_name'):
output += f" • <strong>Location:</strong> {html_escape(loc['city'])}, {html_escape(loc['country_name'])}<br>"
output += "<br>"
if data.get('total', 0) > 5:
output += f"<em>Showing 5 of {data['total']:,} results. Refine your search for more specific results.</em>"
rows.append(("🌐", f"{ip}:{port}", f"{product} {org}"))
sections = [{"title": f"Search: {safe_query}", "rows": rows}]
block = code_block(f"🔍 Shodan Search: {safe_query}", sections)
output = collapsible_summary(f"Shodan Search: {safe_query}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan search results for: {query}")
except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {e}")
logging.error(f"Shodan API error: {e}")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
logging.error(f"Error in shodan_search: {e}")
await bot.api.send_text_message(room.room_id, f"API error: {e}")
async def shodan_host(room, bot, host):
"""Get host information (domain or IP)."""
safe_host = html_escape(host)
try:
url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}"
logging.info(f"Fetching Shodan host info for: {host}")
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=15) as response:
if response.status == 404:
# Try IP lookup instead
async with session.get(url, timeout=15) as resp:
if resp.status == 404:
await shodan_ip_lookup(room, bot, host)
return
elif response.status != 200:
await handle_shodan_error(room, bot, response.status)
return
data = await response.json()
output = f"<strong>🔍 Shodan Host: {html_escape(host)}</strong><br><br>"
resp.raise_for_status()
data = await resp.json()
rows = [("🌐", "Domain", safe_host)]
if data.get('subdomains'):
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>"
for subdomain in sorted(data['subdomains'])[:10]: # Show first 10
output += f"{html_escape(subdomain)}.{html_escape(host)}<br>"
for sub in sorted(data['subdomains'])[:10]:
rows.append(("", "Subdomain", f"{sub}.{safe_host}"))
if len(data['subdomains']) > 10:
output += f"... and {len(data['subdomains']) - 10} more<br>"
if data.get('tags'):
output += f"<br><strong>🏷️ Tags:</strong> {', '.join(html_escape(t) for t in data['tags'])}<br>"
if data.get('data'):
output += f"<br><strong>📊 Records Found:</strong> {len(data['data'])}<br>"
rows.append(("", "", f"... and {len(data['subdomains']) - 10} more"))
sections = [{"title": f"Host: {safe_host}", "rows": rows}]
block = code_block(f"🔍 Shodan Host: {safe_host}", sections)
output = collapsible_summary(f"Shodan Host: {safe_host}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan host info for: {host}")
except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error fetching host info: {e}")
logging.error(f"Shodan API error: {e}")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
logging.error(f"Error in shodan_host: {e}")
await bot.api.send_text_message(room.room_id, f"API error: {e}")
async def shodan_count(room, bot, query):
"""Count results for a search query."""
safe_query = html_escape(query)
try:
url = f"{SHODAN_API_BASE}/shodan/host/count"
params = {
"key": SHODAN_API_KEY,
"query": query
}
logging.info(f"Counting Shodan results for: {query}")
url = f"{SHODAN_API_BASE}/shodan/host/count?key={SHODAN_API_KEY}&query={query}"
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=15) as response:
if response.status != 200:
await handle_shodan_error(room, bot, response.status)
return
data = await response.json()
output = f"<strong>🔍 Shodan Count: '{html_escape(query)}'</strong><br><br>"
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br>"
# Show top countries if available
if data.get('facets') and 'country' in data['facets']:
output += "<br><strong>🌍 Top Countries:</strong><br>"
for country in data['facets']['country'][:5]:
output += f"{html_escape(country['value'])}: {country['count']:,}<br>"
# Show top organizations if available
if data.get('facets') and 'org' in data['facets']:
output += "<br><strong>🏢 Top Organizations:</strong><br>"
for org in data['facets']['org'][:5]:
output += f"{html_escape(org['value'])}: {org['count']:,}<br>"
async with session.get(url, timeout=15) as resp:
resp.raise_for_status()
data = await resp.json()
rows = [("🔢", "Total Results", f"{data.get('total', 0):,}")]
if data.get('facets'):
for facet_name, facet_data in data['facets'].items():
for item in facet_data[:5]:
rows.append(("", facet_name.capitalize(), f"{item['value']}: {item['count']:,}"))
sections = [{"title": f"Count: {safe_query}", "rows": rows}]
block = code_block(f"🔍 Shodan Count: {safe_query}", sections)
output = collapsible_summary(f"Shodan Count: {safe_query}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Sent Shodan count for: {query}")
except aiohttp.ClientError as e:
await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {e}")
logging.error(f"Shodan API error: {e}")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
logging.error(f"Error in shodan_count: {e}")
async def handle_shodan_error(room, bot, status_code):
"""Handle Shodan API errors."""
error_messages = {
401: "Invalid Shodan API key",
403: "Access denied - check API key permissions",
404: "No results found",
429: "Rate limit exceeded - try again later",
500: "Shodan API server error",
503: "Shodan API temporarily unavailable"
}
message = error_messages.get(status_code, f"Shodan API error: {status_code}")
await bot.api.send_text_message(room.room_id, message)
logging.error(f"Shodan API error: {status_code}")
await bot.api.send_text_message(room.room_id, f"API error: {e}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.1"
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Shodan.io reconnaissance"
__help__ = """
@@ -319,13 +169,6 @@ __help__ = """
<li><code>!shodan host &lt;domain&gt;</code> Host & subdomain enumeration</li>
<li><code>!shodan count &lt;query&gt;</code> Result counts</li>
</ul>
<strong>Search Examples:</strong>
<ul>
<li><code>!shodan search apache</code></li>
<li><code>!shodan search "port:22"</code></li>
<li><code>!shodan search "country:US product:nginx"</code></li>
<li><code>!shodan search "net:192.168.1.0/24"</code></li>
</ul>
<p>Requires <strong>SHODAN_KEY</strong> env var.</p>
</details>
"""
+65 -174
View File
@@ -1,6 +1,7 @@
"""
Comprehensive SSL/TLS security scanning and analysis.
All blocking socket calls run in a thread pool; user input is sanitised.
Output is a clean code block with aligned columns.
"""
import asyncio
@@ -10,7 +11,7 @@ import ssl
import OpenSSL
import datetime
import simplematrixbotlib as botlib
from plugins.common import is_public_destination, html_escape, collapsible_summary
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
# SSL/TLS configuration handle missing protocols in modern Python
TLS_VERSIONS = {
@@ -37,9 +38,6 @@ CIPHER_CATEGORIES = {
}
async def handle_command(room, message, bot, prefix, config):
"""
Handle !sslscan command for comprehensive SSL/TLS analysis.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
args = match.args()
@@ -49,7 +47,6 @@ async def handle_command(room, message, bot, prefix, config):
target = args[0].strip()
port = 443
if ':' in target:
parts = target.split(':')
target = parts[0]
@@ -65,12 +62,8 @@ async def handle_command(room, message, bot, prefix, config):
await perform_ssl_scan(room, bot, target, port)
async def show_usage(room, bot):
"""Display sslscan command usage."""
usage = """
<strong>🔐 SSL/TLS Security Scanner</strong>
usage = """<strong>🔐 SSL/TLS Security Scanner</strong>
<strong>!sslscan &lt;domain[:port]&gt;</strong> - Comprehensive SSL/TLS security analysis
<strong>Examples:</strong>
@@ -88,28 +81,21 @@ async def show_usage(room, bot):
"""
await bot.api.send_markdown_message(room.room_id, usage)
# ----- async wrappers for blocking socket calls -----
async def _run_blocking(func, *args, **kwargs):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
def _test_connectivity(target, port):
"""Test basic connectivity."""
try:
with socket.create_connection((target, port), timeout=10):
return True
except:
return False
def _get_certificate_info(target, port):
"""Retrieve detailed certificate info."""
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with socket.create_connection((target, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=target) as ssock:
cert_bin = ssock.getpeercert(binary_form=True)
@@ -117,15 +103,12 @@ def _get_certificate_info(target, port):
subject = cert.get_subject()
issuer = cert.get_issuer()
not_before = cert.get_notBefore().decode('utf-8')
not_after = cert.get_notAfter().decode('utf-8')
sig_alg = cert.get_signature_algorithm().decode('utf-8')
not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
days_remaining = (not_after_dt - datetime.datetime.utcnow()).days
# Extensions summary
extensions = []
for i in range(cert.get_extension_count()):
ext = cert.get_extension(i)
@@ -158,9 +141,7 @@ def _get_certificate_info(target, port):
}
return None
def _test_protocols(target, port):
"""Test support for various SSL/TLS protocols."""
protocols = {}
for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
if proto_name not in TLS_VERSIONS:
@@ -177,9 +158,7 @@ def _test_protocols(target, port):
protocols[proto_name] = False
return protocols
def _test_cipher_suites(target, port):
"""Return list of supported cipher suite names."""
test_ciphers = [
'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384',
@@ -207,130 +186,63 @@ def _test_cipher_suites(target, port):
pass
return supported
# ----- analysis helpers (same logic as original) -----
def _check_vulnerabilities(protocols, cert_info, supported_ciphers):
vulns = []
if protocols.get('SSLv2'):
vulns.append({
'name': 'SSLv2 Support',
'severity': 'CRITICAL',
'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
'cve': 'Multiple CVEs'
})
vulns.append(('SSLv2 Support', 'CRITICAL'))
if protocols.get('SSLv3'):
vulns.append({
'name': 'SSLv3 Support',
'severity': 'HIGH',
'description': 'SSLv3 is vulnerable to POODLE attack',
'cve': 'CVE-2014-3566'
})
vulns.append(('SSLv3 Support', 'HIGH'))
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
vulns.append({
'name': 'Certificate Expiring Soon',
'severity': 'MEDIUM',
'description': f"Certificate expires in {cert_info['days_until_expiry']} days",
'cve': 'N/A'
})
weak_ciphers = [c for c in supported_ciphers
if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])]
vulns.append(('Certificate Expiring Soon', 'MEDIUM'))
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
if weak_ciphers:
vulns.append({
'name': 'Weak Cipher Suites',
'severity': 'HIGH',
'description': f'Weak ciphers supported: {", ".join(weak_ciphers[:3])}',
'cve': 'Multiple CVEs'
})
vulns.append(('Weak Cipher Suites', 'HIGH'))
if not protocols.get('TLSv1.2', False):
vulns.append({
'name': 'TLS 1.2 Not Supported',
'severity': 'HIGH',
'description': 'TLS 1.2 is required for modern security',
'cve': 'N/A'
})
vulns.append(('TLS 1.2 Not Supported', 'HIGH'))
if not protocols.get('TLSv1.3', False):
vulns.append({
'name': 'TLS 1.3 Not Supported',
'severity': 'MEDIUM',
'description': 'TLS 1.3 provides improved security and performance',
'cve': 'N/A'
})
vulns.append(('TLS 1.3 Not Supported', 'MEDIUM'))
return vulns
def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities):
score = 100
if protocols.get('SSLv2'): score -= 30
if protocols.get('SSLv3'): score -= 20
if not protocols.get('TLSv1.2'): score -= 15
if not protocols.get('TLSv1.3'): score -= 10
if cert_info and cert_info.get('days_until_expiry', 0) < 30: score -= 10
if cert_info and cert_info.get('days_until_expiry', 0) < 7: score -= 20
weak_cipher_count = sum(1 for c in supported_ciphers
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
weak_cipher_count = sum(1 for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
score -= min(weak_cipher_count * 5, 25)
for vuln in vulnerabilities:
if vuln['severity'] == 'CRITICAL': score -= 20
elif vuln['severity'] == 'HIGH': score -= 15
elif vuln['severity'] == 'MEDIUM': score -= 10
elif vuln['severity'] == 'LOW': score -= 5
for name, severity in vulnerabilities:
if severity == 'CRITICAL': score -= 20
elif severity == 'HIGH': score -= 15
elif severity == 'MEDIUM': score -= 10
return max(0, score)
def _generate_recommendations(protocols, cert_info, supported_ciphers, score):
recs = []
if protocols.get('SSLv2'): recs.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable")
if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3 - vulnerable to POODLE attack")
if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3 for best security and performance")
if protocols.get('SSLv2'): recs.append("🔴 Disable SSLv2")
if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3")
if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3")
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
recs.append("🟡 Renew SSL certificate - expiring soon")
weak_ciphers = [c for c in supported_ciphers
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
recs.append("🟡 Renew certificate")
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
if weak_ciphers:
recs.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)")
recs.append("🔴 Remove weak ciphers")
if score < 80:
recs.append("🛡️ Implement modern TLS configuration following Mozilla guidelines")
recs.append("🛡️ Improve TLS configuration")
if not any('ECDHE' in c for c in supported_ciphers):
recs.append("🟡 Enable Forward Secrecy with ECDHE cipher suites")
recs.append("️ Note: SSLv2/SSLv3 testing limited by Python security features")
recs.append("🟡 Enable Forward Secrecy")
return recs
def _format_cert_date(date_str):
try:
dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ')
return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
except:
return date_str
# ----- main scan orchestration -----
async def perform_ssl_scan(room, bot, target, port):
safe_target = html_escape(target)
await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {safe_target}:{port}...")
await bot.api.send_text_message(room.room_id, f"🔍 Starting SSL/TLS scan for {safe_target}:{port}...")
if not await _run_blocking(_test_connectivity, target, port):
await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {safe_target}:{port}")
return
# Run blocking checks in parallel
cert_task = _run_blocking(_get_certificate_info, target, port)
proto_task = _run_blocking(_test_protocols, target, port)
cipher_task = _run_blocking(_test_cipher_suites, target, port)
@@ -341,36 +253,25 @@ async def perform_ssl_scan(room, bot, target, port):
score = _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities)
recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score)
# Build output (using safe domain/port)
output = await _format_results(target, port, cert_info, protocols, supported_ciphers,
vulnerabilities, score, recommendations)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Completed SSL scan for {target}:{port}")
sections = []
async def _format_results(target, port, cert_info, protocols, supported_ciphers,
vulnerabilities, score, recommendations):
safe_target = html_escape(target)
# Score
score_emoji = "🟢" if score >= 90 else "🟡" if score >= 80 else "🟠" if score >= 60 else "🔴"
rating = "Excellent" if score >= 90 else "Good" if score >= 80 else "Fair" if score >= 60 else "Poor"
sections.append({"title": f"{score_emoji} Security Score", "rows": [("", "Score", f"{score}/100 ({rating})")]})
body = f"<strong>🔐 SSL/TLS Security Scan: {safe_target}:{port}</strong><br><br>"
body += f"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
# Certificate Information
# Certificate
if cert_info:
body += "<strong>📜 Certificate Information</strong><br>"
body += f" • <strong>Subject:</strong> {html_escape(cert_info['subject'].get('common_name', 'N/A'))}<br>"
body += f" • <strong>Issuer:</strong> {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}<br>"
body += f" • <strong>Valid From:</strong> {_format_cert_date(cert_info['not_before'])}<br>"
body += f" • <strong>Valid Until:</strong> {_format_cert_date(cert_info['not_after'])}<br>"
days = cert_info.get('days_until_expiry', 'N/A')
body += f" • <strong>Expires In:</strong> {days} days<br>"
body += f" • <strong>Signature Algorithm:</strong> {html_escape(cert_info['signature_algorithm'])}<br>"
body += "<br>"
cert_rows = [
("📜", "Subject", cert_info['subject'].get('common_name', 'N/A')),
("🏢", "Issuer", cert_info['issuer'].get('common_name', 'N/A')),
("📅", "Valid Until", cert_info['not_after']),
("", "Expires In", f"{cert_info['days_until_expiry']} days"),
]
sections.append({"title": "📜 Certificate", "rows": cert_rows})
# Protocol Support
body += "<strong>🔌 Protocol Support</strong><br>"
# Protocols
proto_rows = []
for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
supported = protocols.get(proto, False)
if proto in ['SSLv2', 'SSLv3'] and supported:
@@ -381,67 +282,57 @@ async def _format_results(target, port, cert_info, protocols, supported_ciphers,
emoji = "" if supported else ""
status = "Supported" if supported else "Not Supported"
if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS:
status = "Cannot test (Python security)"
status = "Cannot test"
emoji = ""
body += f"{emoji} <strong>{proto}:</strong> {status}<br>"
body += "<br>"
proto_rows.append((emoji, proto, status))
sections.append({"title": "🔌 Protocols", "rows": proto_rows})
# Cipher Suites
body += "<strong>🔐 Cipher Suites</strong><br>"
body += f" • <strong>Total Supported:</strong> {len(supported_ciphers)}<br>"
weak_ciphers = [c for c in supported_ciphers
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
strong_ciphers = [c for c in supported_ciphers if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
cipher_rows = [("🔢", "Total Supported", str(len(supported_ciphers)))]
if weak_ciphers:
body += f" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>"
for cipher in weak_ciphers[:3]:
body += f" └─ 🔴 {html_escape(cipher)}<br>"
strong_ciphers = [c for c in supported_ciphers
if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
cipher_rows.append(("🔴", "Weak Ciphers", str(len(weak_ciphers))))
for c in weak_ciphers[:3]:
cipher_rows.append(("", "", c))
if strong_ciphers:
body += f" • <strong>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>"
body += "<br>"
cipher_rows.append(("🟢", "Strong Ciphers", str(len(strong_ciphers))))
sections.append({"title": "🔐 Cipher Suites", "rows": cipher_rows})
# Vulnerabilities
if vulnerabilities:
body += "<strong>⚠️ Security Vulnerabilities</strong><br>"
for vuln in vulnerabilities[:5]:
sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
body += f"{sev_emoji} <strong>{html_escape(vuln['name'])}</strong> ({vuln['severity']})<br>"
body += f" └─ {html_escape(vuln['description'])}<br>"
body += "<br>"
vuln_rows = []
for name, sev in vulnerabilities:
sev_emoji = "🔴" if sev == 'CRITICAL' else "🟠" if sev == 'HIGH' else "🟡"
vuln_rows.append((sev_emoji, name, sev))
sections.append({"title": "⚠️ Vulnerabilities", "rows": vuln_rows})
# Recommendations
if recommendations:
body += "<strong>💡 Security Recommendations</strong><br>"
for rec in recommendations[:8]:
body += f"{rec}<br>"
body += "<br>"
rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
sections.append({"title": "💡 Recommendations", "rows": rec_rows})
# Quick Assessment
body += "<strong>📊 Quick Assessment</strong><br>"
assessment_rows = []
if score >= 90:
body += "✅ Excellent TLS configuration<br>"
body += " • ✅ Modern protocols and ciphers<br>"
body += " • ✅ Good certificate management<br>"
assessment_rows = [("", "Assessment", "✅ Excellent configuration")]
elif score >= 70:
body += " • ⚠️ Good configuration with minor issues<br>"
body += " • 🔧 Some improvements recommended<br>"
assessment_rows = [("", "Assessment", "⚠️ Good, minor improvements possible")]
else:
body += "🚨 Significant security issues found<br>"
body += " • 🔴 Immediate action required<br>"
body += "<br><em>️ Note: Some protocol tests limited by Python security features</em>"
return collapsible_summary(f"🔐 SSL/TLS Scan: {safe_target}:{port} (Score: {score}/100)", body)
assessment_rows = [("", "Assessment", "🚨 Significant issues found")]
sections.append({"title": "📊 Quick Assessment", "rows": assessment_rows})
block = code_block(f"🔐 SSL/TLS Scan: {safe_target}:{port}", sections)
output = collapsible_summary(f"🔐 SSL/TLS: {safe_target} (Score: {score}/100)", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Completed SSL scan for {target}:{port}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "SSL/TLS security scanner (SSRFsafe, async)"
__description__ = "SSL/TLS security scanner"
__help__ = """
<details>
<summary><strong>!sslscan</strong> SSL/TLS analysis</summary>
+1 -1
View File
@@ -137,7 +137,7 @@ def print_help():
# ---------------------------------------------------------------------------
__version__ = "1.1.2"
__author__ = "Funguy Bot"
__description__ = "Stable Diffusion image generation (async, LORA support)"
__description__ = "Stable Diffusion image generation (LORA support)"
__help__ = """
<details>
<summary><strong>!sd</strong> Generate images via Stable Diffusion</summary>
+130 -139
View File
@@ -2,28 +2,26 @@
"""
plugins/subnet.py Subnet calculator and network splitting plugin for Funguy Bot.
Provides the following commands:
!subnet info <CIDR> Show detailed info about a network
!subnet split <CIDR> --prefix <N> Split network into smaller subnets (new prefix length)
!subnet split <CIDR> --diff <N> Split network into equal subnets (prefixlen delta)
!subnet adjacent <CIDR> <count> Show given network and next <count> adjacent ones
!subnet help Display this help
Commands:
!subnet info <CIDR>
!subnet split <CIDR> --prefix <N>
!subnet split <CIDR> --diff <N>
!subnet adjacent <CIDR> <count>
!subnet help
Examples:
!subnet info 192.168.4.0/26
!subnet split 192.168.4.0/24 --prefix 26
!subnet split 10.0.0.0/16 --diff 2
!subnet adjacent 192.168.4.0/26 3
Output is a clean code block with emojis and perfectly aligned columns.
"""
import ipaddress
import sys
from typing import Union
import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, html_escape, code_block
# ------------------------------- helper functions --------------------------------
# -------------------------------------------------------------------
# Helper functions (synchronous)
# -------------------------------------------------------------------
def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> str:
"""Return a humanreadable string with all relevant subnet details."""
def _fmt_subnet_info_rows(net):
"""Return list of (emoji, label, value) tuples."""
nw = net.network_address
bc = net.broadcast_address if hasattr(net, "broadcast_address") else None
total = net.num_addresses
@@ -50,102 +48,124 @@ def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -
first = last = None
usable_count = 0
lines = [
f"CIDR: {net.with_prefixlen}",
f"Network: {nw}",
f"Broadcast: {bc if bc is not None else 'N/A'}",
f"Netmask: {net.netmask if hasattr(net, 'netmask') else 'N/A'}",
f"Wildcard Mask: {net.hostmask if hasattr(net, 'hostmask') else 'N/A'}",
f"Total IPs: {total}",
f"Usable Hosts: {usable_count}",
rows = [
("🌐", "CIDR", str(net.with_prefixlen)),
("📡", "Network", str(nw)),
("📢", "Broadcast", str(bc) if bc is not None else "N/A"),
("🧱", "Netmask", str(net.netmask) if hasattr(net, "netmask") else "N/A"),
("🕳️", "Wildcard Mask", str(net.hostmask) if hasattr(net, "hostmask") else "N/A"),
("🔢", "Total IPs", str(total)),
("👥", "Usable Hosts", str(usable_count)),
]
if first is not None and last is not None:
lines.append(f"First Usable: {first}")
lines.append(f"Last Usable: {last}")
lines.append(f"Usable Range: {first} - {last}")
return "\n".join(lines)
rows.append(("🏁", "First Usable", str(first)))
rows.append(("🏁", "Last Usable", str(last)))
rows.append(("↔️", "Usable Range", f"{first} - {last}"))
return rows
def _split_by_prefix(net, new_prefix: int) -> str:
def _split_by_prefix(net, new_prefix):
if new_prefix < net.prefixlen:
return f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split."
out = [f"# Splitting {net.with_prefixlen} into /{new_prefix} subnets:"]
for i, sub in enumerate(net.subnets(new_prefix=new_prefix)):
out.append(f"\n-- Subnet #{i+1} --")
out.append(_fmt_subnet_info(sub))
return "\n".join(out)
return None
return list(net.subnets(new_prefix=new_prefix))
def _split_by_diff(net, diff: int) -> str:
new_prefix = net.prefixlen + diff
return _split_by_prefix(net, new_prefix)
def _split_by_diff(net, diff):
return _split_by_prefix(net, net.prefixlen + diff)
def _adjacent_networks(net, count: int) -> str:
out = [f"# Adjacent networks of size /{net.prefixlen} (starting at {net.with_prefixlen}):"]
def _adjacent_networks(net, count):
nets = [net]
current = net
for i in range(count + 1):
out.append(f"\n-- Adjacent #{i} --")
out.append(_fmt_subnet_info(current))
for _ in range(count):
try:
next_net_addr = current.network_address + current.num_addresses
current = ipaddress.ip_network(f"{next_net_addr}/{current.prefixlen}", strict=True)
except ValueError:
out.append("[!] Reached address space limit.")
next_addr = current.network_address + current.num_addresses
current = ipaddress.ip_network(f"{next_addr}/{current.prefixlen}", strict=True)
nets.append(current)
except (ValueError, ipaddress.AddressValueError):
break
return "\n".join(out)
return nets
# ------------------------------- bot plugin entry -------------------------------
# -------------------------------------------------------------------
# Output builders (each returns a collapsible Markdown message)
# -------------------------------------------------------------------
def _info_output(net):
"""Build a collapsible message for a single subnet."""
title = f"🔍 Subnet {net.with_prefixlen}"
rows = _fmt_subnet_info_rows(net)
block = code_block(title, [{"title": "", "rows": rows}])
return collapsible_summary(title, block)
def _split_output(networks):
"""Build a collapsible message for a split operation."""
total = len(networks)
title = f"🔀 Split into {total} subnets"
sections = []
for i, sub in enumerate(networks, 1):
rows = _fmt_subnet_info_rows(sub)
sections.append({"title": f"Subnet {sub.with_prefixlen}", "rows": rows})
block = code_block(title, sections)
return collapsible_summary(title, block)
def _adjacent_output(networks):
"""Build a collapsible message for adjacent networks."""
base = networks[0]
title = f"📐 Adjacent networks (base {base.with_prefixlen})"
sections = []
for i, net in enumerate(networks):
label = "Base network" if i == 0 else f"Adjacent #{i}"
rows = _fmt_subnet_info_rows(net)
sections.append({"title": label, "rows": rows})
block = code_block(title, sections)
return collapsible_summary(title, block)
# -------------------------------------------------------------------
# Help
# -------------------------------------------------------------------
_HELP_MD = """
<details>
<summary><strong>!subnet</strong> Subnet calculator and exploration</summary>
<pre>
!subnet info &lt;CIDR&gt; Show detailed info for a network
!subnet split &lt;CIDR&gt; --prefix &lt;N&gt; Split into smaller subnets (new prefix)
!subnet split &lt;CIDR&gt; --diff &lt;N&gt; Split by prefix delta
!subnet adjacent &lt;CIDR&gt; &lt;count&gt; Show current and adjacent networks
</pre>
<p>Example: <code>!subnet info 192.168.1.0/24</code></p>
<ul>
<li>IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).</li>
<li>IPv6 networks list all addresses as hosts (no broadcast).</li>
</ul>
</details>
"""
# -------------------------------------------------------------------
# Command handler
# -------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
import simplematrixbotlib as botlib
match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix() and match.command("subnet")):
return
args = match.args()
if not args:
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet <info|split|adjacent> ...\n"
" !subnet help show full help"
)
await bot.api.send_text_message(room.room_id, "Usage: !subnet <info|split|adjacent> ...\n !subnet help")
return
subcmd = args[0].lower()
# --- help ---
if subcmd in ("help", "-h", "--help"):
# Send nicely formatted HTML in a details tag via markdown
html = "<details><summary><strong>!subnet</strong> Subnet calculator and exploration</summary>\n"
html += "<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>\n"
html += "<h4>Commands</h4>\n"
html += "<ul>\n"
html += "<li><b>info</b> Show detailed info for a network<br>\n"
html += "<code>!subnet info &lt;CIDR&gt;</code><br>\n"
html += "Example: <code>!subnet info 192.168.1.0/24</code></li>\n"
html += "<li><b>split</b> Split a network into smaller subnets<br>\n"
html += "<code>!subnet split &lt;CIDR&gt; --prefix &lt;new_prefix&gt;</code><br>\n"
html += "Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code><br>\n"
html += "<i>Alternatively, use --diff to split by prefix delta:</i><br>\n"
html += "<code>!subnet split &lt;CIDR&gt; --diff &lt;delta&gt;</code><br>\n"
html += "Example: <code>!subnet split 10.0.0.0/16 --diff 2</code> (creates 4 subnets)</li>\n"
html += "<li><b>adjacent</b> Show the current network and adjacent ones<br>\n"
html += "<code>!subnet adjacent &lt;CIDR&gt; &lt;count&gt;</code><br>\n"
html += "Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>\n"
html += "</ul>\n"
html += "<h4>Notes</h4>\n"
html += "<ul>\n"
html += "<li>IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).</li>\n"
html += "<li>IPv6 networks list all addresses as hosts (no broadcast).</li>\n"
html += "</ul>\n"
html += "</details>"
await bot.api.send_markdown_message(room.room_id, html)
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
return
# --- info (or a CIDR passed directly) ---
if subcmd == "info" or "/" in subcmd:
cidr = args[1] if subcmd == "info" else subcmd
try:
@@ -153,16 +173,13 @@ async def handle_command(room, message, bot, prefix, config):
except ValueError as e:
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
return
await bot.api.send_text_message(room.room_id, _fmt_subnet_info(net))
output = _info_output(net)
await bot.api.send_markdown_message(room.room_id, output)
return
# --- split ---
if subcmd == "split":
if len(args) < 2:
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet split <CIDR> --prefix <new_prefix> OR --diff <delta>"
)
await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --prefix <N> OR !subnet split <CIDR> --diff <delta>")
return
cidr = args[1]
try:
@@ -176,39 +193,31 @@ async def handle_command(room, message, bot, prefix, config):
idx = args.index("--prefix")
new_prefix = int(args[idx + 1])
except (ValueError, IndexError):
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet split <CIDR> --prefix <number>"
)
await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --prefix <number>")
return
result = _split_by_prefix(net, new_prefix)
subnets = _split_by_prefix(net, new_prefix)
elif "--diff" in args:
try:
idx = args.index("--diff")
diff = int(args[idx + 1])
except (ValueError, IndexError):
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet split <CIDR> --diff <delta>"
)
await bot.api.send_text_message(room.room_id, "Usage: !subnet split <CIDR> --diff <delta>")
return
result = _split_by_diff(net, diff)
subnets = _split_by_diff(net, diff)
else:
await bot.api.send_text_message(
room.room_id,
"You must provide either --prefix <N> or --diff <N> for split."
)
await bot.api.send_text_message(room.room_id, "You must provide --prefix <N> or --diff <N> for split.")
return
await bot.api.send_text_message(room.room_id, result)
if subnets is None:
await bot.api.send_text_message(room.room_id, f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split.")
return
output = _split_output(subnets)
await bot.api.send_markdown_message(room.room_id, output)
return
# --- adjacent ---
if subcmd == "adjacent":
if len(args) < 3:
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet adjacent <CIDR> <count>"
)
await bot.api.send_text_message(room.room_id, "Usage: !subnet adjacent <CIDR> <count>")
return
cidr = args[1]
try:
@@ -219,39 +228,21 @@ async def handle_command(room, message, bot, prefix, config):
try:
count = int(args[2])
except ValueError:
await bot.api.send_text_message(
room.room_id,
"Count must be an integer."
)
await bot.api.send_text_message(room.room_id, "Count must be an integer.")
return
result = _adjacent_networks(net, count)
await bot.api.send_text_message(room.room_id, result)
networks = _adjacent_networks(net, count)
output = _adjacent_output(networks)
await bot.api.send_markdown_message(room.room_id, output)
return
# Unknown subcommand
await bot.api.send_text_message(
room.room_id,
f"Unknown subcommand '{subcmd}'. Use !subnet help to see available commands."
)
await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{subcmd}'. Use !subnet help.")
# Plugin metadata
__version__ = "1.0.1"
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.3.2"
__author__ = "Funguy Bot"
__description__ = "Subnet calculator, splitter, and adjacent network enumerator"
__help__ = """
<details>
<summary><strong>!subnet</strong> Subnet calculator and exploration</summary>
<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>
<ul>
<li><code>!subnet info &lt;CIDR&gt;</code> Show detailed info for a network<br>
Example: <code>!subnet info 192.168.1.0/24</code></li>
<li><code>!subnet split &lt;CIDR&gt; --prefix &lt;new_prefix&gt;</code> Split into smaller subnets<br>
Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code></li>
<li><code>!subnet split &lt;CIDR&gt; --diff &lt;delta&gt;</code> Split by prefix delta<br>
Example: <code>!subnet split 10.0.0.0/16 --diff 2</code></li>
<li><code>!subnet adjacent &lt;CIDR&gt; &lt;count&gt;</code> Show adjacent networks<br>
Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>
</ul>
</details>
"""
__description__ = "Subnet calculator"
__help__ = _HELP_MD
+236 -277
View File
@@ -1,354 +1,313 @@
"""
Comprehensive system information and resource monitoring.
All blocking calls (psutil, subprocess) run in a thread pool.
Comprehensive system information code block with emoji + aligned columns.
All blocking calls run in thread pool.
"""
import logging
import platform
import os
import asyncio
import psutil
import socket
import datetime
import subprocess
import logging, platform, os, asyncio, psutil, socket, datetime, subprocess
import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, html_escape
from plugins.common import collapsible_summary, html_escape, code_block
async def handle_command(room, message, bot, prefix, config):
"""
Handle !sysinfo command for system information.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
args = match.args()
if args and args[0].lower() == 'help':
await show_usage(room, bot)
return
await get_system_info(room, bot)
async def show_usage(room, bot):
"""Display sysinfo command usage."""
usage = """
<strong>💻 System Information Plugin</strong>
<strong>!sysinfo</strong> - Display comprehensive system information
<strong>!sysinfo help</strong> - Show this help message
<strong>Information Provided:</strong>
System hardware (CPU, RAM, storage, GPU)
Operating system and kernel details
Network configuration and interfaces
Running processes and resource usage
Temperature and hardware sensors
System load and performance metrics
Docker container status (if available)
"""
await bot.api.send_markdown_message(room.room_id, usage)
# ----- Async wrappers for blocking functions -----
async def _run_blocking(func, *args, **kwargs):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
# ----- Individual data collectors (all sync, run in thread) -----
# ---------- Data collectors (unchanged) ----------
def _system_overview():
boot = datetime.datetime.fromtimestamp(psutil.boot_time())
uptime_delta = datetime.datetime.now() - boot
uptime_str = str(datetime.timedelta(seconds=int(uptime_delta.total_seconds())))
return {
'hostname': socket.gethostname(),
'os': platform.system(),
'os_release': platform.release(),
'os_version': platform.version(),
'architecture': platform.architecture()[0],
'machine': platform.machine(),
'processor': platform.processor(),
'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"),
'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))),
'users': len(psutil.users())
"hostname": socket.gethostname(),
"os": f"{platform.system()} {platform.release()}",
"architecture": platform.architecture()[0],
"machine": platform.machine(),
"processor": platform.processor(),
"boot_time": boot.strftime("%Y-%m-%d %H:%M:%S"),
"uptime": uptime_str,
"users": len(psutil.users())
}
def _cpu_info():
cpu_times = psutil.cpu_times_percent(interval=1)
cpu_freq = psutil.cpu_freq()
load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else (0,0,0)
load = os.getloadavg() if hasattr(os, "getloadavg") else (0,0,0)
return {
'physical_cores': psutil.cpu_count(logical=False),
'total_cores': psutil.cpu_count(logical=True),
'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A",
'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A",
'usage_percent': psutil.cpu_percent(interval=1),
'user_time': cpu_times.user,
'system_time': cpu_times.system,
'idle_time': cpu_times.idle,
'load_avg': ", ".join(f"{l:.2f}" for l in load_avg)
"physical_cores": psutil.cpu_count(logical=False),
"logical_cores": psutil.cpu_count(logical=True),
"max_freq": f"{cpu_freq.max:.0f} MHz" if cpu_freq else "N/A",
"current_freq": f"{cpu_freq.current:.0f} MHz" if cpu_freq else "N/A",
"usage": f"{psutil.cpu_percent(interval=1)}%",
"load_avg": f"{load[0]:.2f} {load[1]:.2f} {load[2]:.2f}"
}
def _memory_info():
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
return {
'total': f"{mem.total / (1024**3):.2f} GB",
'available': f"{mem.available / (1024**3):.2f} GB",
'used': f"{mem.used / (1024**3):.2f} GB",
'usage_percent': mem.percent,
'swap_total': f"{swap.total / (1024**3):.2f} GB",
'swap_used': f"{swap.used / (1024**3):.2f} GB",
'swap_free': f"{swap.free / (1024**3):.2f} GB",
'swap_percent': swap.percent
"total_ram": f"{mem.total / (1024**3):.1f} GB",
"used_ram": f"{mem.used / (1024**3):.1f} GB",
"ram_percent": f"{mem.percent}%",
"available_ram": f"{mem.available / (1024**3):.1f} GB",
"total_swap": f"{swap.total / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
"used_swap": f"{swap.used / (1024**3):.1f} GB" if swap.total > 0 else "N/A",
"swap_percent": f"{swap.percent}%" if swap.total > 0 else "N/A"
}
def _storage_info():
def _disk_info():
partitions = psutil.disk_partitions()
storage_list = []
for part in partitions:
mounted = []
for p in partitions:
try:
usage = psutil.disk_usage(part.mountpoint)
storage_list.append({
'device': part.device,
'mountpoint': part.mountpoint,
'fstype': part.fstype,
'total': f"{usage.total / (1024**3):.2f} GB",
'used': f"{usage.used / (1024**3):.2f} GB",
'free': f"{usage.free / (1024**3):.2f} GB",
'percent': usage.percent
usage = psutil.disk_usage(p.mountpoint)
mounted.append({
"mount": p.mountpoint,
"used": f"{usage.used / (1024**3):.1f} GB",
"total": f"{usage.total / (1024**3):.1f} GB",
"percent": usage.percent
})
except:
pass
disk_io = psutil.disk_io_counters()
io_info = {
'read_count': disk_io.read_count if disk_io else 0,
'write_count': disk_io.write_count if disk_io else 0,
'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB",
'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB"
}
return {'partitions': storage_list, 'io_stats': io_info}
io = psutil.disk_io_counters()
io_read = f"{io.read_bytes / (1024**3):.2f} GB" if io else "0 GB"
io_write = f"{io.write_bytes / (1024**3):.2f} GB" if io else "0 GB"
return mounted, io_read, io_write
def _network_info():
interfaces = psutil.net_if_addrs()
ifaces = psutil.net_if_addrs()
io_counters = psutil.net_io_counters(pernic=True)
net_list = []
for iface, addrs in interfaces.items():
if iface == 'lo':
net = []
for name, addrs in ifaces.items():
if name == "lo":
continue
info = {
'interface': iface,
'ipv4': next((a.address for a in addrs if a.family == socket.AF_INET), 'N/A'),
'ipv6': next((a.address for a in addrs if a.family == socket.AF_INET6), 'N/A'),
'mac': next((a.address for a in addrs if a.family == psutil.AF_LINK), 'N/A'),
}
io = io_counters.get(iface)
if io:
info['bytes_sent'] = f"{io.bytes_sent / (1024**2):.2f} MB"
info['bytes_recv'] = f"{io.bytes_recv / (1024**2):.2f} MB"
else:
info['bytes_sent'] = 'N/A'
info['bytes_recv'] = 'N/A'
net_list.append(info)
return net_list
ip4 = next((a.address for a in addrs if a.family == socket.AF_INET), None)
if ip4:
stats = io_counters.get(name)
sent = f"{stats.bytes_sent / (1024**2):.1f} MB" if stats else "0 MB"
recv = f"{stats.bytes_recv / (1024**2):.1f} MB" if stats else "0 MB"
net.append((name, ip4, sent, recv))
return net
def _process_info():
def _top_processes():
procs = []
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
for p in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
try:
procs.append(proc.info)
procs.append(p.info)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
top_cpu = sorted(procs, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5]
return {'total_processes': len(procs), 'top_cpu': top_cpu}
return top_cpu, len(procs)
def _gpu_info():
info = {}
try:
res = subprocess.run(
['nvidia-smi', '--query-gpu=name,memory.used,memory.total,temperature.gpu,utilization.gpu',
'--format=csv,noheader,nounits'],
capture_output=True, text=True
)
if res.returncode == 0:
gpus = []
for line in res.stdout.strip().split('\n'):
parts = [p.strip() for p in line.split(',')]
if len(parts) >= 5:
gpus.append({
"name": parts[0],
"mem_used": f"{parts[1]} MB",
"mem_total": f"{parts[2]} MB",
"temp": f"{parts[3]}°C",
"usage": f"{parts[4]}%"
})
if gpus:
info["nvidia"] = gpus
except:
pass
try:
res = subprocess.run(['lspci'], capture_output=True, text=True)
if res.returncode == 0:
lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
if lines:
info["detected"] = lines[:2]
except:
pass
return info
def _docker_info():
try:
result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
if result.returncode != 0:
return {'available': False}
result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'],
capture_output=True, text=True)
ver = subprocess.run(['docker', '--version'], capture_output=True, text=True)
if ver.returncode != 0:
return None
ps_res = subprocess.run(
['docker', 'ps', '--format', '{{.Names}}|{{.Status}}'],
capture_output=True, text=True
)
containers = []
for line in result.stdout.strip().split('\n'):
for line in ps_res.stdout.strip().split('\n'):
if line:
parts = line.split('|')
if len(parts) >= 2:
containers.append({'name': parts[0], 'status': parts[1], 'ports': parts[2] if len(parts)>2 else 'N/A'})
return {'available': True, 'containers': containers, 'total_running': len(containers)}
containers.append({"name": parts[0], "status": parts[1]})
return containers
except:
return {'available': False}
return None
def _sensor_info():
temps = psutil.sensors_temperatures()
fans = psutil.sensors_fans()
battery = psutil.sensors_battery()
sensor = {'temperatures': {}, 'fans': {}, 'battery': {}}
data = {"temps": [], "fans": [], "battery": None}
if temps:
for name, entries in temps.items():
sensor['temperatures'][name] = [f"{e.current}°C" for e in entries[:2]]
for chip, entries in temps.items():
for e in entries[:2]:
data["temps"].append(f"{e.label or chip}: {e.current}°C")
if fans:
for name, entries in fans.items():
sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]]
for chip, entries in fans.items():
for e in entries[:2]:
data["fans"].append(f"{e.label or chip}: {e.current} RPM")
if battery:
sensor['battery'] = {
'percent': battery.percent,
'power_plugged': battery.power_plugged,
'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown"
}
return sensor
rem = ""
if battery.secsleft != psutil.POWER_TIME_UNLIMITED and battery.secsleft > 0:
h = battery.secsleft // 3600
m = (battery.secsleft % 3600) // 60
rem = f" ({h}h {m}m left)"
plugged = " 🔌" if battery.power_plugged else ""
data["battery"] = f"{battery.percent}%{plugged}{rem}"
return data
def _gpu_info():
gpu_data = {}
# NVIDIA
try:
res = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu',
'--format=csv,noheader,nounits'], capture_output=True, text=True)
if res.returncode == 0:
nvidia = []
for line in res.stdout.strip().split('\n'):
parts = [p.strip() for p in line.split(',')]
if len(parts) >= 6:
nvidia.append({
'name': parts[0],
'memory_total': f"{parts[1]} MB",
'memory_used': f"{parts[2]} MB",
'memory_free': f"{parts[3]} MB",
'temperature': f"{parts[4]}°C",
'utilization': f"{parts[5]}%"
})
if nvidia:
gpu_data['nvidia'] = nvidia
except:
pass
# lspci fallback
try:
res = subprocess.run(['lspci'], capture_output=True, text=True)
if res.returncode == 0:
gpu_lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
if gpu_lines:
gpu_data['detected'] = gpu_lines[:3]
except:
pass
return gpu_data
# ----- Main info gatherer -----
# -------------------------------------------------------------------
# Main builder
# -------------------------------------------------------------------
async def get_system_info(room, bot):
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
# Run all blocking collectors concurrently
system = await _run_blocking(_system_overview)
cpu = await _run_blocking(_cpu_info)
memory = await _run_blocking(_memory_info)
storage = await _run_blocking(_storage_info)
network = await _run_blocking(_network_info)
processes = await _run_blocking(_process_info)
mem = await _run_blocking(_memory_info)
disks, io_read, io_write = await _run_blocking(_disk_info)
net = await _run_blocking(_network_info)
top_procs, total_procs = await _run_blocking(_top_processes)
gpu = await _run_blocking(_gpu_info)
docker = await _run_blocking(_docker_info)
sensors = await _run_blocking(_sensor_info)
gpu = await _run_blocking(_gpu_info)
# Build output HTML
output = await format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu)
sections = []
# System Overview
sys_rows = [
("💻", "Hostname", system["hostname"]),
("🖥️", "OS", system["os"]),
("📐", "Architecture", system["architecture"]),
("⚙️", "Machine", system["machine"]),
("🔧", "Processor", system["processor"]),
("", "Uptime", system["uptime"]),
("📅", "Boot Time", system["boot_time"]),
("👥", "Users", str(system["users"]))
]
sections.append({"title": "🖥️ System Overview", "rows": sys_rows})
# CPU
cpu_rows = [
("", "CPU Cores", f"{cpu['physical_cores']} physical, {cpu['logical_cores']} logical"),
("📈", "Freq (Max/Cur)", f"{cpu['max_freq']} / {cpu['current_freq']}"),
("📊", "CPU Usage", cpu["usage"]),
("⚖️", "Load Avg", cpu["load_avg"])
]
sections.append({"title": "⚡ CPU", "rows": cpu_rows})
# Memory
mem_rows = [
("🧠", "RAM", f"{mem['used_ram']} / {mem['total_ram']} ({mem['ram_percent']})")
]
if mem["total_swap"] != "N/A":
mem_rows.append(("💾", "Swap", f"{mem['used_swap']} / {mem['total_swap']} ({mem['swap_percent']})"))
sections.append({"title": "🧠 Memory", "rows": mem_rows})
# Storage
disk_rows = []
for d in disks[:5]:
disk_rows.append(("💽", d['mount'], f"{d['used']} / {d['total']} ({d['percent']}%)"))
disk_rows.append(("📀", "Disk I/O", f"Read {io_read} / Write {io_write}"))
sections.append({"title": "💾 Storage", "rows": disk_rows})
# Network
net_rows = []
if net:
for idx, (name, ip, sent, recv) in enumerate(net[:3]):
emoji = "🌐" if idx == 0 else ""
label = "Network" if idx == 0 else ""
net_rows.append((emoji, label, f"{name} - {ip} | ↓{recv}{sent}"))
else:
net_rows.append(("🌐", "Network", "No active interfaces"))
sections.append({"title": "🌐 Network", "rows": net_rows})
# GPU
gpu_rows = []
if "nvidia" in gpu:
for g in gpu["nvidia"]:
gpu_rows.append(("🎮", "GPU", f"{g['name']} | {g['mem_used']}/{g['mem_total']} | {g['temp']} | {g['usage']} util"))
elif "detected" in gpu:
for line in gpu["detected"]:
gpu_rows.append(("🎮", "GPU", line))
else:
gpu_rows.append(("🎮", "GPU", "No dedicated GPU detected"))
sections.append({"title": "🎮 GPU", "rows": gpu_rows})
# Processes
proc_rows = [("🔄", "Processes", f"Total: {total_procs}")]
for p in top_procs:
name = p.get('name', '?')
cpu_p = p.get('cpu_percent') or 0
mem_p = p.get('memory_percent') or 0
proc_rows.append(("", "", f"{name} - CPU {cpu_p:.1f}% / RAM {mem_p:.1f}%"))
sections.append({"title": "🔄 Top Processes", "rows": proc_rows})
# Docker
docker_rows = []
if docker is not None:
if docker:
for c in docker[:5]:
docker_rows.append(("🐳", "Docker", f"{c['name']} - {c['status']}"))
else:
docker_rows.append(("🐳", "Docker", "No containers running"))
else:
docker_rows.append(("🐳", "Docker", "Docker not available"))
sections.append({"title": "🐳 Docker", "rows": docker_rows})
# Sensors
sensor_rows = []
if sensors["temps"]:
sensor_rows.append(("🌡️", "Temperature", ", ".join(sensors["temps"])))
if sensors["fans"]:
sensor_rows.append(("🌀", "Fans", ", ".join(sensors["fans"])))
if sensors["battery"]:
sensor_rows.append(("🔋", "Battery", sensors["battery"]))
if sensor_rows:
sections.append({"title": "🌡️ Sensors", "rows": sensor_rows})
block = code_block(f"💻 System Info: {system['hostname']}", sections)
output = collapsible_summary(f"💻 System Info {html_escape(system['hostname'])}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent system information")
async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu):
hostname = html_escape(system.get('hostname', 'Unknown'))
body = "<strong>💻 System Information</strong><br><br>"
# System Overview
body += "<strong>🖥️ System Overview</strong><br>"
body += f" • <strong>Hostname:</strong> {hostname}<br>"
body += f" • <strong>OS:</strong> {html_escape(system['os'])} {html_escape(system['os_release'])}<br>"
body += f" • <strong>Architecture:</strong> {html_escape(system['architecture'])}<br>"
body += f" • <strong>Uptime:</strong> {html_escape(system['uptime'])}<br>"
body += f" • <strong>Boot Time:</strong> {html_escape(system['boot_time'])}<br>"
body += f" • <strong>Users:</strong> {system['users']}<br><br>"
# CPU
body += "<strong>⚡ CPU Information</strong><br>"
body += f" • <strong>Cores:</strong> {cpu['physical_cores']} physical, {cpu['total_cores']} logical<br>"
body += f" • <strong>Frequency:</strong> {html_escape(cpu['current_frequency'])} (max: {html_escape(cpu['max_frequency'])})<br>"
body += f" • <strong>Usage:</strong> {cpu['usage_percent']}%<br>"
body += f" • <strong>Load Average:</strong> {html_escape(cpu['load_avg'])}<br><br>"
# Memory
body += "<strong>🧠 Memory Information</strong><br>"
body += f" • <strong>Total:</strong> {html_escape(memory['total'])}<br>"
body += f" • <strong>Used:</strong> {html_escape(memory['used'])} ({memory['usage_percent']}%)<br>"
body += f" • <strong>Available:</strong> {html_escape(memory['available'])}<br>"
body += f" • <strong>Swap:</strong> {html_escape(memory['swap_used'])} / {html_escape(memory['swap_total'])} ({memory['swap_percent']}%)<br><br>"
# Storage
if storage and 'error' not in storage:
body += "<strong>💾 Storage Information</strong><br>"
for p in storage['partitions'][:3]:
body += f" • <strong>{html_escape(p['device'])}:</strong> {p['used']} / {p['total']} ({p['percent']}%)<br>"
# IO stats if wanted
io = storage.get('io_stats')
if io:
body += f" • <strong>Disk I/O:</strong> read {io['read_bytes']}, write {io['write_bytes']}<br>"
body += "<br>"
# GPU
if gpu:
if 'nvidia' in gpu:
body += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
for g in gpu['nvidia']:
body += f" • <strong>{html_escape(g['name'])}:</strong> {g['utilization']} usage, {g['temperature']}<br>"
body += "<br>"
elif 'detected' in gpu:
body += "<strong>🎮 GPU Information</strong><br>"
for line in gpu['detected'][:2]:
body += f"{html_escape(line)}<br>"
body += "<br>"
# Network
if network:
body += "<strong>🌐 Network Information</strong><br>"
for iface in network[:2]:
body += f" • <strong>{html_escape(iface['interface'])}:</strong> {html_escape(iface['ipv4'])}<br>"
body += "<br>"
# Top Processes
if processes:
body += "<strong>🔄 Top Processes (by CPU)</strong><br>"
for proc in processes['top_cpu'][:3]:
name = html_escape(proc.get('name', 'N/A'))
cpu_p = proc.get('cpu_percent', 0) or 0
mem_p = proc.get('memory_percent', 0) or 0
body += f" • <strong>{name}:</strong> {cpu_p:.1f}% CPU, {mem_p:.1f}% RAM<br>"
body += f" • <strong>Total Processes:</strong> {processes['total_processes']}<br><br>"
# Docker
if docker and docker.get('available'):
body += "<strong>🐳 Docker Containers</strong><br>"
for c in docker['containers'][:3]:
body += f" • <strong>{html_escape(c['name'])}:</strong> {html_escape(c['status'])}<br>"
body += f" • <strong>Total Running:</strong> {docker['total_running']}<br><br>"
# Sensors
if sensors and 'error' not in sensors:
if sensors.get('temperatures'):
body += "<strong>🌡️ Temperature Sensors</strong><br>"
for sensor, temps in list(sensors['temperatures'].items())[:2]:
body += f" • <strong>{html_escape(sensor)}:</strong> {', '.join(temps[:2])}<br>"
body += "<br>"
if sensors.get('battery'):
bat = sensors['battery']
body += "<strong>🔋 Battery Information</strong><br>"
body += f" • <strong>Charge:</strong> {bat['percent']}%<br>"
body += f" • <strong>Plugged In:</strong> {'Yes' if bat['power_plugged'] else 'No'}<br>"
if bat.get('time_left'):
body += f" • <strong>Time Left:</strong> {bat['time_left']}<br>"
body += "<br>"
# Timestamp
body += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
return collapsible_summary(f"💻 System Information - {hostname}", body)
async def handle_command(room, message, bot, prefix, config):
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
if match.args() and match.args()[0].lower() == 'help':
usage = """
<strong>💻 System Information</strong>
<code>!sysinfo</code> display comprehensive system info in a clean code block.
"""
await bot.api.send_markdown_message(room.room_id, usage)
return
await get_system_info(room, bot)
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.1"
__version__ = "1.3.1"
__author__ = "Funguy Bot"
__description__ = "Comprehensive system information and monitoring"
__description__ = "System information plugin"
__help__ = """
<details>
<summary><strong>!sysinfo</strong> System information</summary>
<p>Displays CPU, RAM, storage, network, Docker, GPU, sensors, and top processes.</p>
<p>Displays CPU, RAM, storage, network, GPU, sensors, top processes, and more in a clean, aligned code block.</p>
</details>
"""
+117 -131
View File
@@ -1,210 +1,196 @@
#!/usr/bin/env python3
"""
Time Zone Plugin completely hardcoded-free using Open-Meteo APIs.
Time Zone Plugin uses pytz for IANA zones and OpenMeteo for city geocoding.
Outputs a clean code block with emojis and aligned columns via shared code_block.
"""
import logging
import aiohttp
import simplematrixbotlib as botlib
from urllib.parse import quote
from datetime import datetime
import pytz
from plugins.common import collapsible_summary, html_escape, code_block
def format_ampm(dt_str: str) -> str:
"""Convert ISO datetime to AM/PM format."""
# -------------------------------------------------------------------
# Offline helper for IANA timezone names
# -------------------------------------------------------------------
def _get_time_for_iana_zone(zone: str) -> dict | None:
"""Return a dict with datetime, timezone, and optional temperature using pytz."""
try:
if '+' in dt_str:
dt_str = dt_str.split('+')[0]
if '.' in dt_str:
dt_str = dt_str.split('.')[0]
dt_str = dt_str.replace('T', ' ')
dt = datetime.fromisoformat(dt_str)
return dt.strftime("%I:%M:%S %p").lstrip("0")
except:
return dt_str
tz = pytz.timezone(zone)
now = datetime.now(tz)
return {
"datetime": now.isoformat(),
"timezone": zone,
"temperature": None # no weather for zone lookups
}
except pytz.UnknownTimeZoneError:
return None
async def geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
"""
Open-Meteo Geocoding API (free, no key, no hardcoding).
Returns (latitude, longitude, display_name) or None.
"""
# -------------------------------------------------------------------
# Online helpers (OpenMeteo)
# -------------------------------------------------------------------
async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
"""Geocode a city name via OpenMeteo. Returns (lat, lon, display_name) or None."""
from urllib.parse import quote
url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
try:
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
if data.get("results") and len(data["results"]) > 0:
result = data["results"][0]
lat = result["latitude"]
lon = result["longitude"]
name = result.get("name", city)
country = result.get("country", "")
admin1 = result.get("admin1", "")
# Build display name: "Lahore, Punjab, Pakistan"
display_parts = [name]
if admin1 and admin1 != name:
display_parts.append(admin1)
if country:
display_parts.append(country)
display_name = ", ".join(display_parts)
logging.info(f"Geocoded: {city}{display_name} ({lat}, {lon})")
return lat, lon, display_name
else:
logging.warning(f"Geocoding API HTTP {resp.status} for {city}")
results = data.get("results", [])
if results:
r = results[0]
lat = float(r["latitude"])
lon = float(r["longitude"])
name = r.get("name", city)
country = r.get("country", "")
admin1 = r.get("admin1", "")
display = ", ".join(filter(None, [name, admin1, country]))
return lat, lon, display
except Exception as e:
logging.warning(f"Geocoding error: {e}")
return None
async def get_timezone(lat: float, lon: float) -> str | None:
"""
Get timezone name from coordinates using timezonedb (free tier, no key).
Alternative: use Open-Meteo's time API directly.
"""
# Open-Meteo's time API accepts coordinates directly
# We'll use this instead of timezonedb
return None # Will be handled in fetch_time_by_coords
async def fetch_time_by_coords(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
async def _fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
"""
Get current time using Open-Meteo (no key required).
Fetch current time and temperature from OpenMeteo (free, no key).
The API returns an ISO 8601 string for the current time.
"""
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true&timezone=auto&timeformat=unixtime"
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true&timezone=auto"
try:
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
current = data.get("current_weather", {})
timezone = data.get("timezone", "Unknown")
unixtime = current.get("time")
temperature = current.get("temperature")
if unixtime:
# Convert UNIX timestamp to datetime
dt = datetime.fromtimestamp(unixtime)
time_str = current.get("time") # ISO 8601, local time
temp_c = current.get("temperature")
tz = data.get("timezone", "Unknown")
if time_str:
return {
"datetime": dt.isoformat(),
"timezone": timezone,
"temperature": temperature
"datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
"timezone": tz,
"temperature": temp_c
}
except Exception as e:
logging.warning(f"Time fetch error: {e}")
logging.warning(f"Weather fetch error: {e}")
return None
async def fetch_time_by_zone(session: aiohttp.ClientSession, zone: str) -> dict | None:
"""Get current time for a named timezone using Open-Meteo."""
# Open-Meteo doesn't have named timezone endpoint, need to geocode a representative city
# Fallback to worldtimeapi.org for IANA zones
url = f"http://worldtimeapi.org/api/timezone/{zone}"
try:
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
return await resp.json()
except Exception as e:
logging.warning(f"Timezone API error: {e}")
return None
# -------------------------------------------------------------------
# Main resolver
# -------------------------------------------------------------------
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
"""Main resolution: geocode any city, then get time."""
query = query.strip().lower()
"""Return (data_dict, display_name) or (None, error_message)."""
query = query.strip()
# Check if it's an IANA zone (contains '/')
if '/' in query or query in ("utc", "gmt"):
data = await fetch_time_by_zone(session, query)
# 1. Try as IANA zone (offline, always works)
if '/' in query or query.lower() in ("utc", "gmt"):
data = _get_time_for_iana_zone(query)
if data:
return data, query.upper()
return None, f"Timezone '{query}' not found"
else:
return None, f"Timezone '{html_escape(query)}' not recognised."
# Geocode the city (no hardcoding!)
geocode_result = await geocode_city(session, query)
# 2. Otherwise geocode as a city name
geocode_result = await _geocode_city(session, query)
if not geocode_result:
return None, f"Could not find city '{query}'. Try being more specific."
return None, f"Could not find city '{html_escape(query)}'. Try a more specific name or use an IANA zone."
lat, lon, display_name = geocode_result
weather_data = await _fetch_weather(session, lat, lon)
if weather_data:
return weather_data, display_name
return None, f"Could not fetch time/weather for '{html_escape(display_name)}'."
# Get time from coordinates
data = await fetch_time_by_coords(session, lat, lon)
if not data:
return None, f"Could not get time for '{display_name}'"
return data, display_name
def format_response(data: dict, display_name: str) -> str:
"""Format time data into HTML."""
# -------------------------------------------------------------------
# Formatting uses shared code_block from common.py
# -------------------------------------------------------------------
def _format_time_output(data: dict, display_name: str) -> str:
"""Convert time data into a code block via the shared formatter."""
raw_time = data.get("datetime", "")
local_time = format_ampm(raw_time) if raw_time else "Unknown"
tz = data.get("timezone", "Unknown")
# Convert ISO string to AM/PM format
try:
if '+' in raw_time:
raw_time = raw_time.split('+')[0]
dt = datetime.fromisoformat(raw_time)
local_time = dt.strftime("%I:%M:%S %p").lstrip("0")
except Exception:
local_time = raw_time
tz_display = data.get("timezone", "Unknown")
temp = data.get("temperature")
temp_str = f"<br>🌡️ <strong>Temperature:</strong> {temp}°C" if temp is not None else ""
if temp is not None:
temp_f = round(temp * 9/5 + 32, 1)
temp_str = f"{temp:.1f}°C / {temp_f:.1f}°F"
else:
temp_str = "N/A"
return f"""
<details>
<summary><strong>🕒 Time in {display_name}</strong></summary>
<p>
📍 <strong>Timezone:</strong> {tz}<br>
📅 <strong>Local time:</strong> {local_time}{temp_str}
</p>
</details>
"""
rows = [
("🌐", "Location", display_name),
("🕒", "Local Time", local_time),
("📅", "Timezone", tz_display),
("🌡️", "Temperature", temp_str),
]
# Wrap rows in a single section with no title (title is part of code_block's main title)
sections = [{"title": "", "rows": rows}]
return code_block("🕒 Time Info", sections)
def help_text() -> str:
return """
# -------------------------------------------------------------------
# Help
# -------------------------------------------------------------------
_HELP_MD = """
<details>
<summary><strong>🕒 Time Plugin Help</strong></summary>
<p>
<strong>!time &lt;any city&gt;</strong> Get current time for ANY city worldwide<br>
<strong>!time &lt;IANA zone&gt;</strong> e.g., Europe/London, Asia/Karachi<br>
<strong>!time help</strong> Show this help<br><br>
<p><strong>!time &lt;any city&gt;</strong> Get current time for ANY city worldwide<br>
<strong>!time &lt;IANA zone&gt;</strong> e.g., <code>Europe/London</code>, <code>Asia/Karachi</code><br>
<strong>!time help</strong> Show this help<br>
<strong>Examples:</strong><br>
<code>!time Lahore</code><br>
<code>!time New York</code><br>
<code>!time Paris</code><br>
<code>!time Asia/Karachi</code><br><br>
<em>No city names are hardcoded. The bot uses Open-Meteo's geocoding API.</em>
<code>!time Europe/London</code><br>
<em>No city names are hardcoded. IANA zones work completely offline.</em>
</p>
</details>
"""
# -------------------------------------------------------------------
# Plugin lifecycle
# -------------------------------------------------------------------
def setup(bot):
logging.info("Time plugin (zero hardcoded cities) loaded.")
logging.info("Time plugin (offline IANA zones + OpenMeteo cities) loaded.")
async def handle_command(room, message, bot, prefix, config):
import simplematrixbotlib as botlib
match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")):
return
args = match.args()
if not args or args[0].lower() == "help":
await bot.api.send_markdown_message(room.room_id, help_text())
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
return
query = " ".join(args).strip()
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {query}...")
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {html_escape(query)}...")
async with aiohttp.ClientSession() as session:
data, display = await resolve_time(session, query)
if data is None:
await bot.api.send_text_message(room.room_id, f"{display}")
return
await bot.api.send_markdown_message(room.room_id, format_response(data, display))
block = _format_time_output(data, display)
output = collapsible_summary(f"🕒 Time in {html_escape(display)}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info(f"Time sent for {query}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.2"
__author__ = "Funguy Bot"
__description__ = "World clock (no hardcoded cities)"
__help__ = """
<details>
<summary><strong>!time</strong> Current time for any city</summary>
<ul>
<li><code>!time &lt;city&gt;</code> Geocode any city (free Open-Meteo API)</li>
<li><code>!time &lt;IANA zone&gt;</code> e.g., <code>Europe/London</code></li>
</ul>
<p>Also shows current temperature if available.</p>
</details>
"""
__description__ = "World clock (offline IANA zones + free geocoding)"
__help__ = _HELP_MD
+1 -1
View File
@@ -87,6 +87,6 @@ async def handle_command(room, message, bot, prefix, config):
__version__ = "1.0.1"
__author__ = "Funguy Bot"
__description__ = "Urban Dictionary definitions (async)"
__description__ = "Urban Dictionary definitions"
__help__ = """<details><summary><strong>!ud</strong> Urban Dictionary</summary>
<ul><li><code>!ud</code> random, <code>!ud &lt;term&gt;</code> top, <code>!ud &lt;term&gt; &lt;index&gt;</code></li></ul></details>"""
-59
View File
@@ -1,59 +0,0 @@
"""
Security utilities for Funguy Bot plugins.
"""
import ipaddress
import socket
import logging
logger = logging.getLogger("security_utils")
# Networks considered unsafe for outbound connections
PRIVATE_RANGES = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'), # linklocal
ipaddress.ip_network('0.0.0.0/8'), # "this" network
ipaddress.ip_network('::1/128'), # IPv6 loopback
ipaddress.ip_network('fc00::/7'), # unique local
ipaddress.ip_network('fe80::/10'), # linklocal
ipaddress.ip_network('::/128'), # unspecified
]
def is_public_destination(target: str) -> bool:
"""
Returns True if `target` (hostname or IP) does NOT resolve to any
private, loopback, or linklocal address.
"""
try:
# Try parsing as an IP address first
addr = ipaddress.ip_address(target)
if any(addr in net for net in PRIVATE_RANGES):
return False
return True
except ValueError:
pass
# Resolve hostname to IPs
try:
addrinfo = socket.getaddrinfo(target, None)
for _, _, _, _, sockaddr in addrinfo:
ip = sockaddr[0]
addr = ipaddress.ip_address(ip)
if any(addr in net for net in PRIVATE_RANGES):
return False
return True
except Exception as e:
logger.warning(f"Cannot resolve {target}: {e}")
return False
# ---------------------------------------------------------------------------
# Noop command handler prevents bot crash because funguy.py calls
# handle_command() on every module in the plugins directory.
# ---------------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
"""This module is not a command plugin; ignore all messages."""
pass
+86 -132
View File
@@ -1,11 +1,6 @@
"""
Weather plugin primary: OpenWeatherMap, fallback: OpenMeteo.
Uses OpenWeatherMap when a valid API key is present and the request succeeds.
Falls back to OpenMeteo (no key required) otherwise.
Commands:
!weather <location> e.g. !weather London or !weather "New York,US"
Outputs a formatted code block with emojis and perfectly aligned columns.
"""
import logging
@@ -14,56 +9,14 @@ import aiohttp
import simplematrixbotlib as botlib
from dotenv import load_dotenv
from urllib.parse import quote
# ---------------------------------------------------------------------------
# Load .env (for OPENWEATHER_API_KEY)
# ---------------------------------------------------------------------------
plugin_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(plugin_dir)
dotenv_path = os.path.join(parent_dir, ".env")
load_dotenv(dotenv_path)
from plugins.common import html_escape, collapsible_summary, code_block
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
# ---------------------------------------------------------------------------
# WMO codes → description + emoji (for OpenMeteo)
# ---------------------------------------------------------------------------
WMO_CODES = {
0: ("Clear sky", "☀️"),
1: ("Mainly clear", "🌤️"),
2: ("Partly cloudy", ""),
3: ("Overcast", "☁️"),
45: ("Fog", "🌫️"),
48: ("Depositing rime fog", "🌫️"),
51: ("Light drizzle", "🌦️"),
53: ("Moderate drizzle", "🌦️"),
55: ("Dense drizzle", "🌧️"),
56: ("Light freezing drizzle", "🌧️"),
57: ("Dense freezing drizzle", "🌧️"),
61: ("Slight rain", "🌧️"),
63: ("Moderate rain", "🌧️"),
65: ("Heavy rain", "🌧️"),
66: ("Light freezing rain", "🌧️"),
67: ("Heavy freezing rain", "🌧️"),
71: ("Slight snow fall", "❄️"),
73: ("Moderate snow fall", "❄️"),
75: ("Heavy snow fall", "❄️"),
77: ("Snow grains", "❄️"),
80: ("Slight rain showers", "🌦️"),
81: ("Moderate rain showers", "🌧️"),
82: ("Violent rain showers", "🌧️"),
85: ("Slight snow showers", "🌨️"),
86: ("Heavy snow showers", "🌨️"),
95: ("Thunderstorm", "⛈️"),
96: ("Thunderstorm with slight hail", "⛈️"),
99: ("Thunderstorm with heavy hail", "⛈️"),
}
# ---------------------------------------------------------------------------
# Primary: OpenWeatherMap
# OpenWeatherMap helpers
# ---------------------------------------------------------------------------
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None:
"""Fetch current weather from OpenWeatherMap. Returns None on failure."""
if not OPENWEATHER_API_KEY:
logging.info("OpenWeatherMap key missing, skipping primary")
return None
@@ -72,7 +25,7 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
params = {
"q": location,
"appid": OPENWEATHER_API_KEY,
"units": "metric", # Celsius
"units": "metric",
}
try:
async with session.get(url, params=params, timeout=10) as resp:
@@ -83,46 +36,10 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
logging.warning(f"OpenWeatherMap request error: {e}")
return None
def format_openweathermap(data: dict) -> str:
"""Build the one-line weather message from OpenWeatherMap data."""
city = data.get("name", "Unknown")
sys_data = data.get("sys", {})
country = sys_data.get("country", "")
main_data = data.get("main", {})
temp_c = main_data.get("temp", 0)
temp_f = round(temp_c * 9 / 5 + 32, 1)
humidity = main_data.get("humidity", 0)
weather_list = data.get("weather", [])
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
emoji = "🌡️"
if weather_list:
wmain = weather_list[0].get("main", "")
emoji = {
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
}.get(wmain, "🌡️")
wind = data.get("wind", {}).get("speed", 0)
return (
f"<strong>[{emoji} Weather for {city}, {country}]</strong>: "
f"<strong>Condition:</strong> {description} | "
f"<strong>Temperature:</strong> {temp_c:.1f}°C ({temp_f:.1f}°F) | "
f"<strong>Humidity:</strong> {humidity}% | "
f"<strong>Wind Speed:</strong> {wind} m/s"
)
# ---------------------------------------------------------------------------
# Fallback: OpenMeteo (no key, free)
# OpenMeteo helpers (fallback)
# ---------------------------------------------------------------------------
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
"""Geocode a city name via OpenMeteo. Returns location info dict or None."""
url = "https://geocoding-api.open-meteo.com/v1/search"
params = {"name": location, "count": 1, "language": "en"}
try:
@@ -144,10 +61,7 @@ async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict |
logging.warning(f"OpenMeteo geocode error: {e}")
return None
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
timezone: str = "auto") -> dict | None:
"""Fetch current weather from OpenMeteo. Returns JSON or None."""
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float, timezone: str = "auto") -> dict | None:
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
@@ -165,35 +79,82 @@ async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
logging.warning(f"OpenMeteo weather error: {e}")
return None
# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
def format_openweathermap(data: dict) -> str:
"""Build a code block from OpenWeatherMap response."""
city = data.get("name", "Unknown")
sys_data = data.get("sys", {})
country = sys_data.get("country", "")
main = data.get("main", {})
temp_c = main.get("temp", 0)
temp_f = round(temp_c * 9 / 5 + 32, 1)
humidity = main.get("humidity", 0)
wind_speed = data.get("wind", {}).get("speed", 0)
weather_list = data.get("weather", [])
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
emoji_map = {
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
}
main_weather = weather_list[0].get("main", "") if weather_list else ""
weather_emoji = emoji_map.get(main_weather, "🌡️")
location = f"{city}, {country}" if country else city
rows = [
("🌍", "Location", location),
(weather_emoji, "Condition", description),
("🌡️", "Temperature", f"{temp_c:.1f}°C / {temp_f:.1f}°F"),
("💧", "Humidity", f"{humidity}%"),
("💨", "Wind Speed", f"{wind_speed} m/s"),
]
sections = [{"title": "", "rows": rows}]
return code_block(f"🌤️ Weather for {location}", sections)
def format_meteo(loc_info: dict, weather_data: dict) -> str:
"""Format OpenMeteo result into the same oneline style."""
"""Build a code block from OpenMeteo response."""
c = weather_data["current_weather"]
code = c["weathercode"]
desc, emoji = WMO_CODES.get(code, ("Unknown", "🌡️"))
wmo_emoji = {
0: ("Clear sky", "☀️"),
1: ("Mainly clear", "🌤️"),
2: ("Partly cloudy", ""),
3: ("Overcast", "☁️"),
45: ("Fog", "🌫️"),
51: ("Light drizzle", "🌦️"),
61: ("Slight rain", "🌧️"),
63: ("Moderate rain", "🌧️"),
71: ("Slight snow", "❄️"),
95: ("Thunderstorm", "⛈️"),
}
desc, emoji = wmo_emoji.get(code, ("Unknown", "🌡️"))
city = loc_info["name"]
country = loc_info.get("country", "")
state = loc_info.get("state", "")
# Build location string
parts = [city]
if state and state != city:
parts.append(state)
if country:
parts.append(country)
loc_str = ", ".join(parts)
location_parts = [loc_info["name"]]
if loc_info.get("state") and loc_info["state"] != loc_info["name"]:
location_parts.append(loc_info["state"])
if loc_info.get("country"):
location_parts.append(loc_info["country"])
location = ", ".join(location_parts)
temp_f = c["temperature"]
temp_c = round((temp_f - 32) * 5 / 9, 1)
wind = c["windspeed"]
wind = c["windspeed"] # mph
return (
f"<strong>[{emoji} Weather for {loc_str}]</strong>: "
f"<strong>Condition:</strong> {desc} | "
f"<strong>Temperature:</strong> {temp_c}°C ({temp_f}°F) | "
f"<strong>Wind Speed:</strong> {wind} mph"
)
rows = [
("🌍", "Location", location),
(emoji, "Condition", desc),
("🌡️", "Temperature", f"{temp_c}°C / {temp_f}°F"),
("💨", "Wind Speed", f"{wind} mph"),
]
sections = [{"title": "", "rows": rows}]
return code_block(f"🌤️ Weather for {location}", sections)
# ---------------------------------------------------------------------------
@@ -218,14 +179,11 @@ async def handle_command(room, message, bot, prefix, config):
async with aiohttp.ClientSession() as session:
# 1. Try OpenWeatherMap
owm_data = await openweathermap_get(session, location)
if owm_data:
if owm_data.get("cod") == 200:
msg = format_openweathermap(owm_data)
await bot.api.send_markdown_message(room.room_id, msg)
logging.info("Sent weather via OpenWeatherMap")
return
# OpenWeatherMap returned an error status inside JSON (e.g., 401, 404)
logging.info("OpenWeatherMap returned error code %s, falling back", owm_data.get("cod"))
if owm_data and owm_data.get("cod") == 200:
block = format_openweathermap(owm_data)
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
await bot.api.send_markdown_message(room.room_id, output)
return
# 2. Fallback: OpenMeteo
logging.info("Falling back to OpenMeteo")
@@ -233,7 +191,7 @@ async def handle_command(room, message, bot, prefix, config):
if not loc_info:
await bot.api.send_text_message(
room.room_id,
f"Location '{location}' not found."
f"Location '{html_escape(location)}' not found."
)
return
@@ -247,28 +205,24 @@ async def handle_command(room, message, bot, prefix, config):
)
return
msg = format_meteo(loc_info, wdata)
await bot.api.send_markdown_message(room.room_id, msg)
block = format_meteo(loc_info, wdata)
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
await bot.api.send_markdown_message(room.room_id, output)
logging.info("Sent weather via OpenMeteo (fallback)")
# ---------------------------------------------------------------------------
# Plugin setup
# ---------------------------------------------------------------------------
def setup(bot):
logging.info("Weather plugin loaded (OpenWeatherMap + OpenMeteo fallback)")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.1"
__author__ = "Funguy Bot"
__description__ = "Weather forecast (OWM primary, OpenMeteo fallback)"
__description__ = "Weather data plugin"
__help__ = """
<details>
<summary><strong>!weather</strong> Current weather</summary>
<p><code>!weather &lt;location&gt;</code> Shows temperature, conditions, humidity, wind.<br>
Uses OpenWeatherMap if a valid API key is present; falls back to free OpenMeteo otherwise.</p>
<p><code>!weather &lt;location&gt;</code> Shows temperature, conditions, humidity, wind in a clean, aligned table. Uses OpenWeatherMap primary, OpenMeteo fallback.</p>
</details>
"""
+69 -158
View File
@@ -1,219 +1,130 @@
"""
This plugin provides WHOIS lookup functionality for domains, IPs, and related network information.
WHOIS lookup plugin outputs a formatted code block with emojis and aligned columns.
"""
import logging
import whois
import ipaddress
import re
import asyncio
import simplematrixbotlib as botlib
from plugins.common import collapsible_summary, html_escape, code_block
def is_valid_domain(domain):
"""
Validate if the provided string is a valid domain name.
Args:
domain (str): The domain to validate.
Returns:
bool: True if valid, False otherwise.
"""
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9-]{1,63}$'
return re.match(pattern, domain) is not None
def is_valid_ip(ip):
"""
Validate if the provided string is a valid IPv4 or IPv6 address.
Args:
ip (str): The IP address to validate.
Returns:
bool: True if valid, False otherwise.
"""
try:
ipaddress.ip_address(ip)
return True
except ValueError:
return False
def _build_rows(data):
"""Build a list of (emoji, label, value) tuples from WHOIS data."""
rows = []
def format_whois_data(domain, data):
"""
Format WHOIS data into a readable format.
# Domain
domain_name = data.domain_name
if isinstance(domain_name, list):
domain_name = ', '.join(domain_name)
rows.append(('🌐', 'Domain', domain_name or 'N/A'))
Args:
domain (str): The queried domain/IP.
data (whois domain object): The WHOIS data object.
Returns:
str: Formatted HTML message.
"""
sections = []
# Domain/Query Information
if hasattr(data, 'domain_name') or hasattr(data, 'query'):
domain_names = getattr(data, 'domain_name', domain)
if isinstance(domain_names, list):
domain_names = ', '.join(domain_names)
sections.append(f"<strong>🔍 Query:</strong> {domain_names}")
# Registrar Information
registrar_items = []
if hasattr(data, 'registrar'):
registrar_items.append(f"<strong>Registrar:</strong> {data.registrar}")
if hasattr(data, 'whois_server'):
registrar_items.append(f"<strong>WHOIS Server:</strong> {data.whois_server}")
if registrar_items:
sections.append('<br>'.join(registrar_items))
# Registrar / WHOIS Server
if data.registrar:
rows.append(('🏢', 'Registrar', data.registrar))
if data.whois_server:
rows.append(('📡', 'WHOIS Server', data.whois_server))
# Dates
date_items = []
if hasattr(data, 'creation_date'):
creation = data.creation_date
if isinstance(creation, list):
creation = creation[0]
date_items.append(f"<strong>Created:</strong> {creation}")
creation_date = data.creation_date
if creation_date:
if isinstance(creation_date, list):
creation_date = creation_date[0]
rows.append(('📅', 'Created', str(creation_date)))
if hasattr(data, 'updated_date'):
updated = data.updated_date
if isinstance(updated, list):
updated = updated[0]
date_items.append(f"<strong>Updated:</strong> {updated}")
updated_date = data.updated_date
if updated_date:
if isinstance(updated_date, list):
updated_date = updated_date[0]
rows.append(('📝', 'Updated', str(updated_date)))
if hasattr(data, 'expiration_date'):
expiration = data.expiration_date
if isinstance(expiration, list):
expiration = expiration[0]
date_items.append(f"<strong>Expires:</strong> {expiration}")
expiration_date = data.expiration_date
if expiration_date:
if isinstance(expiration_date, list):
expiration_date = expiration_date[0]
rows.append(('', 'Expires', str(expiration_date)))
if date_items:
sections.append('<br>'.join(date_items))
# Name servers
if data.name_servers:
ns_sorted = sorted(data.name_servers)
ns_text = ', '.join(ns_sorted[:5])
if len(ns_sorted) > 5:
ns_text += f' (+{len(ns_sorted)-5} more)'
rows.append(('🌍', 'Name Servers', ns_text))
# Status
if hasattr(data, 'status'):
if data.status:
status = data.status
if isinstance(status, list):
status = '<br>'.join(status[:3]) # Limit to first 3 status entries
sections.append(f"<strong>Status:</strong><br>{status}")
status = ', '.join(status[:3])
rows.append(('🔒', 'Status', str(status)))
# Name Servers
if hasattr(data, 'name_servers'):
name_servers = data.name_servers
if isinstance(name_servers, list):
if len(name_servers) > 5:
name_servers_list = '<br>'.join(sorted(name_servers)[:5])
name_servers_list += f"<br><em>...(+{len(name_servers) - 5} more)</em>"
else:
name_servers_list = '<br>'.join(sorted(name_servers))
else:
name_servers_list = str(name_servers)
sections.append(f"<strong>Name Servers:</strong><br>{name_servers_list}")
# Contact Information
contact_items = []
if hasattr(data, 'org'):
contact_items.append(f"<strong>Organization:</strong> {data.org}")
if hasattr(data, 'country'):
contact_items.append(f"<strong>Country:</strong> {data.country}")
if hasattr(data, 'state'):
contact_items.append(f"<strong>State:</strong> {data.state}")
if hasattr(data, 'city'):
contact_items.append(f"<strong>City:</strong> {data.city}")
if contact_items:
sections.append('<br>'.join(contact_items))
# Build the final message
if sections:
content = f"<strong>🌐 WHOIS Report: {domain}</strong><br><br>"
content += '<br><br>'.join(sections)
else:
content = f"<strong>🌐 WHOIS Information for {domain}</strong><br><br>"
content += "<em>No detailed information available or query returned minimal data.</em>"
# Wrap in collapsible details block for Matrix compatibility
message = f"<details><summary><strong>🌐 WHOIS Report: {domain} (Click to expand)</strong></summary>{content}</details>"
return message
# Contact info
if data.org:
rows.append(('🏛️', 'Organization', data.org))
if data.country:
rows.append(('🌍', 'Country', data.country))
if data.state:
rows.append(('🏙️', 'State', data.state))
if data.city:
rows.append(('🏡', 'City', data.city))
return rows
async def handle_command(room, message, bot, prefix, config):
"""
Function to handle the !whois command.
Args:
room (Room): The Matrix room where the command was invoked.
message (RoomMessage): The message object containing the command.
bot (Bot): The bot object.
prefix (str): The command prefix.
config (dict): Configuration parameters.
Returns:
None
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.is_not_from_this_bot() and match.prefix() and match.command("whois"):
args = match.args()
if len(args) < 1:
await bot.api.send_text_message(
room.room_id,
"Usage: !whois <domain/ip>\nExample: !whois example.com\nExample: !whois 8.8.8.8"
)
await bot.api.send_text_message(room.room_id, "Usage: !whois <domain/ip>\nExample: !whois example.com")
return
query = args[0].strip()
logging.info(f"Received !whois command for: {query}")
# Validate the query
if not is_valid_domain(query) and not is_valid_ip(query):
await bot.api.send_text_message(
room.room_id,
f"Invalid domain or IP address format: {query}\nPlease provide a valid domain (e.g., example.com) or IP address."
)
logging.warning(f"Invalid WHOIS query format: {query}")
await bot.api.send_text_message(room.room_id, f"Invalid input: {html_escape(query)}")
return
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {html_escape(query)}...")
try:
# Perform WHOIS lookup
logging.info(f"Performing WHOIS lookup for: {query}")
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {query}...")
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(None, whois.whois, query)
# Use python-whois library
whois_data = whois.whois(query)
# Format and send the results
result_message = format_whois_data(query, whois_data)
await bot.api.send_markdown_message(room.room_id, result_message)
logging.info(f"Successfully sent WHOIS results for {query}")
rows = _build_rows(data)
sections = [{"title": "", "rows": rows}] # no section header
block = code_block(f"🌐 WHOIS Report: {html_escape(query)}", sections)
output = collapsible_summary(f"🌐 WHOIS Report: {html_escape(query)}", block)
await bot.api.send_markdown_message(room.room_id, output)
except whois.parser.PywhoisError as e:
error_msg = f"WHOIS lookup failed for {query}.\n"
error_msg += "Possible reasons:\n- Domain/IP not found\n- WHOIS server unavailable\n- Rate limited by registrar"
await bot.api.send_text_message(room.room_id, error_msg)
logging.error(f"WHOIS lookup error for {query}: {e}")
await bot.api.send_text_message(room.room_id, f"WHOIS lookup failed: {html_escape(str(e))}")
except Exception as e:
await bot.api.send_text_message(
room.room_id,
f"An unexpected error occurred during WHOIS lookup for {query}. Please try again later."
)
logging.error(f"Unexpected error in WHOIS plugin for {query}: {e}", exc_info=True)
await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {html_escape(str(e))}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.2.1"
__author__ = "Funguy Bot"
__description__ = "WHOIS lookup"
__description__ = "Domain WHOIS lookup"
__help__ = """
<details>
<summary><strong>!whois</strong> WHOIS lookup</summary>
<p><code>!whois &lt;domain or IP&gt;</code> Shows registrar, creation/expiry dates, nameservers, contacts.</p>
<pre>
!whois &lt;domain or IP&gt; Shows registrar, dates, nameservers, etc. in a clean table.
</pre>
</details>
"""
+1 -1
View File
@@ -44,6 +44,6 @@ def generate_output(results):
__version__ = "1.0.1"
__author__ = "Funguy Bot"
__description__ = "YouTube video search (async)"
__description__ = "YouTube video search"
__help__ = """<details><summary><strong>!yt</strong> Search YouTube</summary>
<p><code>!yt &lt;search terms&gt;</code></p></details>"""
+9 -14
View File
@@ -1,23 +1,13 @@
simplematrixbotlib>=2.13.0
python-dotenv
requests
nio
markdown2
watchdog
emoji
python-slugify
youtube_title_parse
aiohttp
toml
dnspython
croniter
schedule
yt-dlp
pyopenssl
psutil
toml
python-whois
aiohttp
aiosqlite
pillow
omdbapi
apscheduler
pytz
ddgs
@@ -30,4 +20,9 @@ argon2-cffi
yara-python
asn1crypto
PyYAML
lxml
wcwidth
markdown
python-cryptography-fernet-wrapper
zstandard
requests
markdown2