]> git.giacomolli.uk - onechat/blob - voice_store.go
Keep scenario tests with their implementation
[onechat] / voice_store.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 )
11
12 const (
13 voiceDirName = "voices"
14 voiceMaxBytes = 20 << 20
15 )
16
17 var errVoiceSize = errors.New("voice note is empty or exceeds 20 MB")
18
19 // VoiceStore holds immutable audio shared by all agents. Names are hashes of
20 // the received bytes, not sender filenames. The decoder detects the format.
21 type VoiceStore struct {
22 dir string
23 }
24
25 // Save publishes private audio atomically before its message is acknowledged.
26 // Only nonempty input up to voiceMaxBytes is stored.
27 func (s *VoiceStore) Save(data []byte) (name string, err error) {
28 if len(data) == 0 || len(data) > voiceMaxBytes {
29 return "", errVoiceSize
30 }
31
32 sum := sha256.Sum256(data)
33 name = hex.EncodeToString(sum[:]) + ".audio"
34 err = os.MkdirAll(s.dir, 0o700)
35 if err != nil {
36 return "", fmt.Errorf("create voice directory: %w", err)
37 }
38
39 file, err := os.CreateTemp(s.dir, ".voice-*")
40 if err != nil {
41 return "", fmt.Errorf("create voice file: %w", err)
42 }
43
44 defer func() {
45 err = errors.Join(err, os.Remove(file.Name()))
46 }()
47
48 _, err = file.Write(data)
49 if err == nil {
50 err = file.Sync()
51 }
52
53 err = errors.Join(err, file.Close())
54 if err != nil {
55 return "", fmt.Errorf("write voice: %w", err)
56 }
57
58 err = os.Link(file.Name(), filepath.Join(s.dir, name))
59 if err != nil && !errors.Is(err, os.ErrExist) {
60 return "", fmt.Errorf("publish voice: %w", err)
61 }
62
63 return name, nil
64 }