package main import ( "encoding/json" "errors" "fmt" "log/slog" "os" "slices" "strings" "sync" "go.mau.fi/whatsmeow/types" ) // openRoutes reads the source-to-child map. A missing file has no routes. func openRoutes(path string, logger *slog.Logger) (*Routes, error) { r := &Routes{ path: path, logger: logger, targets: make(map[string]string), } data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return r, nil } if err != nil { return nil, fmt.Errorf("read routes: %w", err) } err = json.Unmarshal(data, &r.targets) if err != nil { return nil, fmt.Errorf("decode routes: %w", err) } for source, target := range r.targets { err = routeSource(source) if err != nil { return nil, fmt.Errorf("validate route: %w", err) } if target == "" { return nil, errors.New("route has an empty agent name") } } if r.targets == nil { r.targets = make(map[string]string) } return r, nil } // Routes owns the root's source map. Writes replace the private file before // changing the map in memory. The mutex protects reads and writes from tools // and status commands. Logs omit source IDs, which can hold phone numbers. type Routes struct { mutex sync.Mutex path string logger *slog.Logger targets map[string]string } // Set maps source to a child name, or removes it when target is empty. The // caller checks that a nonempty target is a child of the root. func (r *Routes) Set(source, target string) error { err := routeSource(source) if err != nil { return fmt.Errorf("validate source: %w", err) } r.mutex.Lock() defer r.mutex.Unlock() targets := make(map[string]string, len(r.targets)+1) for key, value := range r.targets { targets[key] = value } if target == "" { delete(targets, source) } else { targets[source] = target } data, err := json.Marshal(targets) if err != nil { return fmt.Errorf("encode routes: %w", err) } err = writeFileAtomic(r.path, append(data, '\n')) if err != nil { return fmt.Errorf("save routes: %w", err) } r.targets = targets if target == "" { r.logger.Info("route removed", "kind", whatsappSource) } else { r.logger.Info("route set", "kind", whatsappSource, "name", target) } return nil } // Target returns the configured name, or an empty string for an unrouted // source. func (r *Routes) Target(source string) string { r.mutex.Lock() defer r.mutex.Unlock() return r.targets[source] } // List returns the routes in source order, or a short empty result. func (r *Routes) List() string { r.mutex.Lock() defer r.mutex.Unlock() var sources []string for source := range r.targets { sources = append(sources, source) } slices.Sort(sources) var lines []string for _, source := range sources { lines = append(lines, source+" -> "+r.targets[source]) } if len(lines) == 0 { return "No routes." } return strings.Join(lines, "\n") } // routeSource accepts the supported source kind and a canonical private or // group chat ID. The key keeps its kind so later sources can use the same map. func routeSource(source string) error { kind, id, _ := strings.Cut(source, ":") if kind != whatsappSource { return errors.New("unknown source kind: use whatsapp:CHAT-ID") } jid, err := types.ParseJID(id) if err != nil || jid.User == "" || jid.String() != id || jid != jid.ToNonAD() || (jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer) || strings.ContainsAny(id, " \t\r\n") { return errors.New("invalid WhatsApp source: use whatsapp:CHAT-ID from whatsapp_find or a [WhatsApp] message") } return nil }