Compare commits
9 Commits
a51f759259
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0765aaa9f7 | |||
| 15cf9e72bb | |||
| a1ce95f72f | |||
| 733e1b43c5 | |||
| c04e06e556 | |||
| b722a78d21 | |||
| 5c6234a317 | |||
| f822d6a450 | |||
| 52a9621d50 |
@@ -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.
|
## 📋 Table of Contents
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Automatic Installation
|
- [Features](#-features)
|
||||||
Run the installation script
|
- [Requirements](#-requirements)
|
||||||
1. `./install-funguy.sh`
|
- [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
|
- **Modular plugin system** – each command lives in its own `plugins/*.py` file; add or remove features without touching core code
|
||||||
`python3 -m venv venv`
|
- **Runtime plugin management** – load, unload, enable, or disable plugins per-room with no restart required
|
||||||
`source venv/bin/activate`
|
- **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
|
## 📦 Requirements
|
||||||
`cp api.py.patch simplematrixbotlib`
|
|
||||||
`git apply api.py.patch`
|
|
||||||
|
|
||||||
4. Install dependencies:
|
- Python 3.9+
|
||||||
`cd simplematrixbotlib && pip install .`
|
- A Matrix homeserver account for the bot
|
||||||
`cd ../ && pip install -r requirements.txt`
|
- `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`
|
## 🚀 Installation
|
||||||
`playwright install chromium`
|
|
||||||
|
|
||||||
6. Set up environment variables:
|
**1. Clone the repository**
|
||||||
Create/Edit `.env` file in the root directory of the bot and add the following variables:
|
|
||||||
|
|
||||||
```
|
```bash
|
||||||
MATRIX_URL="https://matrix.org" (or another homeserver)
|
git clone https://gitlab.com/Eggzy/funguybot.git
|
||||||
MATRIX_USER=""
|
cd funguybot
|
||||||
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=
|
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Create systemd.service
|
**2. Create and activate a Python virtual environment**
|
||||||
Create `/etc/systemd/system/funguybot.service`
|
|
||||||
Replace `$working_directory` with your bot install path
|
|
||||||
|
|
||||||
|
```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]
|
[Unit]
|
||||||
Description=Funguy Bot Service
|
Description=Funguy Bot Service
|
||||||
After=network.target
|
After=network.target
|
||||||
@@ -88,97 +159,706 @@ SyslogIdentifier=funguybot
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Launch Fungy
|
```bash
|
||||||
```
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable funguybot
|
systemctl enable funguybot
|
||||||
systemctl start funguybot
|
systemctl start funguybot
|
||||||
```
|
```
|
||||||
|
|
||||||
# 🍄 Funguy Bot Commands 🍄
|
### Direct Launch
|
||||||
|
|
||||||
## Available Plugins
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
The bot includes the following plugins:
|
python3 funguy.py
|
||||||
|
```
|
||||||
- **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*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
|
|
||||||
|
|
||||||
@@ -4,70 +4,52 @@
|
|||||||
Funguy Bot Class
|
Funguy Bot Class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Importing necessary libraries and modules
|
import os
|
||||||
import os # Operating System functions
|
import logging
|
||||||
import logging # Logging library for logging messages
|
import importlib
|
||||||
import importlib # Library for dynamically importing modules
|
import simplematrixbotlib as botlib
|
||||||
import simplematrixbotlib as botlib # Library for interacting with Matrix chat
|
from dotenv import load_dotenv
|
||||||
from dotenv import load_dotenv # Library for loading environment variables from a .env file
|
import time
|
||||||
import time # Time-related functions
|
import sys
|
||||||
import sys # System-specific parameters and functions
|
import toml
|
||||||
import toml # Library for parsing TOML configuration files
|
import socket
|
||||||
import socket # For network diagnostics
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
# Importing FunguyConfig class from plugins.config module
|
|
||||||
from plugins.config import FunguyConfig
|
from plugins.config import FunguyConfig
|
||||||
|
|
||||||
|
# Rate limiter settings
|
||||||
|
RATE_LIMIT_WINDOW = 15.0 # seconds
|
||||||
|
MAX_COMMANDS_PER_WINDOW = 3
|
||||||
|
|
||||||
|
|
||||||
class FunguyBot:
|
class FunguyBot:
|
||||||
"""
|
"""
|
||||||
A bot class for managing plugins and handling commands in a Matrix chat environment.
|
A bot class for managing plugins and handling commands in a Matrix chat environment.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
|
||||||
Constructor method for FunguyBot class.
|
|
||||||
"""
|
|
||||||
print("[INIT] Starting FunguyBot initialization...")
|
print("[INIT] Starting FunguyBot initialization...")
|
||||||
|
|
||||||
# Setting up instance variables
|
self.PLUGINS_DIR = "plugins"
|
||||||
self.PLUGINS_DIR = "plugins" # Directory where plugins are stored
|
self.PLUGINS = {}
|
||||||
self.PLUGINS = {} # Dictionary to store loaded plugins
|
self.config = None
|
||||||
self.config = None # Configuration object
|
self.bot = None
|
||||||
self.bot = None # Bot object
|
self.disabled_plugins = {}
|
||||||
self.disabled_plugins = {} # Dictionary to store disabled plugins for each room
|
|
||||||
|
|
||||||
print("[INIT] Loading environment variables...")
|
# Rate limiter state: {sender: [(timestamp, room_id), ...]}
|
||||||
self.load_dotenv() # Loading environment variables from .env file
|
self._rate_limit_buckets = defaultdict(list)
|
||||||
|
|
||||||
print("[INIT] Setting up logging...")
|
load_dotenv() # load once here
|
||||||
self.setup_logging() # Setting up logging configurations
|
self.setup_logging()
|
||||||
|
self.load_plugins()
|
||||||
print("[INIT] Loading plugins...")
|
self.load_config()
|
||||||
self.load_plugins() # Loading plugins
|
self.load_disabled_plugins()
|
||||||
|
|
||||||
print("[INIT] Loading config...")
|
|
||||||
self.load_config() # Loading bot configuration
|
|
||||||
|
|
||||||
print("[INIT] Loading disabled plugins...")
|
|
||||||
self.load_disabled_plugins() # Loading disabled plugins from configuration file
|
|
||||||
|
|
||||||
print("[INIT] FunguyBot initialization complete!")
|
print("[INIT] FunguyBot initialization complete!")
|
||||||
|
|
||||||
def load_dotenv(self):
|
|
||||||
"""
|
|
||||||
Method to load environment variables from a .env file.
|
|
||||||
"""
|
|
||||||
load_dotenv()
|
|
||||||
print("[ENV] Environment variables loaded")
|
|
||||||
|
|
||||||
def setup_logging(self):
|
def setup_logging(self):
|
||||||
"""
|
|
||||||
Method to configure logging settings.
|
|
||||||
"""
|
|
||||||
# Get log level from environment, default to INFO
|
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
# Convert string to logging constant
|
|
||||||
level_map = {
|
level_map = {
|
||||||
"DEBUG": logging.DEBUG,
|
"DEBUG": logging.DEBUG,
|
||||||
"INFO": logging.INFO,
|
"INFO": logging.INFO,
|
||||||
@@ -82,37 +64,23 @@ class FunguyBot:
|
|||||||
level=level
|
level=level
|
||||||
)
|
)
|
||||||
logging.getLogger().setLevel(level)
|
logging.getLogger().setLevel(level)
|
||||||
|
|
||||||
# Optionally silence noisy libraries
|
|
||||||
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
||||||
logging.getLogger("nio").setLevel(logging.WARNING)
|
logging.getLogger("nio").setLevel(logging.WARNING)
|
||||||
|
|
||||||
logging.info(f"Logging configured with level: {log_level}")
|
logging.info(f"Logging configured with level: {log_level}")
|
||||||
|
|
||||||
def load_plugins(self):
|
def load_plugins(self):
|
||||||
"""
|
|
||||||
Method to load plugins from the specified directory.
|
|
||||||
"""
|
|
||||||
# Iterating through files in the plugins directory
|
|
||||||
for plugin_file in os.listdir(self.PLUGINS_DIR):
|
for plugin_file in os.listdir(self.PLUGINS_DIR):
|
||||||
if plugin_file.endswith(".py"): # Checking if file is a Python file
|
if plugin_file.endswith(".py") and plugin_file != "__init__.py":
|
||||||
plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name
|
plugin_name = os.path.splitext(plugin_file)[0]
|
||||||
try:
|
try:
|
||||||
# Importing plugin module dynamically
|
|
||||||
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
||||||
self.PLUGINS[plugin_name] = module # Storing loaded plugin module
|
self.PLUGINS[plugin_name] = module
|
||||||
logging.info(f"Loaded plugin: {plugin_name}") # Logging successful plugin loading
|
logging.info(f"Loaded plugin: {plugin_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error loading plugin {plugin_name}: {e}") # Logging error if plugin loading fails
|
logging.error(f"Error loading plugin {plugin_name}: {e}")
|
||||||
|
|
||||||
def setup_plugins(self):
|
def setup_plugins(self):
|
||||||
"""
|
"""Call setup(bot) on any plugin that defines it, after self.bot exists."""
|
||||||
Method to call setup(bot) on any plugin that defines it.
|
|
||||||
|
|
||||||
This must be called AFTER self.bot is created (i.e. inside run()), so
|
|
||||||
that plugins which register custom event listeners (e.g. on_custom_event
|
|
||||||
for RoomMemberEvent) receive a valid bot instance.
|
|
||||||
"""
|
|
||||||
for plugin_name, plugin_module in self.PLUGINS.items():
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
||||||
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
||||||
try:
|
try:
|
||||||
@@ -122,107 +90,193 @@ class FunguyBot:
|
|||||||
logging.error(f"Error during setup of plugin {plugin_name}: {e}")
|
logging.error(f"Error during setup of plugin {plugin_name}: {e}")
|
||||||
|
|
||||||
def reload_plugins(self):
|
def reload_plugins(self):
|
||||||
"""
|
self.PLUGINS.clear()
|
||||||
Method to reload all plugins.
|
|
||||||
"""
|
|
||||||
self.PLUGINS = {} # Clearing loaded plugins dictionary
|
|
||||||
# Unloading modules from sys.modules
|
|
||||||
for plugin_name in list(sys.modules.keys()):
|
for plugin_name in list(sys.modules.keys()):
|
||||||
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
||||||
del sys.modules[plugin_name] # Deleting plugin module from system modules
|
del sys.modules[plugin_name]
|
||||||
self.load_plugins() # Reloading plugins
|
self.load_plugins()
|
||||||
# Re-run setup for any plugin that needs it (bot already exists at this point)
|
|
||||||
if self.bot is not None:
|
if self.bot is not None:
|
||||||
self.setup_plugins()
|
self.setup_plugins()
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
"""
|
self.config = FunguyConfig()
|
||||||
Method to load configuration settings.
|
|
||||||
"""
|
|
||||||
self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration
|
|
||||||
logging.info("Configuration loaded")
|
logging.info("Configuration loaded")
|
||||||
|
|
||||||
def load_disabled_plugins(self):
|
def load_disabled_plugins(self):
|
||||||
"""
|
|
||||||
Method to load disabled plugins from configuration file.
|
|
||||||
"""
|
|
||||||
# Checking if configuration file exists
|
|
||||||
if os.path.exists('funguy.conf'):
|
if os.path.exists('funguy.conf'):
|
||||||
# Loading configuration data from TOML file
|
|
||||||
with open('funguy.conf', 'r') as f:
|
with open('funguy.conf', 'r') as f:
|
||||||
config_data = toml.load(f)
|
config_data = toml.load(f)
|
||||||
# Extracting disabled plugins from configuration data
|
|
||||||
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
|
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
|
||||||
|
|
||||||
def save_disabled_plugins(self):
|
def save_disabled_plugins(self):
|
||||||
"""
|
|
||||||
Method to save disabled plugins to configuration file.
|
|
||||||
"""
|
|
||||||
existing_config = {}
|
existing_config = {}
|
||||||
# Checking if configuration file exists
|
|
||||||
if os.path.exists('funguy.conf'):
|
if os.path.exists('funguy.conf'):
|
||||||
# Loading existing configuration data
|
|
||||||
with open('funguy.conf', 'r') as f:
|
with open('funguy.conf', 'r') as f:
|
||||||
existing_config = toml.load(f)
|
existing_config = toml.load(f)
|
||||||
# Updating configuration data with disabled plugins
|
|
||||||
existing_config['plugins'] = {'disabled': self.disabled_plugins}
|
existing_config['plugins'] = {'disabled': self.disabled_plugins}
|
||||||
# Writing updated configuration data back to file
|
|
||||||
with open('funguy.conf', 'w') as f:
|
with open('funguy.conf', 'w') as f:
|
||||||
toml.dump(existing_config, f)
|
toml.dump(existing_config, f)
|
||||||
|
|
||||||
|
def _check_rate_limit(self, sender: str) -> bool:
|
||||||
|
"""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):
|
async def handle_commands(self, room, message):
|
||||||
"""
|
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix)
|
||||||
Method to handle incoming commands and dispatch them to appropriate plugins.
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) # Matching message against bot's prefix
|
|
||||||
|
|
||||||
# Reloading plugins command
|
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,
|
||||||
|
"⛔ You're sending commands too quickly. Please wait a few seconds."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Admin commands
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
|
||||||
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
if is_admin:
|
||||||
self.reload_plugins() # Reloading plugins
|
self.reload_plugins()
|
||||||
await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully") # Sending success message
|
await self.bot.api.send_text_message(room.room_id, "All plugins reloaded successfully.")
|
||||||
else:
|
else:
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.") # Sending unauthorized message
|
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
|
return
|
||||||
|
|
||||||
# Disable plugin command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
|
||||||
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
if not is_admin:
|
||||||
args = match.args() # Getting command arguments
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
if len(args) != 2: # Checking if correct number of arguments provided
|
return
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>") # Sending usage message
|
args = match.args()
|
||||||
else:
|
if len(args) != 1:
|
||||||
plugin_name, room_id = args # Extracting plugin name and room ID
|
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin>")
|
||||||
await self.disable_plugin(room_id, plugin_name) # Disabling plugin
|
return
|
||||||
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'") # Sending success message
|
plugin_name = args[0]
|
||||||
else:
|
room_id = room.room_id
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.") # Sending unauthorized message
|
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
|
return
|
||||||
|
|
||||||
# Enable plugin command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
|
||||||
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
if not is_admin:
|
||||||
args = match.args() # Getting command arguments
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
if len(args) != 2: # Checking if correct number of arguments provided
|
return
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>") # Sending usage message
|
args = match.args()
|
||||||
else:
|
if len(args) != 1:
|
||||||
plugin_name, room_id = args # Extracting plugin name and room ID
|
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin>")
|
||||||
await self.enable_plugin(room_id, plugin_name) # Enabling plugin
|
return
|
||||||
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'") # Sending success message
|
plugin_name = args[0]
|
||||||
else:
|
room_id = room.room_id
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.") # Sending unauthorized message
|
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
|
return
|
||||||
|
|
||||||
# Rehash config command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
|
||||||
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
|
if not is_admin:
|
||||||
self.rehash_config() # Rehashing configuration
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload config.")
|
||||||
await self.bot.api.send_text_message(room.room_id, "Config rehashed") # Sending success message
|
return
|
||||||
else:
|
self.load_config()
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.") # Sending unauthorized message
|
self.load_disabled_plugins()
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "🔄 Configuration rehashed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Dispatching commands to plugins
|
# Dispatch to active plugins
|
||||||
for plugin_name, plugin_module in self.PLUGINS.items():
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
||||||
if plugin_name not in self.disabled_plugins.get(room.room_id, []):
|
if plugin_name not in self.disabled_plugins.get(room.room_id, []):
|
||||||
try:
|
try:
|
||||||
@@ -230,47 +284,27 @@ class FunguyBot:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True)
|
logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True)
|
||||||
|
|
||||||
def rehash_config(self):
|
|
||||||
"""
|
|
||||||
Method to rehash the configuration settings.
|
|
||||||
"""
|
|
||||||
del self.config # Deleting current configuration object
|
|
||||||
self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration
|
|
||||||
|
|
||||||
async def disable_plugin(self, room_id, plugin_name):
|
async def disable_plugin(self, room_id, plugin_name):
|
||||||
"""
|
|
||||||
Method to disable a plugin for a specific room.
|
|
||||||
"""
|
|
||||||
if room_id not in self.disabled_plugins:
|
if room_id not in self.disabled_plugins:
|
||||||
self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist
|
self.disabled_plugins[room_id] = []
|
||||||
if plugin_name not in self.disabled_plugins[room_id]:
|
if plugin_name not in self.disabled_plugins[room_id]:
|
||||||
self.disabled_plugins[room_id].append(plugin_name) # Adding plugin to list of disabled plugins for the room
|
self.disabled_plugins[room_id].append(plugin_name)
|
||||||
self.save_disabled_plugins() # Saving disabled plugins to configuration file
|
self.save_disabled_plugins()
|
||||||
|
|
||||||
async def enable_plugin(self, room_id, plugin_name):
|
async def enable_plugin(self, room_id, plugin_name):
|
||||||
"""
|
|
||||||
Method to enable a plugin for a specific room.
|
|
||||||
"""
|
|
||||||
if room_id in self.disabled_plugins and plugin_name in self.disabled_plugins[room_id]:
|
if room_id in self.disabled_plugins and plugin_name in self.disabled_plugins[room_id]:
|
||||||
self.disabled_plugins[room_id].remove(plugin_name) # Removing plugin from list of disabled plugins for the room
|
self.disabled_plugins[room_id].remove(plugin_name)
|
||||||
self.save_disabled_plugins() # Saving disabled plugins to configuration file
|
self.save_disabled_plugins()
|
||||||
|
|
||||||
def test_connectivity(self, hostname, port=443):
|
def test_connectivity(self, hostname, port=443):
|
||||||
"""
|
|
||||||
Test network connectivity to Matrix server.
|
|
||||||
"""
|
|
||||||
logging.info(f"Testing connectivity to {hostname}:{port}...")
|
logging.info(f"Testing connectivity to {hostname}:{port}...")
|
||||||
try:
|
try:
|
||||||
# Test DNS resolution
|
|
||||||
ip_address = socket.gethostbyname(hostname)
|
ip_address = socket.gethostbyname(hostname)
|
||||||
logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}")
|
logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}")
|
||||||
|
|
||||||
# Test socket connection
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(10)
|
sock.settimeout(10)
|
||||||
result = sock.connect_ex((hostname, port))
|
result = sock.connect_ex((hostname, port))
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
if result == 0:
|
if result == 0:
|
||||||
logging.info(f"✓ Socket connection successful to {hostname}:{port}")
|
logging.info(f"✓ Socket connection successful to {hostname}:{port}")
|
||||||
return True
|
return True
|
||||||
@@ -285,46 +319,29 @@ class FunguyBot:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
|
||||||
Method to initialize and run the bot.
|
|
||||||
"""
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("FUNGUY BOT - STARTING")
|
print("FUNGUY BOT - STARTING")
|
||||||
print("="*60 + "\n")
|
print("="*60 + "\n")
|
||||||
|
|
||||||
# Retrieving Matrix credentials from environment variables
|
|
||||||
MATRIX_URL = os.getenv("MATRIX_URL")
|
MATRIX_URL = os.getenv("MATRIX_URL")
|
||||||
MATRIX_USER = os.getenv("MATRIX_USER")
|
MATRIX_USER = os.getenv("MATRIX_USER")
|
||||||
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
||||||
|
|
||||||
# Validate credentials
|
if not MATRIX_URL or not MATRIX_USER or not MATRIX_PASS:
|
||||||
if not MATRIX_URL:
|
logging.error("Missing MATRIX_URL / MATRIX_USER / MATRIX_PASS in .env")
|
||||||
logging.error("MATRIX_URL not set in .env file")
|
|
||||||
return
|
|
||||||
if not MATRIX_USER:
|
|
||||||
logging.error("MATRIX_USER not set in .env file")
|
|
||||||
return
|
|
||||||
if not MATRIX_PASS:
|
|
||||||
logging.error("MATRIX_PASS not set in .env file")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info(f"Matrix URL: {MATRIX_URL}")
|
logging.info(f"Matrix URL: {MATRIX_URL}")
|
||||||
logging.info(f"Matrix User: {MATRIX_USER}")
|
logging.info(f"Matrix User: {MATRIX_USER}")
|
||||||
|
|
||||||
# Extract hostname from URL for connectivity test
|
|
||||||
hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0]
|
hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0]
|
||||||
|
|
||||||
# Test connectivity before attempting to connect
|
|
||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
logging.info("RUNNING NETWORK DIAGNOSTICS")
|
logging.info("RUNNING NETWORK DIAGNOSTICS")
|
||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
|
|
||||||
if not self.test_connectivity(hostname, 443):
|
if not self.test_connectivity(hostname, 443):
|
||||||
logging.error("Connectivity test failed. Please check:")
|
logging.error("Connectivity test failed. See above messages.")
|
||||||
logging.error(" 1. Your internet connection")
|
|
||||||
logging.error(" 2. Firewall settings (outbound port 443)")
|
|
||||||
logging.error(" 3. DNS resolution")
|
|
||||||
logging.error(f" 4. If {hostname} is accessible")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
@@ -332,69 +349,46 @@ class FunguyBot:
|
|||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logging.info(f"Creating credentials object for {MATRIX_USER}...")
|
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS)
|
||||||
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object
|
self.bot = botlib.Bot(creds, self.config)
|
||||||
logging.info("✓ Credentials object created")
|
|
||||||
|
|
||||||
logging.info("Creating bot instance...")
|
|
||||||
self.bot = botlib.Bot(creds, self.config) # Creating bot instance
|
|
||||||
logging.info("✓ Bot instance created")
|
|
||||||
|
|
||||||
# Check if async_client is available
|
|
||||||
if hasattr(self.bot, 'async_client'):
|
|
||||||
logging.info("✓ Async client available")
|
|
||||||
else:
|
|
||||||
logging.warning("⚠ Async client not yet available (will be created on login)")
|
|
||||||
|
|
||||||
logging.info("Calling setup_plugins()...")
|
|
||||||
# Call setup() on any plugin that defines it, now that self.bot exists.
|
|
||||||
self.setup_plugins()
|
self.setup_plugins()
|
||||||
logging.info("✓ Plugin setup complete")
|
|
||||||
|
|
||||||
# ----- NEW: Expose plugins dictionary on bot object -----
|
|
||||||
self.bot.plugins = self.PLUGINS
|
self.bot.plugins = self.PLUGINS
|
||||||
logging.info("✓ Plugin dictionary exposed on bot.plugins")
|
|
||||||
# --------------------------------------------------------
|
|
||||||
|
|
||||||
# Defining listener for message events
|
|
||||||
@self.bot.listener.on_message_event
|
@self.bot.listener.on_message_event
|
||||||
async def wrapper_handle_commands(room, message):
|
async def wrapper_handle_commands(room, message):
|
||||||
await self.handle_commands(room, message) # Calling handle_commands method for incoming messages
|
await self.handle_commands(room, message)
|
||||||
|
|
||||||
logging.info("="*40)
|
|
||||||
logging.info("BOT IS READY - ATTEMPTING TO CONNECT TO MATRIX")
|
|
||||||
logging.info("="*40)
|
|
||||||
logging.info(f"Connecting to {MATRIX_URL} as {MATRIX_USER}...")
|
|
||||||
logging.info("(This may take up to 30 seconds...)")
|
|
||||||
|
|
||||||
self.bot.run() # Running the bot
|
|
||||||
|
|
||||||
|
self.bot.run()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Fatal error during bot startup: {e}", exc_info=True)
|
logging.error(f"Fatal error during bot startup: {e}", exc_info=True)
|
||||||
logging.error("="*40)
|
|
||||||
logging.error("TROUBLESHOOTING SUGGESTIONS:")
|
|
||||||
logging.error("1. Check your internet connection")
|
|
||||||
logging.error("2. Verify MATRIX_URL is correct (should be https://matrix.org)")
|
|
||||||
logging.error("3. Verify MATRIX_USER and MATRIX_PASS are correct")
|
|
||||||
logging.error("4. Check if matrix.org is accessible from your network")
|
|
||||||
logging.error("5. Try increasing timeout in config")
|
|
||||||
logging.error("="*40)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Cleanup resources before shutdown."""
|
||||||
|
if hasattr(self, 'bot') and self.bot is not None:
|
||||||
|
# try to stop any schedulers if needed
|
||||||
|
pass
|
||||||
|
logging.info("Bot stopped.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("FUNGUY BOT LAUNCHER")
|
print("FUNGUY BOT LAUNCHER")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
|
bot = None
|
||||||
try:
|
try:
|
||||||
print("Creating bot instance...")
|
bot = FunguyBot()
|
||||||
bot = FunguyBot() # Creating instance of FunguyBot
|
bot.run()
|
||||||
print("Bot instance created. Running bot...")
|
|
||||||
bot.run() # Running the bot
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n[!] Bot stopped by user")
|
print("\n[!] Bot stopped by user")
|
||||||
|
if bot:
|
||||||
|
bot.stop()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n[!] Fatal error: {e}")
|
print(f"\n[!] Fatal error: {e}")
|
||||||
logging.error(f"Unhandled exception: {e}", exc_info=True)
|
logging.error(f"Unhandled exception: {e}", exc_info=True)
|
||||||
|
if bot:
|
||||||
|
bot.stop()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
+183
-46
@@ -2,10 +2,15 @@
|
|||||||
"""
|
"""
|
||||||
plugins/admin.py – Full room moderation commands.
|
plugins/admin.py – Full room moderation commands.
|
||||||
Supports multi‑word display names, standalone commands (!op, !kick, etc.)
|
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 time
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from collections import defaultdict, deque
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
logger = logging.getLogger("admin")
|
logger = logging.getLogger("admin")
|
||||||
@@ -17,6 +22,17 @@ _pending_resolution = {} # room_id → {"matches": [...], "expires": timestamp}
|
|||||||
_name_cache = {} # room_id → {display_name.lower(): mxid}
|
_name_cache = {} # room_id → {display_name.lower(): mxid}
|
||||||
RESOLUTION_TIMEOUT = 60
|
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():
|
def _cleanup_resolutions():
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired = [r for r, v in _pending_resolution.items() if v["expires"] < now]
|
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": ...}
|
self.matches = matches # list of {"mxid": ..., "display_name": ...}
|
||||||
|
|
||||||
async def _populate_name_cache(bot, room_id):
|
async def _populate_name_cache(bot, room_id):
|
||||||
"""Fetch the full member list and cache display names."""
|
|
||||||
if room_id in _name_cache:
|
if room_id in _name_cache:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -39,7 +54,6 @@ async def _populate_name_cache(bot, room_id):
|
|||||||
for member in resp.members:
|
for member in resp.members:
|
||||||
display = (member.display_name or "").strip().lower()
|
display = (member.display_name or "").strip().lower()
|
||||||
if display:
|
if display:
|
||||||
# If duplicate display name, store None to indicate ambiguity
|
|
||||||
if display in cache:
|
if display in cache:
|
||||||
cache[display] = None
|
cache[display] = None
|
||||||
else:
|
else:
|
||||||
@@ -50,23 +64,17 @@ async def _populate_name_cache(bot, room_id):
|
|||||||
logger.error(f"Could not cache members: {e}")
|
logger.error(f"Could not cache members: {e}")
|
||||||
|
|
||||||
async def _resolve_multiword(bot, room_id, tokens):
|
async def _resolve_multiword(bot, room_id, tokens):
|
||||||
"""
|
clean_tokens = [re.sub(r'<[^>]+>', '', t).strip() for t in 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.
|
|
||||||
"""
|
|
||||||
await _populate_name_cache(bot, room_id)
|
await _populate_name_cache(bot, room_id)
|
||||||
cache = _name_cache.get(room_id, {})
|
cache = _name_cache.get(room_id, {})
|
||||||
|
|
||||||
# Build candidates from 1 token up to all tokens
|
for end in range(len(clean_tokens), 0, -1):
|
||||||
for end in range(len(tokens), 0, -1):
|
candidate = " ".join(clean_tokens[:end]).strip().lower()
|
||||||
candidate = " ".join(tokens[:end]).strip().lower()
|
|
||||||
if candidate in cache:
|
if candidate in cache:
|
||||||
mxid = cache[candidate]
|
mxid = cache[candidate]
|
||||||
if mxid is not None:
|
if mxid is not None:
|
||||||
return mxid, candidate
|
return mxid, candidate
|
||||||
else:
|
else:
|
||||||
# Duplicate display name → fall through to ambiguity handling
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
matches = []
|
matches = []
|
||||||
for member in resp.members:
|
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"]
|
return matches[0]["mxid"], matches[0]["display_name"]
|
||||||
elif len(matches) > 1:
|
elif len(matches) > 1:
|
||||||
raise UserResolutionError(matches)
|
raise UserResolutionError(matches)
|
||||||
# else: not found (unlikely) → continue
|
raise ValueError(f"No member with display name '{' '.join(clean_tokens)}' found.")
|
||||||
raise ValueError(f"No member with display name '{' '.join(tokens)}' found.")
|
|
||||||
|
|
||||||
async def resolve_user_from_target(bot, room_id, target):
|
async def resolve_user_from_target(bot, room_id, target):
|
||||||
"""
|
target = re.sub(r'<[^>]+>', '', target).strip()
|
||||||
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.
|
|
||||||
"""
|
|
||||||
if target.startswith("@"):
|
if target.startswith("@"):
|
||||||
return target, None
|
return target, None
|
||||||
|
|
||||||
_cleanup_resolutions()
|
_cleanup_resolutions()
|
||||||
|
|
||||||
# Check for number reference to a previous ambiguous match
|
|
||||||
if target.isdigit():
|
if target.isdigit():
|
||||||
idx = int(target) - 1
|
idx = int(target) - 1
|
||||||
if room_id in _pending_resolution:
|
if room_id in _pending_resolution:
|
||||||
@@ -106,16 +106,12 @@ async def resolve_user_from_target(bot, room_id, target):
|
|||||||
else:
|
else:
|
||||||
raise ValueError("No pending resolution. Use @user:domain or display name.")
|
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)
|
await _populate_name_cache(bot, room_id)
|
||||||
cache = _name_cache.get(room_id, {})
|
cache = _name_cache.get(room_id, {})
|
||||||
mxid = cache.get(target.strip().lower())
|
mxid = cache.get(target.strip().lower())
|
||||||
if mxid:
|
if mxid:
|
||||||
return mxid, target.strip().lower()
|
return mxid, target.strip().lower()
|
||||||
elif mxid is None:
|
elif mxid is None:
|
||||||
# Ambiguous: fetch and raise
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
matches = []
|
matches = []
|
||||||
for member in resp.members:
|
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}")
|
logger.error(f"Failed to fetch bans: {e}")
|
||||||
return []
|
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
|
# Main command handler
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Dispatches !admin or standalone moderation commands."""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not match.is_not_from_this_bot() or not match.prefix():
|
if not match.is_not_from_this_bot() or not match.prefix():
|
||||||
return
|
return
|
||||||
@@ -200,7 +246,7 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"ban": "ban",
|
"ban": "ban",
|
||||||
"unban": "unban",
|
"unban": "unban",
|
||||||
"invite": "invite",
|
"invite": "invite",
|
||||||
"userinfo": "whois", # <-- renamed from "whois" to "userinfo"
|
"userinfo": "whois",
|
||||||
"op": "op",
|
"op": "op",
|
||||||
"deop": "deop",
|
"deop": "deop",
|
||||||
"topic": "topic",
|
"topic": "topic",
|
||||||
@@ -208,6 +254,8 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"avatar": "avatar",
|
"avatar": "avatar",
|
||||||
"members": "members",
|
"members": "members",
|
||||||
"bans": "bans",
|
"bans": "bans",
|
||||||
|
"mkick": "mkick",
|
||||||
|
"joinrule": "joinrule",
|
||||||
"modhelp": "help",
|
"modhelp": "help",
|
||||||
"admin": "admin",
|
"admin": "admin",
|
||||||
}
|
}
|
||||||
@@ -216,7 +264,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
if cmd not in standalone_actions:
|
if cmd not in standalone_actions:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Permission gate (skip for help)
|
|
||||||
if cmd not in ("modhelp", "help"):
|
if cmd not in ("modhelp", "help"):
|
||||||
if not await has_mod_permission(bot, room_id, sender, config):
|
if not await has_mod_permission(bot, room_id, sender, config):
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
@@ -226,7 +273,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# Determine action and sub_args
|
|
||||||
if cmd == "admin":
|
if cmd == "admin":
|
||||||
if not args:
|
if not args:
|
||||||
await bot.api.send_text_message(room_id, "Usage: !admin <action> [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
|
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:
|
if not sub_args:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]"
|
room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# For op/deop, the last token might be a power level (number)
|
|
||||||
if action in ("op", "deop"):
|
if action in ("op", "deop"):
|
||||||
# Try to parse last token as power level
|
|
||||||
potential_pl = sub_args[-1]
|
potential_pl = sub_args[-1]
|
||||||
try:
|
try:
|
||||||
power = int(potential_pl)
|
power = int(potential_pl)
|
||||||
# Success: power level found, name is sub_args[:-1]
|
|
||||||
name_tokens = sub_args[:-1]
|
name_tokens = sub_args[:-1]
|
||||||
if not name_tokens:
|
if not name_tokens:
|
||||||
await bot.api.send_text_message(room_id, "Missing user name.")
|
await bot.api.send_text_message(room_id, "Missing user name.")
|
||||||
return
|
return
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# No numeric power, whole sub_args is the name
|
|
||||||
name_tokens = sub_args
|
name_tokens = sub_args
|
||||||
power = None
|
power = None
|
||||||
else:
|
else:
|
||||||
# kick, ban, invite, userinfo
|
name_tokens = sub_args
|
||||||
name_tokens = sub_args # entire args is the name
|
|
||||||
power = None
|
power = None
|
||||||
|
|
||||||
# Resolve the multi-word name
|
|
||||||
try:
|
try:
|
||||||
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
|
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
|
||||||
except UserResolutionError as e:
|
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))
|
await bot.api.send_text_message(room_id, "\n".join(lines))
|
||||||
return
|
return
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Fallback: also try the old way with just the first token (maybe they used @user)
|
|
||||||
target_str = sub_args[0]
|
target_str = sub_args[0]
|
||||||
try:
|
try:
|
||||||
target_mxid, target_display = await resolve_user_from_target(bot, room_id, target_str)
|
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))
|
await bot.api.send_text_message(room_id, str(e2))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine reason and power level for op/deop
|
|
||||||
if action in ("op", "deop"):
|
if action in ("op", "deop"):
|
||||||
if action == "op":
|
if action == "op":
|
||||||
requested_pl = power if power is not None else 50
|
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}")
|
await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}")
|
||||||
|
|
||||||
else:
|
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 ""
|
reason = " ".join(sub_args[len(name_tokens):]) if len(sub_args) > len(name_tokens) else ""
|
||||||
|
|
||||||
if action == "kick":
|
if action == "kick":
|
||||||
@@ -345,7 +428,7 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room_id, f"❌ Failed to invite: {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:
|
try:
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
member_info = None
|
member_info = None
|
||||||
@@ -385,7 +468,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
|
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
|
||||||
# (unchanged)
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
elif action == "topic":
|
elif action == "topic":
|
||||||
if not sub_args:
|
if not sub_args:
|
||||||
@@ -477,6 +559,8 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
- `!ban <@user|name> [reason]` – Ban a user
|
- `!ban <@user|name> [reason]` – Ban a user
|
||||||
- `!unban <@user:domain>` – Unban (full MXID required)
|
- `!unban <@user:domain>` – Unban (full MXID required)
|
||||||
- `!invite <@user|name>` – Invite a user
|
- `!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`)
|
- `!userinfo <@user|name>` – Show user details & power level (was `!whois`)
|
||||||
- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator)
|
- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator)
|
||||||
- `!deop <@user|name>` – Demote user to power level 0
|
- `!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`."
|
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
|
# Plugin metadata
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
__version__ = "1.1.1"
|
__version__ = "1.2.3"
|
||||||
__author__ = "Funguy Admin"
|
__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__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Admin / Moderator Commands</strong></summary>
|
<summary><strong>Admin / Moderator Commands</strong></summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!kick</code>, <code>!ban</code>, <code>!unban</code>, <code>!invite</code></li>
|
<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>!op</code> (max PL 50), <code>!deop</code></li>
|
||||||
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
|
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
|
||||||
<li><code>!members</code>, <code>!bans</code></li>
|
<li><code>!members</code>, <code>!bans</code></li>
|
||||||
@@ -516,5 +647,11 @@ __help__ = """
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Power level ≥ 50 required (or global admin).</p>
|
<p>Power level ≥ 50 required (or global admin).</p>
|
||||||
<p>Multi‑word display names are automatically recognized.</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>
|
</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"
|
__version__ = "1.0.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "arXiv academic paper search (with rate limiting and error reporting)"
|
__description__ = "arXiv academic paper search"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!arxiv</strong> – Search academic papers on arXiv</summary>
|
<summary><strong>!arxiv</strong> – Search academic papers on arXiv</summary>
|
||||||
|
|||||||
+12
-73
@@ -1,119 +1,58 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to fetch the current Bitcoin price.
|
This plugin provides a command to fetch the current Bitcoin price.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import html_escape
|
||||||
|
|
||||||
BITCOIN_API_URL = "https://api.bitcointicker.co/trades/bitstamp/btcusd/60/"
|
BITCOIN_API_URL = "https://api.bitcointicker.co/trades/bitstamp/btcusd/60/"
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !btc command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("btc"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("btc"):
|
||||||
logging.info("Received !btc command")
|
logging.info("Received !btc command")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch Bitcoin price data
|
|
||||||
headers = {
|
headers = {
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
'Accept-Encoding': 'gzip, deflate',
|
||||||
'User-Agent': 'FunguyBot/1.0'
|
'User-Agent': 'FunguyBot/1.0'
|
||||||
}
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
logging.info(f"Fetching Bitcoin price from {BITCOIN_API_URL}")
|
async with session.get(BITCOIN_API_URL, headers=headers, timeout=10) as response:
|
||||||
response = requests.get(BITCOIN_API_URL, headers=headers, timeout=10)
|
response.raise_for_status()
|
||||||
response.raise_for_status()
|
data = await response.json()
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data or len(data) == 0:
|
if not data or len(data) == 0:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "No Bitcoin price data available.")
|
||||||
room.room_id,
|
|
||||||
"No Bitcoin price data available."
|
|
||||||
)
|
|
||||||
logging.warning("No Bitcoin price data returned from API")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the most recent trade (last item in the array)
|
|
||||||
latest_trade = data[-1]
|
latest_trade = data[-1]
|
||||||
price = latest_trade.get('price')
|
price = latest_trade.get('price')
|
||||||
|
|
||||||
if price is None:
|
if price is None:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Could not extract Bitcoin price from API response.")
|
||||||
room.room_id,
|
|
||||||
"Could not extract Bitcoin price from API response."
|
|
||||||
)
|
|
||||||
logging.error("Price field not found in API response")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert to float and format with commas
|
|
||||||
try:
|
try:
|
||||||
price_float = float(price)
|
price_float = float(price)
|
||||||
price_formatted = f"${price_float:,.2f}"
|
price_formatted = f"${price_float:,.2f}"
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
price_formatted = f"${price}"
|
price_formatted = f"${price}"
|
||||||
|
|
||||||
# Optional: Get additional info if available
|
|
||||||
timestamp = latest_trade.get('timestamp', '')
|
|
||||||
volume = latest_trade.get('volume', '')
|
|
||||||
|
|
||||||
# Build the message
|
|
||||||
message_text = f"<strong>₿ BTC/USD</strong>"
|
message_text = f"<strong>₿ BTC/USD</strong>"
|
||||||
message_text += f"<strong> Current Price:</strong> {price_formatted}"
|
message_text += f"<strong> Current Price:</strong> {price_formatted}"
|
||||||
|
|
||||||
message_text += ", <em>bitcointicker.co</em>"
|
message_text += ", <em>bitcointicker.co</em>"
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, message_text)
|
await bot.api.send_markdown_message(room.room_id, message_text)
|
||||||
logging.info(f"Sent Bitcoin price: {price_formatted}")
|
logging.info(f"Sent Bitcoin price: {price_formatted}")
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Error fetching Bitcoin price: {e}")
|
||||||
room.room_id,
|
|
||||||
"Request timed out. Bitcoin price API may be slow or unavailable."
|
|
||||||
)
|
|
||||||
logging.error("Bitcoin API timeout")
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
f"Error fetching Bitcoin price: {e}"
|
|
||||||
)
|
|
||||||
logging.error(f"Error fetching Bitcoin price: {e}")
|
logging.error(f"Error fetching Bitcoin price: {e}")
|
||||||
|
|
||||||
except (KeyError, IndexError, ValueError) as e:
|
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
"Error parsing Bitcoin price data."
|
|
||||||
)
|
|
||||||
logging.error(f"Error parsing Bitcoin API response: {e}", exc_info=True)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "An unexpected error occurred.")
|
||||||
room.room_id,
|
|
||||||
"An unexpected error occurred while fetching Bitcoin price."
|
|
||||||
)
|
|
||||||
logging.error(f"Unexpected error in Bitcoin plugin: {e}", exc_info=True)
|
logging.error(f"Unexpected error in Bitcoin plugin: {e}", exc_info=True)
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Current Bitcoin price"
|
__description__ = "Current Bitcoin price"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Shared utilities for FunguyBot plugins.
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 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'),
|
||||||
|
ipaddress.ip_network('0.0.0.0/8'),
|
||||||
|
ipaddress.ip_network('::1/128'),
|
||||||
|
ipaddress.ip_network('fc00::/7'),
|
||||||
|
ipaddress.ip_network('fe80::/10'),
|
||||||
|
ipaddress.ip_network('::/128'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def html_escape(text: str) -> str:
|
||||||
|
"""Escape HTML special characters for safe embedding in messages."""
|
||||||
|
return html.escape(str(text), quote=False)
|
||||||
|
|
||||||
|
def collapsible_summary(title: str, body: str, expanded: bool = False) -> str:
|
||||||
|
"""Wrap content in a collapsible HTML details block."""
|
||||||
|
open_attr = ' open' if expanded else ''
|
||||||
|
return f"<details{open_attr}>\n<summary><strong>{title}</strong></summary>\n{body}\n</details>"
|
||||||
|
|
||||||
|
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:
|
||||||
|
addr = ipaddress.ip_address(target)
|
||||||
|
if any(addr in net for net in _PRIVATE_RANGES):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
"""No-op handler so the bot doesn't crash when loading this module as a plugin."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_html_message(bot, room_id, html_body, markdown_fallback):
|
||||||
|
"""Send an HTML-formatted message with a Markdown fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: simplematrixbotlib.Bot instance
|
||||||
|
room_id: Matrix room ID
|
||||||
|
html_body: HTML string (table, etc.)
|
||||||
|
markdown_fallback: Markdown/plain text for clients that don't render HTML
|
||||||
|
"""
|
||||||
|
content = {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": markdown_fallback,
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": html_body
|
||||||
|
}
|
||||||
|
await bot.async_client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
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.
|
Custom configuration class for the Funguy bot.
|
||||||
Security‑hardened: only the configured admin user can read or change settings.
|
Security‑hardened: only the configured admin user can read or change settings.
|
||||||
Save operation preserves extra sections (plugins.disabled, etc.).
|
Save operation preserves extra sections (plugins.disabled, etc.).
|
||||||
|
Commands:
|
||||||
|
!set <option> <value>
|
||||||
|
!get <option>
|
||||||
|
!show
|
||||||
|
!saveconf
|
||||||
|
!loadconf
|
||||||
|
!reset
|
||||||
|
!config help
|
||||||
"""
|
"""
|
||||||
# plugins/config.py
|
# plugins/config.py
|
||||||
|
|
||||||
@@ -10,6 +18,75 @@ import logging
|
|||||||
import toml
|
import toml
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dataclasses import dataclass
|
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
|
@dataclass
|
||||||
@@ -17,16 +94,24 @@ class FunguyConfig(botlib.Config):
|
|||||||
"""
|
"""
|
||||||
Custom configuration class for the Funguy bot.
|
Custom configuration class for the Funguy bot.
|
||||||
Extends the base Config class to provide additional configuration options.
|
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"):
|
def __init__(self, config_file="funguy.conf"):
|
||||||
super().__init__()
|
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)
|
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
|
self._config_file = config_file
|
||||||
logging.info(f"Loaded configuration from {config_file}")
|
logging.info(f"Loaded configuration from {config_file}")
|
||||||
|
|
||||||
@@ -59,7 +144,16 @@ class FunguyConfig(botlib.Config):
|
|||||||
self._config_file = value
|
self._config_file = value
|
||||||
|
|
||||||
def load_config(self, config_file):
|
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.load_toml(config_file)
|
||||||
self._config_file = config_file
|
self._config_file = config_file
|
||||||
logging.info(f"Loaded configuration from {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
|
Save configuration to a TOML file, **preserving** any extra sections
|
||||||
(e.g., plugins.disabled) not managed by the base library.
|
(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:
|
if config_file is None:
|
||||||
config_file = self._config_file
|
config_file = self._config_file
|
||||||
if not config_file:
|
if not config_file:
|
||||||
raise ValueError("No config file path set for saving.")
|
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"
|
tmp_file = config_file + ".tmp"
|
||||||
try:
|
try:
|
||||||
self.save_toml(tmp_file)
|
self.save_toml(tmp_file)
|
||||||
|
|
||||||
# 2. Read the temporary file (library's view of the config)
|
|
||||||
with open(tmp_file, 'r') as f:
|
with open(tmp_file, 'r') as f:
|
||||||
new_config = toml.load(f)
|
new_config = toml.load(f)
|
||||||
|
|
||||||
# 3. Read the current config file (if it exists) to preserve extra sections
|
|
||||||
original = {}
|
original = {}
|
||||||
if os.path.exists(config_file):
|
if os.path.exists(config_file):
|
||||||
with open(config_file, 'r') as f:
|
with open(config_file, 'r') as f:
|
||||||
original = toml.load(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()
|
merged = original.copy()
|
||||||
for key, value in new_config.items():
|
for key, value in new_config.items():
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
|
|
||||||
# 5. Write back the merged result
|
|
||||||
with open(config_file, 'w') as f:
|
with open(config_file, 'w') as f:
|
||||||
toml.dump(merged, f)
|
toml.dump(merged, f)
|
||||||
|
|
||||||
@@ -107,31 +192,95 @@ class FunguyConfig(botlib.Config):
|
|||||||
logging.error(f"Error saving config to {config_file}: {e}")
|
logging.error(f"Error saving config to {config_file}: {e}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Always remove the temp file
|
|
||||||
if os.path.exists(tmp_file):
|
if os.path.exists(tmp_file):
|
||||||
os.remove(tmp_file)
|
os.remove(tmp_file)
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
# ----------------------------------------------------------------------
|
||||||
"""
|
# Helpers for formatting
|
||||||
Handle commands related to bot configuration.
|
# ----------------------------------------------------------------------
|
||||||
All sub‑commands require the sender to be the configured admin_user.
|
def _bool_to_str(v):
|
||||||
"""
|
return "true" if v else "false"
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
|
||||||
|
|
||||||
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
|
return
|
||||||
|
|
||||||
cmd = match.command()
|
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
|
return
|
||||||
|
|
||||||
sender = str(message.sender)
|
sender = str(message.sender)
|
||||||
if sender != config.admin_user:
|
if sender != config.admin_user:
|
||||||
logging.warning(
|
logging.warning("Unauthorized config command attempt by %s (%s vs %s)", sender, config.admin_user)
|
||||||
"Unauthorized config command attempt by %s in room %s: %s",
|
|
||||||
sender, room.room_id, cmd
|
|
||||||
)
|
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
"⛔ You are not authorized to use configuration commands."
|
"⛔ You are not authorized to use configuration commands."
|
||||||
@@ -139,94 +288,109 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
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:
|
if len(args) != 2:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, "Usage: !set <option> <value>\nUse !config show for options.")
|
||||||
"Usage: !set <config_option> <value>")
|
|
||||||
return
|
return
|
||||||
option, value = args
|
option, value = args
|
||||||
if option == "admin_user":
|
success, msg = _set_config_option(config, option, value)
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, msg)
|
||||||
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.")
|
|
||||||
|
|
||||||
elif cmd == "get":
|
elif subcmd == "get":
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, "Usage: !get <option>")
|
||||||
"Usage: !get <config_option>")
|
|
||||||
return
|
return
|
||||||
option = args[0]
|
option = args[0]
|
||||||
if option == "admin_user":
|
meta = OPTIONS.get(option)
|
||||||
await bot.api.send_text_message(room.room_id,
|
if not meta:
|
||||||
f"Admin user: {config.admin_user}")
|
await bot.api.send_text_message(room.room_id, f"Unknown option '{html_escape(option)}'.")
|
||||||
elif option == "prefix":
|
return
|
||||||
await bot.api.send_text_message(room.room_id,
|
val = getattr(config, option, meta["default"])
|
||||||
f"Prefix: {config.prefix}")
|
await bot.api.send_text_message(room.room_id, f"{html_escape(option)}: {_format_value(option, val)}")
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
"Invalid configuration option.")
|
|
||||||
|
|
||||||
elif cmd == "show":
|
elif subcmd == "show":
|
||||||
await bot.api.send_text_message(
|
rows = []
|
||||||
room.room_id,
|
for key, meta in OPTIONS.items():
|
||||||
f"Admin user: {config.admin_user}\nPrefix: {config.prefix}"
|
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:
|
try:
|
||||||
config.save_config() # uses the stored config_file by default
|
config.save_config()
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "💾 Configuration saved to file.")
|
||||||
room.room_id,
|
|
||||||
"Configuration saved (including disabled plugins)."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ Failed to save: {html_escape(str(e))}")
|
||||||
room.room_id,
|
|
||||||
f"❌ Failed to save configuration: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif cmd == "loadconf":
|
elif subcmd == "loadconf":
|
||||||
config.load_config(config.config_file)
|
try:
|
||||||
await bot.api.send_text_message(
|
config.load_config(config.config_file)
|
||||||
room.room_id,
|
await bot.api.send_text_message(room.room_id, "🔄 Configuration reloaded from file.")
|
||||||
"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":
|
elif subcmd == "reset":
|
||||||
config.prefix = "!"
|
for key, meta in OPTIONS.items():
|
||||||
await bot.api.send_text_message(
|
if key == "admin_user":
|
||||||
room.room_id,
|
continue
|
||||||
"Configuration reset to defaults (admin_user unchanged)."
|
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
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
__version__ = "1.0.2"
|
__version__ = "1.1.1"
|
||||||
__author__ = "Funguy Bot (hardened)"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Admin-only configuration commands (preserves disabled plugins)"
|
__description__ = "Admin‑only configuration management"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Admin Config</strong> (!set, !get, !saveconf, …)</summary>
|
<summary><strong>!config</strong> – Manage bot settings</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!set prefix <value></code> – Change command prefix (admin only)</li>
|
<li><code>!set <option> <value></code> – Change a setting</li>
|
||||||
<li><code>!get <option></code> – Display config value (admin only)</li>
|
<li><code>!get <option></code> – View a setting</li>
|
||||||
<li><code>!show</code> – Show current settings (admin only)</li>
|
<li><code>!show</code> – All settings</li>
|
||||||
<li><code>!saveconf</code> / <code>!loadconf</code> – Save/load config (admin only)</li>
|
<li><code>!saveconf</code> – Save to file</li>
|
||||||
<li><code>!reset</code> – Reset to defaults, preserving admin_user (admin only)</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>
|
</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>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+34
-94
@@ -9,19 +9,15 @@ from html import escape
|
|||||||
|
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from ddgs import DDGS
|
from ddgs import DDGS
|
||||||
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
logger = logging.getLogger("ddg")
|
logger = logging.getLogger("ddg")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Async search wrapper (ddgs is sync, run in executor)
|
||||||
# Async search wrapper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def _async_search(func, *args, **kwargs):
|
async def _async_search(func, *args, **kwargs):
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Command handler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")):
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")):
|
||||||
@@ -34,93 +30,67 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
subcommand = args[0].lower()
|
subcommand = args[0].lower()
|
||||||
|
|
||||||
# ---- Instant answer (default) ----
|
|
||||||
if subcommand in ("instant", "i"):
|
if subcommand in ("instant", "i"):
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
||||||
return
|
return
|
||||||
await instant_answer(room, bot, query)
|
await instant_answer(room, bot, query)
|
||||||
|
|
||||||
# ---- Web search ----
|
|
||||||
elif subcommand == "search":
|
elif subcommand == "search":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
||||||
return
|
return
|
||||||
await web_search(room, bot, query)
|
await web_search(room, bot, query)
|
||||||
|
|
||||||
# ---- Image search ----
|
|
||||||
elif subcommand == "image":
|
elif subcommand == "image":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
||||||
return
|
return
|
||||||
await image_search(room, bot, query)
|
await image_search(room, bot, query)
|
||||||
|
|
||||||
# ---- News search ----
|
|
||||||
elif subcommand == "news":
|
elif subcommand == "news":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
||||||
return
|
return
|
||||||
await news_search(room, bot, query)
|
await news_search(room, bot, query)
|
||||||
|
|
||||||
# ---- Video search ----
|
|
||||||
elif subcommand == "video":
|
elif subcommand == "video":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
||||||
return
|
return
|
||||||
await video_search(room, bot, query)
|
await video_search(room, bot, query)
|
||||||
|
|
||||||
# ---- Bang search ----
|
|
||||||
elif subcommand == "bang":
|
elif subcommand == "bang":
|
||||||
bang_query = " ".join(args[1:]) if len(args) > 1 else ""
|
bang_query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not bang_query:
|
if not bang_query:
|
||||||
await bang_help(room, bot)
|
await bang_help(room, bot)
|
||||||
return
|
return
|
||||||
await bang_search(room, bot, bang_query)
|
await bang_search(room, bot, bang_query)
|
||||||
|
|
||||||
# ---- Definitions ----
|
|
||||||
elif subcommand == "define":
|
elif subcommand == "define":
|
||||||
word = " ".join(args[1:]) if len(args) > 1 else ""
|
word = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not word:
|
if not word:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
||||||
return
|
return
|
||||||
await definition(room, bot, word)
|
await definition(room, bot, word)
|
||||||
|
|
||||||
# ---- Calculator ----
|
|
||||||
elif subcommand == "calc":
|
elif subcommand == "calc":
|
||||||
expr = " ".join(args[1:]) if len(args) > 1 else ""
|
expr = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not expr:
|
if not expr:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
||||||
return
|
return
|
||||||
await calculator(room, bot, expr)
|
await calculator(room, bot, expr)
|
||||||
|
|
||||||
# ---- Weather ----
|
|
||||||
elif subcommand == "weather":
|
elif subcommand == "weather":
|
||||||
location = " ".join(args[1:]) if len(args) > 1 else ""
|
location = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not location:
|
if not location:
|
||||||
location = "current location"
|
location = "current location"
|
||||||
await weather(room, bot, location)
|
await weather(room, bot, location)
|
||||||
|
|
||||||
# ---- Help ----
|
|
||||||
elif subcommand == "help":
|
elif subcommand == "help":
|
||||||
await send_help(room, bot)
|
await send_help(room, bot)
|
||||||
|
|
||||||
# ---- Default: treat as instant answer ----
|
|
||||||
else:
|
else:
|
||||||
query = " ".join(args)
|
query = " ".join(args)
|
||||||
await instant_answer(room, bot, query)
|
await instant_answer(room, bot, query)
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# Result functions (all wrapped in <details>)
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
async def instant_answer(room, bot, query):
|
async def instant_answer(room, bot, query):
|
||||||
"""Top web result wrapped in a collapsible box."""
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.text, query, max_results=1)
|
results = await _async_search(ddgs.text, query, max_results=1)
|
||||||
@@ -128,28 +98,25 @@ async def instant_answer(room, bot, query):
|
|||||||
logger.error(f"DDG instant answer error: {e}")
|
logger.error(f"DDG instant answer error: {e}")
|
||||||
await bot.api.send_markdown_message(
|
await bot.api.send_markdown_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
f"🦆 <strong>DuckDuckGo: {escape(query)}</strong><br><br>Error fetching results. Try again later."
|
f"🦆 <strong>DuckDuckGo: {safe_query}</strong><br><br>Error fetching results."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
content = ""
|
content = ""
|
||||||
if results:
|
if results:
|
||||||
r = results[0]
|
r = results[0]
|
||||||
title = escape(r.get("title", "Result"))
|
title = html_escape(r.get("title", "Result"))
|
||||||
body = escape(r.get("body", ""))
|
body = html_escape(r.get("body", ""))
|
||||||
content = f"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
|
content = f"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
|
||||||
else:
|
else:
|
||||||
search_url = f"https://duckduckgo.com/?q={escape(query)}"
|
search_url = f"https://duckduckgo.com/?q={html_escape(query)}"
|
||||||
content = f"No results found.<br>🔍 <a href='{search_url}'>Search on DuckDuckGo</a>"
|
content = f"No results found.<br>🔍 <a href='{search_url}'>Search on DuckDuckGo</a>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🦆 DuckDuckGo: {safe_query}", content)
|
||||||
<summary>🦆 DuckDuckGo: {escape(query)}</summary>
|
|
||||||
{content}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def web_search(room, bot, query):
|
async def web_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.text, query, max_results=5)
|
results = await _async_search(ddgs.text, query, max_results=5)
|
||||||
@@ -159,23 +126,20 @@ async def web_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
await bot.api.send_text_message(room.room_id, f"No results for '{query}'.")
|
await bot.api.send_text_message(room.room_id, f"No results for '{safe_query}'.")
|
||||||
return
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for r in results:
|
for r in results:
|
||||||
title = escape(r.get("title", "Result"))
|
title = html_escape(r.get("title", "Result"))
|
||||||
body = escape(r.get("body", ""))
|
body = html_escape(r.get("body", ""))
|
||||||
items += f"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
|
items += f"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🔍 Search: {safe_query}", items)
|
||||||
<summary>🔍 Search: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def image_search(room, bot, query):
|
async def image_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.images, query, max_results=3)
|
results = await _async_search(ddgs.images, query, max_results=3)
|
||||||
@@ -185,28 +149,25 @@ async def image_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
await bot.api.send_text_message(room.room_id, f"No images for '{query}'.")
|
await bot.api.send_text_message(room.room_id, f"No images for '{safe_query}'.")
|
||||||
return
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for img in results:
|
for img in results:
|
||||||
title = escape(img.get("title", "Image"))
|
title = html_escape(img.get("title", "Image"))
|
||||||
items += f"• <a href='{img['image']}'>{title}</a>"
|
items += f"• <a href='{img['image']}'>{title}</a>"
|
||||||
if img.get("width") and img.get("height"):
|
if img.get("width") and img.get("height"):
|
||||||
items += f" ({img['width']}×{img['height']})"
|
items += f" ({img['width']}×{img['height']})"
|
||||||
items += "<br>"
|
items += "<br>"
|
||||||
|
|
||||||
search_url = f"https://duckduckgo.com/?q={escape(query)}&iax=images&ia=images"
|
search_url = f"https://duckduckgo.com/?q={html_escape(query)}&iax=images&ia=images"
|
||||||
items += f"<br>🔍 <a href='{search_url}'>View all images</a>"
|
items += f"<br>🔍 <a href='{search_url}'>View all images</a>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🖼️ Images: {safe_query}", items)
|
||||||
<summary>🖼️ Images: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def news_search(room, bot, query):
|
async def news_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.news, query, max_results=3)
|
results = await _async_search(ddgs.news, query, max_results=3)
|
||||||
@@ -216,23 +177,20 @@ async def news_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
await bot.api.send_text_message(room.room_id, f"No news for '{query}'.")
|
await bot.api.send_text_message(room.room_id, f"No news for '{safe_query}'.")
|
||||||
return
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for n in results:
|
for n in results:
|
||||||
title = escape(n.get("title", "Article"))
|
title = html_escape(n.get("title", "Article"))
|
||||||
body = escape(n.get("body", ""))
|
body = html_escape(n.get("body", ""))
|
||||||
items += f"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
|
items += f"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"📰 News: {safe_query}", items)
|
||||||
<summary>📰 News: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def video_search(room, bot, query):
|
async def video_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.videos, query, max_results=3)
|
results = await _async_search(ddgs.videos, query, max_results=3)
|
||||||
@@ -242,49 +200,36 @@ async def video_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
await bot.api.send_text_message(room.room_id, f"No videos for '{query}'.")
|
await bot.api.send_text_message(room.room_id, f"No videos for '{safe_query}'.")
|
||||||
return
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for v in results:
|
for v in results:
|
||||||
title = escape(v.get("title", "Video"))
|
title = html_escape(v.get("title", "Video"))
|
||||||
items += f"• <a href='{v['content']}'>{title}</a><br>"
|
items += f"• <a href='{v['content']}'>{title}</a><br>"
|
||||||
|
|
||||||
search_url = f"https://duckduckgo.com/?q={escape(query)}&iar=videos"
|
search_url = f"https://duckduckgo.com/?q={html_escape(query)}&iar=videos"
|
||||||
items += f"<br>🔍 <a href='{search_url}'>View all videos</a>"
|
items += f"<br>🔍 <a href='{search_url}'>View all videos</a>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🎬 Videos: {safe_query}", items)
|
||||||
<summary>🎬 Videos: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def bang_search(room, bot, bang_query):
|
async def bang_search(room, bot, bang_query):
|
||||||
search_url = f"https://duckduckgo.com/?q={escape(bang_query)}"
|
safe_query = html_escape(bang_query)
|
||||||
content = f"🔗 <a href='{search_url}'>Search with {escape(bang_query)} on DuckDuckGo</a>"
|
search_url = f"https://duckduckgo.com/?q={html_escape(bang_query)}"
|
||||||
msg = f"""<details>
|
content = f"🔗 <a href='{search_url}'>Search with {safe_query} on DuckDuckGo</a>"
|
||||||
<summary>🎯 Bang: {escape(bang_query)}</summary>
|
msg = collapsible_summary(f"🎯 Bang: {safe_query}", content)
|
||||||
{content}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def definition(room, bot, word):
|
async def definition(room, bot, word):
|
||||||
await instant_answer(room, bot, f"define {word}")
|
await instant_answer(room, bot, f"define {word}")
|
||||||
|
|
||||||
|
|
||||||
async def calculator(room, bot, expr):
|
async def calculator(room, bot, expr):
|
||||||
await instant_answer(room, bot, expr)
|
await instant_answer(room, bot, expr)
|
||||||
|
|
||||||
|
|
||||||
async def weather(room, bot, location):
|
async def weather(room, bot, location):
|
||||||
await instant_answer(room, bot, f"weather {location}")
|
await instant_answer(room, bot, f"weather {location}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Help messages (no details wrapper – kept readable)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def bang_help(room, bot):
|
async def bang_help(room, bot):
|
||||||
msg = """
|
msg = """
|
||||||
<strong>🎯 DuckDuckGo Bangs</strong><br>
|
<strong>🎯 DuckDuckGo Bangs</strong><br>
|
||||||
@@ -302,7 +247,6 @@ Usage: <code>!ddg bang !bang query</code><br><br>
|
|||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def send_help(room, bot):
|
async def send_help(room, bot):
|
||||||
help_msg = """
|
help_msg = """
|
||||||
<strong>🦆 DuckDuckGo Commands</strong><br>
|
<strong>🦆 DuckDuckGo Commands</strong><br>
|
||||||
@@ -319,13 +263,9 @@ async def send_help(room, bot):
|
|||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, help_msg)
|
await bot.api.send_markdown_message(room.room_id, help_msg)
|
||||||
|
|
||||||
|
__version__ = "2.1.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
__version__ = "2.1.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
|
__description__ = "DuckDuckGo search plugin"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!ddg</strong> – DuckDuckGo search (web, images, news, etc.)</summary>
|
<summary><strong>!ddg</strong> – DuckDuckGo search (web, images, news, etc.)</summary>
|
||||||
|
|||||||
+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 logging
|
||||||
|
import asyncio
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
import dns.reversename
|
import dns.reversename
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import re
|
import re
|
||||||
|
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV']
|
RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV']
|
||||||
|
|
||||||
@@ -16,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,}$'
|
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
|
||||||
return re.match(pattern, domain) is not None
|
return re.match(pattern, domain) is not None
|
||||||
|
|
||||||
def format_dns_record(record_type, records):
|
|
||||||
if not records:
|
|
||||||
return ""
|
|
||||||
output = f"<strong>{record_type} Records:</strong><br>"
|
|
||||||
for record in records:
|
|
||||||
output += f" • {record}<br>"
|
|
||||||
return output
|
|
||||||
|
|
||||||
async def query_dns_records(domain):
|
async def query_dns_records(domain):
|
||||||
results = {}
|
loop = asyncio.get_running_loop()
|
||||||
resolver = dns.resolver.Resolver()
|
def _resolve():
|
||||||
resolver.timeout = 5
|
results = {}
|
||||||
resolver.lifetime = 5
|
resolver = dns.resolver.Resolver()
|
||||||
for record_type in RECORD_TYPES:
|
resolver.timeout = 5
|
||||||
try:
|
resolver.lifetime = 5
|
||||||
logging.info(f"Querying {record_type} records for {domain}")
|
for record_type in RECORD_TYPES:
|
||||||
answers = resolver.resolve(domain, record_type)
|
try:
|
||||||
records = []
|
answers = resolver.resolve(domain, record_type)
|
||||||
for rdata in answers:
|
records = []
|
||||||
if record_type == 'MX':
|
for rdata in answers:
|
||||||
records.append(f"{rdata.preference} {rdata.exchange}")
|
if record_type == 'MX':
|
||||||
elif record_type == 'SOA':
|
records.append(f"{rdata.preference} {rdata.exchange}")
|
||||||
records.append(f"{rdata.mname} {rdata.rname}")
|
elif record_type == 'SOA':
|
||||||
elif record_type == 'SRV':
|
records.append(f"{rdata.mname} {rdata.rname}")
|
||||||
records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}")
|
elif record_type == 'SRV':
|
||||||
elif record_type == 'TXT':
|
records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}")
|
||||||
txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
|
elif record_type == 'TXT':
|
||||||
records.append(txt_data)
|
txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
|
||||||
else:
|
records.append(txt_data)
|
||||||
records.append(str(rdata))
|
else:
|
||||||
if records:
|
records.append(str(rdata))
|
||||||
results[record_type] = records
|
if records:
|
||||||
logging.info(f"Found {len(records)} {record_type} record(s)")
|
results[record_type] = records
|
||||||
except dns.resolver.NoAnswer:
|
except dns.resolver.NoAnswer:
|
||||||
continue
|
continue
|
||||||
except dns.resolver.NXDOMAIN:
|
except dns.resolver.NXDOMAIN:
|
||||||
logging.warning(f"Domain {domain} does not exist")
|
return None
|
||||||
return None
|
except dns.resolver.Timeout:
|
||||||
except dns.resolver.Timeout:
|
continue
|
||||||
continue
|
except Exception as e:
|
||||||
except Exception as e:
|
logging.error(f"Error querying {record_type} for {domain}: {e}")
|
||||||
logging.error(f"Error querying {record_type} for {domain}: {e}")
|
continue
|
||||||
continue
|
return results
|
||||||
return results
|
return await loop.run_in_executor(None, _resolve)
|
||||||
|
|
||||||
|
RECORD_META = {
|
||||||
|
'A': ('🌐', 'A (IPv4)'),
|
||||||
|
'AAAA': ('🌐', 'AAAA (IPv6)'),
|
||||||
|
'MX': ('📧', 'MX (Mail)'),
|
||||||
|
'NS': ('🌐', 'NS (Nameserver)'),
|
||||||
|
'TXT': ('📄', 'TXT'),
|
||||||
|
'CNAME': ('🔀', 'CNAME'),
|
||||||
|
'SOA': ('📋', 'SOA'),
|
||||||
|
'PTR': ('↩️', 'PTR'),
|
||||||
|
'SRV': ('🔌', 'SRV'),
|
||||||
|
}
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("dns"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("dns"):
|
||||||
logging.info("Received !dns command")
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, "Usage: !dns <domain>\nExample: !dns example.com")
|
||||||
"Usage: !dns <domain>\nExample: !dns example.com")
|
|
||||||
return
|
return
|
||||||
domain = args[0].lower().strip()
|
domain = args[0].lower().strip()
|
||||||
domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
|
domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
|
||||||
|
|
||||||
if not is_valid_domain(domain):
|
if not is_valid_domain(domain):
|
||||||
await bot.api.send_text_message(room.room_id, f"Invalid domain name: {domain}")
|
await bot.api.send_text_message(room.room_id, f"Invalid domain name: {html_escape(domain)}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not is_public_destination(domain):
|
||||||
|
await bot.api.send_text_message(room.room_id, "❌ DNS queries for private/internal domains are not allowed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Performing DNS reconnaissance on {html_escape(domain)}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
f"🔍 Performing DNS reconnaissance on {domain}...")
|
|
||||||
results = await query_dns_records(domain)
|
results = await query_dns_records(domain)
|
||||||
if results is None:
|
if results is None:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, f"Domain {html_escape(domain)} does not exist (NXDOMAIN)")
|
||||||
f"Domain {domain} does not exist (NXDOMAIN)")
|
|
||||||
return
|
return
|
||||||
if not results:
|
if not results:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, f"No DNS records found for {html_escape(domain)}")
|
||||||
f"No DNS records found for {domain}")
|
|
||||||
return
|
return
|
||||||
# SSRF / privacy check: if all A/AAAA records are private, refuse.
|
|
||||||
a_records = results.get('A', [])
|
a_records = results.get('A', [])
|
||||||
aaaa_records = results.get('AAAA', [])
|
aaaa_records = results.get('AAAA', [])
|
||||||
all_ips = a_records + aaaa_records
|
all_ips = a_records + aaaa_records
|
||||||
if all_ips and not any(is_public_destination(ip) for ip in all_ips):
|
if all_ips and not any(is_public_destination(ip) for ip in all_ips):
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, "❌ This domain resolves exclusively to private/internal IPs.")
|
||||||
"❌ This domain resolves exclusively to private/internal IPs.")
|
|
||||||
return
|
return
|
||||||
output = f"<strong>🔍 DNS Records for {domain}</strong><br><br>"
|
|
||||||
preferred_order = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
|
rows = []
|
||||||
for record_type in preferred_order:
|
preferred = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR']
|
||||||
if record_type in results:
|
for rtype in preferred:
|
||||||
output += format_dns_record(record_type, results[record_type])
|
if rtype in results:
|
||||||
output += "<br>"
|
emoji, label = RECORD_META.get(rtype, ('❓', rtype))
|
||||||
for record_type in results:
|
for rec in results[rtype]:
|
||||||
if record_type not in preferred_order:
|
rows.append((emoji, label, rec))
|
||||||
output += format_dns_record(record_type, results[record_type])
|
emoji = ""
|
||||||
output += "<br>"
|
label = ""
|
||||||
if output.count('<br>') > 15:
|
for rtype in results:
|
||||||
output = f"<details><summary><strong>🔍 DNS Records for {domain}</strong></summary>{output}</details>"
|
if rtype not in preferred:
|
||||||
|
emoji, label = RECORD_META.get(rtype, ('❓', rtype))
|
||||||
|
for rec in results[rtype]:
|
||||||
|
rows.append((emoji, label, rec))
|
||||||
|
emoji = ""
|
||||||
|
label = ""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No displayable records for {html_escape(domain)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
sections = [{"title": "", "rows": rows}]
|
||||||
|
block = code_block(f"🔍 DNS Records for {domain}", sections)
|
||||||
|
output = collapsible_summary(f"🔍 DNS: {html_escape(domain)}", block)
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent DNS records for {domain}")
|
logging.info(f"Sent DNS records for {domain}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, f"An error occurred while performing DNS lookup: {str(e)}")
|
||||||
f"An error occurred while performing DNS lookup: {str(e)}")
|
|
||||||
logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True)
|
logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.1.1"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "DNS reconnaissance (SSRF‑safe)"
|
__description__ = "DNS reconnaissance (SSRF‑safe)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!dns</strong> – DNS reconnaissance</summary>
|
<summary><strong>!dns</strong> – DNS reconnaissance</summary>
|
||||||
<p><code>!dns <domain></code> – Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records.</p>
|
<p><code>!dns <domain></code> – Queries A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR records and displays them in a clean, aligned table.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+84
-227
@@ -1,289 +1,146 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping.
|
DNSDumpster.com integration for domain reconnaissance and DNS mapping.
|
||||||
|
Output uses shared code_block for aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from plugins.common import html_escape, code_block, collapsible_summary
|
||||||
|
|
||||||
# Load environment variables from .env file
|
|
||||||
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)
|
|
||||||
|
|
||||||
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
|
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
|
||||||
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
|
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle DNSDumpster 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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
|
||||||
logging.info("Received !dnsdumpster command")
|
|
||||||
|
|
||||||
# Check if API key is configured
|
|
||||||
if not DNSDUMPSTER_API_KEY:
|
if not DNSDUMPSTER_API_KEY:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env.")
|
||||||
room.room_id,
|
|
||||||
"DNSDumpster API key not configured. Please set DNSDUMPSTER_KEY environment variable."
|
|
||||||
)
|
|
||||||
logging.error("DNSDumpster API key not configured")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if it's a test command or domain lookup
|
|
||||||
if args[0].lower() == "test":
|
if args[0].lower() == "test":
|
||||||
await test_dnsdumpster_connection(room, bot)
|
await test_dnsdumpster_connection(room, bot)
|
||||||
else:
|
else:
|
||||||
# Treat the first argument as the domain
|
|
||||||
domain = args[0].lower().strip()
|
domain = args[0].lower().strip()
|
||||||
await dnsdumpster_domain_lookup(room, bot, domain)
|
await dnsdumpster_domain_lookup(room, bot, domain)
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display DNSDumpster command usage."""
|
usage = """<strong>🔍 DNSDumpster Commands:</strong>
|
||||||
usage = """
|
|
||||||
<strong>🔍 DNSDumpster Commands:</strong>
|
|
||||||
|
|
||||||
<strong>!dnsdumpster <domain_name></strong> - Get comprehensive DNS reconnaissance for a domain
|
<strong>!dnsdumpster <domain_name></strong> - Get comprehensive DNS reconnaissance for a domain
|
||||||
<strong>!dnsdumpster test</strong> - Test API connection
|
<strong>!dnsdumpster test</strong> - Test API connection
|
||||||
|
|
||||||
<strong>Examples:</strong>
|
|
||||||
• <code>!dnsdumpster google.com</code>
|
|
||||||
• <code>!dnsdumpster github.com</code>
|
|
||||||
• <code>!dnsdumpster example.com</code>
|
|
||||||
|
|
||||||
<strong>Rate Limit:</strong> 1 request per 2 seconds
|
|
||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def test_dnsdumpster_connection(room, bot):
|
async def test_dnsdumpster_connection(room, bot):
|
||||||
"""Test DNSDumpster API connection."""
|
test_domain = "google.com"
|
||||||
try:
|
try:
|
||||||
test_domain = "google.com" # Changed from example.com to google.com
|
|
||||||
url = f"{DNSDUMPSTER_API_BASE}/domain/{test_domain}"
|
url = f"{DNSDUMPSTER_API_BASE}/domain/{test_domain}"
|
||||||
headers = {
|
headers = {"X-API-Key": DNSDUMPSTER_API_KEY}
|
||||||
"X-API-Key": DNSDUMPSTER_API_KEY
|
async with aiohttp.ClientSession() as session:
|
||||||
}
|
async with session.get(url, headers=headers, timeout=15) as response:
|
||||||
|
status = response.status
|
||||||
logging.info(f"Testing DNSDumpster API with domain: {test_domain}")
|
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>"
|
||||||
response = requests.get(url, headers=headers, timeout=15)
|
if status == 200:
|
||||||
|
data = await response.json()
|
||||||
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>"
|
debug_info += "<strong>✅ SUCCESS</strong><br>"
|
||||||
debug_info += f"<strong>Status Code:</strong> {response.status_code}<br>"
|
if data.get('a'):
|
||||||
debug_info += f"<strong>Test Domain:</strong> {test_domain}<br>"
|
debug_info += f"A Records Found: {len(data['a'])}<br>"
|
||||||
debug_info += f"<strong>Headers Used:</strong> X-API-Key<br>"
|
elif status == 401:
|
||||||
|
debug_info += "<strong>❌ Unauthorized - Invalid API key</strong><br>"
|
||||||
if response.status_code == 200:
|
elif status == 429:
|
||||||
data = response.json()
|
debug_info += "<strong>⚠️ Rate Limit Exceeded</strong><br>"
|
||||||
debug_info += "<strong>✅ SUCCESS - API is working!</strong><br>"
|
else:
|
||||||
debug_info += f"<strong>Response Keys:</strong> {list(data.keys())}<br>"
|
debug_info += f"<strong>❌ Error:</strong> {status}<br>"
|
||||||
|
await bot.api.send_markdown_message(room.room_id, debug_info)
|
||||||
# Show some sample data
|
|
||||||
if data.get('a'):
|
|
||||||
debug_info += f"<strong>A Records Found:</strong> {len(data['a'])}<br>"
|
|
||||||
if data.get('ns'):
|
|
||||||
debug_info += f"<strong>NS Records Found:</strong> {len(data['ns'])}<br>"
|
|
||||||
if data.get('total_a_recs'):
|
|
||||||
debug_info += f"<strong>Total A Records:</strong> {data['total_a_recs']}<br>"
|
|
||||||
|
|
||||||
elif response.status_code == 400:
|
|
||||||
debug_info += "<strong>❌ Bad Request - Check domain format</strong><br>"
|
|
||||||
debug_info += f"<strong>Response:</strong> {response.text[:200]}<br>"
|
|
||||||
elif response.status_code == 401:
|
|
||||||
debug_info += "<strong>❌ Unauthorized - Invalid API key</strong><br>"
|
|
||||||
elif response.status_code == 429:
|
|
||||||
debug_info += "<strong>⚠️ Rate Limit Exceeded - Wait 2 seconds</strong><br>"
|
|
||||||
else:
|
|
||||||
debug_info += f"<strong>❌ Error:</strong> {response.status_code} - {response.text[:200]}<br>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, debug_info)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"Test failed: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Test failed: {str(e)}")
|
||||||
|
|
||||||
async def dnsdumpster_domain_lookup(room, bot, domain):
|
async def dnsdumpster_domain_lookup(room, bot, domain):
|
||||||
"""Get comprehensive DNS reconnaissance for a domain."""
|
safe_domain = html_escape(domain)
|
||||||
try:
|
try:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Processing DNS reconnaissance for {safe_domain}...")
|
||||||
url = f"{DNSDUMPSTER_API_BASE}/domain/{domain}"
|
url = f"{DNSDUMPSTER_API_BASE}/domain/{domain}"
|
||||||
headers = {
|
headers = {"X-API-Key": DNSDUMPSTER_API_KEY}
|
||||||
"X-API-Key": DNSDUMPSTER_API_KEY
|
async with aiohttp.ClientSession() as session:
|
||||||
}
|
async with session.get(url, headers=headers, timeout=30) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"API error: {response.status}")
|
||||||
|
return
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
logging.info(f"Fetching DNSDumpster data for domain: {domain}")
|
sections = []
|
||||||
|
|
||||||
# Send initial processing message
|
# A Records
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Processing DNS reconnaissance for {domain}...")
|
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})
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
# 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})
|
||||||
|
|
||||||
if response.status_code == 400:
|
# MX Records
|
||||||
await bot.api.send_text_message(room.room_id, f"Bad request - check domain format: {domain}")
|
if data.get('mx'):
|
||||||
return
|
rows = []
|
||||||
elif response.status_code == 401:
|
for rec in data['mx']:
|
||||||
await bot.api.send_text_message(room.room_id, "Invalid DNSDumpster API key")
|
host = rec.get('host', 'N/A')
|
||||||
return
|
ips = ', '.join(ip.get('ip', '') for ip in rec.get('ips', []))
|
||||||
elif response.status_code == 403:
|
rows.append(("📧", host, ips))
|
||||||
await bot.api.send_text_message(room.room_id, "Access denied - check API key permissions")
|
sections.append({"title": "MX Records", "rows": rows})
|
||||||
return
|
|
||||||
elif response.status_code == 429:
|
# CNAME
|
||||||
await bot.api.send_text_message(room.room_id, "Rate limit exceeded - wait 2 seconds between requests")
|
if data.get('cname'):
|
||||||
return
|
rows = []
|
||||||
elif response.status_code != 200:
|
for rec in data['cname']:
|
||||||
await bot.api.send_text_message(room.room_id, f"DNSDumpster API error: {response.status_code} - {response.text[:100]}")
|
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
|
return
|
||||||
|
|
||||||
data = response.json()
|
block = code_block(f"🔍 DNSDumpster Report: {safe_domain}", sections)
|
||||||
logging.info(f"DNSDumpster response keys: {list(data.keys())}")
|
output = collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain}", block)
|
||||||
|
|
||||||
# Format the comprehensive DNS report
|
|
||||||
output = await format_dnsdumpster_report(domain, data)
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent DNSDumpster data for {domain}")
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
|
||||||
await bot.api.send_text_message(room.room_id, "DNSDumpster API request timed out")
|
|
||||||
logging.error("DNSDumpster API timeout")
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching DNSDumpster data: {str(e)}")
|
|
||||||
logging.error(f"Error in dnsdumpster_domain_lookup: {e}")
|
|
||||||
|
|
||||||
async def format_dnsdumpster_report(domain, data):
|
|
||||||
"""Format DNSDumpster JSON response into a readable report."""
|
|
||||||
output = f"<strong>🔍 DNSDumpster Report: {domain}</strong><br><br>"
|
|
||||||
|
|
||||||
# Summary statistics
|
|
||||||
if data.get('total_a_recs'):
|
|
||||||
output += f"<strong>📊 Summary</strong><br>"
|
|
||||||
output += f" • <strong>Total A Records:</strong> {data['total_a_recs']}<br>"
|
|
||||||
|
|
||||||
# A Records - Show ALL records
|
|
||||||
if data.get('a') and data['a']:
|
|
||||||
output += f"<br><strong>📍 A Records (IPv4) - {len(data['a'])} found</strong><br>"
|
|
||||||
for record in data['a']: # Show ALL A records
|
|
||||||
host = record.get('host', 'N/A')
|
|
||||||
ips = record.get('ips', [])
|
|
||||||
|
|
||||||
output += f" • <strong>{host}</strong><br>"
|
|
||||||
for ip_info in ips: # Show ALL IPs per host
|
|
||||||
ip = ip_info.get('ip', 'N/A')
|
|
||||||
country = ip_info.get('country', 'Unknown')
|
|
||||||
asn_name = ip_info.get('asn_name', 'Unknown')
|
|
||||||
|
|
||||||
output += f" └─ {ip} ({country})<br>"
|
|
||||||
output += f" └─ {asn_name}<br>"
|
|
||||||
|
|
||||||
# Show banner information if available
|
|
||||||
banners = ip_info.get('banners', {})
|
|
||||||
if banners.get('http') or banners.get('https'):
|
|
||||||
output += f" └─ <em>Web Services:</em> "
|
|
||||||
services = []
|
|
||||||
if banners.get('http'):
|
|
||||||
services.append("HTTP")
|
|
||||||
if banners.get('https'):
|
|
||||||
services.append("HTTPS")
|
|
||||||
output += f"{', '.join(services)}<br>"
|
|
||||||
|
|
||||||
# NS Records - Show ALL records
|
|
||||||
if data.get('ns') and data['ns']:
|
|
||||||
output += f"<br><strong>🔗 NS Records (Name Servers) - {len(data['ns'])} found</strong><br>"
|
|
||||||
for record in data['ns']: # Show ALL NS records
|
|
||||||
host = record.get('host', 'N/A')
|
|
||||||
ips = record.get('ips', [])
|
|
||||||
|
|
||||||
output += f" • <strong>{host}</strong><br>"
|
|
||||||
for ip_info in ips: # Show ALL IPs
|
|
||||||
ip = ip_info.get('ip', 'N/A')
|
|
||||||
country = ip_info.get('country', 'Unknown')
|
|
||||||
output += f" └─ {ip} ({country})<br>"
|
|
||||||
|
|
||||||
# MX Records - Show ALL records
|
|
||||||
if data.get('mx') and data['mx']:
|
|
||||||
output += f"<br><strong>📧 MX Records (Mail Servers) - {len(data['mx'])} found</strong><br>"
|
|
||||||
for record in data['mx']: # Show ALL MX records
|
|
||||||
host = record.get('host', 'N/A')
|
|
||||||
ips = record.get('ips', [])
|
|
||||||
|
|
||||||
output += f" • <strong>{host}</strong><br>"
|
|
||||||
for ip_info in ips: # Show ALL IPs
|
|
||||||
ip = ip_info.get('ip', 'N/A')
|
|
||||||
country = ip_info.get('country', 'Unknown')
|
|
||||||
output += f" └─ {ip} ({country})<br>"
|
|
||||||
|
|
||||||
# CNAME Records - Show ALL records
|
|
||||||
if data.get('cname') and data['cname']:
|
|
||||||
output += f"<br><strong>🔀 CNAME Records - {len(data['cname'])} found</strong><br>"
|
|
||||||
for record in data['cname']: # Show ALL CNAME records
|
|
||||||
host = record.get('host', 'N/A')
|
|
||||||
target = record.get('target', 'N/A')
|
|
||||||
output += f" • {host} → {target}<br>"
|
|
||||||
|
|
||||||
# TXT Records - Show ALL records
|
|
||||||
if data.get('txt') and data['txt']:
|
|
||||||
output += f"<br><strong>📄 TXT Records - {len(data['txt'])} found</strong><br>"
|
|
||||||
for txt in data['txt']: # Show ALL TXT records
|
|
||||||
# Truncate very long TXT records but show more content
|
|
||||||
if len(txt) > 200:
|
|
||||||
txt = txt[:200] + "..."
|
|
||||||
output += f" • {txt}<br>"
|
|
||||||
|
|
||||||
# Additional record types that might be present - Show ALL records
|
|
||||||
other_records = ['aaaa', 'srv', 'soa', 'ptr']
|
|
||||||
for record_type in other_records:
|
|
||||||
if data.get(record_type) and data[record_type]:
|
|
||||||
output += f"<br><strong>🔧 {record_type.upper()} Records - {len(data[record_type])} found</strong><br>"
|
|
||||||
for record in data[record_type]: # Show ALL records
|
|
||||||
if isinstance(record, dict):
|
|
||||||
# Format dictionary records nicely
|
|
||||||
record_str = ", ".join([f"{k}: {v}" for k, v in record.items()])
|
|
||||||
if len(record_str) > 150:
|
|
||||||
record_str = record_str[:150] + "..."
|
|
||||||
output += f" • {record_str}<br>"
|
|
||||||
else:
|
|
||||||
output += f" • {record}<br>"
|
|
||||||
|
|
||||||
# Add rate limit reminder
|
|
||||||
output += "<br><em>💡 Rate Limit: 1 request per 2 seconds</em>"
|
|
||||||
|
|
||||||
# Always wrap in collapsible details since we're showing all results
|
|
||||||
output = f"<details><summary><strong>🔍 DNSDumpster Report: {domain} (Click to expand)</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.2"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "DNSDumpster domain reconnaissance"
|
__description__ = "DNSDumpster domain reconnaissance"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!dnsdumpster</strong> – Comprehensive DNS mapping via DNSDumpster</summary>
|
<summary><strong>!dnsdumpster</strong> – Comprehensive DNS mapping via DNSDumpster</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!dnsdumpster <domain></code> – Full recon (A, NS, MX, CNAME, TXT, etc.)</li>
|
<li><code>!dnsdumpster <domain></code> – Full recon</li>
|
||||||
<li><code>!dnsdumpster test</code> – Test API connection</li>
|
<li><code>!dnsdumpster test</code> – Test API connection</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Requires <strong>DNSDUMPSTER_KEY</strong> env var. Rate limit: 1 req/2 sec.</p>
|
<p>Requires <strong>DNSDUMPSTER_KEY</strong> env var.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1315
File diff suppressed because it is too large
Load Diff
+50
-192
@@ -1,254 +1,112 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to search Exploit-DB for security exploits and vulnerabilities.
|
This plugin provides a command to search Exploit-DB for security exploits.
|
||||||
Uses the searchsploit-style approach with the files.csv database.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from datetime import datetime
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
# Exploit-DB CSV database URL
|
|
||||||
EXPLOITDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
|
EXPLOITDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
|
||||||
|
|
||||||
|
|
||||||
def format_exploit(exploit, index, total):
|
def format_exploit(exploit, index, total):
|
||||||
"""
|
edb_id = html_escape(str(exploit.get('id', 'N/A')))
|
||||||
Format an exploit entry for display.
|
title = html_escape(exploit.get('description', 'No title'))
|
||||||
|
date = html_escape(exploit.get('date', 'Unknown'))
|
||||||
Args:
|
author = html_escape(exploit.get('author', 'Unknown'))
|
||||||
exploit (dict): The exploit data.
|
exploit_type = html_escape(exploit.get('type', 'Unknown'))
|
||||||
index (int): Current result index.
|
platform = html_escape(exploit.get('platform', 'Unknown'))
|
||||||
total (int): Total number of results.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Formatted HTML string.
|
|
||||||
"""
|
|
||||||
edb_id = exploit.get('id', 'N/A')
|
|
||||||
title = exploit.get('description', 'No title')
|
|
||||||
date = exploit.get('date', 'Unknown')
|
|
||||||
author = exploit.get('author', 'Unknown')
|
|
||||||
exploit_type = exploit.get('type', 'Unknown')
|
|
||||||
platform = exploit.get('platform', 'Unknown')
|
|
||||||
|
|
||||||
# Build the URL
|
|
||||||
url = f"https://www.exploit-db.com/exploits/{edb_id}"
|
url = f"https://www.exploit-db.com/exploits/{edb_id}"
|
||||||
|
|
||||||
output = f"""<strong>💣 Exploit {index}/{total}</strong><br>
|
return f"""<strong>💣 Exploit {index}/{total}</strong><br>
|
||||||
<strong>Title:</strong> {title}<br>
|
<strong>Title:</strong> {title}<br>
|
||||||
<strong>EDB-ID:</strong> {edb_id}<br>
|
<strong>EDB-ID:</strong> {edb_id}<br>
|
||||||
<strong>Type:</strong> {exploit_type} | <strong>Platform:</strong> {platform}<br>
|
<strong>Type:</strong> {exploit_type} | <strong>Platform:</strong> {platform}<br>
|
||||||
<strong>Author:</strong> {author} | <strong>Date:</strong> {date}<br>
|
<strong>Author:</strong> {author} | <strong>Date:</strong> {date}<br>
|
||||||
<strong>URL:</strong> <a href="{url}">{url}</a>"""
|
<strong>URL:</strong> <a href="{url}">{url}</a>"""
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
async def search_exploitdb_csv(query, max_results=5):
|
async def search_exploitdb_csv(query, max_results=5):
|
||||||
"""
|
headers = {'User-Agent': 'FunguyBot/1.0'}
|
||||||
Search Exploit-DB CSV database for exploits matching the query.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query (str): Search term.
|
|
||||||
max_results (int): Maximum number of results to return.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of exploit dictionaries, or None on error.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
logging.info(f"Downloading Exploit-DB CSV database...")
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
csv_data = await response.text()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error downloading CSV: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
headers = {
|
results = []
|
||||||
'User-Agent': 'FunguyBot/1.0',
|
try:
|
||||||
}
|
|
||||||
|
|
||||||
# Download the CSV file
|
|
||||||
response = requests.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse CSV
|
|
||||||
csv_data = response.text
|
|
||||||
csv_file = io.StringIO(csv_data)
|
csv_file = io.StringIO(csv_data)
|
||||||
reader = csv.DictReader(csv_file)
|
reader = csv.DictReader(csv_file)
|
||||||
|
|
||||||
# Search through CSV
|
|
||||||
results = []
|
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
|
|
||||||
logging.info(f"Searching CSV for: {query}")
|
|
||||||
|
|
||||||
for row in reader:
|
for row in reader:
|
||||||
# Search in description (title) and other fields
|
|
||||||
description = row.get('description', '').lower()
|
description = row.get('description', '').lower()
|
||||||
file_path = row.get('file', '').lower()
|
file_path = row.get('file', '').lower()
|
||||||
|
|
||||||
if query_lower in description or query_lower in file_path:
|
if query_lower in description or query_lower in file_path:
|
||||||
exploit = {
|
results.append({
|
||||||
'id': row.get('id', 'N/A'),
|
'id': row.get('id', 'N/A'),
|
||||||
'description': row.get('description', 'No title'),
|
'description': row.get('description', 'No title'),
|
||||||
'date': row.get('date_published', row.get('date', 'Unknown')),
|
'date': row.get('date_published', row.get('date', 'Unknown')),
|
||||||
'author': row.get('author', 'Unknown'),
|
'author': row.get('author', 'Unknown'),
|
||||||
'type': row.get('type', 'Unknown'),
|
'type': row.get('type', 'Unknown'),
|
||||||
'platform': row.get('platform', 'Unknown')
|
'platform': row.get('platform', 'Unknown')
|
||||||
}
|
})
|
||||||
results.append(exploit)
|
|
||||||
|
|
||||||
if len(results) >= max_results:
|
if len(results) >= max_results:
|
||||||
break
|
break
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
|
||||||
logging.error("Timeout downloading Exploit-DB database")
|
|
||||||
return None
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logging.error(f"Error downloading Exploit-DB database: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Unexpected error searching Exploit-DB: {e}", exc_info=True)
|
logging.error(f"CSV parse error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def search_exploitdb_google(query, max_results=5):
|
|
||||||
"""
|
|
||||||
Alternative: Search Exploit-DB using site-specific search.
|
|
||||||
Returns formatted search URLs instead of parsing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query (str): Search term.
|
|
||||||
max_results (int): Maximum number of results to return.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Formatted search information.
|
|
||||||
"""
|
|
||||||
# Create search URLs
|
|
||||||
exploitdb_search_url = f"https://www.exploit-db.com/search?q={query}"
|
|
||||||
google_search_url = f"https://www.google.com/search?q=site:exploit-db.com+{query}"
|
|
||||||
|
|
||||||
output = f"""<strong>💣 Exploit-DB Search for: {query}</strong><br><br>
|
|
||||||
<strong>Direct Search:</strong><br>
|
|
||||||
<a href="{exploitdb_search_url}">{exploitdb_search_url}</a><br><br>
|
|
||||||
<strong>Google Site Search:</strong><br>
|
|
||||||
<a href="{google_search_url}">{google_search_url}</a><br><br>
|
|
||||||
<em>💡 Tip: You can also use <code>searchsploit</code> command-line tool for offline searches.</em><br>
|
|
||||||
<em>⚠️ Use responsibly and only on systems you have permission to test.</em>"""
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !exploitdb command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("exploitdb"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("exploitdb"):
|
||||||
logging.info("Received !exploitdb command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
if not args:
|
||||||
if len(args) < 1:
|
await bot.api.send_text_message(room.room_id, "Usage: !exploitdb <search term> [max_results]")
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
"Usage: !exploitdb <search term> [max_results]\n"
|
|
||||||
"Examples:\n"
|
|
||||||
" !exploitdb wordpress\n"
|
|
||||||
" !exploitdb apache 3\n"
|
|
||||||
" !exploitdb windows privilege escalation\n"
|
|
||||||
"Searches Exploit-DB for security vulnerabilities and exploits."
|
|
||||||
)
|
|
||||||
logging.info("Sent usage message for !exploitdb")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if last argument is a number (max results)
|
|
||||||
max_results = 5
|
max_results = 5
|
||||||
search_terms = args
|
search_terms = args
|
||||||
|
|
||||||
if args[-1].isdigit():
|
if args[-1].isdigit():
|
||||||
max_results = int(args[-1])
|
max_results = int(args[-1])
|
||||||
if max_results < 1:
|
if max_results < 1: max_results = 1
|
||||||
max_results = 1
|
elif max_results > 10: max_results = 10
|
||||||
elif max_results > 10:
|
|
||||||
max_results = 10
|
|
||||||
search_terms = args[:-1]
|
search_terms = args[:-1]
|
||||||
|
|
||||||
query = ' '.join(search_terms)
|
query = ' '.join(search_terms)
|
||||||
|
safe_query = html_escape(query)
|
||||||
|
|
||||||
try:
|
await bot.api.send_text_message(room.room_id, f"🔍 Searching Exploit-DB for: {safe_query}...")
|
||||||
# Send "searching" message
|
exploits = await search_exploitdb_csv(query, max_results)
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
f"🔍 Searching Exploit-DB for: {query}... (this may take a moment)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try CSV search first
|
if exploits is None:
|
||||||
exploits = await search_exploitdb_csv(query, max_results)
|
await bot.api.send_text_message(room.room_id, "❌ Failed to search Exploit-DB (network error).")
|
||||||
|
return
|
||||||
|
|
||||||
if exploits is None:
|
if not exploits:
|
||||||
# Fallback to providing search links
|
exploitdb_url = f"https://www.exploit-db.com/search?q={query}"
|
||||||
logging.warning("CSV search failed, providing search links instead")
|
google_url = f"https://www.google.com/search?q=site:exploit-db.com+{query}"
|
||||||
output = await search_exploitdb_google(query, max_results)
|
msg = f"No exploits found for <strong>{safe_query}</strong>.<br>Direct: <a href='{exploitdb_url}'>Exploit-DB</a> | <a href='{google_url}'>Google</a>"
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not exploits:
|
total = len(exploits)
|
||||||
# Also provide search links when no results
|
output = f"<strong>💣 Exploit-DB Search Results for: {safe_query}</strong><br><br>"
|
||||||
output = f"No exploits found in local search for: <strong>{query}</strong><br><br>"
|
for idx, exp in enumerate(exploits, 1):
|
||||||
output += await search_exploitdb_google(query, max_results)
|
output += format_exploit(exp, idx, total) + "<br><br>"
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
output += "<em>⚠️ Use responsibly</em>"
|
||||||
logging.info(f"No exploits found for: {query}")
|
|
||||||
return
|
|
||||||
|
|
||||||
total = len(exploits)
|
if total > 2:
|
||||||
logging.info(f"Found {total} exploit(s) for: {query}")
|
output = collapsible_summary(f"💣 Exploit-DB: {safe_query} ({total} results)", output)
|
||||||
|
|
||||||
# Format all results
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
output = f"<strong>💣 Exploit-DB Search Results for: {query}</strong><br><br>"
|
|
||||||
|
|
||||||
for idx, exploit in enumerate(exploits, 1):
|
__version__ = "1.0.1"
|
||||||
output += format_exploit(exploit, idx, total)
|
|
||||||
output += "<br><br>"
|
|
||||||
|
|
||||||
output += f"<em>⚠️ Use responsibly and only on systems you have permission to test.</em>"
|
|
||||||
|
|
||||||
# Wrap in collapsible details if more than 2 results
|
|
||||||
if total > 2:
|
|
||||||
summary = f"<strong>💣 Exploit-DB: {query}</strong> ({total} results)"
|
|
||||||
output = f"<details><summary>{summary}</summary>{output}</details>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
logging.info(f"Sent {total} exploit(s) for: {query}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
f"An error occurred while searching Exploit-DB: {str(e)}"
|
|
||||||
)
|
|
||||||
logging.error(f"Error in exploitdb plugin: {e}", exc_info=True)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Exploit-DB search"
|
__description__ = "Exploit-DB search"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!exploitdb</strong> – Search Exploit Database</summary>
|
||||||
<details>
|
<p><code>!exploitdb <search term> [max_results]</code></p></details>"""
|
||||||
<summary><strong>!exploitdb</strong> – Search Exploit Database</summary>
|
|
||||||
<p><code>!exploitdb <search term> [max_results]</code> – Search for exploits (title, EDB-ID, type, platform, author, link).<br>
|
|
||||||
Example: <code>!exploitdb wordpress 5</code></p>
|
|
||||||
<p>Fetches from the official Exploit-DB CSV. Falls back to search links if unavailable.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
"""
|
||||||
+19
-28
@@ -1,41 +1,32 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to get a random fortune message.
|
This plugin provides a command to get a random fortune message.
|
||||||
"""
|
"""
|
||||||
# plugins/fortune.py
|
import asyncio
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import logging
|
import logging
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !fortune command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("fortune"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("fortune"):
|
||||||
logging.info("Received !fortune command")
|
logging.info("Received !fortune command")
|
||||||
fortune_output = "🃏 " + subprocess.run(['/usr/games/fortune'], capture_output=True).stdout.decode('UTF-8')
|
try:
|
||||||
await bot.api.send_markdown_message(room.room_id, fortune_output)
|
proc = await asyncio.create_subprocess_exec(
|
||||||
logging.info("Sent fortune to the room")
|
'/usr/games/fortune',
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode == 0 and stdout:
|
||||||
|
fortune_text = "🃏 " + stdout.decode('UTF-8')
|
||||||
|
else:
|
||||||
|
fortune_text = "🃏 Fortune command failed."
|
||||||
|
await bot.api.send_markdown_message(room.room_id, fortune_text)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Fortune error: {e}")
|
||||||
|
await bot.api.send_text_message(room.room_id, "Fortune unavailable.")
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Random fortune message"
|
__description__ = "Random fortune message"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!fortune</strong> – Random fortune</summary>
|
||||||
<details>
|
<p>Runs the <code>/usr/games/fortune</code> utility.</p></details>"""
|
||||||
<summary><strong>!fortune</strong> – Random fortune</summary>
|
|
||||||
<p>Runs the <code>/usr/games/fortune</code> utility and posts a random quote.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+72
-75
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides IP geolocation functionality using free APIs.
|
IP geolocation plugin – uses ip-api.com (primary) and ipapi.co (fallback).
|
||||||
It uses ip-api.com as the primary API with a fallback to ipapi.co.
|
Outputs a formatted code block with emojis and perfectly aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -8,8 +8,7 @@ import aiohttp
|
|||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import socket
|
import socket
|
||||||
import re
|
import re
|
||||||
|
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
async def is_valid_ip(ip):
|
async def is_valid_ip(ip):
|
||||||
"""Check if the provided string is a valid IP address."""
|
"""Check if the provided string is a valid IP address."""
|
||||||
@@ -44,14 +43,10 @@ async def query_ip_api_com(ip):
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url) as response:
|
async with session.get(url) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
return await response.json()
|
||||||
return data
|
|
||||||
else:
|
|
||||||
logging.error(f"ip-api.com returned status {response.status}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error querying ip-api.com: {e}")
|
logging.error(f"ip-api.com error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def query_ipapi_co(ip):
|
async def query_ipapi_co(ip):
|
||||||
"""Query ipapi.co for geolocation information (fallback)."""
|
"""Query ipapi.co for geolocation information (fallback)."""
|
||||||
@@ -60,65 +55,18 @@ async def query_ipapi_co(ip):
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url) as response:
|
async with session.get(url) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
return await response.json()
|
||||||
return data
|
|
||||||
else:
|
|
||||||
logging.error(f"ipapi.co returned status {response.status}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error querying ipapi.co: {e}")
|
logging.error(f"ipapi.co error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def query_geolocation(ip):
|
async def query_geolocation(ip):
|
||||||
"""Query geolocation information using primary and fallback APIs."""
|
"""Query geolocation using primary and fallback APIs."""
|
||||||
data = await query_ip_api_com(ip)
|
data = await query_ip_api_com(ip)
|
||||||
if not data or data.get('status') == 'fail':
|
if not data or data.get('status') == 'fail':
|
||||||
logging.info("Primary API failed, trying fallback API")
|
|
||||||
data = await query_ipapi_co(ip)
|
data = await query_ipapi_co(ip)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def format_geolocation_results(ip, data):
|
|
||||||
"""Format geolocation results into a readable message."""
|
|
||||||
if not data:
|
|
||||||
return f"🔍 No geolocation data found for {ip}."
|
|
||||||
if 'status' in data and data.get('status') == 'fail':
|
|
||||||
return f"🔍 No geolocation data found for {ip}."
|
|
||||||
if 'country' in data:
|
|
||||||
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')
|
|
||||||
else:
|
|
||||||
country = data.get('country_name', data.get('country', 'N/A'))
|
|
||||||
country_code = data.get('country_code', data.get('countryCode', 'N/A'))
|
|
||||||
region = data.get('region', 'N/A')
|
|
||||||
city = data.get('city', 'N/A')
|
|
||||||
postal = data.get('postal', 'N/A')
|
|
||||||
latitude = data.get('latitude', 'N/A')
|
|
||||||
longitude = data.get('longitude', 'N/A')
|
|
||||||
timezone = data.get('timezone', 'N/A')
|
|
||||||
isp = data.get('org', 'N/A')
|
|
||||||
org = data.get('org', 'N/A')
|
|
||||||
asn = data.get('asn', 'N/A')
|
|
||||||
content = f"<strong>🔍 IP Geolocation Results for {ip}</strong><br><br>"
|
|
||||||
content += f"<strong>Country:</strong> {country} ({country_code})<br>"
|
|
||||||
content += f"<strong>Region:</strong> {region}<br>"
|
|
||||||
content += f"<strong>City:</strong> {city}<br>"
|
|
||||||
content += f"<strong>Postal Code:</strong> {postal}<br>"
|
|
||||||
content += f"<strong>Coordinates:</strong> {latitude}, {longitude}<br>"
|
|
||||||
content += f"<strong>Timezone:</strong> {timezone}<br>"
|
|
||||||
content += f"<strong>ISP/Organization:</strong> {isp}<br>"
|
|
||||||
content += f"<strong>ASN:</strong> {asn}<br>"
|
|
||||||
message = f"<details><summary><strong>🔍 Geolocation: {ip}</strong></summary>{content}</details>"
|
|
||||||
return message
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Handle the !geo command."""
|
"""Handle the !geo command."""
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
@@ -132,57 +80,106 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
return
|
return
|
||||||
query = args[0].strip()
|
query = args[0].strip()
|
||||||
logging.info(f"Received !geo command for: {query}")
|
logging.info(f"Received !geo command for: {query}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ip = query
|
ip = query
|
||||||
if is_domain(query):
|
if is_domain(query):
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
f"🔍 Resolving domain {query} to IP address..."
|
f"🔍 Resolving domain {html_escape(query)} to IP address..."
|
||||||
)
|
)
|
||||||
ip = await resolve_domain(query)
|
ip = await resolve_domain(query)
|
||||||
if not ip:
|
if not ip:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
f"Failed to resolve domain {query} to IP address.")
|
f"Failed to resolve domain {html_escape(query)} to IP address.")
|
||||||
return
|
return
|
||||||
if not is_public_destination(ip):
|
if not is_public_destination(ip):
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
"❌ That domain resolves to a private/internal IP, geo not allowed.")
|
"❌ That domain resolves to a private/internal IP, geo not allowed.")
|
||||||
return
|
return
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
f"Domain {query} resolved to IP {ip}")
|
f"Domain {html_escape(query)} resolved to IP {ip}")
|
||||||
elif not await is_valid_ip(query):
|
elif not await is_valid_ip(query):
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
f"Invalid IP address or domain format: {query}")
|
f"Invalid IP address or domain format: {html_escape(query)}")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
if not is_public_destination(ip):
|
if not is_public_destination(ip):
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
"❌ Geolocation of private IP addresses is not allowed.")
|
"❌ Geolocation of private IP addresses is not allowed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
f"🔍 Looking up geolocation for {ip}...")
|
f"🔍 Looking up geolocation for {ip}...")
|
||||||
|
|
||||||
geo_data = await query_geolocation(ip)
|
geo_data = await query_geolocation(ip)
|
||||||
result_message = await format_geolocation_results(ip, geo_data)
|
|
||||||
await bot.api.send_markdown_message(room.room_id, result_message)
|
if not geo_data or ('status' in geo_data and geo_data.get('status') == 'fail'):
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No geolocation data found for {ip}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build rows
|
||||||
|
rows = []
|
||||||
|
if 'country' in geo_data: # ip-api.com format
|
||||||
|
country = geo_data.get('country', 'N/A')
|
||||||
|
country_code = geo_data.get('countryCode', 'N/A')
|
||||||
|
region = geo_data.get('regionName', geo_data.get('region', 'N/A'))
|
||||||
|
city = geo_data.get('city', 'N/A')
|
||||||
|
postal = geo_data.get('zip', 'N/A')
|
||||||
|
latitude = geo_data.get('lat', 'N/A')
|
||||||
|
longitude = geo_data.get('lon', 'N/A')
|
||||||
|
timezone = geo_data.get('timezone', 'N/A')
|
||||||
|
isp = geo_data.get('isp', 'N/A')
|
||||||
|
org = geo_data.get('org', 'N/A')
|
||||||
|
asn = geo_data.get('as', 'N/A')
|
||||||
|
else: # ipapi.co format
|
||||||
|
country = geo_data.get('country_name', geo_data.get('country', 'N/A'))
|
||||||
|
country_code = geo_data.get('country_code', geo_data.get('countryCode', 'N/A'))
|
||||||
|
region = geo_data.get('region', 'N/A')
|
||||||
|
city = geo_data.get('city', 'N/A')
|
||||||
|
postal = geo_data.get('postal', 'N/A')
|
||||||
|
latitude = geo_data.get('latitude', 'N/A')
|
||||||
|
longitude = geo_data.get('longitude', 'N/A')
|
||||||
|
timezone = geo_data.get('timezone', 'N/A')
|
||||||
|
isp = geo_data.get('org', 'N/A')
|
||||||
|
org = geo_data.get('org', 'N/A')
|
||||||
|
asn = geo_data.get('asn', 'N/A')
|
||||||
|
|
||||||
|
rows.append(("🌍", "Country", f"{country} ({country_code})"))
|
||||||
|
rows.append(("🏙️", "City", city))
|
||||||
|
if region and region != city:
|
||||||
|
rows.append(("🏷️", "Region", region))
|
||||||
|
if postal and postal != 'N/A':
|
||||||
|
rows.append(("📮", "Postal Code", postal))
|
||||||
|
rows.append(("📍", "Coordinates", f"{latitude}, {longitude}"))
|
||||||
|
rows.append(("🕒", "Timezone", timezone))
|
||||||
|
rows.append(("📡", "ISP", isp))
|
||||||
|
if org and org != isp:
|
||||||
|
rows.append(("🏢", "Organization", org))
|
||||||
|
if asn and asn != 'N/A':
|
||||||
|
rows.append(("🔢", "ASN", asn))
|
||||||
|
|
||||||
|
sections = [{"title": "", "rows": rows}]
|
||||||
|
block = code_block(f"🔍 IP Geolocation for {ip}", sections)
|
||||||
|
output = collapsible_summary(f"🔍 Geolocation: {ip}", block)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Successfully sent geolocation results for {ip}")
|
logging.info(f"Successfully sent geolocation results for {ip}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id,
|
||||||
f"An error occurred during geolocation lookup for {query}. Please try again later.")
|
f"An error occurred during geolocation lookup for {html_escape(query)}.")
|
||||||
logging.error(f"Error in geo plugin for {query}: {e}", exc_info=True)
|
logging.error(f"Error in geo plugin for {query}: {e}", exc_info=True)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
__version__ = "1.0.1"
|
|
||||||
|
__version__ = "1.1.1"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "IP geolocation lookup"
|
__description__ = "IP geolocation lookup"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!geo</strong> – IP / domain geolocation</summary>
|
<summary><strong>!geo</strong> – IP / domain geolocation</summary>
|
||||||
<ul>
|
<p><code>!geo <ip or domain></code> – Locate an IP address or domain. Shows country, city, coordinates, ISP, ASN, etc.</p>
|
||||||
<li><code>!geo <ip></code> – Locate an IP address</li>
|
|
||||||
<li><code>!geo <domain></code> – Resolves domain then locates</li>
|
|
||||||
</ul>
|
|
||||||
<p>Shows country, region, city, coordinates, ISP, ASN. Uses ip-api.com / ipapi.co.</p>
|
|
||||||
</details>
|
</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 logging
|
||||||
import re
|
import re
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import collapsible_summary, html_escape, code_block
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hash identification logic (unchanged from original)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
def identify_hash(hash_string):
|
def identify_hash(hash_string):
|
||||||
"""
|
|
||||||
Identify the hash type based on comprehensive pattern matching.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hash_string (str): The hash string to identify
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of tuples (hash_type, hashcat_mode, john_format, confidence)
|
|
||||||
"""
|
|
||||||
|
|
||||||
hash_string = hash_string.strip()
|
hash_string = hash_string.strip()
|
||||||
hash_lower = hash_string.lower()
|
hash_lower = hash_string.lower()
|
||||||
length = len(hash_string)
|
length = len(hash_string)
|
||||||
@@ -25,15 +20,10 @@ def identify_hash(hash_string):
|
|||||||
|
|
||||||
# Unix crypt and modular crypt formats (most specific first)
|
# Unix crypt and modular crypt formats (most specific first)
|
||||||
if hash_string.startswith('$'):
|
if hash_string.startswith('$'):
|
||||||
# yescrypt (modern Linux /etc/shadow)
|
|
||||||
if re.match(r'^\$y\$', hash_string):
|
if re.match(r'^\$y\$', hash_string):
|
||||||
possible_types.append(("yescrypt", None, "yescrypt", 95))
|
possible_types.append(("yescrypt", None, "yescrypt", 95))
|
||||||
|
|
||||||
# scrypt
|
|
||||||
elif re.match(r'^\$7\$', hash_string):
|
elif re.match(r'^\$7\$', hash_string):
|
||||||
possible_types.append(("scrypt", "8900", "scrypt", 95))
|
possible_types.append(("scrypt", "8900", "scrypt", 95))
|
||||||
|
|
||||||
# Argon2
|
|
||||||
elif re.match(r'^\$argon2(id?|d)\$', hash_string):
|
elif re.match(r'^\$argon2(id?|d)\$', hash_string):
|
||||||
if '$argon2i$' in hash_string:
|
if '$argon2i$' in hash_string:
|
||||||
possible_types.append(("Argon2i", "10900", "argon2", 95))
|
possible_types.append(("Argon2i", "10900", "argon2", 95))
|
||||||
@@ -41,72 +31,39 @@ def identify_hash(hash_string):
|
|||||||
possible_types.append(("Argon2d", None, "argon2", 95))
|
possible_types.append(("Argon2d", None, "argon2", 95))
|
||||||
elif '$argon2id$' in hash_string:
|
elif '$argon2id$' in hash_string:
|
||||||
possible_types.append(("Argon2id", "10900", "argon2", 95))
|
possible_types.append(("Argon2id", "10900", "argon2", 95))
|
||||||
|
|
||||||
# bcrypt variants
|
|
||||||
elif re.match(r'^\$(2[abxy]?)\$', hash_string):
|
elif re.match(r'^\$(2[abxy]?)\$', hash_string):
|
||||||
bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1)
|
bcrypt_type = re.match(r'^\$(2[abxy]?)\$', hash_string).group(1)
|
||||||
possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95))
|
possible_types.append((f"bcrypt ({bcrypt_type})", "3200", "bcrypt", 95))
|
||||||
|
|
||||||
# SHA-512 Crypt (common in Linux)
|
|
||||||
elif re.match(r'^\$6\$', hash_string):
|
elif re.match(r'^\$6\$', hash_string):
|
||||||
possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95))
|
possible_types.append(("SHA-512 Crypt (Unix)", "1800", "sha512crypt", 95))
|
||||||
|
|
||||||
# SHA-256 Crypt (Unix)
|
|
||||||
elif re.match(r'^\$5\$', hash_string):
|
elif re.match(r'^\$5\$', hash_string):
|
||||||
possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95))
|
possible_types.append(("SHA-256 Crypt (Unix)", "7400", "sha256crypt", 95))
|
||||||
|
|
||||||
# MD5 Crypt (Unix)
|
|
||||||
elif re.match(r'^\$1\$', hash_string):
|
elif re.match(r'^\$1\$', hash_string):
|
||||||
possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95))
|
possible_types.append(("MD5 Crypt (Unix)", "500", "md5crypt", 95))
|
||||||
|
|
||||||
# Apache MD5
|
|
||||||
elif re.match(r'^\$apr1\$', hash_string):
|
elif re.match(r'^\$apr1\$', hash_string):
|
||||||
possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95))
|
possible_types.append(("Apache MD5 (apr1)", "1600", "md5crypt", 95))
|
||||||
|
|
||||||
# AIX SMD5
|
|
||||||
elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE):
|
elif re.match(r'^\{smd5\}', hash_string, re.IGNORECASE):
|
||||||
possible_types.append(("AIX {smd5}", "6300", None, 90))
|
possible_types.append(("AIX {smd5}", "6300", None, 90))
|
||||||
|
|
||||||
# AIX SSHA256
|
|
||||||
elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE):
|
elif re.match(r'^\{ssha256\}', hash_string, re.IGNORECASE):
|
||||||
possible_types.append(("AIX {ssha256}", "6700", None, 90))
|
possible_types.append(("AIX {ssha256}", "6700", None, 90))
|
||||||
|
|
||||||
# AIX SSHA512
|
|
||||||
elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE):
|
elif re.match(r'^\{ssha512\}', hash_string, re.IGNORECASE):
|
||||||
possible_types.append(("AIX {ssha512}", "6800", None, 90))
|
possible_types.append(("AIX {ssha512}", "6800", None, 90))
|
||||||
|
|
||||||
# phpBB3
|
|
||||||
elif re.match(r'^\$H\$', hash_string):
|
elif re.match(r'^\$H\$', hash_string):
|
||||||
possible_types.append(("phpBB3", "400", "phpass", 90))
|
possible_types.append(("phpBB3", "400", "phpass", 90))
|
||||||
|
|
||||||
# Wordpress
|
|
||||||
elif re.match(r'^\$P\$', hash_string):
|
elif re.match(r'^\$P\$', hash_string):
|
||||||
possible_types.append(("Wordpress", "400", "phpass", 90))
|
possible_types.append(("Wordpress", "400", "phpass", 90))
|
||||||
|
|
||||||
# Drupal 7+
|
|
||||||
elif re.match(r'^\$S\$', hash_string):
|
elif re.match(r'^\$S\$', hash_string):
|
||||||
possible_types.append(("Drupal 7+", "7900", "drupal7", 90))
|
possible_types.append(("Drupal 7+", "7900", "drupal7", 90))
|
||||||
|
|
||||||
# WBB3 (Woltlab Burning Board)
|
|
||||||
elif re.match(r'^\$wbb3\$', hash_string):
|
elif re.match(r'^\$wbb3\$', hash_string):
|
||||||
possible_types.append(("WBB3 (Woltlab)", None, None, 85))
|
possible_types.append(("WBB3 (Woltlab)", None, None, 85))
|
||||||
|
|
||||||
# PBKDF2-HMAC-SHA256
|
|
||||||
elif re.match(r'^\$pbkdf2-sha256\$', hash_string):
|
elif re.match(r'^\$pbkdf2-sha256\$', hash_string):
|
||||||
possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90))
|
possible_types.append(("PBKDF2-HMAC-SHA256", "10900", "pbkdf2-hmac-sha256", 90))
|
||||||
|
|
||||||
# PBKDF2-HMAC-SHA512
|
|
||||||
elif re.match(r'^\$pbkdf2-sha512\$', hash_string):
|
elif re.match(r'^\$pbkdf2-sha512\$', hash_string):
|
||||||
possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90))
|
possible_types.append(("PBKDF2-HMAC-SHA512", None, "pbkdf2-hmac-sha512", 90))
|
||||||
|
|
||||||
# Django PBKDF2
|
|
||||||
elif re.match(r'^pbkdf2_sha256\$', hash_string):
|
elif re.match(r'^pbkdf2_sha256\$', hash_string):
|
||||||
possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90))
|
possible_types.append(("Django PBKDF2-SHA256", "10000", "django", 90))
|
||||||
|
|
||||||
# Unknown modular crypt format
|
|
||||||
else:
|
else:
|
||||||
possible_types.append(("Unknown Modular Crypt Format", None, None, 30))
|
possible_types.append(("Unknown Modular Crypt Format", None, None, 30))
|
||||||
|
|
||||||
return possible_types
|
return possible_types
|
||||||
|
|
||||||
# LDAP formats
|
# LDAP formats
|
||||||
@@ -123,31 +80,22 @@ def identify_hash(hash_string):
|
|||||||
possible_types.append(("LDAP CRYPT", None, None, 85))
|
possible_types.append(("LDAP CRYPT", None, None, 85))
|
||||||
return possible_types
|
return possible_types
|
||||||
|
|
||||||
# Check for colon-separated formats (LM:NTLM, username:hash, etc.)
|
# Colon-separated formats
|
||||||
if ':' in hash_string:
|
if ':' in hash_string:
|
||||||
parts = hash_string.split(':')
|
parts = hash_string.split(':')
|
||||||
|
|
||||||
# NetNTLMv1 / NetNTLMv2
|
|
||||||
if len(parts) >= 5:
|
if len(parts) >= 5:
|
||||||
possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85))
|
possible_types.append(("NetNTLMv2", "5600", "netntlmv2", 85))
|
||||||
possible_types.append(("NetNTLMv1", "5500", "netntlm", 75))
|
possible_types.append(("NetNTLMv1", "5500", "netntlm", 75))
|
||||||
|
|
||||||
# LM:NTLM format
|
|
||||||
elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32:
|
elif len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 32:
|
||||||
possible_types.append(("LM:NTLM", "1000", "nt", 90))
|
possible_types.append(("LM:NTLM", "1000", "nt", 90))
|
||||||
|
|
||||||
# Username:Hash or similar
|
|
||||||
elif len(parts) == 2:
|
elif len(parts) == 2:
|
||||||
hash_part = parts[1]
|
hash_part = parts[1]
|
||||||
if len(hash_part) == 32:
|
if len(hash_part) == 32:
|
||||||
possible_types.append(("NTLM (with username)", "1000", "nt", 80))
|
possible_types.append(("NTLM (with username)", "1000", "nt", 80))
|
||||||
elif len(hash_part) == 40:
|
elif len(hash_part) == 40:
|
||||||
possible_types.append(("SHA-1 (with salt/username)", "110", None, 70))
|
possible_types.append(("SHA-1 (with salt/username)", "110", None, 70))
|
||||||
|
|
||||||
# PostgreSQL md5
|
|
||||||
if hash_string.startswith('md5') and len(hash_string) == 35:
|
if hash_string.startswith('md5') and len(hash_string) == 35:
|
||||||
possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90))
|
possible_types.append(("PostgreSQL MD5", "3100", "postgres", 90))
|
||||||
|
|
||||||
return possible_types if possible_types else None
|
return possible_types if possible_types else None
|
||||||
|
|
||||||
# MySQL formats
|
# MySQL formats
|
||||||
@@ -159,7 +107,6 @@ def identify_hash(hash_string):
|
|||||||
if re.match(r'^[A-F0-9]{16}:[A-F0-9]{16}$', hash_string.upper()):
|
if re.match(r'^[A-F0-9]{16}:[A-F0-9]{16}$', hash_string.upper()):
|
||||||
possible_types.append(("Oracle 11g", "112", "oracle11", 90))
|
possible_types.append(("Oracle 11g", "112", "oracle11", 90))
|
||||||
return possible_types
|
return possible_types
|
||||||
|
|
||||||
if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()):
|
if re.match(r'^S:[A-F0-9]{60}$', hash_string.upper()):
|
||||||
possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90))
|
possible_types.append(("Oracle 12c/18c", "12300", "oracle12c", 90))
|
||||||
return possible_types
|
return possible_types
|
||||||
@@ -168,234 +115,84 @@ def identify_hash(hash_string):
|
|||||||
if re.match(r'^0x0100[A-F0-9]{8}[A-F0-9]{40}$', hash_string.upper()):
|
if re.match(r'^0x0100[A-F0-9]{8}[A-F0-9]{40}$', hash_string.upper()):
|
||||||
possible_types.append(("MSSQL 2000", "131", "mssql", 90))
|
possible_types.append(("MSSQL 2000", "131", "mssql", 90))
|
||||||
return possible_types
|
return possible_types
|
||||||
|
|
||||||
if re.match(r'^0x0200[A-F0-9]{8}[A-F0-9]{128}$', hash_string.upper()):
|
if re.match(r'^0x0200[A-F0-9]{8}[A-F0-9]{128}$', hash_string.upper()):
|
||||||
possible_types.append(("MSSQL 2012/2014", "1731", "mssql12", 90))
|
possible_types.append(("MSSQL 2012/2014", "1731", "mssql12", 90))
|
||||||
return possible_types
|
return possible_types
|
||||||
|
|
||||||
# Base64 pattern check
|
|
||||||
is_base64 = re.match(r'^[A-Za-z0-9+/]+=*$', hash_string) and length % 4 == 0
|
|
||||||
|
|
||||||
# Raw hash identification by length
|
# Raw hash identification by length
|
||||||
is_hex = re.match(r'^[a-f0-9]+$', hash_lower)
|
is_hex = re.match(r'^[a-f0-9]+$', hash_lower)
|
||||||
|
|
||||||
if is_hex:
|
if is_hex:
|
||||||
if length == 16:
|
if length == 16:
|
||||||
possible_types.append(("MySQL < 4.1", "200", "mysql", 85))
|
possible_types.append(("MySQL < 4.1", "200", "mysql", 85))
|
||||||
possible_types.append(("Half MD5", None, None, 60))
|
possible_types.append(("Half MD5", None, None, 60))
|
||||||
|
|
||||||
elif length == 32:
|
elif length == 32:
|
||||||
possible_types.append(("MD5", "0", "raw-md5", 80))
|
possible_types.append(("MD5", "0", "raw-md5", 80))
|
||||||
possible_types.append(("MD4", "900", "raw-md4", 70))
|
possible_types.append(("MD4", "900", "raw-md4", 70))
|
||||||
possible_types.append(("NTLM", "1000", "nt", 75))
|
possible_types.append(("NTLM", "1000", "nt", 75))
|
||||||
possible_types.append(("LM", "3000", "lm", 60))
|
possible_types.append(("LM", "3000", "lm", 60))
|
||||||
possible_types.append(("RAdmin v2.x", "9900", None, 50))
|
|
||||||
possible_types.append(("Snefru-128", None, None, 40))
|
|
||||||
possible_types.append(("HMAC-MD5 (key = $pass)", "50", None, 50))
|
|
||||||
|
|
||||||
elif length == 40:
|
elif length == 40:
|
||||||
possible_types.append(("SHA-1", "100", "raw-sha1", 85))
|
possible_types.append(("SHA-1", "100", "raw-sha1", 85))
|
||||||
possible_types.append(("RIPEMD-160", "6000", "ripemd-160", 65))
|
possible_types.append(("RIPEMD-160", "6000", "ripemd-160", 65))
|
||||||
possible_types.append(("Tiger-160", None, None, 50))
|
|
||||||
possible_types.append(("Haval-160", None, None, 45))
|
|
||||||
possible_types.append(("HMAC-SHA1 (key = $pass)", "150", None, 55))
|
|
||||||
|
|
||||||
elif length == 48:
|
|
||||||
possible_types.append(("Tiger-192", None, None, 70))
|
|
||||||
possible_types.append(("Haval-192", None, None, 65))
|
|
||||||
|
|
||||||
elif length == 56:
|
|
||||||
possible_types.append(("SHA-224", "1300", "raw-sha224", 85))
|
|
||||||
possible_types.append(("Haval-224", None, None, 60))
|
|
||||||
|
|
||||||
elif length == 64:
|
elif length == 64:
|
||||||
possible_types.append(("SHA-256", "1400", "raw-sha256", 85))
|
possible_types.append(("SHA-256", "1400", "raw-sha256", 85))
|
||||||
possible_types.append(("RIPEMD-256", None, None, 60))
|
|
||||||
possible_types.append(("SHA3-256", "17400", "raw-sha3", 70))
|
possible_types.append(("SHA3-256", "17400", "raw-sha3", 70))
|
||||||
possible_types.append(("Keccak-256", "17800", "raw-keccak-256", 70))
|
possible_types.append(("Keccak-256", "17800", "raw-keccak-256", 70))
|
||||||
possible_types.append(("Haval-256", None, None, 50))
|
|
||||||
possible_types.append(("GOST R 34.11-94", "6900", None, 55))
|
|
||||||
possible_types.append(("BLAKE2b-256", None, None, 60))
|
|
||||||
|
|
||||||
elif length == 80:
|
|
||||||
possible_types.append(("RIPEMD-320", None, None, 80))
|
|
||||||
|
|
||||||
elif length == 96:
|
|
||||||
possible_types.append(("SHA-384", "10800", "raw-sha384", 85))
|
|
||||||
possible_types.append(("SHA3-384", "17900", None, 70))
|
|
||||||
possible_types.append(("Keccak-384", None, None, 65))
|
|
||||||
|
|
||||||
elif length == 128:
|
elif length == 128:
|
||||||
possible_types.append(("SHA-512", "1700", "raw-sha512", 85))
|
possible_types.append(("SHA-512", "1700", "raw-sha512", 85))
|
||||||
possible_types.append(("Whirlpool", "6100", "whirlpool", 75))
|
possible_types.append(("Whirlpool", "6100", "whirlpool", 75))
|
||||||
possible_types.append(("SHA3-512", "17600", None, 70))
|
|
||||||
possible_types.append(("Keccak-512", None, None, 65))
|
|
||||||
possible_types.append(("BLAKE2b-512", None, None, 60))
|
|
||||||
|
|
||||||
# Base64 encoded hashes
|
|
||||||
elif is_base64:
|
|
||||||
if length == 24:
|
|
||||||
possible_types.append(("MD5 (Base64)", None, None, 75))
|
|
||||||
elif length == 28:
|
|
||||||
possible_types.append(("SHA-1 (Base64)", None, None, 75))
|
|
||||||
elif length == 32:
|
|
||||||
possible_types.append(("SHA-224 (Base64)", None, None, 75))
|
|
||||||
elif length == 44:
|
|
||||||
possible_types.append(("SHA-256 (Base64)", None, None, 75))
|
|
||||||
elif length == 64:
|
|
||||||
possible_types.append(("SHA-384 (Base64)", None, None, 75))
|
|
||||||
elif length == 88:
|
|
||||||
possible_types.append(("SHA-512 (Base64)", None, None, 75))
|
|
||||||
|
|
||||||
return possible_types if possible_types else [("Unknown", None, None, 0)]
|
return possible_types if possible_types else [("Unknown", None, None, 0)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output formatting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _format_results(hash_input, results):
|
||||||
|
"""Build a code block with sections for each possible hash type."""
|
||||||
|
sections = []
|
||||||
|
for idx, (hash_type, hashcat_mode, john_format, confidence) in enumerate(results, 1):
|
||||||
|
emoji = "🟢" if confidence >= 90 else "🟡" if confidence >= 80 else "🟠" if confidence >= 60 else "🔴"
|
||||||
|
title = f"{emoji} Match #{idx}: {hash_type} ({confidence}%)"
|
||||||
|
rows = [
|
||||||
|
("", "Hash Type", hash_type),
|
||||||
|
("", "Confidence", f"{confidence}%"),
|
||||||
|
]
|
||||||
|
if hashcat_mode:
|
||||||
|
rows.append(("", "Hashcat Mode", f"-m {hashcat_mode}"))
|
||||||
|
if john_format:
|
||||||
|
rows.append(("", "John Format", f"--format={john_format}"))
|
||||||
|
sections.append({"title": title, "rows": rows})
|
||||||
|
|
||||||
|
block = code_block(f"🔐 Hash Identification: {hash_input[:30]}...", sections)
|
||||||
|
return collapsible_summary("🔐 Hash Identification Results", block)
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !hashid command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("hashid"):
|
||||||
logging.info("Received !hashid command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
usage_msg = """<strong>🔐 Hash Identifier Usage</strong>
|
await bot.api.send_markdown_message(room.room_id, "<strong>Usage:</strong> <code>!hashid <hash></code>")
|
||||||
|
|
||||||
<strong>Usage:</strong> <code>!hashid <hash></code>
|
|
||||||
|
|
||||||
<strong>Examples:</strong>
|
|
||||||
• <code>!hashid 5f4dcc3b5aa765d61d8327deb882cf99</code>
|
|
||||||
• <code>!hashid 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8</code>
|
|
||||||
• <code>!hashid $6$rounds=5000$salt$hash...</code>
|
|
||||||
• <code>!hashid $y$j9T$...</code> (yescrypt from /etc/shadow)
|
|
||||||
|
|
||||||
<strong>Supported Hash Types:</strong>
|
|
||||||
• <strong>Modern:</strong> yescrypt, scrypt, Argon2, bcrypt
|
|
||||||
• <strong>Unix Crypt:</strong> SHA-512 Crypt, SHA-256 Crypt, MD5 Crypt
|
|
||||||
• <strong>Raw Hashes:</strong> MD5, SHA-1/224/256/384/512, SHA-3, NTLM, LM
|
|
||||||
• <strong>Database:</strong> MySQL, PostgreSQL, Oracle, MSSQL
|
|
||||||
• <strong>CMS:</strong> Wordpress, phpBB3, Drupal, Django
|
|
||||||
• <strong>LDAP:</strong> SSHA, SMD5, and various LDAP formats
|
|
||||||
• <strong>Network:</strong> NetNTLMv1/v2, Kerberos
|
|
||||||
• <strong>Exotic:</strong> Whirlpool, RIPEMD, BLAKE2, Keccak, GOST
|
|
||||||
"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, usage_msg)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
hash_input = ' '.join(args)
|
hash_input = ' '.join(args)
|
||||||
|
results = identify_hash(hash_input)
|
||||||
try:
|
if not results or results[0][0] == "Unknown":
|
||||||
# Identify the hash
|
await bot.api.send_text_message(room.room_id, "Could not identify the hash type.")
|
||||||
identified = identify_hash(hash_input)
|
return
|
||||||
|
# Sort by confidence descending
|
||||||
if not identified:
|
results.sort(key=lambda x: x[3], reverse=True)
|
||||||
await bot.api.send_text_message(
|
output = _format_results(hash_input, results[:6]) # show top 6
|
||||||
room.room_id,
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
"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)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.1.0"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Hash type identifier"
|
__description__ = "Hash type identifier"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!hashid</strong> – Identify hash type</summary>
|
<summary><strong>!hashid</strong> – Identify hash type</summary>
|
||||||
<p><code>!hashid <hash></code> – Recognises 100+ hash formats (MD5, SHA, bcrypt, etc.).<br>
|
<p><code>!hashid <hash></code> – Recognises 100+ formats and displays tool modes in a clean table.</p>
|
||||||
Shows confidence level, Hashcat mode, and John the Ripper format.</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+147
-352
@@ -1,413 +1,208 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides comprehensive HTTP security header analysis.
|
HTTP security header analysis plugin.
|
||||||
|
Outputs a structured code block with perfectly aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
import ssl
|
import ssl
|
||||||
import socket
|
import socket
|
||||||
|
import datetime
|
||||||
|
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||||
|
|
||||||
from plugins.utils import is_public_destination
|
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 handle_command(room, message, bot, prefix, config):
|
async def analyze_http_response(url):
|
||||||
"""
|
|
||||||
Function to handle !headers command for HTTP security header analysis.
|
|
||||||
|
|
||||||
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("headers"):
|
|
||||||
logging.info("Received !headers command")
|
|
||||||
|
|
||||||
args = match.args()
|
|
||||||
|
|
||||||
if len(args) < 1:
|
|
||||||
await show_usage(room, bot)
|
|
||||||
return
|
|
||||||
|
|
||||||
url = args[0].strip()
|
|
||||||
|
|
||||||
# Add protocol if missing
|
|
||||||
if not url.startswith(('http://', 'https://')):
|
|
||||||
url = 'https://' + url
|
|
||||||
|
|
||||||
# SSRF protection: refuse internal hosts
|
|
||||||
parsed = urlparse(url)
|
|
||||||
host = parsed.hostname
|
|
||||||
if not is_public_destination(host):
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
"❌ Scanning of private/internal addresses is not allowed.")
|
|
||||||
return
|
|
||||||
|
|
||||||
await analyze_headers(room, bot, url)
|
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
|
||||||
"""Display headers command usage."""
|
|
||||||
usage = """
|
|
||||||
<strong>🔒 HTTP Security Headers Analysis</strong>
|
|
||||||
|
|
||||||
<strong>!headers <url></strong> - Comprehensive HTTP security header analysis
|
|
||||||
|
|
||||||
<strong>Examples:</strong>
|
|
||||||
• <code>!headers example.com</code>
|
|
||||||
• <code>!headers https://github.com</code>
|
|
||||||
• <code>!headers http://localhost:8080</code>
|
|
||||||
|
|
||||||
<strong>Analyzes:</strong>
|
|
||||||
• Security headers presence and configuration
|
|
||||||
• SSL/TLS certificate information
|
|
||||||
• HTTP to HTTPS redirects
|
|
||||||
• Security scoring and recommendations
|
|
||||||
"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
|
||||||
|
|
||||||
async def analyze_headers(room, bot, url):
|
|
||||||
"""Perform comprehensive HTTP security header analysis."""
|
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {url}")
|
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
|
||||||
|
|
||||||
results = {
|
async def analyze_https_response(url):
|
||||||
'url': url,
|
|
||||||
'http_headers': {},
|
|
||||||
'https_headers': {},
|
|
||||||
'redirect_chain': [],
|
|
||||||
'ssl_info': {},
|
|
||||||
'security_score': 0,
|
|
||||||
'recommendations': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test HTTP first (if HTTPS was provided, we'll still check redirects)
|
|
||||||
parsed = urlparse(url)
|
|
||||||
http_url = f"http://{parsed.netloc or parsed.path}"
|
|
||||||
https_url = f"https://{parsed.netloc or parsed.path}"
|
|
||||||
|
|
||||||
# Analyze HTTP response and redirects
|
|
||||||
await analyze_http_response(results, http_url if not url.startswith('https://') else https_url)
|
|
||||||
|
|
||||||
# Analyze HTTPS response
|
|
||||||
if url.startswith('https://') or results.get('redirects_to_https'):
|
|
||||||
await analyze_https_response(results, https_url)
|
|
||||||
|
|
||||||
# Analyze SSL certificate if HTTPS
|
|
||||||
if url.startswith('https://') or results.get('redirects_to_https'):
|
|
||||||
await analyze_ssl_certificate(results, parsed.netloc or parsed.path)
|
|
||||||
|
|
||||||
# Calculate security score
|
|
||||||
await calculate_security_score(results)
|
|
||||||
|
|
||||||
# Generate recommendations
|
|
||||||
await generate_recommendations(results)
|
|
||||||
|
|
||||||
# Format and send results
|
|
||||||
output = await format_header_analysis(results)
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
|
|
||||||
logging.info(f"Completed header analysis for {url}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error analyzing headers: {str(e)}")
|
|
||||||
logging.error(f"Error in analyze_headers: {e}")
|
|
||||||
|
|
||||||
async def analyze_http_response(results, url):
|
|
||||||
"""Analyze HTTP response and redirect chain."""
|
|
||||||
try:
|
try:
|
||||||
session = requests.Session()
|
async with aiohttp.ClientSession() as session:
|
||||||
session.max_redirects = 5
|
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, {}
|
||||||
|
|
||||||
response = session.get(url, timeout=10, allow_redirects=True)
|
def _get_cert_info(domain):
|
||||||
results['final_url'] = response.url
|
|
||||||
results['status_code'] = response.status_code
|
|
||||||
results['http_headers'] = dict(response.headers)
|
|
||||||
|
|
||||||
# Check if redirects to HTTPS
|
|
||||||
results['redirects_to_https'] = response.url.startswith('https://')
|
|
||||||
|
|
||||||
# Store redirect history
|
|
||||||
results['redirect_chain'] = [{
|
|
||||||
'url': resp.url,
|
|
||||||
'status_code': resp.status_code,
|
|
||||||
'headers': dict(resp.headers)
|
|
||||||
} for resp in response.history]
|
|
||||||
|
|
||||||
except requests.exceptions.SSLError:
|
|
||||||
results['ssl_error'] = True
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
results['http_error'] = str(e)
|
|
||||||
|
|
||||||
async def analyze_https_response(results, url):
|
|
||||||
"""Analyze HTTPS response headers."""
|
|
||||||
try:
|
|
||||||
response = requests.get(url, timeout=10, allow_redirects=False)
|
|
||||||
results['https_headers'] = dict(response.headers)
|
|
||||||
results['https_status'] = response.status_code
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
results['https_error'] = str(e)
|
|
||||||
|
|
||||||
async def analyze_ssl_certificate(results, domain):
|
|
||||||
"""Analyze SSL certificate information."""
|
|
||||||
try:
|
try:
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
with socket.create_connection((domain, 443), timeout=10) as sock:
|
with socket.create_connection((domain, 443), timeout=10) as sock:
|
||||||
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
||||||
cert = ssock.getpeercert()
|
cert = ssock.getpeercert()
|
||||||
|
return {
|
||||||
results['ssl_info'] = {
|
|
||||||
'subject': dict(x[0] for x in cert['subject']),
|
'subject': dict(x[0] for x in cert['subject']),
|
||||||
'issuer': dict(x[0] for x in cert['issuer']),
|
'issuer': dict(x[0] for x in cert['issuer']),
|
||||||
'not_before': cert['notBefore'],
|
'not_before': cert['notBefore'],
|
||||||
'not_after': cert['notAfter'],
|
'not_after': cert['notAfter'],
|
||||||
'san': cert.get('subjectAltName', []),
|
'san': cert.get('subjectAltName', []),
|
||||||
'version': cert.get('version'),
|
|
||||||
'serial_number': cert.get('serialNumber')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results['ssl_error'] = str(e)
|
logging.warning(f"SSL cert error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def calculate_security_score(results):
|
def calculate_score(headers, redirects_to_https, cert_info):
|
||||||
"""Calculate overall security score based on headers and configuration."""
|
|
||||||
score = 100
|
score = 100
|
||||||
missing_headers = []
|
if 'Strict-Transport-Security' not in headers: score -= 15
|
||||||
|
if 'Content-Security-Policy' not in headers: score -= 15
|
||||||
# Critical security headers
|
if 'X-Content-Type-Options' not in headers: score -= 15
|
||||||
critical_headers = [
|
if 'X-Frame-Options' not in headers: score -= 15
|
||||||
'Strict-Transport-Security',
|
if 'X-XSS-Protection' not in headers: score -= 15
|
||||||
'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', '')
|
hsts = headers.get('Strict-Transport-Security', '')
|
||||||
if 'max-age=31536000' not in hsts:
|
if 'max-age=31536000' not in hsts: score -= 10
|
||||||
score -= 10
|
if 'includeSubDomains' not in hsts: score -= 5
|
||||||
if 'includeSubDomains' not in hsts:
|
if 'preload' not in hsts: score -= 5
|
||||||
score -= 5
|
if headers.get('Referrer-Policy'): score += 5
|
||||||
if 'preload' not in hsts:
|
if headers.get('Feature-Policy') or headers.get('Permissions-Policy'): score += 5
|
||||||
score -= 5
|
if headers.get('X-Content-Type-Options') == 'nosniff': score += 5
|
||||||
|
if headers.get('X-Frame-Options') in ['DENY', 'SAMEORIGIN']: score += 5
|
||||||
|
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)
|
||||||
|
|
||||||
# Check CSP configuration
|
def generate_recommendations(headers, redirects_to_https):
|
||||||
csp = headers.get('Content-Security-Policy', '')
|
recs = []
|
||||||
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', {})
|
|
||||||
|
|
||||||
# HSTS recommendations
|
|
||||||
if 'Strict-Transport-Security' not in headers:
|
if 'Strict-Transport-Security' not in headers:
|
||||||
recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload")
|
recs.append("🔒 Implement HSTS with max-age=31536000, includeSubDomains, preload")
|
||||||
else:
|
|
||||||
hsts = headers['Strict-Transport-Security']
|
|
||||||
if 'max-age=31536000' not in hsts:
|
|
||||||
recommendations.append("🔒 Increase HSTS max-age to 31536000 (1 year)")
|
|
||||||
if 'includeSubDomains' not in hsts:
|
|
||||||
recommendations.append("🔒 Add includeSubDomains to HSTS header")
|
|
||||||
if 'preload' not in hsts:
|
|
||||||
recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading")
|
|
||||||
|
|
||||||
# CSP recommendations
|
|
||||||
if 'Content-Security-Policy' not in headers:
|
if 'Content-Security-Policy' not in headers:
|
||||||
recommendations.append("🛡️ Implement Content Security Policy to prevent XSS attacks")
|
recs.append("🛡️ Add Content-Security-Policy")
|
||||||
else:
|
|
||||||
csp = headers['Content-Security-Policy']
|
|
||||||
if "default-src 'self'" not in csp and "default-src 'none'" not in csp:
|
|
||||||
recommendations.append("🛡️ Restrict CSP default-src to 'self' or specific origins")
|
|
||||||
|
|
||||||
# Frame options
|
|
||||||
if 'X-Frame-Options' not in headers:
|
if 'X-Frame-Options' not in headers:
|
||||||
recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)")
|
recs.append("🚫 Add X-Frame-Options (DENY or SAMEORIGIN)")
|
||||||
|
|
||||||
# Content type options
|
|
||||||
if 'X-Content-Type-Options' not in headers:
|
if 'X-Content-Type-Options' not in headers:
|
||||||
recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing")
|
recs.append("📄 Add X-Content-Type-Options: nosniff")
|
||||||
|
if not redirects_to_https:
|
||||||
# Referrer policy
|
recs.append("🔐 Redirect HTTP to HTTPS")
|
||||||
if 'Referrer-Policy' not in headers:
|
|
||||||
recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage")
|
|
||||||
|
|
||||||
# Feature policy
|
|
||||||
if 'Feature-Policy' not in headers and 'Permissions-Policy' not in headers:
|
|
||||||
recommendations.append("⚙️ Implement Feature-Policy/Permissions-Policy to restrict browser features")
|
|
||||||
|
|
||||||
# Remove server information
|
|
||||||
if 'Server' in headers or 'X-Powered-By' in headers:
|
if 'Server' in headers or 'X-Powered-By' in headers:
|
||||||
recommendations.append("🕵️ Remove Server and X-Powered-By headers to avoid information disclosure")
|
recs.append("🕵️ Remove info disclosure headers (Server, X-Powered-By)")
|
||||||
|
return recs
|
||||||
|
|
||||||
# HTTPS enforcement
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
if not results.get('redirects_to_https') and not results['url'].startswith('https://'):
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
recommendations.append("🔐 Implement HTTP to HTTPS redirects")
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("headers")):
|
||||||
|
return
|
||||||
|
|
||||||
results['recommendations'] = recommendations
|
args = match.args()
|
||||||
|
if len(args) < 1:
|
||||||
|
await bot.api.send_markdown_message(room.room_id,
|
||||||
|
"<strong>🔒 HTTP Security Headers Analysis</strong>\n<code>!headers <url></code>")
|
||||||
|
return
|
||||||
|
|
||||||
async def format_header_analysis(results):
|
original_input = args[0].strip()
|
||||||
"""Format the header analysis results for display."""
|
url = original_input
|
||||||
output = f"<strong>🔒 Security Headers Analysis: {results['url']}</strong><br><br>"
|
if not url.startswith(('http://', 'https://')):
|
||||||
|
url = 'https://' + url
|
||||||
|
|
||||||
# Security Score
|
parsed = urlparse(url)
|
||||||
score = results['security_score']
|
host = parsed.hostname
|
||||||
|
if not is_public_destination(host):
|
||||||
|
await bot.api.send_text_message(room.room_id, "❌ Private/internal addresses are not allowed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
safe_input = html_escape(original_input)
|
||||||
|
safe_host = html_escape(host)
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {safe_input}...")
|
||||||
|
|
||||||
|
final_url, status_code, http_headers, redirects_to_https = await analyze_http_response(url)
|
||||||
|
_, https_headers = await analyze_https_response(url) if url.startswith('https://') else (None, {})
|
||||||
|
|
||||||
|
headers = https_headers or http_headers
|
||||||
|
cert_info = None
|
||||||
|
if url.startswith('https://'):
|
||||||
|
cert_info = await _run_in_thread(_get_cert_info, host)
|
||||||
|
|
||||||
|
score = calculate_score(headers, redirects_to_https, cert_info)
|
||||||
|
recommendations = generate_recommendations(headers, redirects_to_https)
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
# Score
|
||||||
score_emoji = "🟢" if score >= 80 else "🟡" if score >= 60 else "🔴"
|
score_emoji = "🟢" if score >= 80 else "🟡" if score >= 60 else "🔴"
|
||||||
output += f"<strong>{score_emoji} Security Score: {score}/100</strong><br><br>"
|
sections.append({
|
||||||
|
"title": f"{score_emoji} Security Score",
|
||||||
|
"rows": [("", "Score", f"{score}/100")]
|
||||||
|
})
|
||||||
|
|
||||||
# Basic Information
|
# Basic Information
|
||||||
output += "<strong>📊 Basic Information</strong><br>"
|
basic_rows = [
|
||||||
output += f" • <strong>Final URL:</strong> {results.get('final_url', 'N/A')}<br>"
|
("🌐", "Final URL", final_url),
|
||||||
output += f" • <strong>Status Code:</strong> {results.get('status_code', 'N/A')}<br>"
|
("📊", "Status Code", str(status_code) if status_code else "N/A"),
|
||||||
if results.get('redirects_to_https'):
|
("🔐", "HTTPS Redirect", "✅ Yes" if redirects_to_https else "❌ No"),
|
||||||
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>"
|
]
|
||||||
else:
|
sections.append({"title": "📊 Basic Information", "rows": basic_rows})
|
||||||
output += f" • <strong>HTTPS Redirect:</strong> ❌ Not enforced<br>"
|
|
||||||
output += f" • <strong>Redirect Chain:</strong> {len(results.get('redirect_chain', []))} hops<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Security Headers Analysis
|
|
||||||
headers = results.get('https_headers') or results.get('http_headers', {})
|
|
||||||
output += "<strong>🛡️ Security Headers Analysis</strong><br>"
|
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
security_headers = {
|
security_headers = {
|
||||||
'Strict-Transport-Security': ('🔒', 'HSTS - HTTP Strict Transport Security'),
|
'Strict-Transport-Security': ('🔒', 'HSTS'),
|
||||||
'Content-Security-Policy': ('🛡️', 'CSP - Content Security Policy'),
|
'Content-Security-Policy': ('🛡️', 'CSP'),
|
||||||
'X-Frame-Options': ('🚫', 'Clickjacking Protection'),
|
'X-Frame-Options': ('🚫', 'Frame Options'),
|
||||||
'X-Content-Type-Options': ('📄', 'MIME Type Sniffing Protection'),
|
'X-Content-Type-Options': ('📄', 'Content Type'),
|
||||||
'X-XSS-Protection': ('❌', 'XSS Protection (Deprecated)'),
|
'X-XSS-Protection': ('❌', 'XSS Protection'),
|
||||||
'Referrer-Policy': ('🔗', 'Referrer Policy'),
|
'Referrer-Policy': ('🔗', 'Referrer Policy'),
|
||||||
'Feature-Policy': ('⚙️', 'Feature Policy'),
|
'Permissions-Policy': ('🔧', 'Permissions Policy'),
|
||||||
'Permissions-Policy': ('🔧', 'Permissions Policy'),
|
'Feature-Policy': ('⚙️', 'Feature Policy'),
|
||||||
}
|
}
|
||||||
|
header_rows = []
|
||||||
for header, (emoji, description) in security_headers.items():
|
for hdr, (emoji, label) in security_headers.items():
|
||||||
if header in headers:
|
if hdr in headers:
|
||||||
value = headers[header]
|
val = headers[hdr][:100]
|
||||||
if len(value) > 100:
|
header_rows.append((emoji, label, f"✅ {val}"))
|
||||||
value = value[:100] + "..."
|
|
||||||
output += f" • {emoji} <strong>{header}:</strong> ✅ {value}<br>"
|
|
||||||
else:
|
else:
|
||||||
output += f" • {emoji} <strong>{header}:</strong> ❌ Missing<br>"
|
header_rows.append((emoji, label, "❌ Missing"))
|
||||||
|
sections.append({"title": "🛡️ Security Headers", "rows": header_rows})
|
||||||
|
|
||||||
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})
|
||||||
|
|
||||||
# Other Headers (Information Disclosure)
|
# SSL Certificate
|
||||||
output += "<strong>📋 Other Headers</strong><br>"
|
if cert_info:
|
||||||
info_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version']
|
ssl_rows = [
|
||||||
for header in info_headers:
|
("📜", "Subject", cert_info['subject'].get('commonName', 'N/A')),
|
||||||
if header in headers:
|
("🏢", "Issuer", cert_info['issuer'].get('organizationName', 'N/A')),
|
||||||
output += f" • 🔍 <strong>{header}:</strong> {headers[header]}<br>"
|
("📅", "Expires", cert_info.get('not_after', 'N/A')),
|
||||||
|
]
|
||||||
output += "<br>"
|
san = [san[1] for san in cert_info.get('san', []) if san[0] == 'DNS']
|
||||||
|
if san:
|
||||||
# SSL Certificate Information (if available)
|
ssl_rows.append(("🌐", "SANs", ", ".join(san[:5])))
|
||||||
if results.get('ssl_info'):
|
sections.append({"title": "🔐 SSL Certificate", "rows": ssl_rows})
|
||||||
output += "<strong>🔐 SSL Certificate</strong><br>"
|
|
||||||
ssl_info = results['ssl_info']
|
|
||||||
if ssl_info.get('subject'):
|
|
||||||
output += f" • <strong>Subject:</strong> {ssl_info['subject'].get('commonName', 'N/A')}<br>"
|
|
||||||
if ssl_info.get('issuer'):
|
|
||||||
output += f" • <strong>Issuer:</strong> {ssl_info['issuer'].get('organizationName', 'N/A')}<br>"
|
|
||||||
if ssl_info.get('not_after'):
|
|
||||||
output += f" • <strong>Expires:</strong> {ssl_info['not_after']}<br>"
|
|
||||||
if ssl_info.get('san'):
|
|
||||||
san_count = len([san for san in ssl_info['san'] if san[0] == 'DNS'])
|
|
||||||
output += f" • <strong>SAN Entries:</strong> {san_count}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Recommendations
|
# Recommendations
|
||||||
if results.get('recommendations'):
|
if recommendations:
|
||||||
output += "<strong>💡 Security Recommendations</strong><br>"
|
rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
|
||||||
for rec in results['recommendations'][:8]: # Show first 8 recommendations
|
sections.append({"title": "💡 Recommendations", "rows": rec_rows})
|
||||||
output += f" • {rec}<br>"
|
|
||||||
|
|
||||||
if len(results['recommendations']) > 8:
|
|
||||||
output += f" • ... and {len(results['recommendations']) - 8} more recommendations<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Missing Headers Summary
|
|
||||||
if results.get('missing_headers'):
|
|
||||||
output += "<strong>⚠️ Critical Headers Missing</strong><br>"
|
|
||||||
for header in results['missing_headers']:
|
|
||||||
output += f" • ❌ {header}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Security Rating
|
|
||||||
score = results['security_score']
|
|
||||||
if score >= 80:
|
|
||||||
rating = "🟢 Excellent"
|
|
||||||
description = "Strong security headers configuration"
|
|
||||||
elif score >= 60:
|
|
||||||
rating = "🟡 Good"
|
|
||||||
description = "Moderate security, room for improvement"
|
|
||||||
elif score >= 40:
|
|
||||||
rating = "🟠 Fair"
|
|
||||||
description = "Basic security, significant improvements needed"
|
|
||||||
else:
|
|
||||||
rating = "🔴 Poor"
|
|
||||||
description = "Weak security headers configuration"
|
|
||||||
|
|
||||||
output += f"<strong>📈 Security Rating:</strong> {rating}<br>"
|
|
||||||
output += f"<strong>📝 Assessment:</strong> {description}<br>"
|
|
||||||
|
|
||||||
# Wrap in collapsible if content is large
|
|
||||||
if len(output) > 1000:
|
|
||||||
output = f"<details><summary><strong>🔒 Security Headers Analysis: {results['url']}</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.1.2"
|
||||||
__version__ = "1.0.1"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "HTTP security header analysis"
|
__description__ = "HTTP security header analysis"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!headers</strong> – HTTP security header scanner</summary>
|
<summary><strong>!headers</strong> – HTTP security headers analysis</summary>
|
||||||
<p><code>!headers <url></code> – Checks HSTS, CSP, X-Frame-Options, etc.<br>
|
<p><code>!headers <url></code> – Analyzes security headers, SSL cert, gives score and recommendations in a clean, aligned table.</p>
|
||||||
Provides security score (0-100) and recommendations. Also shows SSL certificate info.</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+35
-102
@@ -1,40 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Plugin for generating text using Infermatic AI API and sending it to a Matrix chat room.
|
Plugin for generating text using Infermatic AI API and sending it to a Matrix chat room.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import requests
|
import aiohttp
|
||||||
import argparse
|
|
||||||
import json
|
import json
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from asyncio import Queue
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import re
|
import re
|
||||||
|
from plugins.common import html_escape
|
||||||
|
|
||||||
# Load environment variables from .env file in the parent directory
|
# No load_dotenv – handled centrally by funguy.py
|
||||||
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)
|
|
||||||
|
|
||||||
# Infermatic AI API configuration
|
|
||||||
INFERMATIC_API_KEY = os.getenv("INFERMATIC_API", "")
|
INFERMATIC_API_KEY = os.getenv("INFERMATIC_API", "")
|
||||||
DEFAULT_MODEL = os.getenv("INFERMATIC_MODEL", "Sao10K-L3.1-70B-Hanami-x1")
|
DEFAULT_MODEL = os.getenv("INFERMATIC_MODEL", "Sao10K-L3.1-70B-Hanami-x1")
|
||||||
INFERMATIC_API_BASE = "https://api.totalgpt.ai/v1"
|
INFERMATIC_API_BASE = "https://api.totalgpt.ai/v1"
|
||||||
|
|
||||||
# Queue to store pending commands
|
|
||||||
command_queue = Queue()
|
|
||||||
|
|
||||||
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))
|
|
||||||
await bot.api.send_text_message(room.room_id, "Command queued. Please wait for the current request to finish.")
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Handle !text command: generate text using Infermatic AI API."""
|
"""Handle !text command: generate text using Infermatic AI API."""
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
@@ -42,29 +20,20 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
if not (match.prefix() and match.command("text")):
|
if not (match.prefix() and match.command("text")):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if API key is configured
|
|
||||||
if not INFERMATIC_API_KEY:
|
if not INFERMATIC_API_KEY:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Infermatic API key not configured. Set INFERMATIC_API in .env.")
|
||||||
room.room_id,
|
|
||||||
"Infermatic API key not configured. Please set INFERMATIC_API environment variable."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse command arguments
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if it's a --list-models command
|
|
||||||
if args[0] == "--list-models":
|
if args[0] == "--list-models":
|
||||||
await list_models(room, bot)
|
await list_models(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse other arguments
|
|
||||||
try:
|
try:
|
||||||
# Extract options manually since argparse doesn't handle mixed positional/optional well
|
|
||||||
temperature = 0.9
|
temperature = 0.9
|
||||||
max_tokens = 512
|
max_tokens = 512
|
||||||
custom_model = None
|
custom_model = None
|
||||||
@@ -86,13 +55,11 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
prompt = ' '.join(prompt_parts).strip()
|
prompt = ' '.join(prompt_parts).strip()
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
model = custom_model or DEFAULT_MODEL
|
model = custom_model or DEFAULT_MODEL
|
||||||
|
|
||||||
await generate_text(room, bot, prompt, model, temperature, max_tokens)
|
await generate_text(room, bot, prompt, model, temperature, max_tokens)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -101,7 +68,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
await bot.api.send_text_message(room.room_id, f"Error processing command: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Error processing command: {str(e)}")
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display command usage information."""
|
|
||||||
usage = """
|
usage = """
|
||||||
<strong>📄 Infermatic Text Generation Usage:</strong>
|
<strong>📄 Infermatic Text Generation Usage:</strong>
|
||||||
|
|
||||||
@@ -119,75 +85,57 @@ async def show_usage(room, bot):
|
|||||||
<strong>Examples:</strong>
|
<strong>Examples:</strong>
|
||||||
• <code>!text write a python function to calculate fibonacci</code>
|
• <code>!text write a python function to calculate fibonacci</code>
|
||||||
• <code>!text --list-models</code>
|
• <code>!text --list-models</code>
|
||||||
• <code>!text --use-model llama-v3-8b-instruct explain quantum computing</code>
|
|
||||||
• <code>!text --temperature 0.7 write a haiku about AI</code>
|
|
||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def list_models(room, bot):
|
async def list_models(room, bot):
|
||||||
"""List all available models from Infermatic AI."""
|
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, "🔍 Fetching available models...")
|
await bot.api.send_text_message(room.room_id, "🔍 Fetching available models...")
|
||||||
|
|
||||||
url = f"{INFERMATIC_API_BASE}/models"
|
url = f"{INFERMATIC_API_BASE}/models"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, timeout=30) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
models = data.get('data', [])
|
models = data.get('data', [])
|
||||||
|
|
||||||
if not models:
|
if not models:
|
||||||
await bot.api.send_text_message(room.room_id, "No models found or error in response.")
|
await bot.api.send_text_message(room.room_id, "No models found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Format the model list
|
|
||||||
output = "<strong>🔧 Available Models:</strong><br><br>"
|
output = "<strong>🔧 Available Models:</strong><br><br>"
|
||||||
|
|
||||||
for model in models:
|
for model in models:
|
||||||
model_id = model.get('id', 'Unknown')
|
model_id = html_escape(model.get('id', 'Unknown'))
|
||||||
model_name = model.get('name', model_id)
|
model_name = html_escape(model.get('name', model_id))
|
||||||
context_length = model.get('context_length', 'Unknown')
|
context_length = model.get('context_length', 'Unknown')
|
||||||
pricing = model.get('pricing', {})
|
|
||||||
|
|
||||||
output += f"<strong>• {model_name}</strong><br>"
|
output += f"<strong>• {model_name}</strong><br>"
|
||||||
output += f" └─ ID: <code>{model_id}</code><br>"
|
output += f" └─ ID: <code>{model_id}</code><br>"
|
||||||
output += f" └─ Context: {context_length}<br>"
|
output += f" └─ Context: {context_length}<br>"
|
||||||
|
|
||||||
if pricing:
|
|
||||||
prompt_price = pricing.get('prompt', '0')
|
|
||||||
completion_price = pricing.get('completion', '0')
|
|
||||||
output += f" └─ Price: ${prompt_price}/${completion_price} per 1M tokens<br>"
|
|
||||||
|
|
||||||
output += f" └─ <strong>Usage:</strong> <code>!text --use-model {model_id} <prompt></code><br><br>"
|
output += f" └─ <strong>Usage:</strong> <code>!text --use-model {model_id} <prompt></code><br><br>"
|
||||||
|
|
||||||
# Wrap in collapsible details since list can be long
|
# Wrap in collapsible (from common)
|
||||||
output = f"<details><summary><strong>🔧 Available Models (Click to expand)</strong></summary>{output}</details>"
|
from plugins.common import collapsible_summary
|
||||||
|
msg = collapsible_summary("🔧 Available Models (Click to expand)", output)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
except aiohttp.ClientError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"❌ API error: {e}")
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Error fetching models: {str(e)}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"❌ Error: {e}")
|
||||||
|
|
||||||
import re # add at the top of the file
|
|
||||||
|
|
||||||
async def generate_text(room, bot, prompt, model, temperature, max_tokens):
|
async def generate_text(room, bot, prompt, model, temperature, max_tokens):
|
||||||
"""Generate text using the Infermatic AI API."""
|
safe_prompt = html_escape(prompt)
|
||||||
|
safe_model = html_escape(model)
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, f"📝 Generating text...")
|
await bot.api.send_text_message(room.room_id, "📝 Generating text...")
|
||||||
|
|
||||||
url = f"{INFERMATIC_API_BASE}/chat/completions"
|
url = f"{INFERMATIC_API_BASE}/chat/completions"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -197,49 +145,34 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens):
|
|||||||
"max_tokens": max_tokens
|
"max_tokens": max_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=120)
|
async with aiohttp.ClientSession() as session:
|
||||||
response.raise_for_status()
|
async with session.post(url, headers=headers, json=payload, timeout=120) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
generated_text = data.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
generated_text = data.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
||||||
|
|
||||||
if not generated_text:
|
if not generated_text:
|
||||||
await bot.api.send_text_message(room.room_id, "No response generated.")
|
await bot.api.send_text_message(room.room_id, "No response generated.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ---- Clean up blank lines that break list rendering ----
|
# Clean up blank lines that break list rendering
|
||||||
# Remove blank lines directly before a list item (number‐dot or hyphen).
|
|
||||||
generated_text = re.sub(r'\n\n(\d+\.)', r'\n\1', generated_text)
|
generated_text = re.sub(r'\n\n(\d+\.)', r'\n\1', generated_text)
|
||||||
generated_text = re.sub(r'\n\n(- )', r'\n\1', generated_text)
|
generated_text = re.sub(r'\n\n(- )', r'\n\1', generated_text)
|
||||||
|
|
||||||
# Build a pure Markdown message (no HTML)
|
# Escape any stray HTML inside the generated text before embedding
|
||||||
output = f"**Model:** `{model}`\n\n**Prompt:** {prompt}\n\n**Response:**\n\n{generated_text}"
|
generated_text = html_escape(generated_text)
|
||||||
|
|
||||||
|
output = f"<strong>Model:</strong> <code>{safe_model}</code><br><strong>Prompt:</strong> {safe_prompt}<br><br><strong>Response:</strong><br><br>{generated_text}"
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "❌ Request timed out. The model is taking too long to respond.")
|
await bot.api.send_text_message(room.room_id, f"❌ API error: {e}")
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
if e.response.status_code == 401:
|
|
||||||
await bot.api.send_text_message(room.room_id, "❌ Authentication failed. Please check your INFERMATIC_API key.")
|
|
||||||
elif e.response.status_code == 429:
|
|
||||||
await bot.api.send_text_message(room.room_id, "❌ Rate limit exceeded. Please try again later.")
|
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ API error: HTTP {e.response.status_code}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Error generating text: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"❌ Error: {e}")
|
||||||
finally:
|
|
||||||
if not command_queue.empty():
|
|
||||||
next_command = await command_queue.get()
|
|
||||||
await handle_command(*next_command)
|
|
||||||
|
|
||||||
|
__version__ = "1.0.3"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.2"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "AI text generation via Infermatic API (pure Markdown output)"
|
__description__ = "AI text generation via Infermatic API"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!text</strong> – AI text generation (Infermatic)</summary>
|
<summary><strong>!text</strong> – AI text generation (Infermatic)</summary>
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@ import logging
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import socket
|
import socket
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import is_public_destination
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
async def check_http(domain):
|
async def check_http(domain):
|
||||||
"""Check if HTTP service is up for the given domain."""
|
"""Check if HTTP service is up for the given domain."""
|
||||||
|
|||||||
+11
-47
@@ -1,93 +1,57 @@
|
|||||||
"""
|
"""
|
||||||
Plugin for fetching jokes from the Official Joke API.
|
Plugin for fetching jokes from the Official Joke API.
|
||||||
"""
|
"""
|
||||||
# plugins/joke.py
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import simplematrixbotlib as botlib
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !joke command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
|
||||||
# Handle !joke command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("joke"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("joke"):
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# Check if user wants a specific category
|
|
||||||
category = "general"
|
category = "general"
|
||||||
if args:
|
if args:
|
||||||
category = args[0].lower()
|
category = args[0].lower()
|
||||||
if category not in ["general", "programming"]:
|
if category not in ("general", "programming"):
|
||||||
# If invalid category, use general
|
|
||||||
category = "general"
|
category = "general"
|
||||||
|
|
||||||
logging.info(f"Fetching {category} joke")
|
logging.info(f"Fetching {category} joke")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch joke from API
|
|
||||||
if category == "programming":
|
if category == "programming":
|
||||||
url = "https://official-joke-api.appspot.com/jokes/programming/random"
|
url = "https://official-joke-api.appspot.com/jokes/programming/random"
|
||||||
else:
|
else:
|
||||||
url = "https://official-joke-api.appspot.com/random_joke"
|
url = "https://official-joke-api.appspot.com/random_joke"
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url) as response:
|
async with session.get(url, timeout=10) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
if isinstance(data, list) and data:
|
||||||
# Handle different response formats
|
|
||||||
if isinstance(data, list) and len(data) > 0:
|
|
||||||
joke = data[0]
|
joke = data[0]
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
joke = data
|
joke = data
|
||||||
else:
|
else:
|
||||||
await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke right now.")
|
await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract joke parts
|
setup = joke.get("setup", "No setup")
|
||||||
setup = joke.get("setup", "No setup available")
|
punchline = joke.get("punchline", "No punchline")
|
||||||
punchline = joke.get("punchline", "No punchline available")
|
|
||||||
|
|
||||||
# Send the joke with a delay for better effect
|
|
||||||
await bot.api.send_text_message(room.room_id, setup)
|
await bot.api.send_text_message(room.room_id, setup)
|
||||||
# Add a small delay before the punchline for comedic timing
|
|
||||||
import asyncio
|
import asyncio
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
await bot.api.send_text_message(room.room_id, f"... {punchline}")
|
await bot.api.send_text_message(room.room_id, f"... {punchline}")
|
||||||
else:
|
else:
|
||||||
await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke right now. Try again later.")
|
await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error fetching joke: {e}")
|
logging.error(f"Error fetching joke: {e}")
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching joke: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Error fetching joke: {str(e)}")
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Get random jokes from the Official Joke API"
|
__description__ = "Get random jokes from the Official Joke API"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!joke</strong> – Random jokes</summary>
|
<summary><strong>!joke</strong> – Random jokes</summary>
|
||||||
<p>Get random jokes from the Official Joke API.<br>
|
<p><code>!joke</code> for general, <code>!joke programming</code> for programming jokes.</p>
|
||||||
Usage: <code>!joke</code> for a general joke<br>
|
|
||||||
Usage: <code>!joke programming</code> for a programming joke</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+77
-31
@@ -9,6 +9,7 @@ Features:
|
|||||||
* View karma leaderboards (top/bottom)
|
* View karma leaderboards (top/bottom)
|
||||||
* Rate limiting to prevent spam
|
* Rate limiting to prevent spam
|
||||||
* Room-specific karma tracking
|
* Room-specific karma tracking
|
||||||
|
* Per‑target throttle (max votes per target per minute)
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
!karma - Show this help
|
!karma - Show this help
|
||||||
@@ -37,13 +38,18 @@ from datetime import datetime, timedelta
|
|||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
|
import time
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Prevent spam: minimum seconds between karma changes to same target by same user
|
# Per‑target cooldown: one karma point per hour per user
|
||||||
COOLDOWN_SECONDS = 5
|
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
|
# Database file
|
||||||
DB_FILE = "karma.db"
|
DB_FILE = "karma.db"
|
||||||
@@ -55,6 +61,8 @@ display_name_cache = {}
|
|||||||
# Last time we refreshed the cache (per room)
|
# Last time we refreshed the cache (per room)
|
||||||
cache_timestamp = {}
|
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"
|
# 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}")
|
logging.info(f"Refreshing display name cache for room {room_id}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to get room members from the bot's state
|
|
||||||
if hasattr(bot, 'async_client') and bot.async_client:
|
if hasattr(bot, 'async_client') and bot.async_client:
|
||||||
# Get the room state
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
room = bot.async_client.rooms.get(room_id)
|
if resp.members:
|
||||||
if room and hasattr(room, 'users'):
|
|
||||||
# Build mapping of display names to user IDs
|
|
||||||
name_map = {}
|
name_map = {}
|
||||||
for user_id, user_info in room.users.items():
|
for member in resp.members:
|
||||||
# Get display name - try different attributes
|
display_name = (member.display_name or "").strip()
|
||||||
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
|
|
||||||
|
|
||||||
if display_name:
|
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
|
# Also store without emojis for easier matching
|
||||||
clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower()
|
clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower()
|
||||||
if clean_name and clean_name != display_name.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
|
display_name_cache[room_id] = name_map
|
||||||
cache_timestamp[room_id] = now
|
cache_timestamp[room_id] = now
|
||||||
logging.info(f"Cached {len(name_map)} display names for room {room_id}")
|
logging.info(f"Cached {len(name_map)} display names for room {room_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not refresh display name cache: {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
|
Returns None if the input is a Matrix ID (rejected) or if the name
|
||||||
cannot be resolved.
|
cannot be resolved.
|
||||||
"""
|
"""
|
||||||
|
# Strip HTML tags (Matrix mention pills)
|
||||||
|
clean = re.sub(r'<[^>]+>', '', display_name).strip()
|
||||||
|
|
||||||
# Reject Matrix IDs outright
|
# Reject Matrix IDs outright
|
||||||
if is_matrix_id(display_name):
|
if is_matrix_id(display_name):
|
||||||
return None
|
return None
|
||||||
@@ -185,20 +186,15 @@ def resolve_display_name(room_id, display_name, bot=None):
|
|||||||
name_map = display_name_cache[room_id]
|
name_map = display_name_cache[room_id]
|
||||||
|
|
||||||
# Try exact match (case-insensitive)
|
# Try exact match (case-insensitive)
|
||||||
key = display_name.lower()
|
key = clean.lower()
|
||||||
if key in name_map:
|
if key in name_map:
|
||||||
return name_map[key]
|
return name_map[key]
|
||||||
|
|
||||||
# Try without emojis/special characters
|
# 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:
|
if clean_key and clean_key in name_map:
|
||||||
return name_map[clean_key]
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -459,6 +455,28 @@ def format_karma_display(display_name, points):
|
|||||||
return f"⚖️ **{display_name}** has neutral karma (0)"
|
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
|
# Command Handlers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -473,10 +491,13 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
# Debug logging
|
# Debug logging
|
||||||
message_body = message.body if hasattr(message, 'body') else str(message)
|
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.)
|
# 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}'")
|
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!")
|
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma!")
|
||||||
return
|
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
|
# Check cooldown
|
||||||
if is_on_cooldown(room_id, user_id, voter_str):
|
if is_on_cooldown(room_id, user_id, voter_str):
|
||||||
remaining = get_cooldown_remaining(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
|
return
|
||||||
|
|
||||||
# Update karma
|
# 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)
|
new_points = update_karma(room_id, user_id, change, voter_str)
|
||||||
update_cooldown(room_id, user_id, 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
|
# Get display name for response
|
||||||
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
||||||
response = format_karma_display(display_name_resolved, new_points)
|
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>
|
<strong>Notes:</strong>
|
||||||
<ul>
|
<ul>
|
||||||
<li>You cannot modify your own karma</li>
|
<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>Karma is tracked separately per room</li>
|
||||||
<li>Display names with emojis are supported</li>
|
<li>Display names with emojis are supported</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -781,7 +819,7 @@ async def handle_inline_karma(room, message, bot):
|
|||||||
if not matches:
|
if not matches:
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info(f"Found inline karma matches: {matches}")
|
logging.debug(f"Found inline karma matches: {matches}")
|
||||||
|
|
||||||
responses = []
|
responses = []
|
||||||
for display_name, operator in matches:
|
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}")
|
logging.debug(f"Skipping self-modification: {sender} -> {display_name}")
|
||||||
continue
|
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
|
# Check cooldown
|
||||||
if is_on_cooldown(room_id, user_id, sender):
|
if is_on_cooldown(room_id, user_id, sender):
|
||||||
logging.debug(f"Cooldown active for {sender} -> {user_id}")
|
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)
|
new_points = update_karma(room_id, user_id, change, sender)
|
||||||
update_cooldown(room_id, user_id, sender)
|
update_cooldown(room_id, user_id, sender)
|
||||||
|
|
||||||
|
# Record target vote for throttle
|
||||||
|
_record_target_vote(room_id, user_id)
|
||||||
|
|
||||||
# Format response
|
# Format response
|
||||||
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
||||||
arrow = "⬆️" if change > 0 else "⬇️"
|
arrow = "⬆️" if change > 0 else "⬇️"
|
||||||
@@ -854,7 +900,7 @@ def setup(bot):
|
|||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.0.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Room karma tracking system (display names only, no Matrix IDs)"
|
__description__ = "Room karma tracking system (display names only, no Matrix IDs)"
|
||||||
__help__ = """
|
__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>
|
|
||||||
"""
|
|
||||||
+13
-66
@@ -3,40 +3,19 @@ News Aggregator Plugin for Funguy Bot
|
|||||||
|
|
||||||
Fetches latest headlines from various news categories using GNews API.
|
Fetches latest headlines from various news categories using GNews API.
|
||||||
Free tier: 100 requests/day
|
Free tier: 100 requests/day
|
||||||
|
|
||||||
Commands:
|
|
||||||
!news - Get top headlines (default)
|
|
||||||
!news top - Top headlines
|
|
||||||
!news world - World news
|
|
||||||
!news tech - Technology news
|
|
||||||
!news business - Business news
|
|
||||||
!news science - Science news
|
|
||||||
!news health - Health news
|
|
||||||
!news crypto - Cryptocurrency news
|
|
||||||
!news search <query> - Search for specific news
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import os
|
import os
|
||||||
from typing import Optional, Dict, Any, List
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
# Load environment variables
|
# API key loaded centrally
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Get API key from environment variable
|
|
||||||
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
||||||
|
|
||||||
# Number of articles to return per command
|
|
||||||
DEFAULT_ARTICLES = 5
|
DEFAULT_ARTICLES = 5
|
||||||
MAX_ARTICLES = 10
|
MAX_ARTICLES = 10
|
||||||
|
|
||||||
# Category mapping
|
|
||||||
CATEGORIES = {
|
CATEGORIES = {
|
||||||
"top": "general",
|
"top": "general",
|
||||||
"world": "world",
|
"world": "world",
|
||||||
@@ -49,30 +28,15 @@ CATEGORIES = {
|
|||||||
"crypto": "cryptocurrency"
|
"crypto": "cryptocurrency"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _format_news_article(article, index):
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helper Functions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _format_collapsible(title: str, content: str, expanded: bool = False) -> str:
|
|
||||||
"""Format content in a collapsible details/summary block."""
|
|
||||||
open_attr = ' open' if expanded else ''
|
|
||||||
return f"<details{open_attr}>\n<summary>📰 {title}</summary>\n\n{content}\n\n</details>"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_news_article(article: Dict, index: int) -> str:
|
|
||||||
"""Format a single news article as an HTML list item."""
|
"""Format a single news article as an HTML list item."""
|
||||||
title = article.get("title", "No title")
|
title = html_escape(article.get("title", "No title"))
|
||||||
source = article.get("source", {}).get("name", "Unknown source")
|
source = html_escape((article.get("source") or {}).get("name", "Unknown"))
|
||||||
url = article.get("url", "#")
|
url = article.get("url", "#")
|
||||||
description = article.get("description", "No description available")
|
description = html_escape(article.get("description", "No description available"))
|
||||||
published = article.get("publishedAt", "")
|
|
||||||
|
|
||||||
# Truncate description if too long
|
|
||||||
if len(description) > 300:
|
if len(description) > 300:
|
||||||
description = description[:297] + "..."
|
description = description[:297] + "..."
|
||||||
|
published = article.get("publishedAt", "")
|
||||||
# Format date if available
|
|
||||||
date_str = ""
|
date_str = ""
|
||||||
if published:
|
if published:
|
||||||
try:
|
try:
|
||||||
@@ -81,7 +45,6 @@ def _format_news_article(article: Dict, index: int) -> str:
|
|||||||
date_str = f" | 📅 {dt.strftime('%Y-%m-%d %H:%M')}"
|
date_str = f" | 📅 {dt.strftime('%Y-%m-%d %H:%M')}"
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<li>\n"
|
f"<li>\n"
|
||||||
f"<strong>{index}. {title}</strong><br/>\n"
|
f"<strong>{index}. {title}</strong><br/>\n"
|
||||||
@@ -91,17 +54,13 @@ def _format_news_article(article: Dict, index: int) -> str:
|
|||||||
f"</li>"
|
f"</li>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _fetch_news(category="general", query=None, limit=DEFAULT_ARTICLES):
|
||||||
async def _fetch_news(category: str = "general", query: str = None, limit: int = DEFAULT_ARTICLES) -> Optional[List[Dict]]:
|
|
||||||
"""Fetch news articles from GNews API."""
|
|
||||||
if not GNEWS_API_KEY:
|
if not GNEWS_API_KEY:
|
||||||
logging.error("GNews API key not configured. Set GNEWS_API_KEY in .env file")
|
logging.error("GNews API key not configured. Set GNEWS_API_KEY in .env file")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
base_url = "https://gnews.io/api/v4"
|
base_url = "https://gnews.io/api/v4"
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
# Search endpoint
|
|
||||||
url = f"{base_url}/search"
|
url = f"{base_url}/search"
|
||||||
params = {
|
params = {
|
||||||
"q": query,
|
"q": query,
|
||||||
@@ -111,7 +70,6 @@ async def _fetch_news(category: str = "general", query: str = None, limit: int =
|
|||||||
"country": "us"
|
"country": "us"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Top headlines endpoint
|
|
||||||
url = f"{base_url}/top-headlines"
|
url = f"{base_url}/top-headlines"
|
||||||
params = {
|
params = {
|
||||||
"apikey": GNEWS_API_KEY,
|
"apikey": GNEWS_API_KEY,
|
||||||
@@ -135,26 +93,15 @@ async def _fetch_news(category: str = "general", query: str = None, limit: int =
|
|||||||
logging.error(f"Error fetching news: {e}")
|
logging.error(f"Error fetching news: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Setup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def setup(bot):
|
def setup(bot):
|
||||||
"""Initialize plugin with bot instance."""
|
"""Initialize plugin with bot instance."""
|
||||||
global GNEWS_API_KEY
|
global GNEWS_API_KEY
|
||||||
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
||||||
|
|
||||||
if GNEWS_API_KEY:
|
if GNEWS_API_KEY:
|
||||||
logging.info("News plugin loaded with API key")
|
logging.info("News plugin loaded with API key")
|
||||||
else:
|
else:
|
||||||
logging.warning("News plugin loaded but GNEWS_API_KEY not set in .env file")
|
logging.warning("News plugin loaded but GNEWS_API_KEY not set in .env file")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Command Handler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Handle !news commands."""
|
"""Handle !news commands."""
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
@@ -201,9 +148,10 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
# Fetch news
|
# Fetch news
|
||||||
if query:
|
if query:
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Searching for: *{query}*...")
|
safe_title = html_escape(query)
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Searching for: *{safe_title}*...")
|
||||||
articles = await _fetch_news(query=query, limit=limit)
|
articles = await _fetch_news(query=query, limit=limit)
|
||||||
title = f"Search Results: '{query}'"
|
title = f"Search Results: '{safe_title}'"
|
||||||
else:
|
else:
|
||||||
articles = await _fetch_news(category=category, limit=limit)
|
articles = await _fetch_news(category=category, limit=limit)
|
||||||
category_name = next((k for k, v in CATEGORIES.items() if v == category), category)
|
category_name = next((k for k, v in CATEGORIES.items() if v == category), category)
|
||||||
@@ -220,11 +168,10 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
content += f"</ul>\n\n<em>Fetched {len(articles[:limit])} articles</em>"
|
content += f"</ul>\n\n<em>Fetched {len(articles[:limit])} articles</em>"
|
||||||
|
|
||||||
# Format as collapsible and send
|
# Format as collapsible and send
|
||||||
response = _format_collapsible(title, content, expanded=False)
|
response = collapsible_summary(title, content)
|
||||||
await bot.api.send_markdown_message(room.room_id, response)
|
await bot.api.send_markdown_message(room.room_id, response)
|
||||||
logging.info(f"Sent news to {room.room_id}: category={category}, query={query}")
|
logging.info(f"Sent news to {room.room_id}: category={category}, query={query}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
__version__ = "1.0.4"
|
__version__ = "1.0.4"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "List all loaded plugins with count, collapsible"
|
__description__ = "List all loaded plugins with count"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!plugins</strong> – List active plugins</summary>
|
<summary><strong>!plugins</strong> – List active plugins</summary>
|
||||||
|
|||||||
+68
-85
@@ -1,46 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to get random SOCKS5 proxies.
|
This plugin provides a command to get random SOCKS5 proxies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import requests
|
import aiohttp
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ipaddress
|
from plugins.common import is_public_destination, html_escape
|
||||||
|
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
SOCKS5_LIST_URL = 'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/socks5.txt'
|
SOCKS5_LIST_URL = 'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/socks5.txt'
|
||||||
MAX_TRIES = 64
|
MAX_TRIES = 64
|
||||||
PROXY_LIST_FILENAME = 'socks5.txt'
|
PROXY_LIST_FILENAME = 'socks5.txt'
|
||||||
PROXY_LIST_EXPIRATION = timedelta(hours=8)
|
PROXY_LIST_EXPIRATION = timedelta(hours=8)
|
||||||
MAX_THREADS = 128
|
MAX_THREADS = 64 # lowered to avoid resource exhaustion
|
||||||
PROXIES_DB_FILE = 'proxies.db'
|
PROXIES_DB_FILE = 'proxies.db'
|
||||||
MAX_PROXIES_IN_DB = 10
|
MAX_PROXIES_IN_DB = 10
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
|
|
||||||
def test_proxy(proxy):
|
def test_proxy(proxy):
|
||||||
"""Test a SOCKS5 proxy and return the outcome."""
|
"""Test a SOCKS5 proxy and return (success, proxy, latency)."""
|
||||||
try:
|
try:
|
||||||
ip, port = proxy.split(':')
|
ip, port = proxy.split(':')
|
||||||
logging.info(f"Testing SOCKS5 proxy: {ip}:{port}")
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
with socket.create_connection((ip, int(port)), timeout=12) as client:
|
with socket.create_connection((ip, int(port)), timeout=12) as client:
|
||||||
client.sendall(b'\x05\x01\x00')
|
client.sendall(b'\x05\x01\x00')
|
||||||
response = client.recv(2)
|
response = client.recv(2)
|
||||||
if response == b'\x05\x00':
|
if response == b'\x05\x00':
|
||||||
latency = int(round((time.time() - start_time) * 1000, 0))
|
latency = int(round((time.time() - start_time) * 1000))
|
||||||
return True, proxy, latency
|
return True, proxy, latency
|
||||||
else:
|
else:
|
||||||
return False, proxy, None
|
return False, proxy, None
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return False, proxy, None
|
return False, proxy, None
|
||||||
|
|
||||||
async def download_proxy_list():
|
async def download_proxy_list():
|
||||||
@@ -48,13 +43,15 @@ async def download_proxy_list():
|
|||||||
if not os.path.exists(PROXY_LIST_FILENAME) or \
|
if not os.path.exists(PROXY_LIST_FILENAME) or \
|
||||||
datetime.now() - datetime.fromtimestamp(os.path.getctime(PROXY_LIST_FILENAME)) > PROXY_LIST_EXPIRATION:
|
datetime.now() - datetime.fromtimestamp(os.path.getctime(PROXY_LIST_FILENAME)) > PROXY_LIST_EXPIRATION:
|
||||||
logging.info("Downloading SOCKS5 proxy list")
|
logging.info("Downloading SOCKS5 proxy list")
|
||||||
response = requests.get(SOCKS5_LIST_URL, timeout=5)
|
async with aiohttp.ClientSession() as session:
|
||||||
with open(PROXY_LIST_FILENAME, 'w') as f:
|
async with session.get(SOCKS5_LIST_URL, timeout=20) as response:
|
||||||
f.write(response.text)
|
response.raise_for_status()
|
||||||
logging.info("Proxy list downloaded successfully")
|
text = await response.text()
|
||||||
|
with open(PROXY_LIST_FILENAME, 'w') as f:
|
||||||
|
f.write(text)
|
||||||
|
logging.info("Proxy list downloaded")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logging.info("Proxy list already exists and is up-to-date")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error downloading proxy list: {e}")
|
logging.error(f"Error downloading proxy list: {e}")
|
||||||
@@ -64,48 +61,39 @@ def check_db_for_proxy():
|
|||||||
try:
|
try:
|
||||||
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""CREATE TABLE IF NOT EXISTS proxies (
|
||||||
CREATE TABLE IF NOT EXISTS proxies (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
proxy TEXT,
|
||||||
proxy TEXT,
|
latency INTEGER,
|
||||||
latency INTEGER,
|
status TEXT)""")
|
||||||
status TEXT
|
cursor.execute("SELECT proxy, latency FROM proxies WHERE status='working' AND latency<3000 ORDER BY RANDOM() LIMIT 1")
|
||||||
)
|
row = cursor.fetchone()
|
||||||
""")
|
if row:
|
||||||
cursor.execute("SELECT proxy, latency FROM proxies WHERE status = 'working' AND latency < 3000 ORDER BY RANDOM() LIMIT 1")
|
proxy, latency = row
|
||||||
result = cursor.fetchone()
|
|
||||||
if result:
|
|
||||||
proxy, latency = result
|
|
||||||
success, _, _ = test_proxy(proxy)
|
success, _, _ = test_proxy(proxy)
|
||||||
if success:
|
if success:
|
||||||
return proxy, latency
|
return proxy, latency
|
||||||
else:
|
else:
|
||||||
cursor.execute("DELETE FROM proxies WHERE proxy = ?", (proxy,))
|
cursor.execute("DELETE FROM proxies WHERE proxy=?", (proxy,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logging.info(f"Removed non-working proxy from the database: {proxy}")
|
return None, None
|
||||||
return None, None
|
|
||||||
else:
|
|
||||||
return None, None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error checking proxies database: {e}")
|
logging.error(f"DB error: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def save_proxy_to_db(proxy, latency):
|
def save_proxy_to_db(proxy, latency):
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""CREATE TABLE IF NOT EXISTS proxies (
|
||||||
CREATE TABLE IF NOT EXISTS proxies (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
proxy TEXT,
|
||||||
proxy TEXT,
|
latency INTEGER,
|
||||||
latency INTEGER,
|
status TEXT)""")
|
||||||
status TEXT
|
cursor.execute("INSERT INTO proxies (proxy, latency, status) VALUES (?,?,'working')", (proxy, latency))
|
||||||
)
|
|
||||||
""")
|
|
||||||
cursor.execute("INSERT INTO proxies (proxy, latency, status) VALUES (?, ?, 'working')", (proxy, latency))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error saving proxy to database: {e}")
|
logging.error(f"Error saving proxy: {e}")
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
@@ -113,52 +101,47 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
logging.info("Received !proxy command")
|
logging.info("Received !proxy command")
|
||||||
working_proxy, latency = check_db_for_proxy()
|
working_proxy, latency = check_db_for_proxy()
|
||||||
if working_proxy:
|
if working_proxy:
|
||||||
|
safe_proxy = html_escape(working_proxy)
|
||||||
await bot.api.send_markdown_message(room.room_id,
|
await bot.api.send_markdown_message(room.room_id,
|
||||||
f"✅ Using cached working SOCKS5 Proxy: **{working_proxy}** - Latency: **{latency} ms**")
|
f"✅ Using cached working SOCKS5 Proxy: **{safe_proxy}** - Latency: **{latency} ms**")
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
if not await download_proxy_list():
|
|
||||||
await bot.api.send_markdown_message(room.room_id, "Error downloading proxy list")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(PROXY_LIST_FILENAME, 'r') as f:
|
|
||||||
socks5_proxies = [line.replace("socks5://", "") for line in f.read().splitlines()]
|
|
||||||
# Filter out private/internal proxies before testing
|
|
||||||
socks5_proxies = [p for p in socks5_proxies if is_public_destination(p.split(':')[0])]
|
|
||||||
random.shuffle(socks5_proxies)
|
|
||||||
tested_proxies = 0
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
|
|
||||||
futures = []
|
|
||||||
for proxy in socks5_proxies[:MAX_TRIES]:
|
|
||||||
futures.append(executor.submit(test_proxy, proxy))
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
|
||||||
success, proxy, latency = future.result()
|
|
||||||
if success:
|
|
||||||
await bot.api.send_markdown_message(room.room_id,
|
|
||||||
f"✅ Anonymous SOCKS5 Proxy: **{proxy}** - Latency: **{latency} ms**")
|
|
||||||
save_proxy_to_db(proxy, latency)
|
|
||||||
tested_proxies += 1
|
|
||||||
if tested_proxies >= MAX_PROXIES_IN_DB:
|
|
||||||
break
|
|
||||||
working_proxy, latency = check_db_for_proxy()
|
|
||||||
if working_proxy:
|
|
||||||
await bot.api.send_markdown_message(room.room_id,
|
|
||||||
f"✅ Using cached working SOCKS5 Proxy: **{working_proxy}** - Latency: **{latency} ms**")
|
|
||||||
else:
|
|
||||||
await bot.api.send_markdown_message(room.room_id, "❌ No working anonymous SOCKS5 proxy found")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error handling !proxy command: {e}")
|
|
||||||
await bot.api.send_markdown_message(room.room_id, "❌ Error handling !proxy command")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
if not await download_proxy_list():
|
||||||
# Plugin Metadata
|
await bot.api.send_markdown_message(room.room_id, "Error downloading proxy list")
|
||||||
# ---------------------------------------------------------------------------
|
return
|
||||||
__version__ = "1.0.1"
|
|
||||||
|
try:
|
||||||
|
with open(PROXY_LIST_FILENAME, 'r') as f:
|
||||||
|
socks5_proxies = [line.replace("socks5://", "") for line in f.read().splitlines()]
|
||||||
|
socks5_proxies = [p for p in socks5_proxies if is_public_destination(p.split(':')[0])]
|
||||||
|
random.shuffle(socks5_proxies)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
tested = 0
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
|
||||||
|
futures = [loop.run_in_executor(executor, test_proxy, proxy) for proxy in socks5_proxies[:MAX_TRIES]]
|
||||||
|
for future in asyncio.as_completed(futures):
|
||||||
|
success, proxy, latency = await future
|
||||||
|
if success:
|
||||||
|
safe_proxy = html_escape(proxy)
|
||||||
|
await bot.api.send_markdown_message(room.room_id,
|
||||||
|
f"✅ Anonymous SOCKS5 Proxy: **{safe_proxy}** - Latency: **{latency} ms**")
|
||||||
|
save_proxy_to_db(proxy, latency)
|
||||||
|
tested += 1
|
||||||
|
if tested >= MAX_PROXIES_IN_DB:
|
||||||
|
break
|
||||||
|
if tested == 0:
|
||||||
|
await bot.api.send_markdown_message(room.room_id, "❌ No working anonymous SOCKS5 proxy found")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error handling !proxy command: {e}")
|
||||||
|
await bot.api.send_markdown_message(room.room_id, "❌ Error handling !proxy command")
|
||||||
|
|
||||||
|
__version__ = "1.0.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Working SOCKS5 proxy finder (SSRF‑safe)"
|
__description__ = "Working SOCKS5 proxy finder"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!proxy</strong> – Random working SOCKS5 proxy</summary>
|
<summary><strong>!proxy</strong> – Random working SOCKS5 proxy</summary>
|
||||||
<p>Fetches, tests, and returns a random working SOCKS5 proxy with latency. Caches good proxies in SQLite.</p>
|
<p>Fetches, tests, and returns a random working SOCKS5 proxy with latency.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+25
-99
@@ -1,26 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Goodreads Quote Scraper – Playwright (headless Chromium)
|
Goodreads Quote Scraper – Playwright (headless Chromium)
|
||||||
No external APIs, no keys; scrapes directly from goodreads.com
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from urllib.parse import urlencode
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
logger = logging.getLogger("quote")
|
|
||||||
|
|
||||||
GR_POPULAR = "https://www.goodreads.com/quotes"
|
GR_POPULAR = "https://www.goodreads.com/quotes"
|
||||||
GR_SEARCH = "https://www.goodreads.com/quotes/search"
|
GR_SEARCH = "https://www.goodreads.com/quotes/search"
|
||||||
QUOTES_PER_PAGE = 30
|
QUOTES_PER_PAGE = 30
|
||||||
MAX_SEARCH_PAGES = 3
|
MAX_SEARCH_PAGES = 3
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Playwright browser (shared, launched once)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_browser = None
|
_browser = None
|
||||||
_playwright = None
|
_playwright = None
|
||||||
|
|
||||||
@@ -30,64 +23,32 @@ async def _get_browser():
|
|||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
_playwright = await async_playwright().start()
|
_playwright = await async_playwright().start()
|
||||||
_browser = await _playwright.chromium.launch(headless=True)
|
_browser = await _playwright.chromium.launch(headless=True)
|
||||||
logger.info("Playwright browser started")
|
logging.info("Playwright browser started")
|
||||||
return _browser
|
return _browser
|
||||||
|
|
||||||
async def _close_browser():
|
def _extract_quotes(html: str) -> list:
|
||||||
global _browser, _playwright
|
|
||||||
if _browser:
|
|
||||||
await _browser.close()
|
|
||||||
_browser = None
|
|
||||||
if _playwright:
|
|
||||||
await _playwright.stop()
|
|
||||||
_playwright = None
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HTML parsing (Goodreads specific)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def _extract_quotes(html: str) -> list[dict]:
|
|
||||||
"""Parse Goodreads HTML and return a list of {content, author} dicts."""
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
soup = BeautifulSoup(html, "lxml")
|
||||||
quotes = []
|
quotes = []
|
||||||
|
|
||||||
for div in soup.find_all("div", class_="quoteText"):
|
for div in soup.find_all("div", class_="quoteText"):
|
||||||
full_text = div.get_text(" ", strip=True)
|
full_text = div.get_text(" ", strip=True)
|
||||||
# Try curly quotes
|
|
||||||
m = re.search(r"“(.+?)”", full_text)
|
m = re.search(r"“(.+?)”", full_text)
|
||||||
if not m:
|
if not m:
|
||||||
m = re.search(r"(.+?)\s*―", full_text)
|
m = re.search(r"(.+?)\s*―", full_text)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
content = m.group(1).strip()
|
content = m.group(1).strip()
|
||||||
|
|
||||||
author_span = div.find("span", class_="authorOrTitle")
|
author_span = div.find("span", class_="authorOrTitle")
|
||||||
author = author_span.get_text(strip=True).rstrip(",") if author_span else "Unknown"
|
author = author_span.get_text(strip=True).rstrip(",") if author_span else "Unknown"
|
||||||
quotes.append({"content": content, "author": author})
|
quotes.append({"content": content, "author": author})
|
||||||
|
|
||||||
# Alternative layout (if first method yielded nothing)
|
|
||||||
for div in soup.find_all("div", class_="quoteDetails"):
|
|
||||||
text_elem = div.find("div", class_="quoteText")
|
|
||||||
author_elem = div.find("span", class_="authorOrTitle")
|
|
||||||
if text_elem:
|
|
||||||
content = text_elem.get_text(strip=True).strip("“”")
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
author = author_elem.get_text(strip=True).rstrip(",") if author_elem else "Unknown"
|
|
||||||
quotes.append({"content": content, "author": author})
|
|
||||||
|
|
||||||
return quotes
|
return quotes
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Page fetching
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def _scrape(url: str, params: dict = None) -> str:
|
async def _scrape(url: str, params: dict = None) -> str:
|
||||||
browser = await _get_browser()
|
browser = await _get_browser()
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(user_agent="Mozilla/5.0 ...")
|
||||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
try:
|
try:
|
||||||
if params:
|
if params:
|
||||||
|
from urllib.parse import urlencode
|
||||||
full_url = f"{url}?{urlencode(params)}"
|
full_url = f"{url}?{urlencode(params)}"
|
||||||
else:
|
else:
|
||||||
full_url = url
|
full_url = url
|
||||||
@@ -95,17 +56,17 @@ async def _scrape(url: str, params: dict = None) -> str:
|
|||||||
html = await page.content()
|
html = await page.content()
|
||||||
return html
|
return html
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load {full_url}: {e}")
|
logging.error(f"Scrape error: {e}")
|
||||||
return ""
|
return ""
|
||||||
finally:
|
finally:
|
||||||
await page.close()
|
await page.close()
|
||||||
await context.close()
|
await context.close()
|
||||||
|
|
||||||
async def get_random_popular() -> list[dict]:
|
async def get_random_popular() -> list:
|
||||||
html = await _scrape(GR_POPULAR)
|
html = await _scrape(GR_POPULAR)
|
||||||
return _extract_quotes(html)
|
return _extract_quotes(html)
|
||||||
|
|
||||||
async def get_author_quotes(author: str) -> list[dict]:
|
async def get_author_quotes(author: str) -> list:
|
||||||
all_quotes = []
|
all_quotes = []
|
||||||
for page in range(1, MAX_SEARCH_PAGES + 1):
|
for page in range(1, MAX_SEARCH_PAGES + 1):
|
||||||
html = await _scrape(GR_SEARCH, {"q": author, "commit": "Search", "page": page})
|
html = await _scrape(GR_SEARCH, {"q": author, "commit": "Search", "page": page})
|
||||||
@@ -115,52 +76,32 @@ async def get_author_quotes(author: str) -> list[dict]:
|
|||||||
break
|
break
|
||||||
return all_quotes
|
return all_quotes
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def format_quote(q):
|
||||||
# Formatting
|
safe_content = html_escape(q["content"])
|
||||||
# ---------------------------------------------------------------------------
|
safe_author = html_escape(q["author"])
|
||||||
def format_quote(q: dict) -> str:
|
return f'"{safe_content}"\n\n— {safe_author}'
|
||||||
return f'"{q["content"]}"\n\n— {q["author"]}'
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Command handler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("quote")):
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("quote")):
|
||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# Help
|
|
||||||
if args and args[0].lower() in ("help", "-h", "--help"):
|
if args and args[0].lower() in ("help", "-h", "--help"):
|
||||||
help_html = (
|
help_html = collapsible_summary("📖 !quote help",
|
||||||
"<details><summary><strong>📖 !quote help</strong></summary>"
|
"<ul><li><code>!quote</code> – random popular quote</li>"
|
||||||
"<ul>"
|
"<li><code>!quote <author></code> – quote by author</li></ul>")
|
||||||
"<li><code>!quote</code> – random popular quote from Goodreads</li>"
|
|
||||||
"<li><code>!quote <author></code> – random quote by that author</li>"
|
|
||||||
"<li><code>!quote help</code> – this</li>"
|
|
||||||
"</ul>"
|
|
||||||
"<p><b>Examples:</b><br><code>!quote</code><br>"
|
|
||||||
"<code>!quote Terence McKenna</code><br>"
|
|
||||||
"<code>!quote Oscar Wilde</code></p>"
|
|
||||||
"<p>Scraped with Playwright (headless browser).</p>"
|
|
||||||
"</details>"
|
|
||||||
)
|
|
||||||
await bot.api.send_markdown_message(room.room_id, help_html)
|
await bot.api.send_markdown_message(room.room_id, help_html)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args:
|
if args:
|
||||||
author = " ".join(args).strip()
|
author = " ".join(args).strip()
|
||||||
await bot.api.send_text_message(
|
safe_author = html_escape(author)
|
||||||
room.room_id, f"🔍 Searching Goodreads for quotes by **{author}**…"
|
await bot.api.send_text_message(room.room_id, f"🔍 Searching Goodreads for quotes by **{safe_author}**…")
|
||||||
)
|
|
||||||
quotes = await get_author_quotes(author)
|
quotes = await get_author_quotes(author)
|
||||||
if not quotes:
|
if not quotes:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ No quotes found for '{safe_author}'.")
|
||||||
room.room_id,
|
|
||||||
f"❌ No quotes found for '**{author}**'. Try a different spelling."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
chosen = random.choice(quotes)
|
chosen = random.choice(quotes)
|
||||||
else:
|
else:
|
||||||
@@ -172,28 +113,13 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
chosen = random.choice(quotes)
|
chosen = random.choice(quotes)
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, format_quote(chosen))
|
await bot.api.send_markdown_message(room.room_id, format_quote(chosen))
|
||||||
logger.info(f"Quote sent: {chosen['author']}")
|
logging.info(f"Quote sent: {chosen['author']}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unexpected error in quote plugin")
|
logging.exception("Unexpected error in quote plugin")
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ Scraping error: {e}")
|
||||||
room.room_id, f"❌ Scraping error: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
__version__ = "1.0.2"
|
||||||
# Plugin metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
__version__ = "1.0.1"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Goodreads quotes via Playwright (headless browser)"
|
__description__ = "Fetch Goodreads quotes"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!quote</strong> – Quotes from Goodreads</summary>
|
||||||
<details>
|
<p><code>!quote</code> random, <code>!quote <author></code>.</p></details>"""
|
||||||
<summary><strong>!quote</strong> – Quotes from Goodreads (scraped with Playwright)</summary>
|
|
||||||
<ul>
|
|
||||||
<li><code>!quote</code> – random popular quote</li>
|
|
||||||
<li><code>!quote <author></code> – random quote by that author</li>
|
|
||||||
<li><code>!quote help</code></li>
|
|
||||||
</ul>
|
|
||||||
<p>No API keys, no JSON files – just a real browser fetching from Goodreads.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+93
-226
@@ -2,79 +2,56 @@
|
|||||||
"""
|
"""
|
||||||
plugins/roomstats.py — per‑user room statistics (Limnoria‑style).
|
plugins/roomstats.py — per‑user room statistics (Limnoria‑style).
|
||||||
Commands: !roomstats, !rank, !stats
|
Commands: !roomstats, !rank, !stats
|
||||||
|
Output is a clean code block with emojis and aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import nio
|
import nio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import collapsible_summary, code_block
|
||||||
|
|
||||||
logger = logging.getLogger("roomstats")
|
logger = logging.getLogger("roomstats")
|
||||||
|
|
||||||
DB_PATH = "roomstats.db"
|
DB_PATH = "roomstats.db"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# Emoji regex (unchanged)
|
||||||
# Emoji / smiley regex (Unicode blocks)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
EMOJI_RE = re.compile(
|
EMOJI_RE = re.compile(
|
||||||
"["
|
"["
|
||||||
"\U0001F600-\U0001F64F" # Emoticons
|
"\U0001F600-\U0001F64F"
|
||||||
"\U0001F300-\U0001F5FF" # Symbols & pictographs
|
"\U0001F300-\U0001F5FF"
|
||||||
"\U0001F680-\U0001F6FF" # Transport & map
|
"\U0001F680-\U0001F6FF"
|
||||||
"\U0001F1E0-\U0001F1FF" # Flags
|
"\U0001F1E0-\U0001F1FF"
|
||||||
"\U00002702-\U000027B0" # Dingbats
|
"\U00002702-\U000027B0"
|
||||||
"\U000024C2-\U0001F251" # Misc
|
"\U000024C2-\U0001F251"
|
||||||
"]+", re.UNICODE)
|
"]+", re.UNICODE
|
||||||
|
)
|
||||||
|
|
||||||
def count_smileys(text):
|
def count_smileys(text):
|
||||||
"""Return number of emoji occurrences."""
|
|
||||||
return len(EMOJI_RE.findall(text))
|
return len(EMOJI_RE.findall(text))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Database init
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def init_db():
|
def init_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("""
|
c.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS user_room_stats (
|
CREATE TABLE IF NOT EXISTS user_room_stats (
|
||||||
room_id TEXT,
|
room_id TEXT, user_id TEXT,
|
||||||
user_id TEXT,
|
msgs INTEGER DEFAULT 0, chars INTEGER DEFAULT 0, words INTEGER DEFAULT 0,
|
||||||
msgs INTEGER DEFAULT 0,
|
smileys INTEGER DEFAULT 0, actions INTEGER DEFAULT 0,
|
||||||
chars INTEGER DEFAULT 0,
|
joins INTEGER DEFAULT 0, parts INTEGER DEFAULT 0,
|
||||||
words INTEGER DEFAULT 0,
|
kicks_given INTEGER DEFAULT 0, kicked_received INTEGER DEFAULT 0,
|
||||||
smileys INTEGER DEFAULT 0,
|
topics_set INTEGER DEFAULT 0, last_updated INTEGER,
|
||||||
actions INTEGER DEFAULT 0,
|
|
||||||
joins INTEGER DEFAULT 0,
|
|
||||||
parts INTEGER DEFAULT 0,
|
|
||||||
kicks_given INTEGER DEFAULT 0,
|
|
||||||
kicked_received INTEGER DEFAULT 0,
|
|
||||||
topics_set INTEGER DEFAULT 0,
|
|
||||||
last_updated INTEGER,
|
|
||||||
PRIMARY KEY (room_id, user_id)
|
PRIMARY KEY (room_id, user_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Multi‑word user resolution helper
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def resolve_user_from_tokens(bot, room_id, tokens):
|
async def resolve_user_from_tokens(bot, room_id, tokens):
|
||||||
"""
|
|
||||||
Given a list of word tokens, find a matching display name.
|
|
||||||
Returns (mxid, display_name) or raises ValueError.
|
|
||||||
"""
|
|
||||||
# Build cache of (lowered display name → user_id) from joined members
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
if resp.members is None:
|
if resp.members is None:
|
||||||
raise ValueError("Could not fetch member list.")
|
raise ValueError("Could not fetch member list.")
|
||||||
|
|
||||||
# Create a dict: lower_display → (mxid, display_name)
|
|
||||||
# If duplicate display name, store None to signal ambiguity.
|
|
||||||
cache = {}
|
cache = {}
|
||||||
for member in resp.members:
|
for member in resp.members:
|
||||||
display = (member.display_name or "").strip()
|
display = (member.display_name or "").strip()
|
||||||
@@ -85,68 +62,31 @@ async def resolve_user_from_tokens(bot, room_id, tokens):
|
|||||||
cache[key] = None
|
cache[key] = None
|
||||||
else:
|
else:
|
||||||
cache[key] = (member.user_id, display)
|
cache[key] = (member.user_id, display)
|
||||||
|
|
||||||
# Try progressively longer prefixes of the tokens
|
|
||||||
for end in range(len(tokens), 0, -1):
|
for end in range(len(tokens), 0, -1):
|
||||||
candidate = " ".join(tokens[:end]).strip().lower()
|
candidate = " ".join(tokens[:end]).strip().lower()
|
||||||
if candidate in cache:
|
if candidate in cache:
|
||||||
entry = cache[candidate]
|
entry = cache[candidate]
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
return entry # (mxid, display_name)
|
return entry
|
||||||
else:
|
|
||||||
# Ambiguous – we need to fetch and check exactly
|
|
||||||
matches = []
|
|
||||||
for member in resp.members:
|
|
||||||
if (member.display_name or "").strip().lower() == candidate:
|
|
||||||
matches.append((member.user_id, member.display_name or member.user_id))
|
|
||||||
if len(matches) == 1:
|
|
||||||
return matches[0]
|
|
||||||
elif len(matches) > 1:
|
|
||||||
raise ValueError(
|
|
||||||
f"Multiple users have display name '{candidate}'. Use an MXID instead."
|
|
||||||
)
|
|
||||||
# if none, continue
|
|
||||||
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
|
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
|
||||||
|
|
||||||
async def resolve_user(bot, room_id, name_or_tokens):
|
|
||||||
"""
|
|
||||||
Accept either a single string (MXID or single-token display name)
|
|
||||||
or a list of tokens. Returns (mxid, display_name).
|
|
||||||
"""
|
|
||||||
if isinstance(name_or_tokens, str):
|
|
||||||
if name_or_tokens.startswith("@"):
|
|
||||||
return name_or_tokens, None
|
|
||||||
# Single token – try direct cache match or fallback to multi‑word
|
|
||||||
tokens = [name_or_tokens]
|
|
||||||
else:
|
|
||||||
tokens = name_or_tokens
|
|
||||||
|
|
||||||
return await resolve_user_from_tokens(bot, room_id, tokens)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Setup: register custom event listeners for membership & topics
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def setup(bot):
|
def setup(bot):
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
@bot.listener.on_custom_event(nio.RoomMemberEvent)
|
@bot.listener.on_custom_event(nio.RoomMemberEvent)
|
||||||
async def member_event(room, event):
|
async def member_event(room, event):
|
||||||
room_id = room.room_id
|
room_id = room.room_id
|
||||||
membership = event.content.get("membership")
|
membership = event.content.get("membership")
|
||||||
state_key = event.state_key
|
state_key = event.state_key
|
||||||
sender = event.sender
|
sender = event.sender
|
||||||
|
|
||||||
# Ignore the bot's own membership changes
|
|
||||||
if state_key == bot.async_client.user_id:
|
if state_key == bot.async_client.user_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
if membership == "join":
|
if membership == "join":
|
||||||
_incr(room_id, state_key, "joins")
|
_incr(room_id, state_key, "joins")
|
||||||
elif membership == "leave":
|
elif membership == "leave":
|
||||||
if sender != state_key: # kick
|
if sender != state_key:
|
||||||
_incr(room_id, sender, "kicks_given")
|
_incr(room_id, sender, "kicks_given")
|
||||||
_incr(room_id, state_key, "kicked_received")
|
_incr(room_id, state_key, "kicked_received")
|
||||||
else: # part
|
else:
|
||||||
_incr(room_id, state_key, "parts")
|
_incr(room_id, state_key, "parts")
|
||||||
|
|
||||||
@bot.listener.on_custom_event(nio.RoomTopicEvent)
|
@bot.listener.on_custom_event(nio.RoomTopicEvent)
|
||||||
@@ -156,53 +96,34 @@ def setup(bot):
|
|||||||
_incr(room_id, sender, "topics_set")
|
_incr(room_id, sender, "topics_set")
|
||||||
|
|
||||||
def _incr(room_id, user_id, column):
|
def _incr(room_id, user_id, column):
|
||||||
"""Increment a stat column by 1, creating row if needed."""
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute(
|
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, user_id))
|
||||||
"INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)",
|
c.execute(f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
|
||||||
(room_id, user_id)
|
(int(time.time()), room_id, user_id))
|
||||||
)
|
|
||||||
c.execute(
|
|
||||||
f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
|
|
||||||
(int(time.time()), room_id, user_id)
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Message handler – silently records stats, and handles commands
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
room_id = room.room_id
|
room_id = room.room_id
|
||||||
sender = message.sender
|
sender = message.sender
|
||||||
|
|
||||||
# ----- silently record stats for any non‑bot message -----
|
# silently record stats
|
||||||
if sender != bot.async_client.user_id: # <-- FIXED
|
if sender != bot.async_client.user_id:
|
||||||
body = message.body or ""
|
body = message.body or ""
|
||||||
words = len(body.split())
|
words = len(body.split())
|
||||||
chars = len(body)
|
chars = len(body)
|
||||||
smileys = count_smileys(body)
|
smileys = count_smileys(body)
|
||||||
is_action = getattr(message, "msgtype", None) == "m.emote"
|
is_action = getattr(message, "msgtype", None) == "m.emote"
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
|
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
|
||||||
c.execute(
|
c.execute("""UPDATE user_room_stats SET msgs=msgs+1, chars=chars+?, words=words+?, smileys=smileys+?, actions=actions+?, last_updated=?
|
||||||
"""UPDATE user_room_stats
|
WHERE room_id=? AND user_id=?""",
|
||||||
SET msgs = msgs + 1,
|
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender))
|
||||||
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ----- command matching -----
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not match.is_not_from_this_bot() or not match.prefix():
|
if not match.is_not_from_this_bot() or not match.prefix():
|
||||||
return
|
return
|
||||||
@@ -210,33 +131,16 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
cmd = match.command()
|
cmd = match.command()
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# !roomstats
|
|
||||||
# ===============================
|
|
||||||
if cmd == "roomstats":
|
if cmd == "roomstats":
|
||||||
await _handle_roomstats(bot, room_id)
|
await _handle_roomstats(bot, room_id)
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# !rank <expr>
|
|
||||||
# ===============================
|
|
||||||
elif cmd == "rank":
|
elif cmd == "rank":
|
||||||
if not args:
|
if not args:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room_id, "Usage: !rank <stat>")
|
||||||
room_id,
|
|
||||||
"Usage: !rank <stat>\n"
|
|
||||||
"Stats: msgs, chars, words, smileys, actions, joins, parts, "
|
|
||||||
"kicks_given, kicked_received, topics_set"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
col = args[0].lower()
|
col = args[0].lower()
|
||||||
await _handle_rank(bot, room_id, col)
|
await _handle_rank(bot, room_id, col)
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# !stats [<name>]
|
|
||||||
# ===============================
|
|
||||||
elif cmd == "stats":
|
elif cmd == "stats":
|
||||||
if args:
|
if args:
|
||||||
# Use all tokens as the display name (multi‑word)
|
|
||||||
try:
|
try:
|
||||||
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
|
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -244,44 +148,27 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
target_mxid = sender
|
target_mxid = sender
|
||||||
await _handle_user_stats(bot, room_id, target_mxid, sender)
|
await _handle_user_stats(bot, room_id, target_mxid)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Command implementations
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
VALID_STATS = {
|
VALID_STATS = {
|
||||||
"msgs": "Messages",
|
"msgs": "Messages", "chars": "Characters", "words": "Words", "smileys": "Smileys",
|
||||||
"chars": "Characters",
|
"actions": "Actions", "joins": "Joins", "parts": "Parts", "kicks_given": "Kicks given",
|
||||||
"words": "Words",
|
"kicked_received": "Times kicked", "topics_set": "Topics set",
|
||||||
"smileys": "Smileys",
|
|
||||||
"actions": "Actions",
|
|
||||||
"joins": "Joins",
|
|
||||||
"parts": "Parts",
|
|
||||||
"kicks_given": "Kicks given",
|
|
||||||
"kicked_received": "Times kicked",
|
|
||||||
"topics_set": "Topics set",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _get_aggregate(room_id):
|
async def _get_aggregate(room_id):
|
||||||
"""Return dict of aggregate stats for a room."""
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("""SELECT
|
c.execute("""SELECT COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), COALESCE(SUM(words),0),
|
||||||
COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0),
|
COALESCE(SUM(smileys),0), COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
|
||||||
COALESCE(SUM(words),0), COALESCE(SUM(smileys),0),
|
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0), COALESCE(SUM(kicked_received),0),
|
||||||
COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
|
COALESCE(SUM(topics_set),0)
|
||||||
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0),
|
|
||||||
COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0)
|
|
||||||
FROM user_room_stats WHERE room_id=?""", (room_id,))
|
FROM user_room_stats WHERE room_id=?""", (room_id,))
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not row or all(v == 0 for v in row):
|
if not row or all(v == 0 for v in row):
|
||||||
return None
|
return None
|
||||||
return {
|
return dict(zip(VALID_STATS.keys(), row))
|
||||||
"msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3],
|
|
||||||
"actions": row[4], "joins": row[5], "parts": row[6],
|
|
||||||
"kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9]
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _handle_roomstats(bot, room_id):
|
async def _handle_roomstats(bot, room_id):
|
||||||
agg = await _get_aggregate(room_id)
|
agg = await _get_aggregate(room_id)
|
||||||
@@ -289,17 +176,14 @@ async def _handle_roomstats(bot, room_id):
|
|||||||
await bot.api.send_text_message(room_id, "No stats collected yet.")
|
await bot.api.send_text_message(room_id, "No stats collected yet.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get top 10 by msgs
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("""SELECT user_id, msgs FROM user_room_stats
|
c.execute("SELECT user_id, msgs FROM user_room_stats WHERE room_id=? ORDER BY msgs DESC LIMIT 10", (room_id,))
|
||||||
WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
|
|
||||||
top = c.fetchall()
|
top = c.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# Resolve display names for top users
|
|
||||||
top_lines = []
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
top_rows = []
|
||||||
for uid, cnt in top:
|
for uid, cnt in top:
|
||||||
disp = uid
|
disp = uid
|
||||||
if resp.members:
|
if resp.members:
|
||||||
@@ -307,78 +191,63 @@ async def _handle_roomstats(bot, room_id):
|
|||||||
if m.user_id == uid:
|
if m.user_id == uid:
|
||||||
disp = m.display_name or uid
|
disp = m.display_name or uid
|
||||||
break
|
break
|
||||||
top_lines.append(f"<li><code>{disp}</code> — {cnt} msgs</li>")
|
top_rows.append(("📈", disp, f"{cnt} msgs"))
|
||||||
|
|
||||||
msg = f"""<details>
|
sections = [
|
||||||
<summary><strong>Room Statistics</strong></summary>
|
{"title": "Room Statistics", "rows": [
|
||||||
<ul>
|
("📩", "Messages", agg["msgs"]),
|
||||||
<li>📩 Messages: {agg['msgs']}</li>
|
("🔤", "Characters", agg["chars"]),
|
||||||
<li>🔤 Characters: {agg['chars']}</li>
|
("📝", "Words", agg["words"]),
|
||||||
<li>📝 Words: {agg['words']}</li>
|
("😀", "Smileys", agg["smileys"]),
|
||||||
<li>😀 Smileys: {agg['smileys']}</li>
|
("🎭", "Actions", agg["actions"]),
|
||||||
<li>🎭 Actions: {agg['actions']}</li>
|
("🚪", "Joins", agg["joins"]),
|
||||||
<li>🚪 Joins: {agg['joins']}</li>
|
("👋", "Parts", agg["parts"]),
|
||||||
<li>👋 Parts: {agg['parts']}</li>
|
("👢", "Kicks given", agg["kicks_given"]),
|
||||||
<li>👢 Kicks given: {agg['kicks_given']}</li>
|
("🥾", "Times kicked", agg["kicked_received"]),
|
||||||
<li>🥾 Times kicked: {agg['kicked_received']}</li>
|
("📌", "Topics set", agg["topics_set"]),
|
||||||
<li>📌 Topics set: {agg['topics_set']}</li>
|
]},
|
||||||
</ul>
|
{"title": "Top 10 by messages", "rows": top_rows},
|
||||||
<p><strong>Top 10 by messages:</strong></p>
|
]
|
||||||
<ol>
|
block = code_block("📊 Room Statistics", sections)
|
||||||
{''.join(top_lines)}
|
output = collapsible_summary("📊 Room Statistics", block)
|
||||||
</ol>
|
await bot.api.send_markdown_message(room_id, output)
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room_id, msg)
|
|
||||||
|
|
||||||
async def _handle_rank(bot, room_id, col):
|
async def _handle_rank(bot, room_id, col):
|
||||||
# Validate column
|
|
||||||
if col not in VALID_STATS:
|
if col not in VALID_STATS:
|
||||||
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
|
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
|
||||||
return
|
return
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
# Safe to use f-string because col is validated against a hardcoded set
|
c.execute(f"SELECT user_id, {col} FROM user_room_stats WHERE room_id=? AND {col}>0 ORDER BY {col} DESC LIMIT 10", (room_id,))
|
||||||
c.execute(f"""SELECT user_id, {col} FROM user_room_stats
|
|
||||||
WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,))
|
|
||||||
rows = c.fetchall()
|
rows = c.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
|
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
|
||||||
return
|
return
|
||||||
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
items = []
|
rank_rows = []
|
||||||
for i, (uid, val) in enumerate(rows, 1):
|
for uid, val in rows:
|
||||||
disp = uid
|
disp = uid
|
||||||
if resp.members:
|
if resp.members:
|
||||||
for m in resp.members:
|
for m in resp.members:
|
||||||
if m.user_id == uid:
|
if m.user_id == uid:
|
||||||
disp = m.display_name or uid
|
disp = m.display_name or uid
|
||||||
break
|
break
|
||||||
items.append(f"<li>{i}. <code>{disp}</code> — {val}</li>")
|
rank_rows.append(("🏅", disp, str(val)))
|
||||||
|
sections = [{"title": f"Ranking by {VALID_STATS[col]}", "rows": rank_rows}]
|
||||||
|
block = code_block(f"🏆 Top {VALID_STATS[col]}", sections)
|
||||||
|
output = collapsible_summary(f"🏆 {VALID_STATS[col]} Ranking", block)
|
||||||
|
await bot.api.send_markdown_message(room_id, output)
|
||||||
|
|
||||||
msg = f"""<details>
|
async def _handle_user_stats(bot, room_id, user_id):
|
||||||
<summary><strong>Ranking by {VALID_STATS[col]}</strong></summary>
|
|
||||||
<ol>
|
|
||||||
{''.join(items)}
|
|
||||||
</ol>
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room_id, msg)
|
|
||||||
|
|
||||||
async def _handle_user_stats(bot, room_id, user_id, sender):
|
|
||||||
# Fetch stats
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts,
|
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set
|
||||||
kicks_given, kicked_received, topics_set
|
|
||||||
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
|
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not row or all(v == 0 for v in row):
|
if not row or all(v == 0 for v in row):
|
||||||
# No stats, maybe just joined – get display name for the message
|
|
||||||
disp = user_id
|
disp = user_id
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
if resp.members:
|
if resp.members:
|
||||||
@@ -389,46 +258,44 @@ async def _handle_user_stats(bot, room_id, user_id, sender):
|
|||||||
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
|
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get display name
|
|
||||||
disp = user_id
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
disp = user_id
|
||||||
if resp.members:
|
if resp.members:
|
||||||
for m in resp.members:
|
for m in resp.members:
|
||||||
if m.user_id == user_id:
|
if m.user_id == user_id:
|
||||||
disp = m.display_name or user_id
|
disp = m.display_name or user_id
|
||||||
break
|
break
|
||||||
|
|
||||||
msg = f"""<details>
|
rows = [
|
||||||
<summary><strong>Stats for {disp}</strong></summary>
|
("📩", "Messages", row[0]),
|
||||||
<ul>
|
("🔤", "Characters", row[1]),
|
||||||
<li>📩 Messages: {row[0]}</li>
|
("📝", "Words", row[2]),
|
||||||
<li>🔤 Characters: {row[1]}</li>
|
("😀", "Smileys", row[3]),
|
||||||
<li>📝 Words: {row[2]}</li>
|
("🎭", "Actions", row[4]),
|
||||||
<li>😀 Smileys: {row[3]}</li>
|
("🚪", "Joins", row[5]),
|
||||||
<li>🎭 Actions: {row[4]}</li>
|
("👋", "Parts", row[6]),
|
||||||
<li>🚪 Joins: {row[5]}</li>
|
("👢", "Kicks given", row[7]),
|
||||||
<li>👋 Parts: {row[6]}</li>
|
("🥾", "Times kicked", row[8]),
|
||||||
<li>👢 Kicks given: {row[7]}</li>
|
("📌", "Topics set", row[9]),
|
||||||
<li>🥾 Times kicked: {row[8]}</li>
|
]
|
||||||
<li>📌 Topics set: {row[9]}</li>
|
sections = [{"title": f"Stats for {disp}", "rows": rows}]
|
||||||
</ul>
|
block = code_block(f"📊 Stats for {disp}", sections)
|
||||||
</details>"""
|
output = collapsible_summary(f"📊 Stats: {disp}", block)
|
||||||
await bot.api.send_markdown_message(room_id, msg)
|
await bot.api.send_markdown_message(room_id, output)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin metadata
|
# Plugin Metadata
|
||||||
# ------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.1.0"
|
||||||
__author__ = "Funguy Roomstats"
|
__author__ = "Funguy Roomstats"
|
||||||
__description__ = "Per‑user room statistics (Limnoria‑style), with multi‑word name support"
|
__description__ = "Per‑user room statistics"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Room Statistics Commands</strong></summary>
|
<summary><strong>Room Statistics Commands</strong></summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!roomstats</code> – Aggregate room stats + top 10 users</li>
|
<li><code>!roomstats</code> – Aggregate room stats + top 10 users</li>
|
||||||
<li><code>!rank <stat></code> – Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)</li>
|
<li><code>!rank <stat></code> – Top 10 by a specific stat</li>
|
||||||
<li><code>!stats [name]</code> – Show stats for a user (supports multi‑word names)</li>
|
<li><code>!stats [name]</code> – Show stats for a user</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>All commands work in the current room; display names are automatically resolved.</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
"""
|
||||||
+89
-265
@@ -1,93 +1,43 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides Shodan.io integration for security research and reconnaissance.
|
Shodan.io integration for security research and reconnaissance.
|
||||||
|
Output uses shared code_block for aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from plugins.common import html_escape, code_block, collapsible_summary
|
||||||
|
|
||||||
# Load environment variables from .env file
|
|
||||||
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)
|
|
||||||
|
|
||||||
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
|
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
|
||||||
SHODAN_API_BASE = "https://api.shodan.io"
|
SHODAN_API_BASE = "https://api.shodan.io"
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle Shodan commands.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"):
|
||||||
logging.info("Received !shodan command")
|
|
||||||
|
|
||||||
# Check if API key is configured
|
|
||||||
if not SHODAN_API_KEY:
|
if not SHODAN_API_KEY:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Shodan API key not configured.")
|
||||||
room.room_id,
|
|
||||||
"Shodan API key not configured. Please set SHODAN_KEY environment variable."
|
|
||||||
)
|
|
||||||
logging.error("Shodan API key not configured")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
sub = args[0].lower()
|
||||||
subcommand = args[0].lower()
|
if sub == "ip" and len(args) >= 2:
|
||||||
|
await shodan_ip_lookup(room, bot, args[1])
|
||||||
if subcommand == "ip":
|
elif sub == "search" and len(args) >= 2:
|
||||||
if len(args) < 2:
|
query = " ".join(args[1:])
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !shodan ip <ip_address>")
|
|
||||||
return
|
|
||||||
ip = args[1]
|
|
||||||
await shodan_ip_lookup(room, bot, ip)
|
|
||||||
|
|
||||||
elif subcommand == "search":
|
|
||||||
if len(args) < 2:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !shodan search <query>")
|
|
||||||
return
|
|
||||||
query = ' '.join(args[1:])
|
|
||||||
await shodan_search(room, bot, query)
|
await shodan_search(room, bot, query)
|
||||||
|
elif sub == "host" and len(args) >= 2:
|
||||||
elif subcommand == "host":
|
await shodan_host(room, bot, args[1])
|
||||||
if len(args) < 2:
|
elif sub == "count" and len(args) >= 2:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !shodan host <domain/ip>")
|
query = " ".join(args[1:])
|
||||||
return
|
|
||||||
host = args[1]
|
|
||||||
await shodan_host(room, bot, host)
|
|
||||||
|
|
||||||
elif subcommand == "count":
|
|
||||||
if len(args) < 2:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !shodan count <query>")
|
|
||||||
return
|
|
||||||
query = ' '.join(args[1:])
|
|
||||||
await shodan_count(room, bot, query)
|
await shodan_count(room, bot, query)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display Shodan command usage."""
|
usage = """<strong>🔍 Shodan Commands:</strong>
|
||||||
usage = """
|
|
||||||
<strong>🔍 Shodan Commands:</strong>
|
|
||||||
|
|
||||||
<strong>!shodan ip <ip_address></strong> - Get detailed information about an IP
|
<strong>!shodan ip <ip_address></strong> - Get detailed information about an IP
|
||||||
<strong>!shodan search <query></strong> - Search Shodan database
|
<strong>!shodan search <query></strong> - Search Shodan database
|
||||||
<strong>!shodan host <domain/ip></strong> - Get host information
|
<strong>!shodan host <domain/ip></strong> - Get host information
|
||||||
@@ -102,238 +52,112 @@ async def show_usage(room, bot):
|
|||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def shodan_ip_lookup(room, bot, ip):
|
async def shodan_ip_lookup(room, bot, ip):
|
||||||
"""Look up information about a specific IP address."""
|
safe_ip = html_escape(ip)
|
||||||
try:
|
try:
|
||||||
url = f"{SHODAN_API_BASE}/shodan/host/{ip}"
|
url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}"
|
||||||
params = {"key": SHODAN_API_KEY}
|
async with aiohttp.ClientSession() as session:
|
||||||
|
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()
|
||||||
|
|
||||||
logging.info(f"Fetching Shodan IP info for: {ip}")
|
rows = [
|
||||||
response = requests.get(url, params=params, timeout=15)
|
("🌐", "IP", safe_ip),
|
||||||
|
("📍", "Location", f"{data.get('city','N/A')}, {data.get('country_name','N/A')}"),
|
||||||
if response.status_code == 404:
|
("🏢", "Organization", data.get('org', 'N/A')),
|
||||||
await bot.api.send_text_message(room.room_id, f"No information found for IP: {ip}")
|
("💻", "OS", data.get('os', 'N/A')),
|
||||||
return
|
("🔌", "Open Ports", ', '.join(map(str, data.get('ports', []))) or 'None'),
|
||||||
elif response.status_code == 401:
|
]
|
||||||
await bot.api.send_text_message(room.room_id, "Invalid Shodan API key")
|
|
||||||
return
|
|
||||||
elif response.status_code != 200:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status_code}")
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Format the response
|
|
||||||
output = f"<strong>🔍 Shodan IP Lookup: {ip}</strong><br><br>"
|
|
||||||
|
|
||||||
if data.get('country_name'):
|
|
||||||
output += f"<strong>📍 Location:</strong> {data.get('city', 'N/A')}, {data.get('country_name', 'N/A')}<br>"
|
|
||||||
|
|
||||||
if data.get('org'):
|
|
||||||
output += f"<strong>🏢 Organization:</strong> {data['org']}<br>"
|
|
||||||
|
|
||||||
if data.get('os'):
|
|
||||||
output += f"<strong>💻 Operating System:</strong> {data['os']}<br>"
|
|
||||||
|
|
||||||
if data.get('ports'):
|
|
||||||
output += f"<strong>🔌 Open Ports:</strong> {', '.join(map(str, data['ports']))}<br>"
|
|
||||||
|
|
||||||
output += f"<strong>🕒 Last Update:</strong> {data.get('last_update', 'N/A')}<br><br>"
|
|
||||||
|
|
||||||
# Show services
|
|
||||||
if data.get('data'):
|
if data.get('data'):
|
||||||
output += "<strong>📡 Services:</strong><br>"
|
for svc in data['data'][:5]:
|
||||||
for service in data['data'][:5]: # Limit to first 5 services
|
rows.append(("📡", f"Port {svc.get('port')}", svc.get('product','Unknown')))
|
||||||
port = service.get('port', 'N/A')
|
sections = [{"title": f"Shodan IP Lookup: {safe_ip}", "rows": rows}]
|
||||||
product = service.get('product', 'Unknown')
|
block = code_block(f"🔍 Shodan IP Lookup: {safe_ip}", sections)
|
||||||
version = service.get('version', '')
|
output = collapsible_summary(f"🔍 Shodan: {safe_ip}", block)
|
||||||
banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '')
|
|
||||||
|
|
||||||
output += f" • <strong>Port {port}:</strong> {product} {version}<br>"
|
|
||||||
if banner:
|
|
||||||
output += f" <em>{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 = f"<details><summary><strong>🔍 Shodan IP Lookup: {ip}</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan IP info for {ip}")
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||||
logging.error("Shodan API timeout")
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {str(e)}")
|
|
||||||
logging.error(f"Error in shodan_ip_lookup: {e}")
|
|
||||||
|
|
||||||
async def shodan_search(room, bot, query):
|
async def shodan_search(room, bot, query):
|
||||||
"""Search the Shodan database."""
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
url = f"{SHODAN_API_BASE}/shodan/host/search"
|
url = f"{SHODAN_API_BASE}/shodan/host/search?key={SHODAN_API_KEY}&query={query}&minify=true&limit=5"
|
||||||
params = {
|
async with aiohttp.ClientSession() as session:
|
||||||
"key": SHODAN_API_KEY,
|
async with session.get(url, timeout=15) as resp:
|
||||||
"query": query,
|
resp.raise_for_status()
|
||||||
"minify": True,
|
data = await resp.json()
|
||||||
"limit": 5 # Limit results to avoid huge responses
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.info(f"Searching Shodan for: {query}")
|
|
||||||
response = requests.get(url, params=params, timeout=15)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
await handle_shodan_error(room, bot, response.status_code)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data.get('matches'):
|
if not data.get('matches'):
|
||||||
await bot.api.send_text_message(room.room_id, f"No results found for: {query}")
|
await bot.api.send_text_message(room.room_id, f"No results for '{safe_query}'.")
|
||||||
return
|
return
|
||||||
|
|
||||||
output = f"<strong>🔍 Shodan Search: '{query}'</strong><br>"
|
rows = []
|
||||||
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br><br>"
|
for match in data['matches'][:5]:
|
||||||
|
|
||||||
for match in data['matches'][:5]: # Show first 5 results
|
|
||||||
ip = match.get('ip_str', 'N/A')
|
ip = match.get('ip_str', 'N/A')
|
||||||
port = match.get('port', 'N/A')
|
port = match.get('port', '')
|
||||||
org = match.get('org', 'Unknown')
|
org = match.get('org', 'Unknown')
|
||||||
product = match.get('product', 'Unknown')
|
product = match.get('product', 'Unknown')
|
||||||
|
rows.append(("🌐", f"{ip}:{port}", f"{product} – {org}"))
|
||||||
output += f"<strong>🌐 {ip}:{port}</strong><br>"
|
sections = [{"title": f"Search: {safe_query}", "rows": rows}]
|
||||||
output += f" • <strong>Organization:</strong> {org}<br>"
|
block = code_block(f"🔍 Shodan Search: {safe_query}", sections)
|
||||||
output += f" • <strong>Service:</strong> {product}<br>"
|
output = collapsible_summary(f"Shodan Search: {safe_query}", block)
|
||||||
|
|
||||||
if match.get('location'):
|
|
||||||
loc = match['location']
|
|
||||||
if loc.get('city') and loc.get('country_name'):
|
|
||||||
output += f" • <strong>Location:</strong> {loc['city']}, {loc['country_name']}<br>"
|
|
||||||
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
if data.get('total', 0) > 5:
|
|
||||||
output += f"<em>Showing 5 of {data['total']:,} results. Refine your search for more specific results.</em>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan search results for: {query}")
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||||
logging.error("Shodan API timeout")
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {str(e)}")
|
|
||||||
logging.error(f"Error in shodan_search: {e}")
|
|
||||||
|
|
||||||
async def shodan_host(room, bot, host):
|
async def shodan_host(room, bot, host):
|
||||||
"""Get host information (domain or IP)."""
|
safe_host = html_escape(host)
|
||||||
try:
|
try:
|
||||||
url = f"{SHODAN_API_BASE}/dns/domain/{host}"
|
url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}"
|
||||||
params = {"key": SHODAN_API_KEY}
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, timeout=15) as resp:
|
||||||
logging.info(f"Fetching Shodan host info for: {host}")
|
if resp.status == 404:
|
||||||
response = requests.get(url, params=params, timeout=15)
|
await shodan_ip_lookup(room, bot, host)
|
||||||
|
return
|
||||||
if response.status_code == 404:
|
resp.raise_for_status()
|
||||||
# Try IP lookup instead
|
data = await resp.json()
|
||||||
await shodan_ip_lookup(room, bot, host)
|
rows = [("🌐", "Domain", safe_host)]
|
||||||
return
|
|
||||||
elif response.status_code != 200:
|
|
||||||
await handle_shodan_error(room, bot, response.status_code)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🔍 Shodan Host: {host}</strong><br><br>"
|
|
||||||
|
|
||||||
if data.get('subdomains'):
|
if data.get('subdomains'):
|
||||||
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>"
|
for sub in sorted(data['subdomains'])[:10]:
|
||||||
for subdomain in sorted(data['subdomains'])[:10]: # Show first 10
|
rows.append(("", "Subdomain", f"{sub}.{safe_host}"))
|
||||||
output += f" • {subdomain}.{host}<br>"
|
|
||||||
|
|
||||||
if len(data['subdomains']) > 10:
|
if len(data['subdomains']) > 10:
|
||||||
output += f" • ... and {len(data['subdomains']) - 10} more<br>"
|
rows.append(("", "", f"... and {len(data['subdomains']) - 10} more"))
|
||||||
|
sections = [{"title": f"Host: {safe_host}", "rows": rows}]
|
||||||
if data.get('tags'):
|
block = code_block(f"🔍 Shodan Host: {safe_host}", sections)
|
||||||
output += f"<br><strong>🏷️ Tags:</strong> {', '.join(data['tags'])}<br>"
|
output = collapsible_summary(f"Shodan Host: {safe_host}", block)
|
||||||
|
|
||||||
if data.get('data'):
|
|
||||||
output += f"<br><strong>📊 Records Found:</strong> {len(data['data'])}<br>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan host info for: {host}")
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||||
logging.error("Shodan API timeout")
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching host info: {str(e)}")
|
|
||||||
logging.error(f"Error in shodan_host: {e}")
|
|
||||||
|
|
||||||
async def shodan_count(room, bot, query):
|
async def shodan_count(room, bot, query):
|
||||||
"""Count results for a search query."""
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
url = f"{SHODAN_API_BASE}/shodan/host/count"
|
url = f"{SHODAN_API_BASE}/shodan/host/count?key={SHODAN_API_KEY}&query={query}"
|
||||||
params = {
|
async with aiohttp.ClientSession() as session:
|
||||||
"key": SHODAN_API_KEY,
|
async with session.get(url, timeout=15) as resp:
|
||||||
"query": query
|
resp.raise_for_status()
|
||||||
}
|
data = await resp.json()
|
||||||
|
rows = [("🔢", "Total Results", f"{data.get('total', 0):,}")]
|
||||||
logging.info(f"Counting Shodan results for: {query}")
|
if data.get('facets'):
|
||||||
response = requests.get(url, params=params, timeout=15)
|
for facet_name, facet_data in data['facets'].items():
|
||||||
|
for item in facet_data[:5]:
|
||||||
if response.status_code != 200:
|
rows.append(("", facet_name.capitalize(), f"{item['value']}: {item['count']:,}"))
|
||||||
await handle_shodan_error(room, bot, response.status_code)
|
sections = [{"title": f"Count: {safe_query}", "rows": rows}]
|
||||||
return
|
block = code_block(f"🔍 Shodan Count: {safe_query}", sections)
|
||||||
|
output = collapsible_summary(f"Shodan Count: {safe_query}", block)
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🔍 Shodan Count: '{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" • {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" • {org['value']}: {org['count']:,}<br>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan count for: {query}")
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"API error: {e}")
|
||||||
logging.error("Shodan API timeout")
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {str(e)}")
|
|
||||||
logging.error(f"Error in shodan_count: {e}")
|
|
||||||
|
|
||||||
async def handle_shodan_error(room, bot, status_code):
|
|
||||||
"""Handle Shodan API errors."""
|
|
||||||
error_messages = {
|
|
||||||
401: "Invalid Shodan API key",
|
|
||||||
403: "Access denied - check API key permissions",
|
|
||||||
404: "No results found",
|
|
||||||
429: "Rate limit exceeded - try again later",
|
|
||||||
500: "Shodan API server error",
|
|
||||||
503: "Shodan API temporarily unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
message = error_messages.get(status_code, f"Shodan API error: {status_code}")
|
|
||||||
await bot.api.send_text_message(room.room_id, message)
|
|
||||||
logging.error(f"Shodan API error: {status_code}")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.2"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Shodan.io reconnaissance"
|
__description__ = "Shodan.io reconnaissance"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
|
|||||||
+214
-492
@@ -1,92 +1,52 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides comprehensive SSL/TLS security scanning and analysis.
|
Comprehensive SSL/TLS security scanning and analysis.
|
||||||
|
All blocking socket calls run in a thread pool; user input is sanitised.
|
||||||
|
Output is a clean code block with aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import OpenSSL
|
import OpenSSL
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from urllib.parse import urlparse
|
from plugins.common import is_public_destination, html_escape, collapsible_summary, code_block
|
||||||
|
|
||||||
from plugins.utils import is_public_destination
|
# SSL/TLS configuration – handle missing protocols in modern Python
|
||||||
|
|
||||||
# SSL/TLS configuration - handle missing protocols in modern Python
|
|
||||||
TLS_VERSIONS = {
|
TLS_VERSIONS = {
|
||||||
'TLSv1.2': ssl.PROTOCOL_TLSv1_2,
|
'TLSv1.2': ssl.PROTOCOL_TLSv1_2,
|
||||||
'TLSv1.3': ssl.PROTOCOL_TLS
|
'TLSv1.3': ssl.PROTOCOL_TLS
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to add older protocols if available (they're removed in modern Python)
|
|
||||||
try:
|
try:
|
||||||
TLS_VERSIONS['TLSv1.1'] = ssl.PROTOCOL_TLSv1_1
|
TLS_VERSIONS['TLSv1.1'] = ssl.PROTOCOL_TLSv1_1
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
TLS_VERSIONS['TLSv1'] = ssl.PROTOCOL_TLSv1
|
TLS_VERSIONS['TLSv1'] = ssl.PROTOCOL_TLSv1
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Cipher suites by strength and category
|
|
||||||
CIPHER_CATEGORIES = {
|
CIPHER_CATEGORIES = {
|
||||||
'STRONG': [
|
'STRONG': [
|
||||||
'TLS_AES_256_GCM_SHA384',
|
'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256',
|
||||||
'TLS_CHACHA20_POLY1305_SHA256',
|
'TLS_AES_128_GCM_SHA256', 'ECDHE-RSA-AES256-GCM-SHA384',
|
||||||
'TLS_AES_128_GCM_SHA256',
|
'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-CHACHA20-POLY1305',
|
||||||
'ECDHE-RSA-AES256-GCM-SHA384',
|
'ECDHE-ECDSA-CHACHA20-POLY1305', 'DHE-RSA-AES256-GCM-SHA384'
|
||||||
'ECDHE-ECDSA-AES256-GCM-SHA384',
|
|
||||||
'ECDHE-RSA-CHACHA20-POLY1305',
|
|
||||||
'ECDHE-ECDSA-CHACHA20-POLY1305',
|
|
||||||
'DHE-RSA-AES256-GCM-SHA384'
|
|
||||||
],
|
],
|
||||||
'WEAK': [
|
'WEAK': ['RC4', 'DES', '3DES', 'MD5', 'EXPORT', 'NULL', 'ANON', 'ADH', 'CBC'],
|
||||||
'RC4',
|
|
||||||
'DES',
|
|
||||||
'3DES',
|
|
||||||
'MD5',
|
|
||||||
'EXPORT',
|
|
||||||
'NULL',
|
|
||||||
'ANON',
|
|
||||||
'ADH',
|
|
||||||
'CBC'
|
|
||||||
],
|
|
||||||
'OBSOLETE': [
|
|
||||||
'SSLv2',
|
|
||||||
'SSLv3'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle !sslscan command for comprehensive SSL/TLS analysis.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
|
||||||
logging.info("Received !sslscan command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
target = args[0].strip()
|
target = args[0].strip()
|
||||||
port = 443
|
port = 443
|
||||||
|
|
||||||
# Parse port if provided
|
|
||||||
if ':' in target:
|
if ':' in target:
|
||||||
parts = target.split(':')
|
parts = target.split(':')
|
||||||
target = parts[0]
|
target = parts[0]
|
||||||
@@ -96,27 +56,19 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
await bot.api.send_text_message(room.room_id, "Invalid port number")
|
await bot.api.send_text_message(room.room_id, "Invalid port number")
|
||||||
return
|
return
|
||||||
|
|
||||||
# SSRF protection: refuse internal hosts
|
|
||||||
if not is_public_destination(target):
|
if not is_public_destination(target):
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "❌ Scanning of private/internal addresses is not allowed.")
|
||||||
room.room_id,
|
|
||||||
"❌ Scanning of private/internal addresses is not allowed."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await perform_ssl_scan(room, bot, target, port)
|
await perform_ssl_scan(room, bot, target, port)
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display sslscan command usage."""
|
usage = """<strong>🔐 SSL/TLS Security Scanner</strong>
|
||||||
usage = """
|
|
||||||
<strong>🔐 SSL/TLS Security Scanner</strong>
|
|
||||||
|
|
||||||
<strong>!sslscan <domain[:port]></strong> - Comprehensive SSL/TLS security analysis
|
<strong>!sslscan <domain[:port]></strong> - Comprehensive SSL/TLS security analysis
|
||||||
|
|
||||||
<strong>Examples:</strong>
|
<strong>Examples:</strong>
|
||||||
• <code>!sslscan example.com</code>
|
• <code>!sslscan example.com</code>
|
||||||
• <code>!sslscan github.com:443</code>
|
• <code>!sslscan github.com:443</code>
|
||||||
• <code>!sslscan localhost:8443</code>
|
|
||||||
|
|
||||||
<strong>Tests Performed:</strong>
|
<strong>Tests Performed:</strong>
|
||||||
• SSL/TLS protocol support and versions
|
• SSL/TLS protocol support and versions
|
||||||
@@ -129,488 +81,258 @@ async def show_usage(room, bot):
|
|||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def perform_ssl_scan(room, bot, target, port):
|
async def _run_blocking(func, *args, **kwargs):
|
||||||
"""Perform comprehensive SSL/TLS security scan."""
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
|
def _test_connectivity(target, port):
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {target}:{port}...")
|
with socket.create_connection((target, port), timeout=10):
|
||||||
|
return True
|
||||||
scan_results = {
|
|
||||||
'target': target,
|
|
||||||
'port': port,
|
|
||||||
'certificate': {},
|
|
||||||
'protocols': {},
|
|
||||||
'ciphers': {},
|
|
||||||
'vulnerabilities': [],
|
|
||||||
'recommendations': [],
|
|
||||||
'security_score': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test basic connectivity
|
|
||||||
if not await test_connectivity(target, port):
|
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {target}:{port}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Perform comprehensive tests
|
|
||||||
await get_certificate_info(scan_results, target, port)
|
|
||||||
await test_protocol_support(scan_results, target, port)
|
|
||||||
await test_cipher_suites(scan_results, target, port)
|
|
||||||
await check_vulnerabilities(scan_results)
|
|
||||||
await calculate_security_score(scan_results)
|
|
||||||
await generate_recommendations(scan_results)
|
|
||||||
|
|
||||||
# Format and send results
|
|
||||||
output = await format_ssl_scan_results(scan_results)
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
|
|
||||||
logging.info(f"Completed SSL scan for {target}:{port}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error performing SSL scan: {str(e)}")
|
|
||||||
logging.error(f"Error in perform_ssl_scan: {e}")
|
|
||||||
|
|
||||||
async def test_connectivity(target, port):
|
|
||||||
"""Test basic connectivity to the target."""
|
|
||||||
try:
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
sock.settimeout(10)
|
|
||||||
result = sock.connect_ex((target, port))
|
|
||||||
sock.close()
|
|
||||||
return result == 0
|
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_certificate_info(scan_results, target, port):
|
def _get_certificate_info(target, port):
|
||||||
"""Get comprehensive certificate information."""
|
context = ssl.create_default_context()
|
||||||
try:
|
context.check_hostname = False
|
||||||
context = ssl.create_default_context()
|
context.verify_mode = ssl.CERT_NONE
|
||||||
context.check_hostname = False
|
with socket.create_connection((target, port), timeout=10) as sock:
|
||||||
context.verify_mode = ssl.CERT_NONE
|
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
||||||
|
cert_bin = ssock.getpeercert(binary_form=True)
|
||||||
|
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin)
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=10) as sock:
|
subject = cert.get_subject()
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
issuer = cert.get_issuer()
|
||||||
cert_bin = ssock.getpeercert(binary_form=True)
|
not_before = cert.get_notBefore().decode('utf-8')
|
||||||
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin)
|
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
|
||||||
|
|
||||||
# Basic certificate info
|
extensions = []
|
||||||
subject = cert.get_subject()
|
for i in range(cert.get_extension_count()):
|
||||||
issuer = cert.get_issuer()
|
ext = cert.get_extension(i)
|
||||||
|
extensions.append({
|
||||||
|
'name': ext.get_short_name().decode('utf-8'),
|
||||||
|
'value': str(ext)
|
||||||
|
})
|
||||||
|
|
||||||
scan_results['certificate'] = {
|
return {
|
||||||
'subject': {
|
'subject': {
|
||||||
'common_name': subject.CN,
|
'common_name': subject.CN,
|
||||||
'organization': subject.O,
|
'organization': subject.O,
|
||||||
'organizational_unit': subject.OU,
|
'organizational_unit': subject.OU,
|
||||||
'country': subject.C,
|
'country': subject.C,
|
||||||
'state': subject.ST,
|
'state': subject.ST,
|
||||||
'locality': subject.L
|
'locality': subject.L
|
||||||
},
|
},
|
||||||
'issuer': {
|
'issuer': {
|
||||||
'common_name': issuer.CN,
|
'common_name': issuer.CN,
|
||||||
'organization': issuer.O,
|
'organization': issuer.O,
|
||||||
'organizational_unit': issuer.OU
|
'organizational_unit': issuer.OU
|
||||||
},
|
},
|
||||||
'serial_number': cert.get_serial_number(),
|
'serial_number': cert.get_serial_number(),
|
||||||
'version': cert.get_version(),
|
'version': cert.get_version(),
|
||||||
'not_before': cert.get_notBefore().decode('utf-8'),
|
'not_before': not_before,
|
||||||
'not_after': cert.get_notAfter().decode('utf-8'),
|
'not_after': not_after,
|
||||||
'signature_algorithm': cert.get_signature_algorithm().decode('utf-8'),
|
'signature_algorithm': sig_alg,
|
||||||
'extensions': []
|
'days_until_expiry': days_remaining,
|
||||||
}
|
'extensions': extensions
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
# Parse extensions
|
def _test_protocols(target, port):
|
||||||
for i in range(cert.get_extension_count()):
|
protocols = {}
|
||||||
ext = cert.get_extension(i)
|
for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
|
||||||
scan_results['certificate']['extensions'].append({
|
if proto_name not in TLS_VERSIONS:
|
||||||
'name': ext.get_short_name().decode('utf-8'),
|
protocols[proto_name] = False
|
||||||
'value': str(ext)
|
continue
|
||||||
})
|
|
||||||
|
|
||||||
# Calculate days until expiration
|
|
||||||
not_after = datetime.datetime.strptime(scan_results['certificate']['not_after'], '%Y%m%d%H%M%SZ')
|
|
||||||
days_until_expiry = (not_after - datetime.datetime.utcnow()).days
|
|
||||||
scan_results['certificate']['days_until_expiry'] = days_until_expiry
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
scan_results['certificate_error'] = str(e)
|
|
||||||
|
|
||||||
async def test_protocol_support(scan_results, target, port):
|
|
||||||
"""Test support for various SSL/TLS protocols."""
|
|
||||||
protocols = {
|
|
||||||
'SSLv2': False,
|
|
||||||
'SSLv3': False,
|
|
||||||
'TLSv1': False,
|
|
||||||
'TLSv1.1': False,
|
|
||||||
'TLSv1.2': False,
|
|
||||||
'TLSv1.3': False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test available protocols
|
|
||||||
for protocol_name in protocols.keys():
|
|
||||||
try:
|
try:
|
||||||
if protocol_name in TLS_VERSIONS:
|
ctx = ssl.SSLContext(TLS_VERSIONS[proto_name])
|
||||||
context = ssl.SSLContext(TLS_VERSIONS[protocol_name])
|
ctx.check_hostname = False
|
||||||
else:
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
# For protocols not available in this Python version, assume False
|
|
||||||
protocols[protocol_name] = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=5) as sock:
|
with socket.create_connection((target, port), timeout=5) as sock:
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
with ctx.wrap_socket(sock, server_hostname=target):
|
||||||
protocols[protocol_name] = True
|
protocols[proto_name] = True
|
||||||
# Get negotiated protocol
|
|
||||||
if hasattr(ssock, 'version'):
|
|
||||||
scan_results['negotiated_protocol'] = ssock.version()
|
|
||||||
except:
|
except:
|
||||||
protocols[protocol_name] = False
|
protocols[proto_name] = False
|
||||||
|
return protocols
|
||||||
|
|
||||||
scan_results['protocols'] = protocols
|
def _test_cipher_suites(target, port):
|
||||||
|
|
||||||
async def test_cipher_suites(scan_results, target, port):
|
|
||||||
"""Test supported cipher suites."""
|
|
||||||
try:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
# Get default cipher suites
|
|
||||||
context.set_ciphers('ALL:COMPLEMENTOFALL')
|
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=10) as sock:
|
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
|
||||||
cipher = ssock.cipher()
|
|
||||||
scan_results['ciphers'] = {
|
|
||||||
'negotiated_cipher': cipher[0] if cipher else 'Unknown',
|
|
||||||
'supported_ciphers': await get_supported_ciphers(target, port),
|
|
||||||
'weak_ciphers': [],
|
|
||||||
'strong_ciphers': []
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
scan_results['cipher_error'] = str(e)
|
|
||||||
|
|
||||||
async def get_supported_ciphers(target, port):
|
|
||||||
"""Get list of supported cipher suites."""
|
|
||||||
supported_ciphers = []
|
|
||||||
|
|
||||||
# Test common cipher suites
|
|
||||||
test_ciphers = [
|
test_ciphers = [
|
||||||
'ECDHE-RSA-AES256-GCM-SHA384',
|
'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384',
|
||||||
'ECDHE-ECDSA-AES256-GCM-SHA384',
|
'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384',
|
||||||
'ECDHE-RSA-AES256-SHA384',
|
'ECDHE-RSA-AES256-SHA', 'ECDHE-ECDSA-AES256-SHA',
|
||||||
'ECDHE-ECDSA-AES256-SHA384',
|
'AES256-GCM-SHA384', 'AES256-SHA256', 'AES256-SHA',
|
||||||
'ECDHE-RSA-AES256-SHA',
|
'CAMELLIA256-SHA', 'PSK-AES256-CBC-SHA',
|
||||||
'ECDHE-ECDSA-AES256-SHA',
|
'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES128-GCM-SHA256',
|
||||||
'AES256-GCM-SHA384',
|
'ECDHE-RSA-AES128-SHA256', 'ECDHE-ECDSA-AES128-SHA256',
|
||||||
'AES256-SHA256',
|
'ECDHE-RSA-AES128-SHA', 'ECDHE-ECDSA-AES128-SHA',
|
||||||
'AES256-SHA',
|
'AES128-GCM-SHA256', 'AES128-SHA256', 'AES128-SHA',
|
||||||
'CAMELLIA256-SHA',
|
'CAMELLIA128-SHA', 'PSK-AES128-CBC-SHA',
|
||||||
'PSK-AES256-CBC-SHA',
|
'DES-CBC3-SHA', 'RC4-SHA', 'RC4-MD5'
|
||||||
'ECDHE-RSA-AES128-GCM-SHA256',
|
|
||||||
'ECDHE-ECDSA-AES128-GCM-SHA256',
|
|
||||||
'ECDHE-RSA-AES128-SHA256',
|
|
||||||
'ECDHE-ECDSA-AES128-SHA256',
|
|
||||||
'ECDHE-RSA-AES128-SHA',
|
|
||||||
'ECDHE-ECDSA-AES128-SHA',
|
|
||||||
'AES128-GCM-SHA256',
|
|
||||||
'AES128-SHA256',
|
|
||||||
'AES128-SHA',
|
|
||||||
'CAMELLIA128-SHA',
|
|
||||||
'PSK-AES128-CBC-SHA',
|
|
||||||
'DES-CBC3-SHA',
|
|
||||||
'RC4-SHA',
|
|
||||||
'RC4-MD5'
|
|
||||||
]
|
]
|
||||||
|
supported = []
|
||||||
for cipher in test_ciphers:
|
for cipher in test_ciphers:
|
||||||
try:
|
try:
|
||||||
context = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
context.check_hostname = False
|
ctx.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
context.set_ciphers(cipher)
|
ctx.set_ciphers(cipher)
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=5) as sock:
|
with socket.create_connection((target, port), timeout=5) as sock:
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
with ctx.wrap_socket(sock, server_hostname=target):
|
||||||
if ssock.cipher():
|
supported.append(cipher)
|
||||||
supported_ciphers.append(cipher)
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
return supported
|
||||||
|
|
||||||
return supported_ciphers
|
def _check_vulnerabilities(protocols, cert_info, supported_ciphers):
|
||||||
|
vulns = []
|
||||||
async def check_vulnerabilities(scan_results):
|
if protocols.get('SSLv2'):
|
||||||
"""Check for common SSL/TLS vulnerabilities."""
|
vulns.append(('SSLv2 Support', 'CRITICAL'))
|
||||||
vulnerabilities = []
|
if protocols.get('SSLv3'):
|
||||||
|
vulns.append(('SSLv3 Support', 'HIGH'))
|
||||||
# Check for weak protocols
|
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
|
||||||
if scan_results['protocols'].get('SSLv2', False):
|
vulns.append(('Certificate Expiring Soon', 'MEDIUM'))
|
||||||
vulnerabilities.append({
|
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||||
'name': 'SSLv2 Support',
|
|
||||||
'severity': 'CRITICAL',
|
|
||||||
'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
|
|
||||||
'cve': 'Multiple CVEs'
|
|
||||||
})
|
|
||||||
|
|
||||||
if scan_results['protocols'].get('SSLv3', False):
|
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'SSLv3 Support',
|
|
||||||
'severity': 'HIGH',
|
|
||||||
'description': 'SSLv3 is vulnerable to POODLE attack',
|
|
||||||
'cve': 'CVE-2014-3566'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check certificate expiration
|
|
||||||
cert = scan_results.get('certificate', {})
|
|
||||||
if cert.get('days_until_expiry', 0) < 30:
|
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'Certificate Expiring Soon',
|
|
||||||
'severity': 'MEDIUM',
|
|
||||||
'description': f"Certificate expires in {cert['days_until_expiry']} days",
|
|
||||||
'cve': 'N/A'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check for weak ciphers
|
|
||||||
supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', [])
|
|
||||||
weak_ciphers_found = []
|
|
||||||
|
|
||||||
for cipher in supported_ciphers:
|
|
||||||
if any(weak in cipher.upper() for weak in CIPHER_CATEGORIES['WEAK']):
|
|
||||||
weak_ciphers_found.append(cipher)
|
|
||||||
|
|
||||||
if weak_ciphers_found:
|
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'Weak Cipher Suites',
|
|
||||||
'severity': 'HIGH',
|
|
||||||
'description': f'Weak ciphers supported: {", ".join(weak_ciphers_found[:3])}',
|
|
||||||
'cve': 'Multiple CVEs'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check for missing modern protocols
|
|
||||||
if not scan_results['protocols'].get('TLSv1.2', False):
|
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'TLS 1.2 Not Supported',
|
|
||||||
'severity': 'HIGH',
|
|
||||||
'description': 'TLS 1.2 is required for modern security',
|
|
||||||
'cve': 'N/A'
|
|
||||||
})
|
|
||||||
|
|
||||||
if not scan_results['protocols'].get('TLSv1.3', False):
|
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'TLS 1.3 Not Supported',
|
|
||||||
'severity': 'MEDIUM',
|
|
||||||
'description': 'TLS 1.3 provides improved security and performance',
|
|
||||||
'cve': 'N/A'
|
|
||||||
})
|
|
||||||
|
|
||||||
scan_results['vulnerabilities'] = vulnerabilities
|
|
||||||
|
|
||||||
async def calculate_security_score(scan_results):
|
|
||||||
"""Calculate overall security score."""
|
|
||||||
score = 100
|
|
||||||
|
|
||||||
# Protocol penalties
|
|
||||||
if scan_results['protocols'].get('SSLv2', False):
|
|
||||||
score -= 30
|
|
||||||
if scan_results['protocols'].get('SSLv3', False):
|
|
||||||
score -= 20
|
|
||||||
if not scan_results['protocols'].get('TLSv1.2', False):
|
|
||||||
score -= 15
|
|
||||||
if not scan_results['protocols'].get('TLSv1.3', False):
|
|
||||||
score -= 10
|
|
||||||
|
|
||||||
# Certificate penalties
|
|
||||||
cert = scan_results.get('certificate', {})
|
|
||||||
if cert.get('days_until_expiry', 0) < 30:
|
|
||||||
score -= 10
|
|
||||||
if cert.get('days_until_expiry', 0) < 7:
|
|
||||||
score -= 20
|
|
||||||
|
|
||||||
# Cipher penalties
|
|
||||||
supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', [])
|
|
||||||
weak_cipher_count = sum(1 for cipher in supported_ciphers
|
|
||||||
if any(weak in cipher.upper() for weak in CIPHER_CATEGORIES['WEAK']))
|
|
||||||
score -= min(weak_cipher_count * 5, 25)
|
|
||||||
|
|
||||||
# Vulnerability penalties
|
|
||||||
for vuln in scan_results.get('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
|
|
||||||
|
|
||||||
scan_results['security_score'] = max(0, score)
|
|
||||||
|
|
||||||
async def generate_recommendations(scan_results):
|
|
||||||
"""Generate security recommendations."""
|
|
||||||
recommendations = []
|
|
||||||
|
|
||||||
# Protocol recommendations
|
|
||||||
if scan_results['protocols'].get('SSLv2', False):
|
|
||||||
recommendations.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable")
|
|
||||||
if scan_results['protocols'].get('SSLv3', False):
|
|
||||||
recommendations.append("🔴 Disable SSLv3 - vulnerable to POODLE attack")
|
|
||||||
if not scan_results['protocols'].get('TLSv1.3', False):
|
|
||||||
recommendations.append("🟡 Enable TLSv1.3 for best security and performance")
|
|
||||||
|
|
||||||
# Certificate recommendations
|
|
||||||
cert = scan_results.get('certificate', {})
|
|
||||||
if cert.get('days_until_expiry', 0) < 30:
|
|
||||||
recommendations.append("🟡 Renew SSL certificate - expiring soon")
|
|
||||||
|
|
||||||
# Cipher recommendations
|
|
||||||
supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', [])
|
|
||||||
weak_ciphers = [c for c in supported_ciphers
|
|
||||||
if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])]
|
|
||||||
|
|
||||||
if weak_ciphers:
|
if weak_ciphers:
|
||||||
recommendations.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)")
|
vulns.append(('Weak Cipher Suites', 'HIGH'))
|
||||||
|
if not protocols.get('TLSv1.2', False):
|
||||||
|
vulns.append(('TLS 1.2 Not Supported', 'HIGH'))
|
||||||
|
if not protocols.get('TLSv1.3', False):
|
||||||
|
vulns.append(('TLS 1.3 Not Supported', 'MEDIUM'))
|
||||||
|
return vulns
|
||||||
|
|
||||||
# General recommendations
|
def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities):
|
||||||
if scan_results['security_score'] < 80:
|
score = 100
|
||||||
recommendations.append("🛡️ Implement modern TLS configuration following Mozilla guidelines")
|
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']))
|
||||||
|
score -= min(weak_cipher_count * 5, 25)
|
||||||
|
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("🔴 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 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 ciphers")
|
||||||
|
if score < 80:
|
||||||
|
recs.append("🛡️ Improve TLS configuration")
|
||||||
if not any('ECDHE' in c for c in supported_ciphers):
|
if not any('ECDHE' in c for c in supported_ciphers):
|
||||||
recommendations.append("🟡 Enable Forward Secrecy with ECDHE cipher suites")
|
recs.append("🟡 Enable Forward Secrecy")
|
||||||
|
return recs
|
||||||
|
|
||||||
# Add note about Python version limitations
|
async def perform_ssl_scan(room, bot, target, port):
|
||||||
recommendations.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features")
|
safe_target = html_escape(target)
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Starting SSL/TLS scan for {safe_target}:{port}...")
|
||||||
|
|
||||||
scan_results['recommendations'] = recommendations
|
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
|
||||||
|
|
||||||
async def format_ssl_scan_results(scan_results):
|
cert_task = _run_blocking(_get_certificate_info, target, port)
|
||||||
"""Format comprehensive SSL scan results."""
|
proto_task = _run_blocking(_test_protocols, target, port)
|
||||||
output = f"<strong>🔐 SSL/TLS Security Scan: {scan_results['target']}:{scan_results['port']}</strong><br><br>"
|
cipher_task = _run_blocking(_test_cipher_suites, target, port)
|
||||||
|
|
||||||
# Security Score
|
cert_info, protocols, supported_ciphers = await asyncio.gather(cert_task, proto_task, cipher_task)
|
||||||
score = scan_results['security_score']
|
|
||||||
if score >= 90:
|
|
||||||
score_emoji, rating = "🟢", "Excellent"
|
|
||||||
elif score >= 80:
|
|
||||||
score_emoji, rating = "🟡", "Good"
|
|
||||||
elif score >= 60:
|
|
||||||
score_emoji, rating = "🟠", "Fair"
|
|
||||||
else:
|
|
||||||
score_emoji, rating = "🔴", "Poor"
|
|
||||||
|
|
||||||
output += f"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
|
vulnerabilities = _check_vulnerabilities(protocols, cert_info, supported_ciphers)
|
||||||
|
score = _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities)
|
||||||
|
recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score)
|
||||||
|
|
||||||
# Certificate Information
|
sections = []
|
||||||
cert = scan_results.get('certificate', {})
|
|
||||||
if cert:
|
|
||||||
output += "<strong>📜 Certificate Information</strong><br>"
|
|
||||||
output += f" • <strong>Subject:</strong> {cert.get('subject', {}).get('common_name', 'N/A')}<br>"
|
|
||||||
output += f" • <strong>Issuer:</strong> {cert.get('issuer', {}).get('common_name', 'N/A')}<br>"
|
|
||||||
output += f" • <strong>Valid From:</strong> {format_cert_date(cert.get('not_before', ''))}<br>"
|
|
||||||
output += f" • <strong>Valid Until:</strong> {format_cert_date(cert.get('not_after', ''))}<br>"
|
|
||||||
output += f" • <strong>Expires In:</strong> {cert.get('days_until_expiry', 'N/A')} days<br>"
|
|
||||||
output += f" • <strong>Signature Algorithm:</strong> {cert.get('signature_algorithm', 'N/A')}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Protocol Support
|
# Score
|
||||||
output += "<strong>🔌 Protocol Support</strong><br>"
|
score_emoji = "🟢" if score >= 90 else "🟡" if score >= 80 else "🟠" if score >= 60 else "🔴"
|
||||||
protocols = scan_results.get('protocols', {})
|
rating = "Excellent" if score >= 90 else "Good" if score >= 80 else "Fair" if score >= 60 else "Poor"
|
||||||
for proto, supported in protocols.items():
|
sections.append({"title": f"{score_emoji} Security Score", "rows": [("", "Score", f"{score}/100 ({rating})")]})
|
||||||
# Handle protocols that can't be tested in this Python version
|
|
||||||
if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS:
|
# Certificate
|
||||||
emoji = "⚫"
|
if cert_info:
|
||||||
status = "Cannot test (Python security)"
|
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})
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
emoji = "🔴"
|
||||||
|
elif proto == 'TLSv1.3' and supported:
|
||||||
|
emoji = "✅"
|
||||||
else:
|
else:
|
||||||
emoji = "✅" if supported else "❌"
|
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"
|
||||||
|
emoji = "⚫"
|
||||||
|
proto_rows.append((emoji, proto, status))
|
||||||
|
sections.append({"title": "🔌 Protocols", "rows": proto_rows})
|
||||||
|
|
||||||
# Highlight insecure protocols
|
# Cipher Suites
|
||||||
if proto in ['SSLv2', 'SSLv3'] and supported:
|
weak_ciphers = [c for c in supported_ciphers if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||||
emoji = "🔴"
|
strong_ciphers = [c for c in supported_ciphers if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
|
||||||
elif proto in ['TLSv1.3'] and supported:
|
cipher_rows = [("🔢", "Total Supported", str(len(supported_ciphers)))]
|
||||||
emoji = "✅"
|
if weak_ciphers:
|
||||||
|
cipher_rows.append(("🔴", "Weak Ciphers", str(len(weak_ciphers))))
|
||||||
output += f" • {emoji} <strong>{proto}:</strong> {status if 'status' in locals() else 'Supported' if supported else 'Not Supported'}<br>"
|
for c in weak_ciphers[:3]:
|
||||||
output += "<br>"
|
cipher_rows.append(("", "", c))
|
||||||
|
if strong_ciphers:
|
||||||
# Cipher Information
|
cipher_rows.append(("🟢", "Strong Ciphers", str(len(strong_ciphers))))
|
||||||
ciphers = scan_results.get('ciphers', {})
|
sections.append({"title": "🔐 Cipher Suites", "rows": cipher_rows})
|
||||||
if ciphers.get('supported_ciphers'):
|
|
||||||
output += "<strong>🔐 Cipher Suites</strong><br>"
|
|
||||||
output += f" • <strong>Negotiated:</strong> {ciphers.get('negotiated_cipher', 'Unknown')}<br>"
|
|
||||||
output += f" • <strong>Total Supported:</strong> {len(ciphers['supported_ciphers'])}<br>"
|
|
||||||
|
|
||||||
# Show weak ciphers if any
|
|
||||||
weak_ciphers = [c for c in ciphers['supported_ciphers']
|
|
||||||
if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])]
|
|
||||||
if weak_ciphers:
|
|
||||||
output += f" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>"
|
|
||||||
for cipher in weak_ciphers[:3]:
|
|
||||||
output += f" └─ 🔴 {cipher}<br>"
|
|
||||||
|
|
||||||
# Show strong ciphers if any
|
|
||||||
strong_ciphers = [c for c in ciphers['supported_ciphers']
|
|
||||||
if any(strong in c.upper() for strong in CIPHER_CATEGORIES['STRONG'])]
|
|
||||||
if strong_ciphers:
|
|
||||||
output += f" • <strong>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Vulnerabilities
|
# Vulnerabilities
|
||||||
vulnerabilities = scan_results.get('vulnerabilities', [])
|
|
||||||
if vulnerabilities:
|
if vulnerabilities:
|
||||||
output += "<strong>⚠️ Security Vulnerabilities</strong><br>"
|
vuln_rows = []
|
||||||
for vuln in vulnerabilities[:5]: # Show top 5
|
for name, sev in vulnerabilities:
|
||||||
severity_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
|
sev_emoji = "🔴" if sev == 'CRITICAL' else "🟠" if sev == 'HIGH' else "🟡"
|
||||||
output += f" • {severity_emoji} <strong>{vuln['name']}</strong> ({vuln['severity']})<br>"
|
vuln_rows.append((sev_emoji, name, sev))
|
||||||
output += f" └─ {vuln['description']}<br>"
|
sections.append({"title": "⚠️ Vulnerabilities", "rows": vuln_rows})
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Recommendations
|
# Recommendations
|
||||||
recommendations = scan_results.get('recommendations', [])
|
|
||||||
if recommendations:
|
if recommendations:
|
||||||
output += "<strong>💡 Security Recommendations</strong><br>"
|
rec_rows = [("💡", "Recommendation", rec) for rec in recommendations]
|
||||||
for rec in recommendations[:8]:
|
sections.append({"title": "💡 Recommendations", "rows": rec_rows})
|
||||||
output += f" • {rec}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Quick Assessment
|
# Quick Assessment
|
||||||
output += "<strong>📊 Quick Assessment</strong><br>"
|
assessment_rows = []
|
||||||
if score >= 90:
|
if score >= 90:
|
||||||
output += " • ✅ Excellent TLS configuration<br>"
|
assessment_rows = [("", "Assessment", "✅ Excellent configuration")]
|
||||||
output += " • ✅ Modern protocols and ciphers<br>"
|
|
||||||
output += " • ✅ Good certificate management<br>"
|
|
||||||
elif score >= 70:
|
elif score >= 70:
|
||||||
output += " • ⚠️ Good configuration with minor issues<br>"
|
assessment_rows = [("", "Assessment", "⚠️ Good, minor improvements possible")]
|
||||||
output += " • 🔧 Some improvements recommended<br>"
|
|
||||||
else:
|
else:
|
||||||
output += " • 🚨 Significant security issues found<br>"
|
assessment_rows = [("", "Assessment", "🚨 Significant issues found")]
|
||||||
output += " • 🔴 Immediate action required<br>"
|
sections.append({"title": "📊 Quick Assessment", "rows": assessment_rows})
|
||||||
|
|
||||||
# Add note about testing limitations
|
|
||||||
output += "<br><em>ℹ️ Note: Some protocol tests limited by Python security features</em>"
|
|
||||||
|
|
||||||
# Always wrap in collapsible due to comprehensive output
|
|
||||||
output = f"<details><summary><strong>🔐 SSL/TLS Scan: {scan_results['target']}:{scan_results['port']} (Score: {score}/100)</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
def format_cert_date(date_str):
|
|
||||||
"""Format certificate date string for display."""
|
|
||||||
try:
|
|
||||||
if date_str:
|
|
||||||
dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ')
|
|
||||||
return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return date_str
|
|
||||||
|
|
||||||
|
block = code_block(f"🔐 SSL/TLS Scan: {safe_target}:{port}", sections)
|
||||||
|
output = collapsible_summary(f"🔐 SSL/TLS: {safe_target} (Score: {score}/100)", block)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
logging.info(f"Completed SSL scan for {target}:{port}")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.2"
|
||||||
__version__ = "1.0.1"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "SSL/TLS security scanner (SSRF‑safe)"
|
__description__ = "SSL/TLS security scanner"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!sslscan</strong> – SSL/TLS analysis</summary>
|
<summary><strong>!sslscan</strong> – SSL/TLS analysis</summary>
|
||||||
|
|||||||
+36
-90
@@ -1,97 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Plugin for generating images using self-hosted Stable Diffusion and sending them to a Matrix chat room.
|
Plugin for generating images using self-hosted Stable Diffusion and sending them to a Matrix chat room.
|
||||||
|
|
||||||
|
Now fully asynchronous (uses aiohttp). All original parameters and help text are preserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import aiohttp
|
||||||
import base64
|
import base64
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
from asyncio import Queue
|
|
||||||
import argparse
|
import argparse
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import markdown2
|
|
||||||
from slugify import slugify
|
|
||||||
|
|
||||||
# Queue to store pending commands
|
|
||||||
command_queue = Queue()
|
|
||||||
|
|
||||||
def slugify_prompt(prompt: str) -> str:
|
|
||||||
"""
|
|
||||||
Generates a URL-friendly slug from the given prompt.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt (str): The prompt to slugify.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: A URL-friendly slug version of the prompt.
|
|
||||||
"""
|
|
||||||
return slugify(prompt)
|
|
||||||
|
|
||||||
def markdown_to_html(markdown_text: str) -> str:
|
|
||||||
"""
|
|
||||||
Converts Markdown text to HTML.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
markdown_text (str): The Markdown text to convert.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The HTML version of the input Markdown text.
|
|
||||||
"""
|
|
||||||
return markdown2.markdown(markdown_text)
|
|
||||||
|
|
||||||
async def process_command(room, message, bot, prefix, config):
|
|
||||||
"""
|
|
||||||
Processes !sd commands and queues them if already running.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room: Matrix room object
|
|
||||||
message: Matrix message object
|
|
||||||
bot: Bot instance
|
|
||||||
prefix: Command prefix
|
|
||||||
config: Bot config object
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
|
||||||
if match.prefix() and match.command("sd"):
|
|
||||||
if command_queue.empty():
|
|
||||||
await handle_command(room, message, bot, prefix, config)
|
|
||||||
else:
|
|
||||||
await command_queue.put((room, message, bot, prefix, config))
|
|
||||||
await bot.api.send_text_message(room.room_id, "Command queued. Please wait for the current image to finish.")
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Handles !sd command: generates image using Stable Diffusion API.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room: Matrix room object
|
|
||||||
message: Matrix message object
|
|
||||||
bot: Bot instance
|
|
||||||
prefix: Command prefix
|
|
||||||
config: Bot config object
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.prefix() and match.command("sd")):
|
if not (match.prefix() and match.command("sd")):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if API is available
|
# Check if API is reachable
|
||||||
try:
|
try:
|
||||||
health_check = requests.get("http://127.0.0.1:7860/docs", timeout=3)
|
async with aiohttp.ClientSession() as session:
|
||||||
if health_check.status_code != 200:
|
async with session.get("http://127.0.0.1:7860/docs", timeout=3) as resp:
|
||||||
await bot.api.send_text_message(room.room_id, "Stable Diffusion API is not running!")
|
if resp.status != 200:
|
||||||
return
|
await bot.api.send_text_message(room.room_id, "Stable Diffusion API is not running!")
|
||||||
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
await bot.api.send_text_message(room.room_id, "Could not reach Stable Diffusion API!")
|
await bot.api.send_text_message(room.room_id, "Could not reach Stable Diffusion API!")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse command-line arguments
|
|
||||||
parser = argparse.ArgumentParser(description='Generate images using self-hosted Stable Diffusion')
|
parser = argparse.ArgumentParser(description='Generate images using self-hosted Stable Diffusion')
|
||||||
parser.add_argument('--steps', type=int, default=4, help='Number of steps, default=4')
|
parser.add_argument('--steps', type=int, default=4, help='Number of steps, default=4')
|
||||||
parser.add_argument('--cfg', type=int, default=2, help='CFG scale, default=2')
|
parser.add_argument('--cfg', type=int, default=2, help='CFG scale, default=2')
|
||||||
parser.add_argument('--h', type=int, default=512, help='Height of the image, default=512')
|
parser.add_argument('--h', type=int, default=512, help='Height of the image, default=512')
|
||||||
parser.add_argument('--w', type=int, default=512, help='Width of the image, default=512')
|
parser.add_argument('--w', type=int, default=512, help='Width of the image, default=512')
|
||||||
parser.add_argument('--neg', type=str, nargs='+', default=['((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))'], help='Negative prompt')
|
parser.add_argument('--neg', type=str, nargs='+',
|
||||||
parser.add_argument('--sampler', type=str, nargs='*', default=['DPM++', 'SDE'], help='Sampler name, default=DPM++ SDE')
|
default=['((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))'],
|
||||||
|
help='Negative prompt')
|
||||||
|
parser.add_argument('--sampler', type=str, nargs='*', default=['DPM++', 'SDE Karras'],
|
||||||
|
help='Sampler name, default=DPM++ SDE')
|
||||||
|
parser.add_argument('--seed', type=int, default=None,
|
||||||
|
help='Seed for deterministic generation (omit for random)')
|
||||||
parser.add_argument('prompt', type=str, nargs='*', help='Prompt for the image')
|
parser.add_argument('prompt', type=str, nargs='*', help='Prompt for the image')
|
||||||
|
|
||||||
args = parser.parse_args(message.body.split()[1:]) # skip command prefix
|
args = parser.parse_args(message.body.split()[1:]) # skip command prefix
|
||||||
@@ -112,22 +61,26 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"width": args.w,
|
"width": args.w,
|
||||||
"height": args.h,
|
"height": args.h,
|
||||||
}
|
}
|
||||||
|
if args.seed is not None:
|
||||||
|
payload["seed"] = args.seed
|
||||||
|
|
||||||
url = "http://127.0.0.1:7860/sdapi/v1/txt2img"
|
async with aiohttp.ClientSession() as session:
|
||||||
response = requests.post(url=url, json=payload, timeout=600)
|
async with session.post("http://127.0.0.1:7860/sdapi/v1/txt2img", json=payload, timeout=600) as response:
|
||||||
r = response.json()
|
response.raise_for_status()
|
||||||
|
r = await response.json()
|
||||||
|
|
||||||
# Use secure temporary file
|
# Save and send image
|
||||||
|
image_data = base64.b64decode(r['images'][0])
|
||||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
|
||||||
filename = temp_file.name
|
filename = temp_file.name
|
||||||
temp_file.write(base64.b64decode(r['images'][0]))
|
temp_file.write(image_data)
|
||||||
|
|
||||||
# Send image to Matrix room
|
|
||||||
await bot.api.send_image_message(room_id=room.room_id, image_filepath=filename)
|
await bot.api.send_image_message(room_id=room.room_id, image_filepath=filename)
|
||||||
|
|
||||||
# Optional: send info about generated image
|
# Optional info message (commented out to avoid spam, but can be enabled)
|
||||||
neg_prompt_clean = neg_prompt.replace(" ", "")
|
# neg_prompt_clean = neg_prompt.replace(" ", "")
|
||||||
info_msg = f"""<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br><strong>Steps:</strong> {args.steps}<br><strong>Dimensions:</strong> {args.h}x{args.w}<br><strong>Sampler:</strong> {sampler_name}<br><strong>CFG Scale:</strong> {args.cfg}<br><strong>Negative Prompt:</strong> {neg_prompt_clean}</details>"""
|
# seed_info = f"<br><strong>Seed:</strong> {args.seed}" if args.seed is not None else ""
|
||||||
|
# info_msg = f"<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br>...</details>"
|
||||||
# await bot.api.send_markdown_message(room.room_id, info_msg)
|
# await bot.api.send_markdown_message(room.room_id, info_msg)
|
||||||
|
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
@@ -138,18 +91,10 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
await bot.api.send_markdown_message(room.room_id, "<details><summary>Stable Diffusion Help</summary>" + print_help() + "</details>")
|
await bot.api.send_markdown_message(room.room_id, "<details><summary>Stable Diffusion Help</summary>" + print_help() + "</details>")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"Error processing the command: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Error processing the command: {str(e)}")
|
||||||
finally:
|
|
||||||
# Process next queued command
|
|
||||||
if not command_queue.empty():
|
|
||||||
next_command = await command_queue.get()
|
|
||||||
await handle_command(*next_command)
|
|
||||||
|
|
||||||
def print_help():
|
def print_help():
|
||||||
"""
|
"""
|
||||||
Generates help text for the 'sd' command.
|
Generates the full help text for the 'sd' command, including LORA list.
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Help text for the 'sd' command.
|
|
||||||
"""
|
"""
|
||||||
return """
|
return """
|
||||||
<p>Generate images using self-hosted Stable Diffusion</p>
|
<p>Generate images using self-hosted Stable Diffusion</p>
|
||||||
@@ -167,6 +112,7 @@ def print_help():
|
|||||||
<li>--w W - Width of the image, default=512</li>
|
<li>--w W - Width of the image, default=512</li>
|
||||||
<li>--neg NEG - Negative prompt, default=((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))</li>
|
<li>--neg NEG - Negative prompt, default=((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))</li>
|
||||||
<li>--sampler SAMPLER - Sampler name, default=DPM++ SDE</li>
|
<li>--sampler SAMPLER - Sampler name, default=DPM++ SDE</li>
|
||||||
|
<li>--seed SEED - Seed for deterministic generation (omit for random)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>LORA List:</p>
|
<p>LORA List:</p>
|
||||||
@@ -186,14 +132,12 @@ def print_help():
|
|||||||
</ul>
|
</ul>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.1.2"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Stable Diffusion image generation"
|
__description__ = "Stable Diffusion image generation (LORA support)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!sd</strong> – Generate images via Stable Diffusion</summary>
|
<summary><strong>!sd</strong> – Generate images via Stable Diffusion</summary>
|
||||||
@@ -204,7 +148,9 @@ __help__ = """
|
|||||||
<li><code>--h H --w W</code> – Image dimensions (default 512)</li>
|
<li><code>--h H --w W</code> – Image dimensions (default 512)</li>
|
||||||
<li><code>--neg <negative prompt></code></li>
|
<li><code>--neg <negative prompt></code></li>
|
||||||
<li><code>--sampler SAMPLER</code> – Sampler name (default DPM++ SDE)</li>
|
<li><code>--sampler SAMPLER</code> – Sampler name (default DPM++ SDE)</li>
|
||||||
|
<li><code>--seed SEED</code> – Deterministic seed (optional)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>LORAs: <code><lora:filename:weight></code></p>
|
||||||
<p>Requires a locally running Stable Diffusion API.</p>
|
<p>Requires a locally running Stable Diffusion API.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
plugins/subnet.py – Subnet calculator and network splitting plugin for Funguy Bot.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
!subnet info <CIDR>
|
||||||
|
!subnet split <CIDR> --prefix <N>
|
||||||
|
!subnet split <CIDR> --diff <N>
|
||||||
|
!subnet adjacent <CIDR> <count>
|
||||||
|
!subnet help
|
||||||
|
|
||||||
|
Output is a clean code block with emojis and perfectly aligned columns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import collapsible_summary, html_escape, code_block
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Helper functions (synchronous)
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if net.version == 4:
|
||||||
|
if net.prefixlen == 32:
|
||||||
|
usable_count = 1
|
||||||
|
first = last = nw
|
||||||
|
elif net.prefixlen == 31:
|
||||||
|
usable_count = 2
|
||||||
|
first = nw
|
||||||
|
last = bc
|
||||||
|
else:
|
||||||
|
usable_count = max(0, total - 2)
|
||||||
|
first = nw + 1 if usable_count > 0 else None
|
||||||
|
last = bc - 1 if usable_count > 0 else None
|
||||||
|
else:
|
||||||
|
hosts_iter = net.hosts()
|
||||||
|
try:
|
||||||
|
first = next(hosts_iter)
|
||||||
|
last = net.network_address + (total - 1)
|
||||||
|
usable_count = total
|
||||||
|
except StopIteration:
|
||||||
|
first = last = None
|
||||||
|
usable_count = 0
|
||||||
|
|
||||||
|
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:
|
||||||
|
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):
|
||||||
|
if new_prefix < net.prefixlen:
|
||||||
|
return None
|
||||||
|
return list(net.subnets(new_prefix=new_prefix))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_by_diff(net, diff):
|
||||||
|
return _split_by_prefix(net, net.prefixlen + diff)
|
||||||
|
|
||||||
|
|
||||||
|
def _adjacent_networks(net, count):
|
||||||
|
nets = [net]
|
||||||
|
current = net
|
||||||
|
for _ in range(count):
|
||||||
|
try:
|
||||||
|
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 nets
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 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):
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
|
||||||
|
subcmd = args[0].lower()
|
||||||
|
|
||||||
|
if subcmd in ("help", "-h", "--help"):
|
||||||
|
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
|
||||||
|
return
|
||||||
|
|
||||||
|
if subcmd == "info" or "/" in subcmd:
|
||||||
|
cidr = args[1] if subcmd == "info" else subcmd
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||||
|
return
|
||||||
|
output = _info_output(net)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
return
|
||||||
|
|
||||||
|
if subcmd == "split":
|
||||||
|
if len(args) < 2:
|
||||||
|
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:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "--prefix" in args:
|
||||||
|
try:
|
||||||
|
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>")
|
||||||
|
return
|
||||||
|
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>")
|
||||||
|
return
|
||||||
|
subnets = _split_by_diff(net, diff)
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room.room_id, "You must provide --prefix <N> or --diff <N> for split.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if subnets is None:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split.")
|
||||||
|
return
|
||||||
|
output = _split_output(subnets)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
return
|
||||||
|
|
||||||
|
if subcmd == "adjacent":
|
||||||
|
if len(args) < 3:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Usage: !subnet adjacent <CIDR> <count>")
|
||||||
|
return
|
||||||
|
cidr = args[1]
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
count = int(args[2])
|
||||||
|
except ValueError:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Count must be an integer.")
|
||||||
|
return
|
||||||
|
networks = _adjacent_networks(net, count)
|
||||||
|
output = _adjacent_output(networks)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
return
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{subcmd}'. Use !subnet help.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin Metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
__version__ = "1.3.2"
|
||||||
|
__author__ = "Funguy Bot"
|
||||||
|
__description__ = "Subnet calculator"
|
||||||
|
__help__ = _HELP_MD
|
||||||
+267
-409
@@ -1,455 +1,313 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides comprehensive system information and resource monitoring.
|
Comprehensive system information – code block with emoji + aligned columns.
|
||||||
|
All blocking calls run in thread pool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging, platform, os, asyncio, psutil, socket, datetime, subprocess
|
||||||
import platform
|
|
||||||
import os
|
|
||||||
import psutil
|
|
||||||
import socket
|
|
||||||
import datetime
|
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import subprocess
|
from plugins.common import collapsible_summary, html_escape, code_block
|
||||||
import sys
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def _run_blocking(func, *args, **kwargs):
|
||||||
"""
|
loop = asyncio.get_running_loop()
|
||||||
Function to handle !sysinfo command for system information.
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
Args:
|
# ---------- Data collectors (unchanged) ----------
|
||||||
room (Room): The Matrix room where the command was invoked.
|
def _system_overview():
|
||||||
message (RoomMessage): The message object containing the command.
|
boot = datetime.datetime.fromtimestamp(psutil.boot_time())
|
||||||
bot (Bot): The bot object.
|
uptime_delta = datetime.datetime.now() - boot
|
||||||
prefix (str): The command prefix.
|
uptime_str = str(datetime.timedelta(seconds=int(uptime_delta.total_seconds())))
|
||||||
config (dict): Configuration parameters.
|
return {
|
||||||
|
"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())
|
||||||
|
}
|
||||||
|
|
||||||
Returns:
|
def _cpu_info():
|
||||||
None
|
cpu_freq = psutil.cpu_freq()
|
||||||
"""
|
load = os.getloadavg() if hasattr(os, "getloadavg") else (0,0,0)
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
return {
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
|
"physical_cores": psutil.cpu_count(logical=False),
|
||||||
logging.info("Received !sysinfo command")
|
"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}"
|
||||||
|
}
|
||||||
|
|
||||||
args = match.args()
|
def _memory_info():
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
swap = psutil.swap_memory()
|
||||||
|
return {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
||||||
if len(args) > 0 and args[0].lower() == 'help':
|
def _disk_info():
|
||||||
await show_usage(room, bot)
|
partitions = psutil.disk_partitions()
|
||||||
return
|
mounted = []
|
||||||
|
for p in partitions:
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
await get_system_info(room, bot)
|
def _network_info():
|
||||||
|
ifaces = psutil.net_if_addrs()
|
||||||
|
io_counters = psutil.net_io_counters(pernic=True)
|
||||||
|
net = []
|
||||||
|
for name, addrs in ifaces.items():
|
||||||
|
if name == "lo":
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
def _top_processes():
|
||||||
"""Display sysinfo command usage."""
|
procs = []
|
||||||
usage = """
|
for p in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
|
||||||
<strong>💻 System Information Plugin</strong>
|
try:
|
||||||
|
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 top_cpu, len(procs)
|
||||||
|
|
||||||
<strong>!sysinfo</strong> - Display comprehensive system information
|
def _gpu_info():
|
||||||
<strong>!sysinfo help</strong> - Show this help message
|
info = {}
|
||||||
|
|
||||||
<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 def get_system_info(room, bot):
|
|
||||||
"""Collect and display comprehensive system information."""
|
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
|
res = subprocess.run(
|
||||||
|
['nvidia-smi', '--query-gpu=name,memory.used,memory.total,temperature.gpu,utilization.gpu',
|
||||||
sysinfo = {
|
'--format=csv,noheader,nounits'],
|
||||||
'system': await get_system_info_basic(),
|
capture_output=True, text=True
|
||||||
'cpu': await get_cpu_info(),
|
)
|
||||||
'memory': await get_memory_info(),
|
if res.returncode == 0:
|
||||||
'storage': await get_storage_info(),
|
gpus = []
|
||||||
'network': await get_network_info(),
|
for line in res.stdout.strip().split('\n'):
|
||||||
'processes': await get_process_info(),
|
parts = [p.strip() for p in line.split(',')]
|
||||||
'docker': await get_docker_info(),
|
if len(parts) >= 5:
|
||||||
'sensors': await get_sensor_info(),
|
gpus.append({
|
||||||
'gpu': await get_gpu_info()
|
"name": parts[0],
|
||||||
}
|
"mem_used": f"{parts[1]} MB",
|
||||||
|
"mem_total": f"{parts[2]} MB",
|
||||||
output = await format_system_info(sysinfo)
|
"temp": f"{parts[3]}°C",
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
"usage": f"{parts[4]}%"
|
||||||
|
})
|
||||||
logging.info("Sent system information")
|
if gpus:
|
||||||
|
info["nvidia"] = gpus
|
||||||
except Exception as e:
|
except:
|
||||||
await bot.api.send_text_message(room.room_id, f"Error gathering system info: {str(e)}")
|
pass
|
||||||
logging.error(f"Error in get_system_info: {e}")
|
|
||||||
|
|
||||||
async def get_system_info_basic():
|
|
||||||
"""Get basic system information."""
|
|
||||||
try:
|
try:
|
||||||
return {
|
res = subprocess.run(['lspci'], capture_output=True, text=True)
|
||||||
'hostname': socket.gethostname(),
|
if res.returncode == 0:
|
||||||
'os': platform.system(),
|
lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
|
||||||
'os_release': platform.release(),
|
if lines:
|
||||||
'os_version': platform.version(),
|
info["detected"] = lines[:2]
|
||||||
'architecture': platform.architecture()[0],
|
except:
|
||||||
'machine': platform.machine(),
|
pass
|
||||||
'processor': platform.processor(),
|
return info
|
||||||
'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
'uptime': str(datetime.timedelta(seconds=psutil.boot_time() - datetime.datetime.now().timestamp())).split('.')[0],
|
|
||||||
'users': len(psutil.users())
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_cpu_info():
|
def _docker_info():
|
||||||
"""Get CPU information and usage."""
|
|
||||||
try:
|
try:
|
||||||
cpu_times = psutil.cpu_times_percent(interval=1)
|
ver = subprocess.run(['docker', '--version'], capture_output=True, text=True)
|
||||||
cpu_freq = psutil.cpu_freq()
|
if ver.returncode != 0:
|
||||||
|
return None
|
||||||
return {
|
ps_res = subprocess.run(
|
||||||
'physical_cores': psutil.cpu_count(logical=False),
|
['docker', 'ps', '--format', '{{.Names}}|{{.Status}}'],
|
||||||
'total_cores': psutil.cpu_count(logical=True),
|
capture_output=True, text=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': os.getloadavg() if hasattr(os, 'getloadavg') else "N/A"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_memory_info():
|
|
||||||
"""Get memory and swap information."""
|
|
||||||
try:
|
|
||||||
memory = psutil.virtual_memory()
|
|
||||||
swap = psutil.swap_memory()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total': f"{memory.total / (1024**3):.2f} GB",
|
|
||||||
'available': f"{memory.available / (1024**3):.2f} GB",
|
|
||||||
'used': f"{memory.used / (1024**3):.2f} GB",
|
|
||||||
'usage_percent': memory.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
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_storage_info():
|
|
||||||
"""Get storage device information."""
|
|
||||||
try:
|
|
||||||
partitions = psutil.disk_partitions()
|
|
||||||
storage_info = []
|
|
||||||
|
|
||||||
for partition in partitions:
|
|
||||||
try:
|
|
||||||
usage = psutil.disk_usage(partition.mountpoint)
|
|
||||||
storage_info.append({
|
|
||||||
'device': partition.device,
|
|
||||||
'mountpoint': partition.mountpoint,
|
|
||||||
'fstype': partition.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
|
|
||||||
})
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get disk I/O statistics
|
|
||||||
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_info,
|
|
||||||
'io_stats': io_info
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_network_info():
|
|
||||||
"""Get network interface information."""
|
|
||||||
try:
|
|
||||||
interfaces = psutil.net_if_addrs()
|
|
||||||
io_counters = psutil.net_io_counters(pernic=True)
|
|
||||||
|
|
||||||
network_info = []
|
|
||||||
for interface, addrs in interfaces.items():
|
|
||||||
if interface not in ['lo']: # Skip loopback
|
|
||||||
interface_io = io_counters.get(interface, None)
|
|
||||||
network_info.append({
|
|
||||||
'interface': interface,
|
|
||||||
'ipv4': next((addr.address for addr in addrs if addr.family == socket.AF_INET), 'N/A'),
|
|
||||||
'ipv6': next((addr.address for addr in addrs if addr.family == socket.AF_INET6), 'N/A'),
|
|
||||||
'mac': next((addr.address for addr in addrs if addr.family == psutil.AF_LINK), 'N/A'),
|
|
||||||
'bytes_sent': f"{interface_io.bytes_sent / (1024**2):.2f} MB" if interface_io else "N/A",
|
|
||||||
'bytes_recv': f"{interface_io.bytes_recv / (1024**2):.2f} MB" if interface_io else "N/A"
|
|
||||||
})
|
|
||||||
|
|
||||||
return network_info
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_process_info():
|
|
||||||
"""Get process and system load information."""
|
|
||||||
try:
|
|
||||||
processes = []
|
|
||||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
|
|
||||||
try:
|
|
||||||
processes.append(proc.info)
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort by CPU usage and get top 5
|
|
||||||
top_processes = sorted(processes, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5]
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_processes': len(processes),
|
|
||||||
'top_cpu': top_processes
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_docker_info():
|
|
||||||
"""Get Docker container information if available."""
|
|
||||||
try:
|
|
||||||
# Check if docker is available
|
|
||||||
result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return {'available': False}
|
|
||||||
|
|
||||||
# Get running containers
|
|
||||||
result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'],
|
|
||||||
capture_output=True, text=True)
|
|
||||||
|
|
||||||
containers = []
|
containers = []
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in ps_res.stdout.strip().split('\n'):
|
||||||
if line:
|
if line:
|
||||||
parts = line.split('|')
|
parts = line.split('|')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
containers.append({
|
containers.append({"name": parts[0], "status": parts[1]})
|
||||||
'name': parts[0],
|
return containers
|
||||||
'status': parts[1],
|
except:
|
||||||
'ports': parts[2] if len(parts) > 2 else 'N/A'
|
return None
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
def _sensor_info():
|
||||||
'available': True,
|
temps = psutil.sensors_temperatures()
|
||||||
'containers': containers,
|
fans = psutil.sensors_fans()
|
||||||
'total_running': len(containers)
|
battery = psutil.sensors_battery()
|
||||||
}
|
data = {"temps": [], "fans": [], "battery": None}
|
||||||
except Exception as e:
|
if temps:
|
||||||
return {'available': False, 'error': str(e)}
|
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 chip, entries in fans.items():
|
||||||
|
for e in entries[:2]:
|
||||||
|
data["fans"].append(f"{e.label or chip}: {e.current} RPM")
|
||||||
|
if battery:
|
||||||
|
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
|
||||||
|
|
||||||
async def get_sensor_info():
|
# -------------------------------------------------------------------
|
||||||
"""Get hardware sensor information."""
|
# Main builder
|
||||||
try:
|
# -------------------------------------------------------------------
|
||||||
temperatures = psutil.sensors_temperatures()
|
async def get_system_info(room, bot):
|
||||||
fans = psutil.sensors_fans()
|
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
|
||||||
battery = psutil.sensors_battery()
|
|
||||||
|
|
||||||
sensor_info = {
|
system = await _run_blocking(_system_overview)
|
||||||
'temperatures': {},
|
cpu = await _run_blocking(_cpu_info)
|
||||||
'fans': {},
|
mem = await _run_blocking(_memory_info)
|
||||||
'battery': {}
|
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)
|
||||||
|
|
||||||
# Temperature sensors
|
sections = []
|
||||||
if temperatures:
|
|
||||||
for name, entries in temperatures.items():
|
|
||||||
sensor_info['temperatures'][name] = [
|
|
||||||
f"{entry.current}°C" for entry in entries[:2] # Show first 2 sensors per type
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fan speeds
|
|
||||||
if fans:
|
|
||||||
for name, entries in fans.items():
|
|
||||||
sensor_info['fans'][name] = [
|
|
||||||
f"{entry.current} RPM" for entry in entries[:2]
|
|
||||||
]
|
|
||||||
|
|
||||||
# Battery information
|
|
||||||
if battery:
|
|
||||||
sensor_info['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_info
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_gpu_info():
|
|
||||||
"""Get GPU information using various methods."""
|
|
||||||
try:
|
|
||||||
gpu_info = {}
|
|
||||||
|
|
||||||
# Try nvidia-smi first
|
|
||||||
try:
|
|
||||||
result = 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 result.returncode == 0:
|
|
||||||
nvidia_gpus = []
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if line:
|
|
||||||
parts = [part.strip() for part in line.split(',')]
|
|
||||||
if len(parts) >= 6:
|
|
||||||
nvidia_gpus.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]}%"
|
|
||||||
})
|
|
||||||
gpu_info['nvidia'] = nvidia_gpus
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Try lspci for generic GPU detection
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['lspci'], capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
gpu_lines = [line for line in result.stdout.split('\n') if 'VGA' in line or '3D' in line]
|
|
||||||
gpu_info['detected'] = gpu_lines[:3] # Show first 3 GPUs
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return gpu_info
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def format_system_info(sysinfo):
|
|
||||||
"""Format system information for display."""
|
|
||||||
output = "<strong>💻 System Information</strong><br><br>"
|
|
||||||
|
|
||||||
# System Overview
|
# System Overview
|
||||||
system = sysinfo.get('system', {})
|
sys_rows = [
|
||||||
output += "<strong>🖥️ System Overview</strong><br>"
|
("💻", "Hostname", system["hostname"]),
|
||||||
output += f" • <strong>Hostname:</strong> {system.get('hostname', 'N/A')}<br>"
|
("🖥️", "OS", system["os"]),
|
||||||
output += f" • <strong>OS:</strong> {system.get('os', 'N/A')} {system.get('os_release', '')}<br>"
|
("📐", "Architecture", system["architecture"]),
|
||||||
output += f" • <strong>Architecture:</strong> {system.get('architecture', 'N/A')}<br>"
|
("⚙️", "Machine", system["machine"]),
|
||||||
output += f" • <strong>Uptime:</strong> {system.get('uptime', 'N/A')}<br>"
|
("🔧", "Processor", system["processor"]),
|
||||||
output += f" • <strong>Boot Time:</strong> {system.get('boot_time', 'N/A')}<br>"
|
("⏰", "Uptime", system["uptime"]),
|
||||||
output += f" • <strong>Users:</strong> {system.get('users', 'N/A')}<br>"
|
("📅", "Boot Time", system["boot_time"]),
|
||||||
output += "<br>"
|
("👥", "Users", str(system["users"]))
|
||||||
|
]
|
||||||
|
sections.append({"title": "🖥️ System Overview", "rows": sys_rows})
|
||||||
|
|
||||||
# CPU Information
|
# CPU
|
||||||
cpu = sysinfo.get('cpu', {})
|
cpu_rows = [
|
||||||
if 'error' not in cpu:
|
("⚡", "CPU Cores", f"{cpu['physical_cores']} physical, {cpu['logical_cores']} logical"),
|
||||||
output += "<strong>⚡ CPU Information</strong><br>"
|
("📈", "Freq (Max/Cur)", f"{cpu['max_freq']} / {cpu['current_freq']}"),
|
||||||
output += f" • <strong>Cores:</strong> {cpu.get('physical_cores', 'N/A')} physical, {cpu.get('total_cores', 'N/A')} logical<br>"
|
("📊", "CPU Usage", cpu["usage"]),
|
||||||
output += f" • <strong>Frequency:</strong> {cpu.get('current_frequency', 'N/A')} (max: {cpu.get('max_frequency', 'N/A')})<br>"
|
("⚖️", "Load Avg", cpu["load_avg"])
|
||||||
output += f" • <strong>Usage:</strong> {cpu.get('usage_percent', 'N/A')}%<br>"
|
]
|
||||||
if cpu.get('load_avg') != "N/A":
|
sections.append({"title": "⚡ CPU", "rows": cpu_rows})
|
||||||
output += f" • <strong>Load Average:</strong> {', '.join([f'{load:.2f}' for load in cpu.get('load_avg', [0,0,0])])}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Memory Information
|
# Memory
|
||||||
memory = sysinfo.get('memory', {})
|
mem_rows = [
|
||||||
if 'error' not in memory:
|
("🧠", "RAM", f"{mem['used_ram']} / {mem['total_ram']} ({mem['ram_percent']})")
|
||||||
output += "<strong>🧠 Memory Information</strong><br>"
|
]
|
||||||
output += f" • <strong>Total:</strong> {memory.get('total', 'N/A')}<br>"
|
if mem["total_swap"] != "N/A":
|
||||||
output += f" • <strong>Used:</strong> {memory.get('used', 'N/A')} ({memory.get('usage_percent', 'N/A')}%)<br>"
|
mem_rows.append(("💾", "Swap", f"{mem['used_swap']} / {mem['total_swap']} ({mem['swap_percent']})"))
|
||||||
output += f" • <strong>Available:</strong> {memory.get('available', 'N/A')}<br>"
|
sections.append({"title": "🧠 Memory", "rows": mem_rows})
|
||||||
output += f" • <strong>Swap:</strong> {memory.get('swap_used', 'N/A')} / {memory.get('swap_total', 'N/A')} ({memory.get('swap_percent', 'N/A')}%)<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Storage Information
|
# Storage
|
||||||
storage = sysinfo.get('storage', {})
|
disk_rows = []
|
||||||
if 'error' not in storage:
|
for d in disks[:5]:
|
||||||
output += "<strong>💾 Storage Information</strong><br>"
|
disk_rows.append(("💽", d['mount'], f"{d['used']} / {d['total']} ({d['percent']}%)"))
|
||||||
partitions = storage.get('partitions', [])
|
disk_rows.append(("📀", "Disk I/O", f"Read {io_read} / Write {io_write}"))
|
||||||
for partition in partitions[:3]: # Show first 3 partitions
|
sections.append({"title": "💾 Storage", "rows": disk_rows})
|
||||||
output += f" • <strong>{partition.get('device', 'N/A')}:</strong> {partition.get('used', 'N/A')} / {partition.get('total', 'N/A')} ({partition.get('percent', 'N/A')}%)<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# GPU Information
|
# Network
|
||||||
gpu = sysinfo.get('gpu', {})
|
net_rows = []
|
||||||
if gpu.get('nvidia'):
|
if net:
|
||||||
output += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
|
for idx, (name, ip, sent, recv) in enumerate(net[:3]):
|
||||||
for gpu_info in gpu['nvidia']:
|
emoji = "🌐" if idx == 0 else ""
|
||||||
output += f" • <strong>{gpu_info.get('name', 'N/A')}:</strong> {gpu_info.get('utilization', 'N/A')} usage, {gpu_info.get('temperature', 'N/A')}<br>"
|
label = "Network" if idx == 0 else ""
|
||||||
output += "<br>"
|
net_rows.append((emoji, label, f"{name} - {ip} | ↓{recv} ↑{sent}"))
|
||||||
elif gpu.get('detected'):
|
else:
|
||||||
output += "<strong>🎮 GPU Information</strong><br>"
|
net_rows.append(("🌐", "Network", "No active interfaces"))
|
||||||
for gpu_line in gpu['detected'][:2]:
|
sections.append({"title": "🌐 Network", "rows": net_rows})
|
||||||
output += f" • {gpu_line}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Network Information
|
# GPU
|
||||||
network = sysinfo.get('network', [])
|
gpu_rows = []
|
||||||
if network and 'error' not in network:
|
if "nvidia" in gpu:
|
||||||
output += "<strong>🌐 Network Information</strong><br>"
|
for g in gpu["nvidia"]:
|
||||||
for interface in network[:2]: # Show first 2 interfaces
|
gpu_rows.append(("🎮", "GPU", f"{g['name']} | {g['mem_used']}/{g['mem_total']} | {g['temp']} | {g['usage']} util"))
|
||||||
output += f" • <strong>{interface.get('interface', 'N/A')}:</strong> {interface.get('ipv4', 'N/A')}<br>"
|
elif "detected" in gpu:
|
||||||
output += "<br>"
|
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})
|
||||||
|
|
||||||
# Process Information
|
# Processes
|
||||||
processes = sysinfo.get('processes', {})
|
proc_rows = [("🔄", "Processes", f"Total: {total_procs}")]
|
||||||
if 'error' not in processes:
|
for p in top_procs:
|
||||||
output += "<strong>🔄 Top Processes (by CPU)</strong><br>"
|
name = p.get('name', '?')
|
||||||
for proc in processes.get('top_cpu', [])[:3]:
|
cpu_p = p.get('cpu_percent') or 0
|
||||||
output += f" • <strong>{proc.get('name', 'N/A')}:</strong> {proc.get('cpu_percent', 0):.1f}% CPU, {proc.get('memory_percent', 0):.1f}% RAM<br>"
|
mem_p = p.get('memory_percent') or 0
|
||||||
output += f" • <strong>Total Processes:</strong> {processes.get('total_processes', 'N/A')}<br>"
|
proc_rows.append(("", "", f"{name} - CPU {cpu_p:.1f}% / RAM {mem_p:.1f}%"))
|
||||||
output += "<br>"
|
sections.append({"title": "🔄 Top Processes", "rows": proc_rows})
|
||||||
|
|
||||||
# Docker Information
|
# Docker
|
||||||
docker = sysinfo.get('docker', {})
|
docker_rows = []
|
||||||
if docker.get('available'):
|
if docker is not None:
|
||||||
output += "<strong>🐳 Docker Containers</strong><br>"
|
if docker:
|
||||||
for container in docker.get('containers', [])[:3]:
|
for c in docker[:5]:
|
||||||
output += f" • <strong>{container.get('name', 'N/A')}:</strong> {container.get('status', 'N/A')}<br>"
|
docker_rows.append(("🐳", "Docker", f"{c['name']} - {c['status']}"))
|
||||||
output += f" • <strong>Total Running:</strong> {docker.get('total_running', 'N/A')}<br>"
|
else:
|
||||||
output += "<br>"
|
docker_rows.append(("🐳", "Docker", "No containers running"))
|
||||||
|
else:
|
||||||
|
docker_rows.append(("🐳", "Docker", "Docker not available"))
|
||||||
|
sections.append({"title": "🐳 Docker", "rows": docker_rows})
|
||||||
|
|
||||||
# Sensor Information
|
# Sensors
|
||||||
sensors = sysinfo.get('sensors', {})
|
sensor_rows = []
|
||||||
if 'error' not in sensors:
|
if sensors["temps"]:
|
||||||
if sensors.get('temperatures'):
|
sensor_rows.append(("🌡️", "Temperature", ", ".join(sensors["temps"])))
|
||||||
output += "<strong>🌡️ Temperature Sensors</strong><br>"
|
if sensors["fans"]:
|
||||||
for sensor, temps in list(sensors['temperatures'].items())[:2]:
|
sensor_rows.append(("🌀", "Fans", ", ".join(sensors["fans"])))
|
||||||
output += f" • <strong>{sensor}:</strong> {', '.join(temps[:2])}<br>"
|
if sensors["battery"]:
|
||||||
output += "<br>"
|
sensor_rows.append(("🔋", "Battery", sensors["battery"]))
|
||||||
|
if sensor_rows:
|
||||||
|
sections.append({"title": "🌡️ Sensors", "rows": sensor_rows})
|
||||||
|
|
||||||
if sensors.get('battery'):
|
block = code_block(f"💻 System Info: {system['hostname']}", sections)
|
||||||
battery = sensors['battery']
|
output = collapsible_summary(f"💻 System Info – {html_escape(system['hostname'])}", block)
|
||||||
output += "<strong>🔋 Battery Information</strong><br>"
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
output += f" • <strong>Charge:</strong> {battery.get('percent', 'N/A')}%<br>"
|
logging.info("Sent system information")
|
||||||
output += f" • <strong>Plugged In:</strong> {'Yes' if battery.get('power_plugged') else 'No'}<br>"
|
|
||||||
if battery.get('time_left'):
|
|
||||||
output += f" • <strong>Time Left:</strong> {battery.get('time_left', 'N/A')}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Add timestamp
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
output += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
|
||||||
# Wrap in collapsible due to comprehensive output
|
if match.args() and match.args()[0].lower() == 'help':
|
||||||
output = f"<details><summary><strong>💻 System Information - {system.get('hostname', 'Unknown')}</strong></summary>{output}</details>"
|
usage = """
|
||||||
|
<strong>💻 System Information</strong>
|
||||||
return output
|
<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
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.3.1"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "System information and monitoring"
|
__description__ = "System information plugin"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!sysinfo</strong> – System information</summary>
|
<summary><strong>!sysinfo</strong> – System information</summary>
|
||||||
<p>Displays CPU, RAM, storage, network, Docker, GPU, sensors, and top processes.</p>
|
<p>Displays CPU, RAM, storage, network, GPU, sensors, top processes, and more in a clean, aligned code block.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+117
-131
@@ -1,210 +1,196 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Time Zone Plugin – completely hardcoded-free using Open-Meteo APIs.
|
Time Zone Plugin – uses pytz for IANA zones and Open‑Meteo for city geocoding.
|
||||||
|
Outputs a clean code block with emojis and aligned columns via shared code_block.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from urllib.parse import quote
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
from plugins.common import collapsible_summary, html_escape, code_block
|
||||||
|
|
||||||
def format_ampm(dt_str: str) -> str:
|
# -------------------------------------------------------------------
|
||||||
"""Convert ISO datetime to AM/PM format."""
|
# Offline helper for IANA timezone names
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
def _get_time_for_iana_zone(zone: str) -> dict | None:
|
||||||
|
"""Return a dict with datetime, timezone, and optional temperature using pytz."""
|
||||||
try:
|
try:
|
||||||
if '+' in dt_str:
|
tz = pytz.timezone(zone)
|
||||||
dt_str = dt_str.split('+')[0]
|
now = datetime.now(tz)
|
||||||
if '.' in dt_str:
|
return {
|
||||||
dt_str = dt_str.split('.')[0]
|
"datetime": now.isoformat(),
|
||||||
dt_str = dt_str.replace('T', ' ')
|
"timezone": zone,
|
||||||
dt = datetime.fromisoformat(dt_str)
|
"temperature": None # no weather for zone lookups
|
||||||
return dt.strftime("%I:%M:%S %p").lstrip("0")
|
}
|
||||||
except:
|
except pytz.UnknownTimeZoneError:
|
||||||
return dt_str
|
return None
|
||||||
|
|
||||||
async def geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
|
|
||||||
"""
|
# -------------------------------------------------------------------
|
||||||
Open-Meteo Geocoding API (free, no key, no hardcoding).
|
# Online helpers (Open‑Meteo)
|
||||||
Returns (latitude, longitude, display_name) or None.
|
# -------------------------------------------------------------------
|
||||||
"""
|
async def _geocode_city(session: aiohttp.ClientSession, city: str) -> tuple[float, float, str] | None:
|
||||||
|
"""Geocode a city name via Open‑Meteo. Returns (lat, lon, display_name) or None."""
|
||||||
|
from urllib.parse import quote
|
||||||
url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
|
url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote(city)}&count=1&language=en&format=json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.get(url, timeout=10) as resp:
|
async with session.get(url, timeout=10) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
if data.get("results") and len(data["results"]) > 0:
|
results = data.get("results", [])
|
||||||
result = data["results"][0]
|
if results:
|
||||||
lat = result["latitude"]
|
r = results[0]
|
||||||
lon = result["longitude"]
|
lat = float(r["latitude"])
|
||||||
name = result.get("name", city)
|
lon = float(r["longitude"])
|
||||||
country = result.get("country", "")
|
name = r.get("name", city)
|
||||||
admin1 = result.get("admin1", "")
|
country = r.get("country", "")
|
||||||
|
admin1 = r.get("admin1", "")
|
||||||
# Build display name: "Lahore, Punjab, Pakistan"
|
display = ", ".join(filter(None, [name, admin1, country]))
|
||||||
display_parts = [name]
|
return lat, lon, display
|
||||||
if admin1 and admin1 != name:
|
|
||||||
display_parts.append(admin1)
|
|
||||||
if country:
|
|
||||||
display_parts.append(country)
|
|
||||||
display_name = ", ".join(display_parts)
|
|
||||||
|
|
||||||
logging.info(f"Geocoded: {city} → {display_name} ({lat}, {lon})")
|
|
||||||
return lat, lon, display_name
|
|
||||||
else:
|
|
||||||
logging.warning(f"Geocoding API HTTP {resp.status} for {city}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Geocoding error: {e}")
|
logging.warning(f"Geocoding error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_timezone(lat: float, lon: float) -> str | None:
|
|
||||||
"""
|
|
||||||
Get timezone name from coordinates using timezonedb (free tier, no key).
|
|
||||||
Alternative: use Open-Meteo's time API directly.
|
|
||||||
"""
|
|
||||||
# Open-Meteo's time API accepts coordinates directly
|
|
||||||
# We'll use this instead of timezonedb
|
|
||||||
return None # Will be handled in fetch_time_by_coords
|
|
||||||
|
|
||||||
async def fetch_time_by_coords(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
async def _fetch_weather(session: aiohttp.ClientSession, lat: float, lon: float) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Get current time using Open-Meteo (no key required).
|
Fetch current time and temperature from Open‑Meteo (free, no key).
|
||||||
|
The API returns an ISO 8601 string for the current time.
|
||||||
"""
|
"""
|
||||||
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto&timeformat=unixtime"
|
url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true&timezone=auto"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.get(url, timeout=10) as resp:
|
async with session.get(url, timeout=10) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
current = data.get("current_weather", {})
|
current = data.get("current_weather", {})
|
||||||
timezone = data.get("timezone", "Unknown")
|
time_str = current.get("time") # ISO 8601, local time
|
||||||
unixtime = current.get("time")
|
temp_c = current.get("temperature")
|
||||||
temperature = current.get("temperature")
|
tz = data.get("timezone", "Unknown")
|
||||||
|
if time_str:
|
||||||
if unixtime:
|
|
||||||
# Convert UNIX timestamp to datetime
|
|
||||||
dt = datetime.fromtimestamp(unixtime)
|
|
||||||
return {
|
return {
|
||||||
"datetime": dt.isoformat(),
|
"datetime": time_str, # raw ISO string (e.g. "2024-05-09T14:30")
|
||||||
"timezone": timezone,
|
"timezone": tz,
|
||||||
"temperature": temperature
|
"temperature": temp_c
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Time fetch error: {e}")
|
logging.warning(f"Weather fetch error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def fetch_time_by_zone(session: aiohttp.ClientSession, zone: str) -> dict | None:
|
|
||||||
"""Get current time for a named timezone using Open-Meteo."""
|
|
||||||
# Open-Meteo doesn't have named timezone endpoint, need to geocode a representative city
|
|
||||||
# Fallback to worldtimeapi.org for IANA zones
|
|
||||||
url = f"http://worldtimeapi.org/api/timezone/{zone}"
|
|
||||||
try:
|
|
||||||
async with session.get(url, timeout=10) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
return await resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Timezone API error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Main resolver
|
||||||
|
# -------------------------------------------------------------------
|
||||||
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
|
async def resolve_time(session: aiohttp.ClientSession, query: str) -> tuple[dict | None, str]:
|
||||||
"""Main resolution: geocode any city, then get time."""
|
"""Return (data_dict, display_name) or (None, error_message)."""
|
||||||
query = query.strip().lower()
|
query = query.strip()
|
||||||
|
|
||||||
# Check if it's an IANA zone (contains '/')
|
# 1. Try as IANA zone (offline, always works)
|
||||||
if '/' in query or query in ("utc", "gmt"):
|
if '/' in query or query.lower() in ("utc", "gmt"):
|
||||||
data = await fetch_time_by_zone(session, query)
|
data = _get_time_for_iana_zone(query)
|
||||||
if data:
|
if data:
|
||||||
return data, query.upper()
|
return data, query.upper()
|
||||||
return None, f"Timezone '{query}' not found"
|
else:
|
||||||
|
return None, f"Timezone '{html_escape(query)}' not recognised."
|
||||||
|
|
||||||
# Geocode the city (no hardcoding!)
|
# 2. Otherwise geocode as a city name
|
||||||
geocode_result = await geocode_city(session, query)
|
geocode_result = await _geocode_city(session, query)
|
||||||
if not geocode_result:
|
if not geocode_result:
|
||||||
return None, f"Could not find city '{query}'. Try being more specific."
|
return None, f"Could not find city '{html_escape(query)}'. Try a more specific name or use an IANA zone."
|
||||||
|
|
||||||
lat, lon, display_name = geocode_result
|
lat, lon, display_name = geocode_result
|
||||||
|
weather_data = await _fetch_weather(session, lat, lon)
|
||||||
|
if weather_data:
|
||||||
|
return weather_data, display_name
|
||||||
|
return None, f"Could not fetch time/weather for '{html_escape(display_name)}'."
|
||||||
|
|
||||||
# Get time from coordinates
|
|
||||||
data = await fetch_time_by_coords(session, lat, lon)
|
|
||||||
if not data:
|
|
||||||
return None, f"Could not get time for '{display_name}'"
|
|
||||||
|
|
||||||
return data, display_name
|
# -------------------------------------------------------------------
|
||||||
|
# Formatting – uses shared code_block from common.py
|
||||||
def format_response(data: dict, display_name: str) -> str:
|
# -------------------------------------------------------------------
|
||||||
"""Format time data into HTML."""
|
def _format_time_output(data: dict, display_name: str) -> str:
|
||||||
|
"""Convert time data into a code block via the shared formatter."""
|
||||||
raw_time = data.get("datetime", "")
|
raw_time = data.get("datetime", "")
|
||||||
local_time = format_ampm(raw_time) if raw_time else "Unknown"
|
# Convert ISO string to AM/PM format
|
||||||
tz = data.get("timezone", "Unknown")
|
try:
|
||||||
|
if '+' in raw_time:
|
||||||
|
raw_time = raw_time.split('+')[0]
|
||||||
|
dt = datetime.fromisoformat(raw_time)
|
||||||
|
local_time = dt.strftime("%I:%M:%S %p").lstrip("0")
|
||||||
|
except Exception:
|
||||||
|
local_time = raw_time
|
||||||
|
|
||||||
|
tz_display = data.get("timezone", "Unknown")
|
||||||
temp = data.get("temperature")
|
temp = data.get("temperature")
|
||||||
temp_str = f"<br>🌡️ <strong>Temperature:</strong> {temp}°C" if temp is not None else ""
|
if temp is not None:
|
||||||
|
temp_f = round(temp * 9/5 + 32, 1)
|
||||||
|
temp_str = f"{temp:.1f}°C / {temp_f:.1f}°F"
|
||||||
|
else:
|
||||||
|
temp_str = "N/A"
|
||||||
|
|
||||||
return f"""
|
rows = [
|
||||||
<details>
|
("🌐", "Location", display_name),
|
||||||
<summary><strong>🕒 Time in {display_name}</strong></summary>
|
("🕒", "Local Time", local_time),
|
||||||
<p>
|
("📅", "Timezone", tz_display),
|
||||||
📍 <strong>Timezone:</strong> {tz}<br>
|
("🌡️", "Temperature", temp_str),
|
||||||
📅 <strong>Local time:</strong> {local_time}{temp_str}
|
]
|
||||||
</p>
|
# Wrap rows in a single section with no title (title is part of code_block's main title)
|
||||||
</details>
|
sections = [{"title": "", "rows": rows}]
|
||||||
"""
|
return code_block("🕒 Time Info", sections)
|
||||||
|
|
||||||
def help_text() -> str:
|
|
||||||
return """
|
# -------------------------------------------------------------------
|
||||||
|
# Help
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
_HELP_MD = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>🕒 Time Plugin Help</strong></summary>
|
<summary><strong>🕒 Time Plugin Help</strong></summary>
|
||||||
<p>
|
<p><strong>!time <any city></strong> – Get current time for ANY city worldwide<br>
|
||||||
<strong>!time <any city></strong> – Get current time for ANY city worldwide<br>
|
<strong>!time <IANA zone></strong> – e.g., <code>Europe/London</code>, <code>Asia/Karachi</code><br>
|
||||||
<strong>!time <IANA zone></strong> – e.g., Europe/London, Asia/Karachi<br>
|
<strong>!time help</strong> – Show this help<br>
|
||||||
<strong>!time help</strong> – Show this help<br><br>
|
|
||||||
<strong>Examples:</strong><br>
|
<strong>Examples:</strong><br>
|
||||||
<code>!time Lahore</code><br>
|
<code>!time Lahore</code><br>
|
||||||
<code>!time New York</code><br>
|
<code>!time New York</code><br>
|
||||||
<code>!time Paris</code><br>
|
<code>!time Europe/London</code><br>
|
||||||
<code>!time Asia/Karachi</code><br><br>
|
<em>No city names are hardcoded. IANA zones work completely offline.</em>
|
||||||
<em>No city names are hardcoded. The bot uses Open-Meteo's geocoding API.</em>
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Plugin lifecycle
|
||||||
|
# -------------------------------------------------------------------
|
||||||
def setup(bot):
|
def setup(bot):
|
||||||
logging.info("Time plugin (zero hardcoded cities) loaded.")
|
logging.info("Time plugin (offline IANA zones + Open‑Meteo cities) loaded.")
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")):
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("time")):
|
||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
if not args or args[0].lower() == "help":
|
if not args or args[0].lower() == "help":
|
||||||
await bot.api.send_markdown_message(room.room_id, help_text())
|
await bot.api.send_markdown_message(room.room_id, _HELP_MD)
|
||||||
return
|
return
|
||||||
|
|
||||||
query = " ".join(args).strip()
|
query = " ".join(args).strip()
|
||||||
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {query}...")
|
await bot.api.send_text_message(room.room_id, f"🕒 Looking up time for: {html_escape(query)}...")
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
data, display = await resolve_time(session, query)
|
data, display = await resolve_time(session, query)
|
||||||
if data is None:
|
if data is None:
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ {display}")
|
await bot.api.send_text_message(room.room_id, f"❌ {display}")
|
||||||
return
|
return
|
||||||
await bot.api.send_markdown_message(room.room_id, format_response(data, display))
|
block = _format_time_output(data, display)
|
||||||
|
output = collapsible_summary(f"🕒 Time in {html_escape(display)}", block)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Time sent for {query}")
|
logging.info(f"Time sent for {query}")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "World clock (no hardcoded cities)"
|
__description__ = "World clock (offline IANA zones + free geocoding)"
|
||||||
__help__ = """
|
__help__ = _HELP_MD
|
||||||
<details>
|
|
||||||
<summary><strong>!time</strong> – Current time for any city</summary>
|
|
||||||
<ul>
|
|
||||||
<li><code>!time <city></code> – Geocode any city (free Open-Meteo API)</li>
|
|
||||||
<li><code>!time <IANA zone></code> – e.g., <code>Europe/London</code></li>
|
|
||||||
</ul>
|
|
||||||
<p>Also shows current temperature if available.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+44
-164
@@ -1,212 +1,92 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to fetch definitions from Urban Dictionary.
|
Urban Dictionary definitions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import html
|
import html
|
||||||
|
from plugins.common import html_escape
|
||||||
|
|
||||||
URBAN_API_URL = "https://api.urbandictionary.com/v0/define"
|
URBAN_API_URL = "https://api.urbandictionary.com/v0/define"
|
||||||
RANDOM_API_URL = "https://api.urbandictionary.com/v0/random"
|
RANDOM_API_URL = "https://api.urbandictionary.com/v0/random"
|
||||||
|
|
||||||
|
|
||||||
def format_definition(term, definition, example, author, thumbs_up, thumbs_down, permalink, index=None, total=None):
|
def format_definition(term, definition, example, author, thumbs_up, thumbs_down, permalink, index=None, total=None):
|
||||||
"""
|
safe_term = html_escape(term)
|
||||||
Format an Urban Dictionary definition for display.
|
safe_author = html_escape(author)
|
||||||
|
# definition and example may contain [word] markup, we'll just escape all HTML
|
||||||
|
definition = html.escape(definition).replace('[', '<strong>').replace(']', '</strong>')
|
||||||
|
example = html.escape(example).replace('[', '<em>').replace(']', '</em>')
|
||||||
|
|
||||||
Args:
|
header = f"<strong>📖 Urban Dictionary: {safe_term}</strong>"
|
||||||
term (str): The term being defined.
|
if index and total:
|
||||||
definition (str): The definition text.
|
|
||||||
example (str): Example usage.
|
|
||||||
author (str): Author of the definition.
|
|
||||||
thumbs_up (int): Number of upvotes.
|
|
||||||
thumbs_down (int): Number of downvotes.
|
|
||||||
permalink (str): URL to the definition.
|
|
||||||
index (int, optional): Current definition index.
|
|
||||||
total (int, optional): Total number of definitions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Formatted HTML message.
|
|
||||||
"""
|
|
||||||
# Clean up the text - Urban Dictionary uses [brackets] for links
|
|
||||||
definition = definition.replace('[', '<strong>').replace(']', '</strong>')
|
|
||||||
example = example.replace('[', '<em>').replace(']', '</em>')
|
|
||||||
|
|
||||||
# Escape any HTML that might be in the original text
|
|
||||||
term = html.escape(term)
|
|
||||||
author = html.escape(author)
|
|
||||||
|
|
||||||
# Build the message
|
|
||||||
header = f"<strong>📖 Urban Dictionary: {term}</strong>"
|
|
||||||
if index is not None and total is not None:
|
|
||||||
header += f" (Definition {index}/{total})"
|
header += f" (Definition {index}/{total})"
|
||||||
|
|
||||||
message = f"""{header}
|
msg = f"""{header}
|
||||||
<strong>Definition:</strong>
|
<strong>Definition:</strong><br>{definition}<br>"""
|
||||||
{definition}
|
if example.strip():
|
||||||
"""
|
msg += f"""<strong>Example:</strong><br><em>{example}</em><br>"""
|
||||||
if example and example.strip():
|
msg += f"""<strong>Author:</strong> {safe_author} | 👍 {thumbs_up} 👎 {thumbs_down}<br>
|
||||||
message += f"""
|
<a href="{permalink}">View on Urban Dictionary</a>"""
|
||||||
<strong>Example:</strong>
|
return msg
|
||||||
<em>{example}</em>
|
|
||||||
"""
|
|
||||||
message += f"""
|
|
||||||
<strong>Author:</strong> {author} | 👍 {thumbs_up} 👎 {thumbs_down}
|
|
||||||
<a href="{permalink}">View on Urban Dictionary</a>
|
|
||||||
"""
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !ud (Urban Dictionary) command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("ud"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("ud"):
|
||||||
logging.info("Received !ud command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Case 1: No arguments - get random definition
|
|
||||||
if len(args) == 0:
|
if len(args) == 0:
|
||||||
logging.info("Fetching random Urban Dictionary definition")
|
# random
|
||||||
response = requests.get(RANDOM_API_URL, timeout=10)
|
async with aiohttp.ClientSession() as session:
|
||||||
response.raise_for_status()
|
async with session.get(RANDOM_API_URL, timeout=10) as resp:
|
||||||
data = response.json()
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
if not data.get('list'):
|
if not data.get('list'):
|
||||||
await bot.api.send_text_message(room.room_id, "No random definition found.")
|
await bot.api.send_text_message(room.room_id, "No random definition found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get first random entry
|
|
||||||
entry = data['list'][0]
|
entry = data['list'][0]
|
||||||
formatted = format_definition(
|
msg = format_definition(entry['word'], entry['definition'], entry.get('example',''),
|
||||||
term=entry['word'],
|
entry['author'], entry['thumbs_up'], entry['thumbs_down'],
|
||||||
definition=entry['definition'],
|
entry['permalink'])
|
||||||
example=entry.get('example', ''),
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
author=entry['author'],
|
|
||||||
thumbs_up=entry['thumbs_up'],
|
|
||||||
thumbs_down=entry['thumbs_down'],
|
|
||||||
permalink=entry['permalink']
|
|
||||||
)
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, formatted)
|
|
||||||
logging.info(f"Sent random definition: {entry['word']}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Case 2: One or more arguments - search for term
|
# Search
|
||||||
# Check if last argument is a number (definition index)
|
|
||||||
index = None
|
index = None
|
||||||
search_term = ' '.join(args)
|
search_term = ' '.join(args)
|
||||||
|
|
||||||
if args[-1].isdigit():
|
if args[-1].isdigit():
|
||||||
index = int(args[-1])
|
index = int(args[-1])
|
||||||
search_term = ' '.join(args[:-1])
|
search_term = ' '.join(args[:-1])
|
||||||
|
|
||||||
if not search_term:
|
if not search_term:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Usage: !ud [term] [index]")
|
||||||
room.room_id,
|
|
||||||
"Usage: !ud [term] [index]\nExamples:\n !ud - random definition\n !ud yeet - first definition of 'yeet'\n !ud yeet 2 - second definition of 'yeet'"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info(f"Searching Urban Dictionary for: {search_term}")
|
async with aiohttp.ClientSession() as session:
|
||||||
params = {'term': search_term}
|
async with session.get(URBAN_API_URL, params={'term': search_term}, timeout=10) as resp:
|
||||||
response = requests.get(URBAN_API_URL, params=params, timeout=10)
|
resp.raise_for_status()
|
||||||
response.raise_for_status()
|
data = await resp.json()
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
definitions = data.get('list', [])
|
definitions = data.get('list', [])
|
||||||
|
|
||||||
if not definitions:
|
if not definitions:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"No definition for '{html_escape(search_term)}'")
|
||||||
room.room_id,
|
|
||||||
f"No definition found for '{search_term}'"
|
|
||||||
)
|
|
||||||
logging.info(f"No definition found for: {search_term}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
total = len(definitions)
|
total = len(definitions)
|
||||||
|
|
||||||
# If no index specified, use first definition
|
|
||||||
if index is None:
|
if index is None:
|
||||||
index = 1
|
index = 1
|
||||||
|
|
||||||
# Validate index
|
|
||||||
if index < 1 or index > total:
|
if index < 1 or index > total:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Index out of range (1-{total})")
|
||||||
room.room_id,
|
|
||||||
f"Invalid index. '{search_term}' has {total} definition(s). Use !ud {search_term} [1-{total}]"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the requested definition (convert to 0-based index)
|
|
||||||
entry = definitions[index - 1]
|
entry = definitions[index - 1]
|
||||||
|
msg = format_definition(entry['word'], entry['definition'], entry.get('example',''),
|
||||||
|
entry['author'], entry['thumbs_up'], entry['thumbs_down'],
|
||||||
|
entry['permalink'], index, total)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
formatted = format_definition(
|
except aiohttp.ClientError as e:
|
||||||
term=entry['word'],
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
definition=entry['definition'],
|
|
||||||
example=entry.get('example', ''),
|
|
||||||
author=entry['author'],
|
|
||||||
thumbs_up=entry['thumbs_up'],
|
|
||||||
thumbs_down=entry['thumbs_down'],
|
|
||||||
permalink=entry['permalink'],
|
|
||||||
index=index,
|
|
||||||
total=total
|
|
||||||
)
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, formatted)
|
|
||||||
logging.info(f"Sent definition {index}/{total} for: {search_term}")
|
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
"Request timed out. Urban Dictionary may be slow or unavailable."
|
|
||||||
)
|
|
||||||
logging.error("Urban Dictionary API timeout")
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
f"Error fetching from Urban Dictionary: {e}"
|
|
||||||
)
|
|
||||||
logging.error(f"Error fetching from Urban Dictionary: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
|
||||||
room.room_id,
|
|
||||||
"An error occurred while processing the Urban Dictionary request."
|
|
||||||
)
|
|
||||||
logging.error(f"Unexpected error in Urban Dictionary plugin: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Urban Dictionary definitions"
|
__description__ = "Urban Dictionary definitions"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!ud</strong> – Urban Dictionary</summary>
|
||||||
<details>
|
<ul><li><code>!ud</code> random, <code>!ud <term></code> top, <code>!ud <term> <index></code></li></ul></details>"""
|
||||||
<summary><strong>!ud</strong> – Urban Dictionary</summary>
|
|
||||||
<ul>
|
|
||||||
<li><code>!ud</code> – Random definition</li>
|
|
||||||
<li><code>!ud <term></code> – Top definition</li>
|
|
||||||
<li><code>!ud <term> <index></code> – Nth definition</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.
|
Weather plugin – primary: OpenWeatherMap, fallback: Open‑Meteo.
|
||||||
|
Outputs a formatted code block with emojis and perfectly aligned columns.
|
||||||
Uses OpenWeatherMap when a valid API key is present and the request succeeds.
|
|
||||||
Falls back to Open‑Meteo (no key required) otherwise.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
!weather <location> e.g. !weather London or !weather "New York,US"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -14,56 +9,14 @@ import aiohttp
|
|||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from plugins.common import html_escape, collapsible_summary, code_block
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Load .env (for OPENWEATHER_API_KEY)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
parent_dir = os.path.dirname(plugin_dir)
|
|
||||||
dotenv_path = os.path.join(parent_dir, ".env")
|
|
||||||
load_dotenv(dotenv_path)
|
|
||||||
|
|
||||||
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
|
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# WMO codes → description + emoji (for Open‑Meteo)
|
# OpenWeatherMap helpers
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
WMO_CODES = {
|
|
||||||
0: ("Clear sky", "☀️"),
|
|
||||||
1: ("Mainly clear", "🌤️"),
|
|
||||||
2: ("Partly cloudy", "⛅"),
|
|
||||||
3: ("Overcast", "☁️"),
|
|
||||||
45: ("Fog", "🌫️"),
|
|
||||||
48: ("Depositing rime fog", "🌫️"),
|
|
||||||
51: ("Light drizzle", "🌦️"),
|
|
||||||
53: ("Moderate drizzle", "🌦️"),
|
|
||||||
55: ("Dense drizzle", "🌧️"),
|
|
||||||
56: ("Light freezing drizzle", "🌧️"),
|
|
||||||
57: ("Dense freezing drizzle", "🌧️"),
|
|
||||||
61: ("Slight rain", "🌧️"),
|
|
||||||
63: ("Moderate rain", "🌧️"),
|
|
||||||
65: ("Heavy rain", "🌧️"),
|
|
||||||
66: ("Light freezing rain", "🌧️"),
|
|
||||||
67: ("Heavy freezing rain", "🌧️"),
|
|
||||||
71: ("Slight snow fall", "❄️"),
|
|
||||||
73: ("Moderate snow fall", "❄️"),
|
|
||||||
75: ("Heavy snow fall", "❄️"),
|
|
||||||
77: ("Snow grains", "❄️"),
|
|
||||||
80: ("Slight rain showers", "🌦️"),
|
|
||||||
81: ("Moderate rain showers", "🌧️"),
|
|
||||||
82: ("Violent rain showers", "🌧️"),
|
|
||||||
85: ("Slight snow showers", "🌨️"),
|
|
||||||
86: ("Heavy snow showers", "🌨️"),
|
|
||||||
95: ("Thunderstorm", "⛈️"),
|
|
||||||
96: ("Thunderstorm with slight hail", "⛈️"),
|
|
||||||
99: ("Thunderstorm with heavy hail", "⛈️"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Primary: OpenWeatherMap
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None:
|
async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> dict | None:
|
||||||
"""Fetch current weather from OpenWeatherMap. Returns None on failure."""
|
|
||||||
if not OPENWEATHER_API_KEY:
|
if not OPENWEATHER_API_KEY:
|
||||||
logging.info("OpenWeatherMap key missing, skipping primary")
|
logging.info("OpenWeatherMap key missing, skipping primary")
|
||||||
return None
|
return None
|
||||||
@@ -72,7 +25,7 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
|
|||||||
params = {
|
params = {
|
||||||
"q": location,
|
"q": location,
|
||||||
"appid": OPENWEATHER_API_KEY,
|
"appid": OPENWEATHER_API_KEY,
|
||||||
"units": "metric", # Celsius
|
"units": "metric",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
async with session.get(url, params=params, timeout=10) as resp:
|
async with session.get(url, params=params, timeout=10) as resp:
|
||||||
@@ -83,46 +36,10 @@ async def openweathermap_get(session: aiohttp.ClientSession, location: str) -> d
|
|||||||
logging.warning(f"OpenWeatherMap request error: {e}")
|
logging.warning(f"OpenWeatherMap request error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def format_openweathermap(data: dict) -> str:
|
|
||||||
"""Build the one-line weather message from OpenWeatherMap data."""
|
|
||||||
city = data.get("name", "Unknown")
|
|
||||||
sys_data = data.get("sys", {})
|
|
||||||
country = sys_data.get("country", "")
|
|
||||||
|
|
||||||
main_data = data.get("main", {})
|
|
||||||
temp_c = main_data.get("temp", 0)
|
|
||||||
temp_f = round(temp_c * 9 / 5 + 32, 1)
|
|
||||||
humidity = main_data.get("humidity", 0)
|
|
||||||
|
|
||||||
weather_list = data.get("weather", [])
|
|
||||||
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
|
|
||||||
emoji = "🌡️"
|
|
||||||
if weather_list:
|
|
||||||
wmain = weather_list[0].get("main", "")
|
|
||||||
emoji = {
|
|
||||||
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
|
|
||||||
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
|
|
||||||
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
|
|
||||||
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
|
|
||||||
}.get(wmain, "🌡️")
|
|
||||||
|
|
||||||
wind = data.get("wind", {}).get("speed", 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"<strong>[{emoji} Weather for {city}, {country}]</strong>: "
|
|
||||||
f"<strong>Condition:</strong> {description} | "
|
|
||||||
f"<strong>Temperature:</strong> {temp_c:.1f}°C ({temp_f:.1f}°F) | "
|
|
||||||
f"<strong>Humidity:</strong> {humidity}% | "
|
|
||||||
f"<strong>Wind Speed:</strong> {wind} m/s"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fallback: Open‑Meteo (no key, free)
|
# Open‑Meteo helpers (fallback)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
|
async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict | None:
|
||||||
"""Geocode a city name via Open‑Meteo. Returns location info dict or None."""
|
|
||||||
url = "https://geocoding-api.open-meteo.com/v1/search"
|
url = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
params = {"name": location, "count": 1, "language": "en"}
|
params = {"name": location, "count": 1, "language": "en"}
|
||||||
try:
|
try:
|
||||||
@@ -144,10 +61,7 @@ async def meteo_geocode(session: aiohttp.ClientSession, location: str) -> dict |
|
|||||||
logging.warning(f"Open‑Meteo geocode error: {e}")
|
logging.warning(f"Open‑Meteo geocode error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float, timezone: str = "auto") -> dict | None:
|
||||||
async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
|
|
||||||
timezone: str = "auto") -> dict | None:
|
|
||||||
"""Fetch current weather from Open‑Meteo. Returns JSON or None."""
|
|
||||||
url = "https://api.open-meteo.com/v1/forecast"
|
url = "https://api.open-meteo.com/v1/forecast"
|
||||||
params = {
|
params = {
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
@@ -165,35 +79,82 @@ async def meteo_weather(session: aiohttp.ClientSession, lat: float, lon: float,
|
|||||||
logging.warning(f"Open‑Meteo weather error: {e}")
|
logging.warning(f"Open‑Meteo weather error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Formatting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def format_openweathermap(data: dict) -> str:
|
||||||
|
"""Build a code block from OpenWeatherMap response."""
|
||||||
|
city = data.get("name", "Unknown")
|
||||||
|
sys_data = data.get("sys", {})
|
||||||
|
country = sys_data.get("country", "")
|
||||||
|
main = data.get("main", {})
|
||||||
|
temp_c = main.get("temp", 0)
|
||||||
|
temp_f = round(temp_c * 9 / 5 + 32, 1)
|
||||||
|
humidity = main.get("humidity", 0)
|
||||||
|
wind_speed = data.get("wind", {}).get("speed", 0)
|
||||||
|
weather_list = data.get("weather", [])
|
||||||
|
description = weather_list[0]["description"].capitalize() if weather_list else "Unknown"
|
||||||
|
|
||||||
|
emoji_map = {
|
||||||
|
"Clear": "☀️", "Clouds": "☁️", "Rain": "🌧️", "Drizzle": "🌦️",
|
||||||
|
"Thunderstorm": "⛈️", "Snow": "❄️", "Mist": "🌫️", "Fog": "🌫️",
|
||||||
|
"Haze": "🌫️", "Smoke": "🌫️", "Dust": "🌫️", "Sand": "🌫️",
|
||||||
|
"Ash": "🌫️", "Squall": "💨", "Tornado": "🌪️",
|
||||||
|
}
|
||||||
|
main_weather = weather_list[0].get("main", "") if weather_list else ""
|
||||||
|
weather_emoji = emoji_map.get(main_weather, "🌡️")
|
||||||
|
|
||||||
|
location = f"{city}, {country}" if country else city
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
("🌍", "Location", location),
|
||||||
|
(weather_emoji, "Condition", description),
|
||||||
|
("🌡️", "Temperature", f"{temp_c:.1f}°C / {temp_f:.1f}°F"),
|
||||||
|
("💧", "Humidity", f"{humidity}%"),
|
||||||
|
("💨", "Wind Speed", f"{wind_speed} m/s"),
|
||||||
|
]
|
||||||
|
sections = [{"title": "", "rows": rows}]
|
||||||
|
return code_block(f"🌤️ Weather for {location}", sections)
|
||||||
|
|
||||||
|
|
||||||
def format_meteo(loc_info: dict, weather_data: dict) -> str:
|
def format_meteo(loc_info: dict, weather_data: dict) -> str:
|
||||||
"""Format Open‑Meteo result into the same one‑line style."""
|
"""Build a code block from Open‑Meteo response."""
|
||||||
c = weather_data["current_weather"]
|
c = weather_data["current_weather"]
|
||||||
code = c["weathercode"]
|
code = c["weathercode"]
|
||||||
desc, emoji = WMO_CODES.get(code, ("Unknown", "🌡️"))
|
wmo_emoji = {
|
||||||
|
0: ("Clear sky", "☀️"),
|
||||||
|
1: ("Mainly clear", "🌤️"),
|
||||||
|
2: ("Partly cloudy", "⛅"),
|
||||||
|
3: ("Overcast", "☁️"),
|
||||||
|
45: ("Fog", "🌫️"),
|
||||||
|
51: ("Light drizzle", "🌦️"),
|
||||||
|
61: ("Slight rain", "🌧️"),
|
||||||
|
63: ("Moderate rain", "🌧️"),
|
||||||
|
71: ("Slight snow", "❄️"),
|
||||||
|
95: ("Thunderstorm", "⛈️"),
|
||||||
|
}
|
||||||
|
desc, emoji = wmo_emoji.get(code, ("Unknown", "🌡️"))
|
||||||
|
|
||||||
city = loc_info["name"]
|
location_parts = [loc_info["name"]]
|
||||||
country = loc_info.get("country", "")
|
if loc_info.get("state") and loc_info["state"] != loc_info["name"]:
|
||||||
state = loc_info.get("state", "")
|
location_parts.append(loc_info["state"])
|
||||||
|
if loc_info.get("country"):
|
||||||
# Build location string
|
location_parts.append(loc_info["country"])
|
||||||
parts = [city]
|
location = ", ".join(location_parts)
|
||||||
if state and state != city:
|
|
||||||
parts.append(state)
|
|
||||||
if country:
|
|
||||||
parts.append(country)
|
|
||||||
loc_str = ", ".join(parts)
|
|
||||||
|
|
||||||
temp_f = c["temperature"]
|
temp_f = c["temperature"]
|
||||||
temp_c = round((temp_f - 32) * 5 / 9, 1)
|
temp_c = round((temp_f - 32) * 5 / 9, 1)
|
||||||
wind = c["windspeed"]
|
wind = c["windspeed"] # mph
|
||||||
|
|
||||||
return (
|
rows = [
|
||||||
f"<strong>[{emoji} Weather for {loc_str}]</strong>: "
|
("🌍", "Location", location),
|
||||||
f"<strong>Condition:</strong> {desc} | "
|
(emoji, "Condition", desc),
|
||||||
f"<strong>Temperature:</strong> {temp_c}°C ({temp_f}°F) | "
|
("🌡️", "Temperature", f"{temp_c}°C / {temp_f}°F"),
|
||||||
f"<strong>Wind Speed:</strong> {wind} mph"
|
("💨", "Wind Speed", f"{wind} mph"),
|
||||||
)
|
]
|
||||||
|
sections = [{"title": "", "rows": rows}]
|
||||||
|
return code_block(f"🌤️ Weather for {location}", sections)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -218,14 +179,11 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
# 1. Try OpenWeatherMap
|
# 1. Try OpenWeatherMap
|
||||||
owm_data = await openweathermap_get(session, location)
|
owm_data = await openweathermap_get(session, location)
|
||||||
if owm_data:
|
if owm_data and owm_data.get("cod") == 200:
|
||||||
if owm_data.get("cod") == 200:
|
block = format_openweathermap(owm_data)
|
||||||
msg = format_openweathermap(owm_data)
|
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info("Sent weather via OpenWeatherMap")
|
return
|
||||||
return
|
|
||||||
# OpenWeatherMap returned an error status inside JSON (e.g., 401, 404)
|
|
||||||
logging.info("OpenWeatherMap returned error code %s, falling back", owm_data.get("cod"))
|
|
||||||
|
|
||||||
# 2. Fallback: Open‑Meteo
|
# 2. Fallback: Open‑Meteo
|
||||||
logging.info("Falling back to Open‑Meteo")
|
logging.info("Falling back to Open‑Meteo")
|
||||||
@@ -233,7 +191,7 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
if not loc_info:
|
if not loc_info:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
f"Location '{location}' not found."
|
f"Location '{html_escape(location)}' not found."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -247,28 +205,24 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
msg = format_meteo(loc_info, wdata)
|
block = format_meteo(loc_info, wdata)
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
output = collapsible_summary(f"🌤️ Weather: {html_escape(location)}", block)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info("Sent weather via Open‑Meteo (fallback)")
|
logging.info("Sent weather via Open‑Meteo (fallback)")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin setup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def setup(bot):
|
def setup(bot):
|
||||||
logging.info("Weather plugin loaded (OpenWeatherMap + Open‑Meteo fallback)")
|
logging.info("Weather plugin loaded (OpenWeatherMap + Open‑Meteo fallback)")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
__version__ = "1.0.0"
|
|
||||||
|
__version__ = "1.1.1"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Weather forecast (OWM primary, Open‑Meteo fallback)"
|
__description__ = "Weather data plugin"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!weather</strong> – Current weather</summary>
|
<summary><strong>!weather</strong> – Current weather</summary>
|
||||||
<p><code>!weather <location></code> – Shows temperature, conditions, humidity, wind.<br>
|
<p><code>!weather <location></code> – Shows temperature, conditions, humidity, wind in a clean, aligned table. Uses OpenWeatherMap primary, Open‑Meteo fallback.</p>
|
||||||
Uses OpenWeatherMap if a valid API key is present; falls back to free Open‑Meteo otherwise.</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+69
-158
@@ -1,219 +1,130 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides WHOIS lookup functionality for domains, IPs, and related network information.
|
WHOIS lookup plugin – outputs a formatted code block with emojis and aligned columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import whois
|
import whois
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import collapsible_summary, html_escape, code_block
|
||||||
|
|
||||||
def is_valid_domain(domain):
|
def is_valid_domain(domain):
|
||||||
"""
|
|
||||||
Validate if the provided string is a valid domain name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
domain (str): The domain to validate.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if valid, False otherwise.
|
|
||||||
"""
|
|
||||||
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9-]{1,63}$'
|
pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9-]{1,63}$'
|
||||||
return re.match(pattern, domain) is not None
|
return re.match(pattern, domain) is not None
|
||||||
|
|
||||||
|
|
||||||
def is_valid_ip(ip):
|
def is_valid_ip(ip):
|
||||||
"""
|
|
||||||
Validate if the provided string is a valid IPv4 or IPv6 address.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ip (str): The IP address to validate.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if valid, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
ipaddress.ip_address(ip)
|
ipaddress.ip_address(ip)
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _build_rows(data):
|
||||||
|
"""Build a list of (emoji, label, value) tuples from WHOIS data."""
|
||||||
|
rows = []
|
||||||
|
|
||||||
def format_whois_data(domain, data):
|
# Domain
|
||||||
"""
|
domain_name = data.domain_name
|
||||||
Format WHOIS data into a readable format.
|
if isinstance(domain_name, list):
|
||||||
|
domain_name = ', '.join(domain_name)
|
||||||
|
rows.append(('🌐', 'Domain', domain_name or 'N/A'))
|
||||||
|
|
||||||
Args:
|
# Registrar / WHOIS Server
|
||||||
domain (str): The queried domain/IP.
|
if data.registrar:
|
||||||
data (whois domain object): The WHOIS data object.
|
rows.append(('🏢', 'Registrar', data.registrar))
|
||||||
|
if data.whois_server:
|
||||||
Returns:
|
rows.append(('📡', 'WHOIS Server', data.whois_server))
|
||||||
str: Formatted HTML message.
|
|
||||||
"""
|
|
||||||
sections = []
|
|
||||||
|
|
||||||
# Domain/Query Information
|
|
||||||
if hasattr(data, 'domain_name') or hasattr(data, 'query'):
|
|
||||||
domain_names = getattr(data, 'domain_name', domain)
|
|
||||||
if isinstance(domain_names, list):
|
|
||||||
domain_names = ', '.join(domain_names)
|
|
||||||
sections.append(f"<strong>🔍 Query:</strong> {domain_names}")
|
|
||||||
|
|
||||||
# Registrar Information
|
|
||||||
registrar_items = []
|
|
||||||
if hasattr(data, 'registrar'):
|
|
||||||
registrar_items.append(f"<strong>Registrar:</strong> {data.registrar}")
|
|
||||||
if hasattr(data, 'whois_server'):
|
|
||||||
registrar_items.append(f"<strong>WHOIS Server:</strong> {data.whois_server}")
|
|
||||||
if registrar_items:
|
|
||||||
sections.append('<br>'.join(registrar_items))
|
|
||||||
|
|
||||||
# Dates
|
# Dates
|
||||||
date_items = []
|
creation_date = data.creation_date
|
||||||
if hasattr(data, 'creation_date'):
|
if creation_date:
|
||||||
creation = data.creation_date
|
if isinstance(creation_date, list):
|
||||||
if isinstance(creation, list):
|
creation_date = creation_date[0]
|
||||||
creation = creation[0]
|
rows.append(('📅', 'Created', str(creation_date)))
|
||||||
date_items.append(f"<strong>Created:</strong> {creation}")
|
|
||||||
|
|
||||||
if hasattr(data, 'updated_date'):
|
updated_date = data.updated_date
|
||||||
updated = data.updated_date
|
if updated_date:
|
||||||
if isinstance(updated, list):
|
if isinstance(updated_date, list):
|
||||||
updated = updated[0]
|
updated_date = updated_date[0]
|
||||||
date_items.append(f"<strong>Updated:</strong> {updated}")
|
rows.append(('📝', 'Updated', str(updated_date)))
|
||||||
|
|
||||||
if hasattr(data, 'expiration_date'):
|
expiration_date = data.expiration_date
|
||||||
expiration = data.expiration_date
|
if expiration_date:
|
||||||
if isinstance(expiration, list):
|
if isinstance(expiration_date, list):
|
||||||
expiration = expiration[0]
|
expiration_date = expiration_date[0]
|
||||||
date_items.append(f"<strong>Expires:</strong> {expiration}")
|
rows.append(('⏰', 'Expires', str(expiration_date)))
|
||||||
|
|
||||||
if date_items:
|
# Name servers
|
||||||
sections.append('<br>'.join(date_items))
|
if data.name_servers:
|
||||||
|
ns_sorted = sorted(data.name_servers)
|
||||||
|
ns_text = ', '.join(ns_sorted[:5])
|
||||||
|
if len(ns_sorted) > 5:
|
||||||
|
ns_text += f' (+{len(ns_sorted)-5} more)'
|
||||||
|
rows.append(('🌍', 'Name Servers', ns_text))
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
if hasattr(data, 'status'):
|
if data.status:
|
||||||
status = data.status
|
status = data.status
|
||||||
if isinstance(status, list):
|
if isinstance(status, list):
|
||||||
status = '<br>'.join(status[:3]) # Limit to first 3 status entries
|
status = ', '.join(status[:3])
|
||||||
sections.append(f"<strong>Status:</strong><br>{status}")
|
rows.append(('🔒', 'Status', str(status)))
|
||||||
|
|
||||||
# Name Servers
|
# Contact info
|
||||||
if hasattr(data, 'name_servers'):
|
if data.org:
|
||||||
name_servers = data.name_servers
|
rows.append(('🏛️', 'Organization', data.org))
|
||||||
if isinstance(name_servers, list):
|
if data.country:
|
||||||
if len(name_servers) > 5:
|
rows.append(('🌍', 'Country', data.country))
|
||||||
name_servers_list = '<br>'.join(sorted(name_servers)[:5])
|
if data.state:
|
||||||
name_servers_list += f"<br><em>...(+{len(name_servers) - 5} more)</em>"
|
rows.append(('🏙️', 'State', data.state))
|
||||||
else:
|
if data.city:
|
||||||
name_servers_list = '<br>'.join(sorted(name_servers))
|
rows.append(('🏡', 'City', data.city))
|
||||||
else:
|
|
||||||
name_servers_list = str(name_servers)
|
|
||||||
sections.append(f"<strong>Name Servers:</strong><br>{name_servers_list}")
|
|
||||||
|
|
||||||
# Contact Information
|
|
||||||
contact_items = []
|
|
||||||
if hasattr(data, 'org'):
|
|
||||||
contact_items.append(f"<strong>Organization:</strong> {data.org}")
|
|
||||||
if hasattr(data, 'country'):
|
|
||||||
contact_items.append(f"<strong>Country:</strong> {data.country}")
|
|
||||||
if hasattr(data, 'state'):
|
|
||||||
contact_items.append(f"<strong>State:</strong> {data.state}")
|
|
||||||
if hasattr(data, 'city'):
|
|
||||||
contact_items.append(f"<strong>City:</strong> {data.city}")
|
|
||||||
|
|
||||||
if contact_items:
|
|
||||||
sections.append('<br>'.join(contact_items))
|
|
||||||
|
|
||||||
# Build the final message
|
|
||||||
if sections:
|
|
||||||
content = f"<strong>🌐 WHOIS Report: {domain}</strong><br><br>"
|
|
||||||
content += '<br><br>'.join(sections)
|
|
||||||
else:
|
|
||||||
content = f"<strong>🌐 WHOIS Information for {domain}</strong><br><br>"
|
|
||||||
content += "<em>No detailed information available or query returned minimal data.</em>"
|
|
||||||
|
|
||||||
# Wrap in collapsible details block for Matrix compatibility
|
|
||||||
message = f"<details><summary><strong>🌐 WHOIS Report: {domain} (Click to expand)</strong></summary>{content}</details>"
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle the !whois command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (Bot): The bot object.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): Configuration parameters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("whois"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("whois"):
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Usage: !whois <domain/ip>\nExample: !whois example.com")
|
||||||
room.room_id,
|
|
||||||
"Usage: !whois <domain/ip>\nExample: !whois example.com\nExample: !whois 8.8.8.8"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
query = args[0].strip()
|
query = args[0].strip()
|
||||||
logging.info(f"Received !whois command for: {query}")
|
|
||||||
|
|
||||||
# Validate the query
|
|
||||||
if not is_valid_domain(query) and not is_valid_ip(query):
|
if not is_valid_domain(query) and not is_valid_ip(query):
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Invalid input: {html_escape(query)}")
|
||||||
room.room_id,
|
|
||||||
f"Invalid domain or IP address format: {query}\nPlease provide a valid domain (e.g., example.com) or IP address."
|
|
||||||
)
|
|
||||||
logging.warning(f"Invalid WHOIS query format: {query}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {html_escape(query)}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Perform WHOIS lookup
|
loop = asyncio.get_running_loop()
|
||||||
logging.info(f"Performing WHOIS lookup for: {query}")
|
data = await loop.run_in_executor(None, whois.whois, query)
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Performing WHOIS lookup for {query}...")
|
|
||||||
|
|
||||||
# Use python-whois library
|
rows = _build_rows(data)
|
||||||
whois_data = whois.whois(query)
|
sections = [{"title": "", "rows": rows}] # no section header
|
||||||
|
block = code_block(f"🌐 WHOIS Report: {html_escape(query)}", sections)
|
||||||
# Format and send the results
|
output = collapsible_summary(f"🌐 WHOIS Report: {html_escape(query)}", block)
|
||||||
result_message = format_whois_data(query, whois_data)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
await bot.api.send_markdown_message(room.room_id, result_message)
|
|
||||||
logging.info(f"Successfully sent WHOIS results for {query}")
|
|
||||||
|
|
||||||
except whois.parser.PywhoisError as e:
|
except whois.parser.PywhoisError as e:
|
||||||
error_msg = f"WHOIS lookup failed for {query}.\n"
|
await bot.api.send_text_message(room.room_id, f"❌ WHOIS lookup failed: {html_escape(str(e))}")
|
||||||
error_msg += "Possible reasons:\n- Domain/IP not found\n- WHOIS server unavailable\n- Rate limited by registrar"
|
|
||||||
await bot.api.send_text_message(room.room_id, error_msg)
|
|
||||||
logging.error(f"WHOIS lookup error for {query}: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {html_escape(str(e))}")
|
||||||
room.room_id,
|
|
||||||
f"An unexpected error occurred during WHOIS lookup for {query}. Please try again later."
|
|
||||||
)
|
|
||||||
logging.error(f"Unexpected error in WHOIS plugin for {query}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.2.1"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "WHOIS lookup"
|
__description__ = "Domain WHOIS lookup"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!whois</strong> – WHOIS lookup</summary>
|
<summary><strong>!whois</strong> – WHOIS lookup</summary>
|
||||||
<p><code>!whois <domain or IP></code> – Shows registrar, creation/expiry dates, nameservers, contacts.</p>
|
<pre>
|
||||||
|
!whois <domain or IP> Shows registrar, dates, nameservers, etc. in a clean table.
|
||||||
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+67
-37
@@ -1,60 +1,90 @@
|
|||||||
"""
|
"""
|
||||||
Provides a command to fetch random xkcd comic
|
Provides a command to fetch random or specific xkcd comics.
|
||||||
|
Usage: !xkcd -> random comic
|
||||||
|
!xkcd <number> -> comic #<number> (e.g. !xkcd 538)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import aiohttp
|
||||||
import tempfile
|
import tempfile
|
||||||
import random
|
import random
|
||||||
|
import os
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
# Define the XKCD API URL
|
XKCD_LATEST_URL = "https://xkcd.com/info.0.json"
|
||||||
XKCD_API_URL = "https://xkcd.com/info.0.json"
|
XKCD_COMIC_URL = "https://xkcd.com/{}/info.0.json"
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.prefix() and match.command("xkcd"):
|
if not (match.prefix() and match.command("xkcd")):
|
||||||
# Fetch the latest comic number from XKCD API
|
return
|
||||||
try:
|
|
||||||
response = requests.get(XKCD_API_URL, timeout=10)
|
args = match.args()
|
||||||
response.raise_for_status() # Raise an exception for non-200 status codes
|
|
||||||
latest_comic_num = response.json()["num"]
|
try:
|
||||||
# Choose a random comic number
|
async with aiohttp.ClientSession() as session:
|
||||||
random_comic_num = random.randint(1, latest_comic_num)
|
# Get latest comic number
|
||||||
# Fetch the random comic data
|
async with session.get(XKCD_LATEST_URL, timeout=10) as resp:
|
||||||
random_comic_url = f"https://xkcd.com/{random_comic_num}/info.0.json"
|
resp.raise_for_status()
|
||||||
comic_response = requests.get(random_comic_url, timeout=10)
|
latest_data = await resp.json()
|
||||||
comic_response.raise_for_status()
|
latest_num = latest_data["num"]
|
||||||
comic_data = comic_response.json()
|
|
||||||
|
# Determine target comic number
|
||||||
|
if args and args[0].isdigit():
|
||||||
|
requested_num = int(args[0])
|
||||||
|
if requested_num < 1 or requested_num > latest_num:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
f"❌ Comic #{requested_num} doesn't exist. Valid range: 1 – {latest_num}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
comic_num = requested_num
|
||||||
|
else:
|
||||||
|
comic_num = random.randint(1, latest_num)
|
||||||
|
|
||||||
|
# Fetch the comic data
|
||||||
|
comic_url = XKCD_COMIC_URL.format(comic_num)
|
||||||
|
async with session.get(comic_url, timeout=10) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
comic_data = await resp.json()
|
||||||
|
|
||||||
image_url = comic_data["img"]
|
image_url = comic_data["img"]
|
||||||
|
title = comic_data.get("safe_title", comic_data.get("title", "xkcd"))
|
||||||
|
alt = comic_data.get("alt", "")
|
||||||
|
|
||||||
# Download the image
|
# Download the image
|
||||||
image_response = requests.get(image_url, timeout=10)
|
async with session.get(image_url, timeout=10) as img_resp:
|
||||||
image_response.raise_for_status()
|
img_resp.raise_for_status()
|
||||||
|
image_data = await img_resp.read()
|
||||||
|
|
||||||
# Use secure temporary file
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
tmp.write(image_data)
|
||||||
image_path = temp_file.name
|
img_path = tmp.name
|
||||||
temp_file.write(image_response.content)
|
|
||||||
|
|
||||||
# Send the image to the room
|
# Send image
|
||||||
await bot.api.send_image_message(room_id=room.room_id, image_filepath=image_path)
|
await bot.api.send_image_message(room_id=room.room_id, image_filepath=img_path)
|
||||||
|
|
||||||
# Clean up temp file
|
# Send comic info as text (optional but helpful)
|
||||||
import os
|
info = f"**#{comic_num} – {title}**"
|
||||||
os.remove(image_path)
|
if alt:
|
||||||
except Exception as e:
|
info += f"\n*{alt}*"
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching XKCD comic: {str(e)}")
|
await bot.api.send_markdown_message(room.room_id, info)
|
||||||
|
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
except aiohttp.ClientError as e:
|
||||||
# Plugin Metadata
|
await bot.api.send_text_message(room.room_id, f"❌ Network error fetching xkcd: {e}")
|
||||||
# ---------------------------------------------------------------------------
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"❌ Error: {str(e)}")
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Random XKCD comic"
|
__description__ = "Fetch random or specific xkcd comics"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!xkcd</strong> – Random XKCD comic</summary>
|
<summary><strong>!xkcd</strong> – xkcd comics</summary>
|
||||||
<p>Posts a random XKCD comic image.</p>
|
<ul>
|
||||||
|
<li><code>!xkcd</code> – random comic</li>
|
||||||
|
<li><code>!xkcd <number></code> – specific comic (e.g. <code>!xkcd 538</code>)</li>
|
||||||
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+22
-61
@@ -1,25 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Plugin for providing a command to search for YouTube videos in the room.
|
Plugin for providing a command to search for YouTube videos.
|
||||||
|
Uses async wrapper around youtube_search library (synchronous).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from youtube_search import YoutubeSearch
|
from youtube_search import YoutubeSearch
|
||||||
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
async def handle_command(room, message, bot, PREFIX, config):
|
async def handle_command(room, message, bot, PREFIX, config):
|
||||||
"""
|
|
||||||
Asynchronously handles the command to search for YouTube videos in the room.
|
|
||||||
|
|
||||||
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)
|
match = botlib.MessageMatch(room, message, bot, PREFIX)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("yt"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("yt"):
|
||||||
args = match.args()
|
args = match.args()
|
||||||
@@ -27,62 +17,33 @@ async def handle_command(room, message, bot, PREFIX, config):
|
|||||||
await bot.api.send_text_message(room.room_id, "Usage: !yt <search terms>")
|
await bot.api.send_text_message(room.room_id, "Usage: !yt <search terms>")
|
||||||
else:
|
else:
|
||||||
search_terms = " ".join(args)
|
search_terms = " ".join(args)
|
||||||
logging.info(f"Performing YouTube search for: {search_terms}")
|
logging.info(f"YouTube search for: {search_terms}")
|
||||||
results = YoutubeSearch(search_terms, max_results=3).to_dict()
|
results = await asyncio.to_thread(YoutubeSearch, search_terms, max_results=3)
|
||||||
|
results = results.to_dict()
|
||||||
if results:
|
if results:
|
||||||
output = generate_output(results)
|
output = generate_output(results)
|
||||||
await send_collapsible_message(room, bot, output)
|
safe_terms = html_escape(search_terms)
|
||||||
|
msg = collapsible_summary(f"🍄 Funguy ▶YouTube Search: {safe_terms}", output)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
else:
|
else:
|
||||||
await bot.api.send_text_message(room.room_id, "No results found.")
|
await bot.api.send_text_message(room.room_id, "No results found.")
|
||||||
|
|
||||||
def generate_output(results):
|
def generate_output(results):
|
||||||
"""
|
|
||||||
Generates HTML output for displaying YouTube search results.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results (list): A list of dictionaries containing information about YouTube videos.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: HTML formatted output containing YouTube search results.
|
|
||||||
"""
|
|
||||||
output = ""
|
output = ""
|
||||||
for video in results:
|
for video in results:
|
||||||
output += f'<a href="https://www.youtube.com/watch?v={video["id"]}">'
|
vid_id = html_escape(video["id"])
|
||||||
output += f'<img src="{video["thumbnails"][0]}"></img><br>'
|
title = html_escape(video["title"])
|
||||||
output += f'<strong>{video["title"]}</strong><br>'
|
thumb = video["thumbnails"][0]
|
||||||
output += f'Length: {video["duration"]} | Views: {video["views"]}<br>'
|
duration = html_escape(str(video["duration"]))
|
||||||
if video["long_desc"]:
|
views = html_escape(str(video["views"]))
|
||||||
output += f'Description: {video["long_desc"]}<br>'
|
output += f'<a href="https://www.youtube.com/watch?v={vid_id}">'
|
||||||
output += "</a><br>"
|
output += f'<img src="{thumb}"></img><br>'
|
||||||
|
output += f'<strong>{title}</strong><br>'
|
||||||
|
output += f'Length: {duration} | Views: {views}<br></a><br>'
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
async def send_collapsible_message(room, bot, content):
|
|
||||||
"""
|
|
||||||
Sends a collapsible message containing YouTube search results to the room.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the message will be sent.
|
|
||||||
bot (MatrixBot): The Matrix bot instance.
|
|
||||||
content (str): HTML content to be included in the collapsible message.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
message = f'<details><summary><strong>🍄Funguy ▶YouTube Search🍄<br>⤵︎Click Here To See Results⤵︎</strong></summary>{content}</details>'
|
|
||||||
await bot.api.send_markdown_message(room.room_id, message)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "YouTube video search"
|
__description__ = "YouTube video search"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!yt</strong> – Search YouTube</summary>
|
||||||
<details>
|
<p><code>!yt <search terms></code></p></details>"""
|
||||||
<summary><strong>!yt</strong> – Search YouTube</summary>
|
|
||||||
<p><code>!yt <search terms></code> – Returns top 3 results with thumbnails and descriptions.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+16
-14
@@ -1,26 +1,28 @@
|
|||||||
|
simplematrixbotlib>=2.13.0
|
||||||
python-dotenv
|
python-dotenv
|
||||||
requests
|
aiohttp
|
||||||
nio
|
toml
|
||||||
markdown2
|
|
||||||
watchdog
|
|
||||||
emoji
|
|
||||||
python-slugify
|
|
||||||
youtube_title_parse
|
|
||||||
dnspython
|
dnspython
|
||||||
croniter
|
|
||||||
schedule
|
|
||||||
yt-dlp
|
|
||||||
pyopenssl
|
pyopenssl
|
||||||
psutil
|
psutil
|
||||||
toml
|
|
||||||
python-whois
|
python-whois
|
||||||
aiohttp
|
|
||||||
aiosqlite
|
aiosqlite
|
||||||
pillow
|
pillow
|
||||||
omdbapi
|
|
||||||
apscheduler
|
apscheduler
|
||||||
pytz
|
pytz
|
||||||
ddgs
|
ddgs
|
||||||
playwright
|
playwright
|
||||||
lxml
|
lxml
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
cryptography
|
||||||
|
bcrypt
|
||||||
|
argon2-cffi
|
||||||
|
yara-python
|
||||||
|
asn1crypto
|
||||||
|
PyYAML
|
||||||
|
wcwidth
|
||||||
|
markdown
|
||||||
|
python-cryptography-fernet-wrapper
|
||||||
|
zstandard
|
||||||
|
requests
|
||||||
|
markdown2
|
||||||
|
|||||||
Reference in New Issue
Block a user