Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ Every 4 hours, it delivers a **detailed report** complete with a 24-hour trend g
Every 30 minutes (`SLEEP_TIME` in `main.py`, default 1800 seconds):

1. **Speed Test:** Measures download/upload speeds, ping latency, ISP, and test server details using `speedtest-cli` (see [the note on measurement mode](#a-note-on-measurement-mode)).
2. **LAN Scan:** Scans the local subnet using `nmap` ARP scan to count active connected devices.
3. **Local Storage:** Saves metrics & device tallies directly to a local `metrics.sql` SQLite database.
2. **LAN Scan:** Scans the local subnet using `nmap` ARP scan to identify active devices, including MAC address, vendor, and hostname where resolvable (see [Device Watch](#device-watch)).
3. **Local Storage:** Saves metrics & device details directly to a local `metrics.sql` SQLite database.
4. **Status Alert:** Sends a concise status update to your chosen notifier (*"all good"* or *"line is dying"*).
5. **24h AI Report:** Every 8th cycle (every 4h), generates a **24-hour trend graph** via `matplotlib` alongside a sarcastic LLM analysis of network load and speed fluctuations.
5. **24h AI Report:** Every 8th cycle (every 4h), generates a **24-hour trend graph** via `matplotlib` alongside a sarcastic LLM analysis of network load, speed fluctuations, and any notable new devices on the network.

---

Expand Down Expand Up @@ -194,6 +194,14 @@ Since netmon exists to track *trends*, consistency matters more than peak number

---

## Device Watch

Every device scan records each device's MAC address, vendor (resolved from `nmap`'s built-in OUI database), and hostname where available. `nmap` can only resolve a MAC for hosts on the same local subnet it can ARP directly — off-subnet or otherwise hidden devices are still counted, just not identified.

Using MAC address history, the detailed AI report includes a **Device Watch** section that flags any device whose MAC hasn't been seen on the network in the last 14 days, alongside a vendor-count breakdown of everything currently online. A device with no resolvable MAC is never flagged as new, since there's no reliable identity to compare against.

---

## Example Output

### Hourly Short Status Update
Expand Down Expand Up @@ -238,6 +246,9 @@ Devices Online: 9
<b>24-Hour Dynamics Analysis</b>
Over the last 24 hours, the download speed averaged <code>140 Mbps</code>, but we saw a massive drop to <code>20 Mbps</code> at 8:00 PM right as device count jumped from <code>4</code> to <code>11 devices</code>. Clearly, someone's hogging the bandwidth or the ISP's mice were busy chewing on the fiber line again. Latency remained stable except for a brief spike during peak hours.

<b>Device Watch</b>
One new gadget joined the party today: a device with no vendor or hostname info at all — worth a second glance. Everything else is the same suspects as always.

<b>Data Transfer (Latest Test)</b>
<pre>
Downloaded: 160.0 MB
Expand All @@ -248,6 +259,9 @@ Uploaded: 70.0 MB
Expect periodic speed drops whenever local freeloaders stream 4K movies or the ISP potato infrastructure struggles.
```

> [!NOTE]
> The AI is only ever asked for three short text fields (the dynamics analysis, the Device Watch line, and the conclusion) — the surrounding HTML structure above is assembled deterministically in code, not generated by the model. This keeps report formatting consistent regardless of which LLM is behind `AI_BASE_URL`, including smaller local models that would otherwise struggle to reproduce a long literal template reliably.

---

## Project Structure
Expand Down
207 changes: 139 additions & 68 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import signal
import sys
import json
import logging
import graphs
import config as cfg
Expand All @@ -13,83 +14,77 @@
from notifier import ChatAction, Notifier

REPORT_SYSTEM_PROMPT = """
You are a sarcastic, cynical network analyst bot. Your job is to output a short network speed test and 24-hour trend report in Telegram HTML format.
You will receive a list of speed tests from the last 24 hours in chronological order (the last line is the latest test).
You are a sarcastic, cynical network analyst bot.

You must write the report in ENGLISH.
You must follow the EXACT structure below. Do not deviate from this layout, header naming, or formatting.
You will receive: (1) a chronological list of network speed test results from the last 24 hours, (2) a device vendor breakdown for the network (vendor name + how many devices of that vendor are currently online), and (3) a list of any devices that are NEW this cycle (their MAC address has not been seen on this network in the last 14 days), each with whatever vendor/hostname/IP is available.

EXPECTED STRUCTURE:
<b>Network Speed Test Report (24h Analysis)</b>
Respond with ONLY a single raw JSON object -- no ```json code fences, no preamble, no explanation before or after it. Just the JSON object, starting with { and ending with }. It must have exactly these three string keys:

Client: <b>[Client ISP]</b>
Server: <b>[Server Name]</b>
{
"dynamics_analysis": "2-3 short sentences analyzing speed/ping trends and drops over the given period. ONLY claim a link between device count and speed/latency swings if the numbers actually move together in the same window (e.g. speed visibly drops as device count rises). If device count swings while speed/ping stay flat, say plainly that device count does NOT explain it, and point at the ISP/line instead -- never invent a correlation the numbers don't support. If ping reads exactly 0.00 ms while download speed is very low, that means the real ping was too high to register and got floored to zero -- call it a red flag, not a strength, never a good sign. Do NOT blame server changes for fluctuations -- assume the server choice is optimal. Wrap key numbers in <code>...</code> tags, e.g. <code>148.31 Mbps</code>.",
"device_watch": "1-2 short sentences. If the NEW devices list you were given is empty, say so plainly (e.g. 'No new devices -- same suspects as always.'). Otherwise mention only devices from that NEW list, identified by vendor/hostname if given, otherwise by IP. Call out anything more suspicious than the rest -- e.g. a NEW device with no vendor or hostname info at all is more worth a second glance than a NEW device from a recognizable vendor. Never invent a device, vendor, hostname, or IP not given to you, and do not restate the full vendor breakdown here -- that's background context only, not something to list out.",
"conclusion": "Exactly 1 short, witty, sarcastic sentence summarizing the network's overall quality/reliability over the period."
}

<b>Latest Test Metrics</b>
<pre>
Download: [Download Speed] Mbps
Upload: [Upload Speed] Mbps
Ping: [Ping Latency] ms
Devices Online: [Device Count]
</pre>
TONE (this matters more than anything else): sarcastic, informal, and funny throughout. Blame heavy users/leeches on the network or the ISP for problems -- e.g. "a bunch of idiots clogging the bandwidth", "the ISP dropping the ball", "mice chewing the optic fiber cables", "yet another gadget joining the freeloader party" -- but only when the data actually supports that story. A flat, neutral, corporate-analyst tone is a FAILED response even if the JSON is technically valid -- the personality is not optional decoration, it is the entire point of this bot. If in doubt, lean funnier and more informal, not safer and more clinical.

<b>24-Hour Dynamics Analysis</b>
[Analyze the dynamics, drops, and load of the network over the last 24 hours. Note any major drops in download/upload speeds or ping spikes.
Also look at how the device count changed over the same period. ONLY claim a link between device count and speed/latency swings if the numbers actually move together (e.g. speed visibly drops in the same window device count rises). If device count swings around while speed/ping stay flat, say plainly that device count does NOT explain it this period, and point at the ISP/line instead. Never invent a correlation that isn't supported by the numbers.
If ping reads exactly 0.00 ms while download speed is very low (a few Mbps or less), do NOT describe that as a good/perfect ping. That reading means the real ping was too high to register and got floored to zero — call it a red flag, not a strength.
Use a sarcastic, informal tone when describing speed drops, latency spikes, or a sudden herd of new devices, blaming heavy users/leeches on the network or the ISP (e.g. "a bunch of idiots clogging the bandwidth", "ISP dropping the ball", "mice chewing the optic fiber cables", or "yet another gadget joining the freeloader party") — but only when the data actually supports that story.
CRITICAL: Do NOT blame server changes for fluctuations. Assume the server choice is optimal and fluctuations reflect real network load, device count, or ISP issues.
Wrap key numbers in <code> tags, e.g., <code>148.31 Mbps</code>, <code>15.18 ms</code>, or <code>7 devices</code>.]
LENGTH LIMITS (hard requirements): "dynamics_analysis" under 500 characters, "device_watch" under 250 characters, "conclusion" under 150 characters.

<b>Data Transfer (Latest Test)</b>
<pre>
Downloaded: [Downloaded MB] MB
Uploaded: [Uploaded MB] MB
</pre>

<b>Conclusion</b>
[A sarcastic, witty 1 short sentence summary of the network's overall quality and reliability over the past day.]


TEMPLATE EXAMPLE OF THE OUTPUT:
<b>Network Speed Test Report (24h Analysis)</b>
Output ONLY the JSON object and nothing else -- no markdown formatting, no headers, no bullet points, no explanatory text, no restating of the raw data you were given.
"""

Client: <b>nameserver</b>
Server: <b>New York</b>
# The AI is only ever asked to produce the three free-text fields above --
# never the surrounding HTML structure. This is deliberate: several
# capable local models (tested: llama3.1:8b, qwen2.5:7b-instruct) reliably
# abandon a long literal HTML template under a rich, multi-constraint
# prompt and fall back to a generic "helpful assistant summarizing data"
# response instead, even with a large context window. Handling the
# skeleton in code guarantees correct, consistent formatting regardless of
# which model is behind AI_BASE_URL, and only requires the model to
# reliably produce three short strings in a JSON object -- a much easier
# and more commonly well-supported task for small/local instruct models
# than exact literal markup reproduction.
REPORT_TEMPLATE_SHELL = """<b>Network Speed Test Report (24h Analysis)</b>

Client: <b>{client}</b>
Server: <b>{server}</b>

<b>Latest Test Metrics</b>
<pre>
Download: 140.3 Mbps
Upload: 62.8 Mbps
Ping: 15.2 ms
Devices Online: 7
Download: {download:.1f} Mbps
Upload: {upload:.1f} Mbps
Ping: {ping:.1f} ms
Devices Online: {device_count}
</pre>

<b>24-Hour Dynamics Analysis</b>
Over the last 24 hours, the download speed averaged <code>140 Mbps</code>, but we saw a massive drop to <code>20 Mbps</code> at 8:00 PM right as device count jumped from <code>4</code> to <code>11 devices</code>. Clearly, a bunch of idiots decided to stream 4K movies all at once, or the ISP's mice were busy chewing on the fiber line again. Latency remained stable except for a brief spike to <code>95 ms</code> during the speed dip.
{dynamics_analysis}

<b>Device Watch</b>
{device_watch}

<b>Data Transfer (Latest Test)</b>
<pre>
Downloaded: 160.0 MB
Uploaded: 70.0 MB
Downloaded: {download_mb:.1f} MB
Uploaded: {upload_mb:.1f} MB
</pre>

<b>Conclusion</b>
Expect periodic speed deaths whenever the local leechers wake up or the ISP fails to maintain their potato infrastructure.


CRITICAL RULES:
1. Do NOT use <br> or <br/> tags. For line breaks, use normal newlines.
2. The entire report must be in English.
3. Keep the "24-Hour Dynamics Analysis" to exactly 2-3 short sentences.
4. Do NOT write any description text below the "Data Transfer (Latest Test)" pre-block.
5. Keep the "Conclusion" to exactly 1 short sentence.
6. Highlight all numeric metric values in the text using <code>[Value]</code>.
7. Do NOT output any markdown blocks like ```html. Output raw HTML tags directly.
8. Make sure all HTML tags are closed correctly.
9. Be sarcastic, informal, and funny when describing performance dips or network load.
10. The entire output MUST be under 800 characters to ensure it easily fits within Telegram limits.
"""
{conclusion}"""

# Defensive per-field caps applied in code regardless of what the prompt
# asked for -- a safety net, not the primary mechanism, since the prompt's
# own instructed limits should normally keep fields well under these.
_DYNAMICS_ANALYSIS_MAX_CHARS = 600
_DEVICE_WATCH_MAX_CHARS = 320
_CONCLUSION_MAX_CHARS = 200


def _clip(text: str, max_chars: int) -> str:
text = (text or "").strip()
if len(text) <= max_chars:
return text
return text[: max_chars - 1].rstrip() + "…"

REPORT_USER_TEMPLATE = """
Network speed test results:
Expand All @@ -105,6 +100,10 @@
- Devices online: {device_count}
"""

VENDOR_COUNT_TEMPLATE = "- {vendor}: {count} device(s)"
NEW_DEVICE_ENTRY_TEMPLATE = "- {ip}{vendor_part}{hostname_part}"


MINI_REPORT_TEMPLATE = """<b>Network Status Update</b>
Here is the latest snapshot of your internet speed:

Expand Down Expand Up @@ -191,20 +190,92 @@ def main():
device_count=device_count
) + "\n"

device_details = database.get_latest_devices_with_novelty()
if device_details:
vendor_counts: dict[str, int] = {}
for d in device_details:
if d["vendor"]:
key = d["vendor"]
elif d["mac"]:
key = "Unknown vendor"
else:
key = "Unidentified (off-subnet, no MAC resolved)"
vendor_counts[key] = vendor_counts.get(key, 0) + 1

vendor_lines = [
VENDOR_COUNT_TEMPLATE.format(vendor=vendor, count=count)
for vendor, count in sorted(vendor_counts.items(), key=lambda kv: -kv[1])
]
user_message += "\nDevice vendor breakdown (currently online):\n" + "\n".join(vendor_lines) + "\n"

new_devices = [d for d in device_details if d["is_new"]]
if new_devices:
new_lines = []
for d in new_devices:
vendor_part = f" | Vendor: {d['vendor']}" if d["vendor"] else " | Vendor: unknown"
hostname_part = f" | Hostname: {d['hostname']}" if d["hostname"] else ""
new_lines.append(NEW_DEVICE_ENTRY_TEMPLATE.format(
ip=d["ip"],
vendor_part=vendor_part,
hostname_part=hostname_part,
))
user_message += "\nNEW devices this cycle:\n" + "\n".join(new_lines) + "\n"
else:
user_message += "\nNEW devices this cycle: none.\n"
else:
user_message += "\nDevice vendor breakdown (currently online): none detected this cycle.\nNEW devices this cycle: none.\n"

t.send_chat_action(ChatAction.TYPING)
raw_response = None
try:
report = netmon_ai.send_message(user_message, REPORT_SYSTEM_PROMPT)
report = report.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
raw_response = netmon_ai.send_message(user_message, REPORT_SYSTEM_PROMPT)

# Defensive cleanup: some models wrap JSON in ```json
# fences despite being told not to -- strip those if present.
cleaned = raw_response.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()

parsed = json.loads(cleaned)

dynamics_analysis = _clip(str(parsed.get("dynamics_analysis", "")), _DYNAMICS_ANALYSIS_MAX_CHARS)
device_watch = _clip(str(parsed.get("device_watch", "")), _DEVICE_WATCH_MAX_CHARS)
conclusion = _clip(str(parsed.get("conclusion", "")), _CONCLUSION_MAX_CHARS)

if not dynamics_analysis or not device_watch or not conclusion:
raise ValueError(f"AI response missing one or more required fields: {parsed!r}")

except Exception as e:
# AI backend down/unreachable/misconfigured: don't lose the
# whole report, just send the graph with a plain notice
# instead of a sarcastic AI-written one.
log.error(f"AI report generation failed, sending graph without commentary: {e}")
report = (
"<b>Network Speed Test Report (24h Analysis)</b>\n\n"
"<i>AI commentary unavailable this cycle — the AI backend "
"could not be reached. Raw graph data is attached below.</i>"
# Covers both AI backend failures (unreachable/misconfigured)
# and the model returning malformed/incomplete JSON -- either
# way, don't lose the whole report, just fall back to plain
# non-AI text for the three commentary fields. The skeleton
# itself is unaffected either way since it's built in code.
log.error(
f"AI report generation failed or returned invalid data: {e}\n"
f"Raw AI response was: {raw_response!r}",
exc_info=True,
)
dynamics_analysis = "AI commentary unavailable this cycle — the AI backend could not be reached or returned an unexpected response."
device_watch = "AI commentary unavailable this cycle."
conclusion = "Raw graph data is attached below."

report = REPORT_TEMPLATE_SHELL.format(
client=metric.client,
server=metric.server,
download=metric.download / 10**6,
upload=metric.upload / 10**6,
ping=metric.ping,
device_count=len(all_devices),
dynamics_analysis=dynamics_analysis,
device_watch=device_watch,
download_mb=metric.bytes_received / 10**6,
upload_mb=metric.bytes_sent / 10**6,
conclusion=conclusion,
)

t.send_chat_action(ChatAction.UPLOAD_PHOTO)
graph = graphs.NetmonGraph(metrics, device_counts)
Expand Down
Loading