16d94894b7
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.
280 lines
9.0 KiB
Go
280 lines
9.0 KiB
Go
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
|
||
}
|