]> git.giacomolli.uk - onechat/blob - whatsapp.go
Show the WhatsApp link with /whatsapp status
[onechat] / whatsapp.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "time"
14
15 "go.mau.fi/whatsmeow"
16 "go.mau.fi/whatsmeow/store"
17 "go.mau.fi/whatsmeow/store/sqlstore"
18 "go.mau.fi/whatsmeow/types/events"
19 waLog "go.mau.fi/whatsmeow/util/log"
20
21 // The session store of the library opens SQLite with this pure Go
22 // driver.
23 _ "modernc.org/sqlite"
24 )
25
26 const (
27 // whatsappDirName is the directory of the WhatsApp files in the data
28 // directory.
29 whatsappDirName = "whatsapp"
30
31 // whatsappSessionFileName is the session database of the library in
32 // whatsappDirName.
33 whatsappSessionFileName = "session.db"
34
35 // whatsappCommand starts the input lines of the WhatsApp commands, such
36 // as "/whatsapp status".
37 whatsappCommand = "/whatsapp"
38
39 // whatsappUsage is the answer to a WhatsApp command that does not exist.
40 whatsappUsage = "Use /whatsapp status."
41
42 // whatsappTimeLayout is the layout of the times in the answers.
43 whatsappTimeLayout = "2006-01-02 15:04"
44 )
45
46 // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
47 // real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
48 // fake.
49 type WhatsAppClient interface {
50 ConnectContext(ctx context.Context) error
51 Disconnect()
52 }
53
54 // openWhatsApp opens the WhatsApp session in the data directory dir. Without
55 // the session file, WhatsApp is not linked. The session gives full access to
56 // the account, so a file that another user can read is an error. The clock
57 // gives the times of the status, logger gets the failures, and newClient makes
58 // the client of a linked device with its event handler.
59 func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
60 w := &WhatsApp{
61 dir: filepath.Join(dir, whatsappDirName),
62 clock: clock,
63 logger: logger,
64 newClient: newClient,
65 }
66
67 path := filepath.Join(w.dir, whatsappSessionFileName)
68 info, err := os.Stat(path)
69 if errors.Is(err, os.ErrNotExist) {
70 return w, nil
71 }
72
73 if err != nil {
74 return nil, fmt.Errorf("read WhatsApp session: %w", err)
75 }
76
77 if info.Mode().Perm()&0o077 != 0 {
78 return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
79 }
80
81 address, err := whatsappSessionAddress(path)
82 if err != nil {
83 return nil, err
84 }
85
86 w.container, err = sqlstore.New(ctx, "sqlite", address, waLog.Noop)
87 if err != nil {
88 return nil, fmt.Errorf("open WhatsApp session: %w", err)
89 }
90
91 w.device, err = w.container.GetFirstDevice(ctx)
92 if err != nil {
93 return nil, errors.Join(fmt.Errorf("read WhatsApp device: %w", err), w.container.Close())
94 }
95
96 return w, nil
97 }
98
99 // WhatsApp is the link of the daemon to the WhatsApp account of the user, as
100 // a linked device. The session database of the library in dir holds the keys
101 // of the device. A device with an ID is linked, and Run keeps it connected.
102 // The library connects again by itself after a network failure. The mutex
103 // protects the state of the connection, which the event handler of the library
104 // changes from its own goroutines.
105 //
106 // The library logs nothing, because its log lines hold the ID of the own
107 // account, which is the phone number of the user.
108 type WhatsApp struct {
109 dir string
110 clock func() time.Time
111 logger *log.Logger
112 newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
113 container *sqlstore.Container
114 device *store.Device
115 client WhatsAppClient
116
117 mutex sync.Mutex
118 connected bool
119 connectedAt time.Time
120 disconnectedAt time.Time
121 }
122
123 // Run connects a linked device and keeps the connection until ctx ends. A
124 // failed first connection is logged, and the library tries again when the
125 // failure is one of the network.
126 func (w *WhatsApp) Run(ctx context.Context) {
127 if !w.linked() {
128 <-ctx.Done()
129 return
130 }
131
132 w.client = w.newClient(w.device, w.handle)
133 err := w.client.ConnectContext(ctx)
134 if err != nil {
135 w.logger.Printf("connect: %v", err)
136 }
137
138 <-ctx.Done()
139 w.client.Disconnect()
140 }
141
142 // Command runs the WhatsApp command args, the text after /whatsapp, and gives
143 // each answer to answer. The answers go only to the client that sent the
144 // command.
145 func (w *WhatsApp) Command(args string, answer func(text string)) {
146 switch strings.TrimSpace(args) {
147 case "status":
148 answer(w.Status())
149 default:
150 answer(whatsappUsage)
151 }
152 }
153
154 // Status returns the state of the link: not linked, linked and connected, or
155 // linked and disconnected, with the times of the connection. It never holds
156 // the phone number.
157 func (w *WhatsApp) Status() string {
158 w.mutex.Lock()
159 defer w.mutex.Unlock()
160
161 switch {
162 case !w.linked():
163 return "WhatsApp is not linked."
164 case w.connected:
165 return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
166 case w.disconnectedAt.IsZero():
167 return "WhatsApp is linked and not connected yet."
168 case w.connectedAt.IsZero():
169 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
170 default:
171 return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
172 }
173 }
174
175 // Close closes the session database.
176 func (w *WhatsApp) Close() error {
177 if w.container == nil {
178 return nil
179 }
180
181 return w.container.Close()
182 }
183
184 // linked tells if the session holds a linked device.
185 func (w *WhatsApp) linked() bool {
186 return w.device != nil && w.device.ID != nil
187 }
188
189 // handle takes one event of the library and tells if it was handled. A
190 // disconnection keeps the time of its start while the library tries again.
191 func (w *WhatsApp) handle(event any) bool {
192 w.mutex.Lock()
193 defer w.mutex.Unlock()
194
195 switch event.(type) {
196 case *events.Connected:
197 w.connected = true
198 w.connectedAt = w.clock()
199 case *events.Disconnected:
200 if w.connected || w.disconnectedAt.IsZero() {
201 w.disconnectedAt = w.clock()
202 }
203
204 w.connected = false
205 }
206
207 return true
208 }
209
210 // whatsappNewClient returns the client of the library for device, with handle
211 // as its event handler. The client tries again after a failed first
212 // connection, acknowledges a message only after handle returns true, and
213 // downloads no old history.
214 func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
215 client := whatsmeow.NewClient(device, waLog.Noop)
216 client.InitialAutoReconnect = true
217 client.SynchronousAck = true
218 client.ManualHistorySyncDownload = true
219 client.AddEventHandlerWithSuccessStatus(handle)
220
221 return client
222 }
223
224 // whatsappSessionAddress returns the SQLite address of the session database at
225 // path, with foreign keys on, as the library needs, and a wait for a busy
226 // database.
227 func whatsappSessionAddress(path string) (string, error) {
228 path, err := filepath.Abs(path)
229 if err != nil {
230 return "", fmt.Errorf("find WhatsApp session: %w", err)
231 }
232
233 address := url.URL{
234 Scheme: "file",
235 Path: path,
236 RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
237 }
238
239 return address.String(), nil
240 }
241
242 // whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
243 // command, or false for any other line.
244 func whatsappCommandArgs(line string) (string, bool) {
245 line = strings.TrimSpace(line)
246 if line == whatsappCommand {
247 return "", true
248 }
249
250 args, found := strings.CutPrefix(line, whatsappCommand+" ")
251 if !found {
252 return "", false
253 }
254
255 return strings.TrimSpace(args), true
256 }