package main import ( "context" "fmt" "log" "strings" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/netscan" ) // 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. const scanBudget = 20 * time.Second // scanReadOut — how many hosts she names out loud. The rest are a count: a // spoken list of twenty IP addresses is not an answer. const scanReadOut = 6 // 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 } // wireNetScan builds the scanner. nil unless the block is enabled and valid. func wireNetScan(cfg *config.Config) *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} } // scanSummary answers "какие устройства в сети?" in one line. func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { if w == nil { return "", false } ctx, cancel := context.WithTimeout(ctx, scanBudget) defer cancel() hosts, err := w.scanner.Scan(ctx) if err != nil { log.Printf("netscan: scan: %v", err) return "не получилось просканировать сеть.", true } if len(hosts) == 0 { return "в сети никого не нашла.", true } shown := hosts if len(shown) > scanReadOut { shown = shown[:scanReadOut] } parts := make([]string, 0, len(shown)) 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, ", ") + ")" } parts = append(parts, s) } out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; ")) if len(hosts) > len(shown) { out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown)) } return out + ".", true } // 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 } network := false 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 }