Files
Maven/cmd/mavend/netscan.go
T
claude 16d94894b7 mavend: say the query answers from the file (V-503)
The three daemon tests that pinned a wording ask the entry instead. The eval
scores every query variant on the persona checks, minus hisgender: it reads her
own feminine verb next to "у тебя" as addressing him as a woman.
2026-08-04 01:32:15 +04:00

280 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/netscan"
"github.com/kami/maven/internal/phraser"
)
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
// longer than this has already failed as a turn, so the scan returns whatever
// it found rather than keeping him waiting.
//
// It has to be consistent with the shipped defaults or every scan is truncated:
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
// second is a little over ten seconds plus the tail dials. 30s leaves room for
// that without pretending a slower rate would fit.
const scanBudget = 30 * time.Second
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
// to be two full sweeps of the LAN, up to a thousand connections each. The
// network does not change on the scale of a follow-up question, and the cheapest
// packet is the one not sent.
const scanCacheTTL = 2 * time.Minute
// scanReadOut — how many hosts go into the written record's first lines before
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
const scanReadOut = 20
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
// never puts a discovery packet on the network.
//
// Unlike the house, a scan is a READ, so it is a query source rather than an
// act: there is no allowlist row and no confirm turn, because nothing changes.
// What makes that safe is that the range is not an argument — see
// internal/netscan's package comment.
type netWiring struct {
scanner *netscan.Scanner
subnets []string
// api — where the address list is WRITTEN. The spoken answer is a count
// and a shape, so the detail has to land somewhere readable; a note under
// source "scan:lan" puts it on /history and, through the intake decorator,
// on /events. It is also the only record that Maven put packets on the LAN
// at all. nil ⇒ nothing is written, which is what the tests use.
api ipc.CoreAPI
now func() time.Time
mu sync.Mutex
cached netscan.Result
cachedAt time.Time
}
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
nc, ok := cfg.NetScanner()
if !ok {
return nil
}
if err := netscan.Validate(nc); err != nil {
// config.validate already ran this, so reaching here is a programming
// error rather than a config one. Not fatal: the scanner off is a
// working Maven.
log.Printf("netscan: not wired: %v", err)
return nil
}
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
}
// scan runs a scan, or reuses one younger than scanCacheTTL.
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := w.now()
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
return w.cached, nil
}
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
defer cancel()
res, err := w.scanner.Scan(scanCtx)
if err != nil {
return res, err
}
w.cached, w.cachedAt = res, now
// Written on a fresh scan only: the record is a trace of packets going out,
// so a cached answer must not forge a second one.
w.writeScanRecord(ctx, res)
return res, nil
}
// scanSummary answers "какие устройства в сети?" in one spoken line.
//
// It does NOT read addresses out. This is the query path, so the reply goes to
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
// a digit stream nobody can follow through a speaker. She says how many and
// what shape they are; the addresses go into a note (see writeScanRecord).
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
if w == nil {
return "", false
}
res, err := w.scan(ctx)
if err != nil {
log.Printf("netscan: scan: %v", err)
return phraser.Q(phraser.QueryFailNetscan, nil), true
}
// A truncated run is not a statement about the LAN. Saying "нашла 6
// устройств" after stopping two thirds of the way through the range is a
// false claim, and the addresses at the end are the ones that go missing.
tail := ""
if res.Truncated {
tail = ", но успела посмотреть не всю сеть"
}
if len(res.Hosts) == 0 {
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
}
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape
}
out += tail
if w.api != nil {
out += ". список записала"
}
return out + ".", true
}
// scanShape describes the hosts by what they answer on, which is the part of
// the answer that carries meaning out loud: "два с вебом" says more about the
// flat than four octets do.
func scanShape(hosts []netscan.Host) string {
var web, ssh, quiet int
for _, h := range hosts {
hasWeb, hasSSH := false, false
for _, p := range h.Ports {
switch p {
case 80, 443, 8080:
hasWeb = true
case 22:
hasSSH = true
}
}
if hasWeb {
web++
}
if hasSSH {
ssh++
}
// No open port at all: seen only through the ARP cache.
if len(h.Ports) == 0 {
quiet++
}
}
var parts []string
if web > 0 {
parts = append(parts, fmt.Sprintf("%d с вебом", web))
}
if ssh > 0 {
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
}
if quiet > 0 {
parts = append(parts, fmt.Sprintf("%d молча", quiet))
}
if len(parts) == 0 {
return ""
}
return "из них " + strings.Join(parts, ", ")
}
// writeScanRecord stores the address list as a note. This is both where the
// detail becomes readable and the only trace that a scan happened at all: a
// scan is a read, but "when did she last put packets on the LAN" deserves an
// answer.
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil {
return
}
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
shown := res.Hosts
if len(shown) > scanReadOut {
shown = shown[:scanReadOut]
}
for _, h := range shown {
s := h.Addr
if len(h.Ports) > 0 {
ps := make([]string, 0, len(h.Ports))
for _, p := range h.Ports {
ps = append(ps, fmt.Sprintf("%d", p))
}
s += " (" + strings.Join(ps, ", ") + ")"
}
if h.MAC != "" {
s += " " + h.MAC
}
lines = append(lines, s)
}
if len(res.Hosts) > len(shown) {
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
}
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
log.Printf("netscan: write scan note: %v", err)
}
}
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
// 5+ устройств, and the teens are all the last form.
func hostWord(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "устройств"
}
switch n % 10 {
case 1:
return "устройство"
case 2, 3, 4:
return "устройства"
default:
return "устройств"
}
}
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
// bounds exist to prevent.
func isNetworkQuery(u string) bool {
s := strings.ToLower(strings.TrimSpace(u))
if s == "" {
return false
}
// Whole tokens for the network nouns: the bare substring "сети" is inside
// "посетил", so "сколько машин я посетил?" used to read as a request to
// scan the LAN. The prefix forms below are stems that have no such
// collisions.
network := false
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
if homeWord(s, w) {
network = true
break
}
}
if !network {
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
if strings.Contains(s, w) {
network = true
break
}
}
}
if !network {
return false
}
// An explicit ask to scan, or a phrase that can only be about the LAN.
// "кто в сети" carries no device noun but means nothing else.
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
if strings.Contains(s, w) {
return true
}
}
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
if !ask {
return false
}
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
if strings.Contains(s, w) {
return true
}
}
return false
}