feat: add internetdb plugin (Shodan InternetDB host recon)
Free, keyless API returning open ports, hostnames, CPEs, tags, and known CVEs for any public IP. All requests routed through SOCKS5. 21 test cases (927 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
TASKS.md
13
TASKS.md
@@ -1,6 +1,17 @@
|
||||
# derp - Tasks
|
||||
|
||||
## Current Sprint -- v1.2.8 ASN Backend Replacement (2026-02-19)
|
||||
## Current Sprint -- v1.2.9 InternetDB Plugin (2026-02-19)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | Shodan InternetDB plugin (`plugins/internetdb.py`) -- free, no API key |
|
||||
| P0 | [x] | Fetch via SOCKS5 proxy (`derp.http.urlopen`) |
|
||||
| P1 | [x] | Compact formatting: hostnames, ports, CPEs, tags, CVEs with truncation |
|
||||
| P1 | [x] | Input validation: IPv4/IPv6, private/loopback rejection |
|
||||
| P2 | [x] | Tests: fetch, format, command handler (21 cases, 927 total) |
|
||||
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md) |
|
||||
|
||||
## Previous Sprint -- v1.2.8 ASN Backend Replacement (2026-02-19)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
|
||||
69
TODO.md
69
TODO.md
@@ -9,15 +9,72 @@
|
||||
- [ ] Webhook listener (HTTP endpoint for push events to channels)
|
||||
- [ ] Granular ACLs (per-command: trusted, operator, admin)
|
||||
|
||||
## LLM Bridge
|
||||
|
||||
Goal: let an LLM agent interact with the bot over IRC in real-time,
|
||||
with full machine access (bash, file ops, etc.).
|
||||
|
||||
### Architecture
|
||||
|
||||
Owner addresses the bot on IRC. A bridge daemon reads addressed
|
||||
messages, feeds them to an LLM with tool access, and writes replies
|
||||
back through the bot. The bot already has `owner` config
|
||||
(`[bot] owner` hostmask patterns) to gate who can trigger LLM
|
||||
interactions.
|
||||
|
||||
```
|
||||
IRC -> bot stdout (addressed msgs) -> bridge -> LLM API -> bridge -> bot inbox -> IRC
|
||||
```
|
||||
|
||||
### Approach options (ranked)
|
||||
|
||||
1. **Claude Code Agent SDK** (clean, non-trivial)
|
||||
- Custom Python agent using `anthropic` SDK with `tool_use`
|
||||
- Define tools: bash exec, file read/write, web fetch
|
||||
- Persistent conversation memory across messages
|
||||
- Full control over the event loop -- real-time IRC is natural
|
||||
- Tradeoff: must implement and maintain tool definitions
|
||||
|
||||
2. **Claude Code CLI per-message** (simple, stateless)
|
||||
- `echo "user said X" | claude --print --allowedTools bash,read,write`
|
||||
- Each invocation is a cold start with no conversation memory
|
||||
- Simple to implement but slow startup, no multi-turn context
|
||||
- Could pass conversation history via system prompt (fragile)
|
||||
|
||||
3. **Persistent Claude Code subprocess** (hack, fragile)
|
||||
- Long-running `claude` process with stdin/stdout piped
|
||||
- Keeps context across messages within a session
|
||||
- Not designed for this -- output parsing is brittle
|
||||
- Session may drift or hit context limits
|
||||
|
||||
### Bot-side plumbing needed
|
||||
|
||||
- `--llm` CLI flag: route logging to file, stdout for addressed msgs
|
||||
- `_is_addressed()`: DM or nick-prefixed messages
|
||||
- `_is_owner()`: only owner hostmasks trigger LLM routing
|
||||
- Inbox file polling (`/tmp/llm-inbox`): bridge writes `<target> <msg>`
|
||||
- `llm-send` script: line splitting (400 char), FlaskPaste overflow
|
||||
- Stdout format: `HH:MM [#chan] <nick> text` / `HH:MM --- status`
|
||||
- Only LLM-originated replies echoed to stdout (not all bot output)
|
||||
|
||||
### Previous attempt (reverted)
|
||||
|
||||
The `--llm` mode was implemented and tested (commit ea6f079, reverted
|
||||
in 6f1f4b2). The stdout/stdin plumbing worked but Claude Code CLI
|
||||
cannot act as a real-time daemon -- each tool call is a blocking
|
||||
round-trip, making interactive IRC conversation impractical. The code
|
||||
is preserved in git history for reference.
|
||||
|
||||
## Plugins -- Security/OSINT
|
||||
|
||||
- [ ] `emailcheck` -- SMTP VRFY/RCPT TO verification
|
||||
- [x] `emailcheck` -- SMTP VRFY/RCPT TO verification
|
||||
- [ ] `canary` -- canary token generator/tracker
|
||||
- [ ] `virustotal` -- hash/URL/IP/domain lookup (free API)
|
||||
- [ ] `abuseipdb` -- IP abuse confidence scoring (free tier)
|
||||
- [ ] `jwt` -- decode tokens, show claims/expiry, flag weaknesses
|
||||
- [ ] `mac` -- OUI vendor lookup (local IEEE database)
|
||||
- [ ] `pastemoni` -- monitor paste sites for keywords
|
||||
- [x] `virustotal` -- hash/URL/IP/domain lookup (free API)
|
||||
- [x] `abuseipdb` -- IP abuse confidence scoring (free tier)
|
||||
- [x] `jwt` -- decode tokens, show claims/expiry, flag weaknesses
|
||||
- [x] `mac` -- OUI vendor lookup (local IEEE database)
|
||||
- [x] `pastemoni` -- monitor paste sites for keywords
|
||||
- [x] `internetdb` -- Shodan InternetDB host recon (free, no API key)
|
||||
|
||||
## Plugins -- Utility
|
||||
|
||||
|
||||
@@ -237,9 +237,10 @@ Categories: sqli, xss, ssti, lfi, cmdi, xxe
|
||||
!blacklist 1.2.3.4 # DNSBL reputation check
|
||||
```
|
||||
|
||||
## Intelligence (local databases)
|
||||
## Intelligence (local databases + APIs)
|
||||
|
||||
```
|
||||
!internetdb 8.8.8.8 # Shodan InternetDB: ports, CVEs, CPEs, tags
|
||||
!geoip 8.8.8.8 # GeoIP: city, country, coords, tz
|
||||
!asn 8.8.8.8 # ASN: number + organization
|
||||
!tor 1.2.3.4 # Check Tor exit node
|
||||
|
||||
@@ -133,6 +133,7 @@ format = "text" # Log format: "text" (default) or "json"
|
||||
| `!abuse <ip> report <cats> <comment>` | Report IP to AbuseIPDB (admin) |
|
||||
| `!vt <hash\|ip\|domain\|url>` | VirusTotal lookup |
|
||||
| `!emailcheck <email> [email2 ...]` | SMTP email verification (admin) |
|
||||
| `!internetdb <ip>` | Shodan InternetDB host recon (ports, CVEs, CPEs) |
|
||||
| `!shorten <url>` | Shorten a URL via FlaskPaste |
|
||||
| `!pastemoni <add\|del\|list\|check>` | Paste site keyword monitoring |
|
||||
|
||||
@@ -935,6 +936,29 @@ Polling and announcements:
|
||||
- `list` shows keyword and per-backend error counts
|
||||
- `check` forces an immediate poll across all backends
|
||||
|
||||
### `!internetdb` -- Shodan InternetDB
|
||||
|
||||
Look up host information from Shodan's free InternetDB API. Returns open ports,
|
||||
reverse hostnames, CPE software fingerprints, tags, and known CVEs. No API key
|
||||
required.
|
||||
|
||||
```
|
||||
!internetdb 8.8.8.8
|
||||
```
|
||||
|
||||
Output format:
|
||||
|
||||
```
|
||||
8.8.8.8 -- dns.google | Ports: 53, 443 | CPEs: cpe:/a:isc:bind | Tags: cloud
|
||||
```
|
||||
|
||||
- Single IP per query (IPv4 or IPv6)
|
||||
- Private/loopback addresses are rejected
|
||||
- Hostnames truncated to first 5; CVEs truncated to first 10 (with `+N more`)
|
||||
- CPEs truncated to first 8
|
||||
- All requests routed through SOCKS5 proxy
|
||||
- Returns "no data available" for IPs not in the InternetDB index
|
||||
|
||||
### FlaskPaste Configuration
|
||||
|
||||
```toml
|
||||
|
||||
105
plugins/internetdb.py
Normal file
105
plugins/internetdb.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Plugin: Shodan InternetDB -- free host reconnaissance (no API key)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
|
||||
from derp.http import urlopen as _urlopen
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_API_URL = "https://internetdb.shodan.io"
|
||||
_TIMEOUT = 15
|
||||
|
||||
|
||||
def _fetch(addr: str) -> dict | None:
|
||||
"""Fetch InternetDB data for an IP address.
|
||||
|
||||
Returns parsed JSON dict, or None on 404 (no data).
|
||||
Raises on network/server errors.
|
||||
"""
|
||||
import urllib.error
|
||||
|
||||
try:
|
||||
resp = _urlopen(f"{_API_URL}/{addr}", timeout=_TIMEOUT)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _format_result(addr: str, data: dict) -> str:
|
||||
"""Format InternetDB response into a compact IRC message."""
|
||||
lines = []
|
||||
|
||||
hostnames = data.get("hostnames", [])
|
||||
if hostnames:
|
||||
lines.append(f"{addr} -- {', '.join(hostnames[:5])}")
|
||||
else:
|
||||
lines.append(addr)
|
||||
|
||||
ports = data.get("ports", [])
|
||||
if ports:
|
||||
lines.append(f"Ports: {', '.join(str(p) for p in sorted(ports))}")
|
||||
|
||||
cpes = data.get("cpes", [])
|
||||
if cpes:
|
||||
lines.append(f"CPEs: {', '.join(cpes[:8])}")
|
||||
|
||||
tags = data.get("tags", [])
|
||||
if tags:
|
||||
lines.append(f"Tags: {', '.join(tags)}")
|
||||
|
||||
vulns = data.get("vulns", [])
|
||||
if vulns:
|
||||
shown = vulns[:10]
|
||||
suffix = f" (+{len(vulns) - 10} more)" if len(vulns) > 10 else ""
|
||||
lines.append(f"CVEs: {', '.join(shown)}{suffix}")
|
||||
|
||||
return " | ".join(lines)
|
||||
|
||||
|
||||
@command("internetdb", help="Shodan InternetDB: !internetdb <ip>")
|
||||
async def cmd_internetdb(bot, message):
|
||||
"""Look up host information from Shodan InternetDB.
|
||||
|
||||
Returns open ports, hostnames, CPEs, tags, and known CVEs.
|
||||
Free API, no key required.
|
||||
|
||||
Usage:
|
||||
!internetdb 8.8.8.8
|
||||
"""
|
||||
parts = message.text.split(None, 2)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !internetdb <ip>")
|
||||
return
|
||||
|
||||
addr = parts[1].strip()
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError:
|
||||
await bot.reply(message, f"Invalid IP address: {addr}")
|
||||
return
|
||||
|
||||
if ip.is_private or ip.is_loopback:
|
||||
await bot.reply(message, f"{addr}: private/loopback address")
|
||||
return
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
data = await loop.run_in_executor(None, _fetch, str(ip))
|
||||
except Exception as exc:
|
||||
log.error("internetdb: lookup failed for %s: %s", addr, exc)
|
||||
await bot.reply(message, f"{addr}: lookup failed ({exc})")
|
||||
return
|
||||
|
||||
if data is None:
|
||||
await bot.reply(message, f"{addr}: no data available")
|
||||
return
|
||||
|
||||
await bot.reply(message, _format_result(str(ip), data))
|
||||
281
tests/test_internetdb.py
Normal file
281
tests/test_internetdb.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Tests for the InternetDB plugin (Shodan free host recon)."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.internetdb",
|
||||
Path(__file__).resolve().parent.parent / "plugins" / "internetdb.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.internetdb import ( # noqa: E402
|
||||
_fetch,
|
||||
_format_result,
|
||||
cmd_internetdb,
|
||||
)
|
||||
|
||||
# -- Sample API responses ----------------------------------------------------
|
||||
|
||||
SAMPLE_FULL = {
|
||||
"cpes": ["cpe:/a:apache:http_server:2.4.41", "cpe:/a:openssl:openssl:1.1.1"],
|
||||
"hostnames": ["dns.google"],
|
||||
"ip": "8.8.8.8",
|
||||
"ports": [443, 53],
|
||||
"tags": ["cloud"],
|
||||
"vulns": ["CVE-2021-23017", "CVE-2021-3449"],
|
||||
}
|
||||
|
||||
SAMPLE_MINIMAL = {
|
||||
"cpes": [],
|
||||
"hostnames": [],
|
||||
"ip": "203.0.113.1",
|
||||
"ports": [80],
|
||||
"tags": [],
|
||||
"vulns": [],
|
||||
}
|
||||
|
||||
SAMPLE_EMPTY = {
|
||||
"cpes": [],
|
||||
"hostnames": [],
|
||||
"ip": "198.51.100.1",
|
||||
"ports": [],
|
||||
"tags": [],
|
||||
"vulns": [],
|
||||
}
|
||||
|
||||
SAMPLE_MANY_VULNS = {
|
||||
"cpes": [],
|
||||
"hostnames": ["vuln.example.com"],
|
||||
"ip": "192.0.2.1",
|
||||
"ports": [22, 80, 443],
|
||||
"tags": ["self-signed", "eol-os"],
|
||||
"vulns": [f"CVE-2021-{i}" for i in range(15)],
|
||||
}
|
||||
|
||||
SAMPLE_MANY_HOSTNAMES = {
|
||||
"cpes": [],
|
||||
"hostnames": [f"host{i}.example.com" for i in range(8)],
|
||||
"ip": "192.0.2.2",
|
||||
"ports": [80],
|
||||
"tags": [],
|
||||
"vulns": [],
|
||||
}
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self):
|
||||
self.replied: list[str] = []
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
|
||||
def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message:
|
||||
return Message(
|
||||
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||
command="PRIVMSG", params=[target, text], tags={},
|
||||
)
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""Minimal file-like response for urlopen mocking."""
|
||||
|
||||
def __init__(self, data: bytes, status: int = 200):
|
||||
self._data = data
|
||||
self.status = status
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestFetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFetch:
|
||||
def test_success(self):
|
||||
body = json.dumps(SAMPLE_FULL).encode()
|
||||
with patch.object(_mod, "_urlopen", return_value=_FakeResp(body)):
|
||||
result = _fetch("8.8.8.8")
|
||||
assert result == SAMPLE_FULL
|
||||
|
||||
def test_not_found(self):
|
||||
exc = urllib.error.HTTPError(
|
||||
"https://internetdb.shodan.io/192.0.2.1", 404, "Not Found", {}, None,
|
||||
)
|
||||
with patch.object(_mod, "_urlopen", side_effect=exc):
|
||||
result = _fetch("192.0.2.1")
|
||||
assert result is None
|
||||
|
||||
def test_server_error_raises(self):
|
||||
exc = urllib.error.HTTPError(
|
||||
"https://internetdb.shodan.io/192.0.2.1", 500, "Server Error", {}, None,
|
||||
)
|
||||
with patch.object(_mod, "_urlopen", side_effect=exc):
|
||||
try:
|
||||
_fetch("192.0.2.1")
|
||||
assert False, "Expected HTTPError"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 500
|
||||
|
||||
def test_builds_correct_url(self):
|
||||
calls = []
|
||||
|
||||
def _mock_urlopen(url, **kw):
|
||||
calls.append(url)
|
||||
return _FakeResp(json.dumps(SAMPLE_MINIMAL).encode())
|
||||
|
||||
with patch.object(_mod, "_urlopen", side_effect=_mock_urlopen):
|
||||
_fetch("203.0.113.1")
|
||||
assert calls[0] == "https://internetdb.shodan.io/203.0.113.1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestFormatResult
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatResult:
|
||||
def test_full_response(self):
|
||||
result = _format_result("8.8.8.8", SAMPLE_FULL)
|
||||
assert "8.8.8.8 -- dns.google" in result
|
||||
assert "Ports: 53, 443" in result
|
||||
assert "cpe:/a:apache:http_server:2.4.41" in result
|
||||
assert "Tags: cloud" in result
|
||||
assert "CVE-2021-23017" in result
|
||||
|
||||
def test_minimal_response(self):
|
||||
result = _format_result("203.0.113.1", SAMPLE_MINIMAL)
|
||||
assert "203.0.113.1" in result
|
||||
assert "Ports: 80" in result
|
||||
# No hostnames, CPEs, tags, or vulns sections
|
||||
assert "--" not in result
|
||||
assert "CPEs:" not in result
|
||||
assert "Tags:" not in result
|
||||
assert "CVEs:" not in result
|
||||
|
||||
def test_empty_response(self):
|
||||
result = _format_result("198.51.100.1", SAMPLE_EMPTY)
|
||||
assert result == "198.51.100.1"
|
||||
|
||||
def test_many_vulns_truncated(self):
|
||||
result = _format_result("192.0.2.1", SAMPLE_MANY_VULNS)
|
||||
assert "+5 more" in result
|
||||
# First 10 shown
|
||||
assert "CVE-2021-0" in result
|
||||
assert "CVE-2021-9" in result
|
||||
|
||||
def test_many_hostnames_truncated(self):
|
||||
result = _format_result("192.0.2.2", SAMPLE_MANY_HOSTNAMES)
|
||||
# Only first 5 hostnames shown
|
||||
assert "host0.example.com" in result
|
||||
assert "host4.example.com" in result
|
||||
assert "host5.example.com" not in result
|
||||
|
||||
def test_ports_sorted(self):
|
||||
data = {**SAMPLE_FULL, "ports": [8080, 22, 443, 80]}
|
||||
result = _format_result("8.8.8.8", data)
|
||||
assert "Ports: 22, 80, 443, 8080" in result
|
||||
|
||||
def test_many_cpes_truncated(self):
|
||||
data = {**SAMPLE_EMPTY, "cpes": [f"cpe:/a:vendor{i}:prod" for i in range(12)]}
|
||||
result = _format_result("198.51.100.1", data)
|
||||
assert "cpe:/a:vendor0:prod" in result
|
||||
assert "cpe:/a:vendor7:prod" in result
|
||||
assert "cpe:/a:vendor8:prod" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommand:
|
||||
def _run(self, bot, msg, data=None, side_effect=None):
|
||||
"""Run command with mocked _fetch."""
|
||||
if side_effect is not None:
|
||||
with patch.object(_mod, "_fetch", side_effect=side_effect):
|
||||
asyncio.run(cmd_internetdb(bot, msg))
|
||||
else:
|
||||
with patch.object(_mod, "_fetch", return_value=data):
|
||||
asyncio.run(cmd_internetdb(bot, msg))
|
||||
|
||||
def test_valid_ip(self):
|
||||
bot = _FakeBot()
|
||||
self._run(bot, _msg("!internetdb 8.8.8.8"), data=SAMPLE_FULL)
|
||||
assert "dns.google" in bot.replied[0]
|
||||
assert "Ports:" in bot.replied[0]
|
||||
|
||||
def test_no_data(self):
|
||||
bot = _FakeBot()
|
||||
self._run(bot, _msg("!internetdb 4.4.4.4"), data=None)
|
||||
assert "no data available" in bot.replied[0]
|
||||
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_internetdb(bot, _msg("!internetdb")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_invalid_ip(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_internetdb(bot, _msg("!internetdb notanip")))
|
||||
assert "Invalid IP" in bot.replied[0]
|
||||
|
||||
def test_private_ip(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_internetdb(bot, _msg("!internetdb 192.168.1.1")))
|
||||
assert "private/loopback" in bot.replied[0]
|
||||
|
||||
def test_loopback(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_internetdb(bot, _msg("!internetdb 127.0.0.1")))
|
||||
assert "private/loopback" in bot.replied[0]
|
||||
|
||||
def test_ipv6(self):
|
||||
bot = _FakeBot()
|
||||
self._run(bot, _msg("!internetdb 2606:4700::1"), data=SAMPLE_MINIMAL)
|
||||
assert "Ports:" in bot.replied[0]
|
||||
|
||||
def test_network_error(self):
|
||||
bot = _FakeBot()
|
||||
self._run(
|
||||
bot, _msg("!internetdb 8.8.8.8"),
|
||||
side_effect=ConnectionError("timeout"),
|
||||
)
|
||||
assert "lookup failed" in bot.replied[0]
|
||||
|
||||
def test_normalized_ip(self):
|
||||
"""ipaddress normalization (e.g. ::ffff:8.8.8.8 -> mapped)."""
|
||||
calls = []
|
||||
|
||||
def _mock_fetch(addr):
|
||||
calls.append(addr)
|
||||
return SAMPLE_MINIMAL
|
||||
|
||||
bot = _FakeBot()
|
||||
with patch.object(_mod, "_fetch", side_effect=_mock_fetch):
|
||||
asyncio.run(cmd_internetdb(bot, _msg("!internetdb 8.008.8.8")))
|
||||
# Leading zeros rejected by Python's strict parser -> "Invalid IP"
|
||||
assert "Invalid IP" in bot.replied[0]
|
||||
|
||||
def test_empty_result(self):
|
||||
bot = _FakeBot()
|
||||
self._run(bot, _msg("!internetdb 198.51.100.1"), data=SAMPLE_EMPTY)
|
||||
assert "198.51.100.1" in bot.replied[0]
|
||||
Reference in New Issue
Block a user