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