]> git.giacomolli.uk - onechat/blob - routes.go
Import WhatsApp history into a child
[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 // openRoutes reads the source-to-child map. A missing file has no routes.
17 func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
18         r := &Routes{
19                 path:    path,
20                 logger:  logger,
21                 targets: make(map[string]string),
22         }
23         data, err := os.ReadFile(path)
24         if errors.Is(err, os.ErrNotExist) {
25                 return r, nil
26         }
27
28         if err != nil {
29                 return nil, fmt.Errorf("read routes: %w", err)
30         }
31
32         err = json.Unmarshal(data, &r.targets)
33         if err != nil {
34                 return nil, fmt.Errorf("decode routes: %w", err)
35         }
36
37         for source, target := range r.targets {
38                 err = routeSource(source)
39                 if err != nil {
40                         return nil, fmt.Errorf("validate route: %w", err)
41                 }
42
43                 if target == "" {
44                         return nil, errors.New("route has an empty agent name")
45                 }
46         }
47
48         if r.targets == nil {
49                 r.targets = make(map[string]string)
50         }
51
52         return r, nil
53 }
54
55 // Routes owns the root's source map. Writes replace the private file before
56 // changing the map in memory. The mutex protects reads and writes from tools
57 // and status commands. Logs omit source IDs, which can hold phone numbers.
58 type Routes struct {
59         mutex   sync.Mutex
60         path    string
61         logger  *slog.Logger
62         targets map[string]string
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 }