]> git.giacomolli.uk - onechat/blob - routes.go
Order model and chat declarations
[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 data, err := os.ReadFile(path)
34 if errors.Is(err, os.ErrNotExist) {
35 return r, nil
36 }
37
38 if err != nil {
39 return nil, fmt.Errorf("read routes: %w", err)
40 }
41
42 err = json.Unmarshal(data, &r.targets)
43 if err != nil {
44 return nil, fmt.Errorf("decode routes: %w", err)
45 }
46
47 for source, target := range r.targets {
48 err = routeSource(source)
49 if err != nil {
50 return nil, fmt.Errorf("validate route: %w", err)
51 }
52
53 if target == "" {
54 return nil, errors.New("route has an empty agent name")
55 }
56 }
57
58 if r.targets == nil {
59 r.targets = make(map[string]string)
60 }
61
62 return r, nil
63 }
64
65 // Set maps source to a child name, or removes it when target is empty. The
66 // caller checks that a nonempty target is a child of the root.
67 func (r *Routes) Set(source, target string) error {
68 err := routeSource(source)
69 if err != nil {
70 return fmt.Errorf("validate source: %w", err)
71 }
72
73 r.mutex.Lock()
74 defer r.mutex.Unlock()
75
76 targets := make(map[string]string, len(r.targets)+1)
77 for key, value := range r.targets {
78 targets[key] = value
79 }
80
81 if target == "" {
82 delete(targets, source)
83 } else {
84 targets[source] = target
85 }
86
87 data, err := json.Marshal(targets)
88 if err != nil {
89 return fmt.Errorf("encode routes: %w", err)
90 }
91
92 err = writeFileAtomic(r.path, append(data, '\n'))
93 if err != nil {
94 return fmt.Errorf("save routes: %w", err)
95 }
96
97 r.targets = targets
98 if target == "" {
99 r.logger.Info("route removed", "kind", whatsappSource)
100 } else {
101 r.logger.Info("route set", "kind", whatsappSource, "name", target)
102 }
103
104 return nil
105 }
106
107 // Target returns the configured name, or an empty string for an unrouted
108 // source.
109 func (r *Routes) Target(source string) string {
110 r.mutex.Lock()
111 defer r.mutex.Unlock()
112
113 return r.targets[source]
114 }
115
116 // List returns the routes in source order, or a short empty result.
117 func (r *Routes) List() string {
118 r.mutex.Lock()
119 defer r.mutex.Unlock()
120
121 var sources []string
122
123 for source := range r.targets {
124 sources = append(sources, source)
125 }
126
127 slices.Sort(sources)
128
129 var lines []string
130
131 for _, source := range sources {
132 lines = append(lines, source+" -> "+r.targets[source])
133 }
134
135 if len(lines) == 0 {
136 return "No routes."
137 }
138
139 return strings.Join(lines, "\n")
140 }
141
142 // routeSource accepts the supported source kind and a canonical private or
143 // group chat ID. The key keeps its kind so later sources can use the same map.
144 func routeSource(source string) error {
145 kind, id, _ := strings.Cut(source, ":")
146 if kind != whatsappSource {
147 return errors.New("unknown source kind: use whatsapp:CHAT-ID")
148 }
149
150 jid, err := types.ParseJID(id)
151 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") {
152 return errors.New("invalid WhatsApp source: use whatsapp:CHAT-ID from whatsapp_find or a [WhatsApp] message")
153 }
154
155 return nil
156 }