]> git.giacomolli.uk - onechat/blob - routes.go
Separate multiline Go statements
[onechat] / routes.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "slices"
10 "strings"
11 "sync"
12
13 "go.mau.fi/whatsmeow/types"
14 )
15
16 // Routes owns the root's source map. Writes replace the private file before
17 // changing the map in memory. The mutex protects reads and writes from tools
18 // and status commands. Logs omit source IDs, which can hold phone numbers.
19 type Routes struct {
20 mutex sync.Mutex
21 path string
22 logger *slog.Logger
23 targets map[string]string
24 }
25
26 // openRoutes reads the source-to-child map. A missing file has no routes.
27 func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
28 r := &Routes{
29 path: path,
30 logger: logger,
31 targets: make(map[string]string),
32 }
33
34 data, err := os.ReadFile(path)
35 if errors.Is(err, os.ErrNotExist) {
36 return r, nil
37 }
38
39 if err != nil {
40 return nil, fmt.Errorf("read routes: %w", err)
41 }
42
43 err = json.Unmarshal(data, &r.targets)
44 if err != nil {
45 return nil, fmt.Errorf("decode routes: %w", err)
46 }
47
48 for source, target := range r.targets {
49 err = routeSource(source)
50 if err != nil {
51 return nil, fmt.Errorf("validate route: %w", err)
52 }
53
54 if target == "" {
55 return nil, errors.New("route has an empty agent name")
56 }
57 }
58
59 if r.targets == nil {
60 r.targets = make(map[string]string)
61 }
62
63 return r, nil
64 }
65
66 // Set maps source to a child name, or removes it when target is empty. The
67 // caller checks that a nonempty target is a child of the root.
68 func (r *Routes) Set(source, target string) error {
69 err := routeSource(source)
70 if err != nil {
71 return fmt.Errorf("validate source: %w", err)
72 }
73
74 r.mutex.Lock()
75 defer r.mutex.Unlock()
76
77 targets := make(map[string]string, len(r.targets)+1)
78 for key, value := range r.targets {
79 targets[key] = value
80 }
81
82 if target == "" {
83 delete(targets, source)
84 } else {
85 targets[source] = target
86 }
87
88 data, err := json.Marshal(targets)
89 if err != nil {
90 return fmt.Errorf("encode routes: %w", err)
91 }
92
93 err = writeFileAtomic(r.path, append(data, '\n'))
94 if err != nil {
95 return fmt.Errorf("save routes: %w", err)
96 }
97
98 r.targets = targets
99 if target == "" {
100 r.logger.Info("route removed", "kind", whatsappSource)
101 } else {
102 r.logger.Info("route set", "kind", whatsappSource, "name", target)
103 }
104
105 return nil
106 }
107
108 // Target returns the configured name, or an empty string for an unrouted
109 // source.
110 func (r *Routes) Target(source string) string {
111 r.mutex.Lock()
112 defer r.mutex.Unlock()
113
114 return r.targets[source]
115 }
116
117 // List returns the routes in source order, or a short empty result.
118 func (r *Routes) List() string {
119 r.mutex.Lock()
120 defer r.mutex.Unlock()
121
122 var sources []string
123
124 for source := range r.targets {
125 sources = append(sources, source)
126 }
127
128 slices.Sort(sources)
129
130 var lines []string
131
132 for _, source := range sources {
133 lines = append(lines, source+" -> "+r.targets[source])
134 }
135
136 if len(lines) == 0 {
137 return "No routes."
138 }
139
140 return strings.Join(lines, "\n")
141 }
142
143 // routeSource accepts the supported source kind and a canonical private or
144 // group chat ID. The key keeps its kind so later sources can use the same map.
145 func routeSource(source string) error {
146 kind, id, _ := strings.Cut(source, ":")
147 if kind != whatsappSource {
148 return errors.New("unknown source kind: use whatsapp:CHAT-ID")
149 }
150
151 jid, err := types.ParseJID(id)
152 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") {
153 return errors.New("invalid WhatsApp source: use whatsapp:CHAT-ID from whatsapp_find or a [WhatsApp] message")
154 }
155
156 return nil
157 }