detached 5f6a3d0

refactor: move protocol-agnostic helpers from telegram/ to bot/

commit
5f6a3d0ca8f2b48e665b0afa8f7d656c402bfa38
author
mi-skam · · 2026-07-06 07:55 UTC
parent
15563ff
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
refs
tag: v0.2.62
9 files · +163 -103
A packages/agent/modes/bot/daemon.go +66 -0
@@ -0,0 +1,66 @@
1+package bot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "time"
11+)
12+
13+// WritePIDFile persists pid to path. Overwrites any existing file.
14+func WritePIDFile(path string, pid int) error {
15+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
16+ return err
17+ }
18+ return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644)
19+}
20+
21+// ReadPIDFile returns the pid stored at path, or 0 if the file doesn't exist.
22+func ReadPIDFile(path string) (int, error) {
23+ b, err := os.ReadFile(path)
24+ if errors.Is(err, os.ErrNotExist) {
25+ return 0, nil
26+ }
27+ if err != nil {
28+ return 0, err
29+ }
30+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
31+ if err != nil {
32+ return 0, fmt.Errorf("parse pid: %w", err)
33+ }
34+ return pid, nil
35+}
36+
37+// RemovePIDFile deletes the pid file if it exists.
38+func RemovePIDFile(path string) error {
39+ err := os.Remove(path)
40+ if errors.Is(err, os.ErrNotExist) {
41+ return nil
42+ }
43+ return err
44+}
45+
46+// IsRunningAt reports whether a live process is recorded at pidPath.
47+// Semantics identical to the old telegram.IsRunning.
48+func IsRunningAt(pidPath string) (int, bool, error) {
49+ pid, err := ReadPIDFile(pidPath)
50+ if err != nil {
51+ return 0, false, err
52+ }
53+ if pid <= 0 {
54+ return 0, false, nil
55+ }
56+ alive, err := processAlive(pid)
57+ if err != nil {
58+ return pid, false, nil
59+ }
60+ return pid, alive, nil
61+}
62+
63+// StopProcess asks pid to exit, waits up to graceful, then force-kills.
64+func StopProcess(pid int, graceful time.Duration) error {
65+ return stopProcess(pid, graceful)
66+}
@@ -0,0 +1,66 @@
1+package bot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "time"
11+)
12+
13+// WritePIDFile persists pid to path. Overwrites any existing file.
14+func WritePIDFile(path string, pid int) error {
15+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
16+ return err
17+ }
18+ return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644)
19+}
20+
21+// ReadPIDFile returns the pid stored at path, or 0 if the file doesn't exist.
22+func ReadPIDFile(path string) (int, error) {
23+ b, err := os.ReadFile(path)
24+ if errors.Is(err, os.ErrNotExist) {
25+ return 0, nil
26+ }
27+ if err != nil {
28+ return 0, err
29+ }
30+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
31+ if err != nil {
32+ return 0, fmt.Errorf("parse pid: %w", err)
33+ }
34+ return pid, nil
35+}
36+
37+// RemovePIDFile deletes the pid file if it exists.
38+func RemovePIDFile(path string) error {
39+ err := os.Remove(path)
40+ if errors.Is(err, os.ErrNotExist) {
41+ return nil
42+ }
43+ return err
44+}
45+
46+// IsRunningAt reports whether a live process is recorded at pidPath.
47+// Semantics identical to the old telegram.IsRunning.
48+func IsRunningAt(pidPath string) (int, bool, error) {
49+ pid, err := ReadPIDFile(pidPath)
50+ if err != nil {
51+ return 0, false, err
52+ }
53+ if pid <= 0 {
54+ return 0, false, nil
55+ }
56+ alive, err := processAlive(pid)
57+ if err != nil {
58+ return pid, false, nil
59+ }
60+ return pid, alive, nil
61+}
62+
63+// StopProcess asks pid to exit, waits up to graceful, then force-kills.
64+func StopProcess(pid int, graceful time.Duration) error {
65+ return stopProcess(pid, graceful)
66+}
R packages/agent/modes/telegram/process_unix.go → packages/agent/modes/bot/process_unix.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 //go:build !windows
22
3-package telegram
3+package bot
44
55 import (
66 "errors"
@@ -1,6 +1,6 @@
1 //go:build !windows1 //go:build !windows
2 2
3-package telegram3+package bot
4 4
5 import (5 import (
6 "errors"6 "errors"
R packages/agent/modes/telegram/process_windows.go → packages/agent/modes/bot/process_windows.go +1 -1 renamed, 98% similar
@@ -1,6 +1,6 @@
11 //go:build windows
22
3-package telegram
3+package bot
44
55 import (
66 "errors"
@@ -1,6 +1,6 @@
1 //go:build windows1 //go:build windows
2 2
3-package telegram3+package bot
4 4
5 import (5 import (
6 "errors"6 "errors"
R packages/agent/modes/telegram/bot.go → packages/agent/modes/bot/util.go +8 -8 renamed, 80% similar
@@ -1,13 +1,13 @@
1-package telegram
1+package bot
22
33 import (
44 "strings"
55 "unicode/utf8"
66 )
77
8-// chunkMessage splits s into chunks no larger than limit runes, on line
8+// ChunkMessage splits s into chunks no larger than limit runes, on line
99 // boundaries when possible.
10-func chunkMessage(s string, limit int) []string {
10+func ChunkMessage(s string, limit int) []string {
1111 if limit <= 0 || utf8.RuneCountInString(s) <= limit {
1212 return []string{s}
1313 }
@@ -63,9 +63,9 @@ func byteIndexAfterRunes(s string, n int) int {
6363 return len(s)
6464 }
6565
66-// isImageMIME returns true for MIME types the model can probably ingest
66+// IsImageMIME returns true for MIME types the model can probably ingest
6767 // as a vision input.
68-func isImageMIME(m string) bool {
68+func IsImageMIME(m string) bool {
6969 switch strings.ToLower(m) {
7070 case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":
7171 return true
@@ -73,10 +73,10 @@ func isImageMIME(m string) bool {
7373 return false
7474 }
7575
76-// guessImageMIME infers a mime type from a filename suffix. Falls back
77-// to image/png because telegram photos are always re-encoded to jpeg
76+// GuessImageMIME infers a mime type from a filename suffix. Falls back
77+// to image/jpeg because telegram photos are always re-encoded to jpeg
7878 // but getFile's file_path may omit the extension.
79-func guessImageMIME(path string) string {
79+func GuessImageMIME(path string) string {
8080 lower := strings.ToLower(path)
8181 switch {
8282 case strings.HasSuffix(lower, ".png"):
@@ -1,13 +1,13 @@
1-package telegram1+package bot
2 2
3 import (3 import (
4 "strings"4 "strings"
5 "unicode/utf8"5 "unicode/utf8"
6 )6 )
7 7
8-// chunkMessage splits s into chunks no larger than limit runes, on line8+// ChunkMessage splits s into chunks no larger than limit runes, on line
9 // boundaries when possible.9 // boundaries when possible.
10-func chunkMessage(s string, limit int) []string {10+func ChunkMessage(s string, limit int) []string {
11 if limit <= 0 || utf8.RuneCountInString(s) <= limit {11 if limit <= 0 || utf8.RuneCountInString(s) <= limit {
12 return []string{s}12 return []string{s}
13 }13 }
@@ -63,9 +63,9 @@ func byteIndexAfterRunes(s string, n int) int {
63 return len(s)63 return len(s)
64 }64 }
65 65
66-// isImageMIME returns true for MIME types the model can probably ingest66+// IsImageMIME returns true for MIME types the model can probably ingest
67 // as a vision input.67 // as a vision input.
68-func isImageMIME(m string) bool {68+func IsImageMIME(m string) bool {
69 switch strings.ToLower(m) {69 switch strings.ToLower(m) {
70 case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":70 case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp":
71 return true71 return true
@@ -73,10 +73,10 @@ func isImageMIME(m string) bool {
73 return false73 return false
74 }74 }
75 75
76-// guessImageMIME infers a mime type from a filename suffix. Falls back76+// GuessImageMIME infers a mime type from a filename suffix. Falls back
77-// to image/png because telegram photos are always re-encoded to jpeg77+// to image/jpeg because telegram photos are always re-encoded to jpeg
78 // but getFile's file_path may omit the extension.78 // but getFile's file_path may omit the extension.
79-func guessImageMIME(path string) string {79+func GuessImageMIME(path string) string {
80 lower := strings.ToLower(path)80 lower := strings.ToLower(path)
81 switch {81 switch {
82 case strings.HasSuffix(lower, ".png"):82 case strings.HasSuffix(lower, ".png"):
A packages/agent/modes/bot/util_test.go +71 -0
@@ -0,0 +1,71 @@
1+package bot
2+
3+import (
4+ "strings"
5+ "testing"
6+ "unicode/utf8"
7+)
8+
9+func TestChunkMessageShort(t *testing.T) {
10+ got := ChunkMessage("hello", 10)
11+ if len(got) != 1 || got[0] != "hello" {
12+ t.Fatalf("got %#v", got)
13+ }
14+}
15+
16+func TestChunkMessageSplitsOnLines(t *testing.T) {
17+ in := strings.Repeat("aaaa\n", 5) + "bbbb"
18+ for _, c := range ChunkMessage(in, 10) {
19+ if utf8.RuneCountInString(c) > 10 {
20+ t.Fatalf("chunk too long: %q", c)
21+ }
22+ }
23+}
24+
25+func TestChunkMessageHardSplitsLongLine(t *testing.T) {
26+ in := strings.Repeat("x", 25)
27+ got := ChunkMessage(in, 10)
28+ if len(got) != 3 {
29+ t.Fatalf("want 3 chunks, got %d: %#v", len(got), got)
30+ }
31+}
32+
33+// Ported from telegram/bot_test.go to preserve rune-boundary coverage.
34+func TestChunkMessageUsesRuneLimit(t *testing.T) {
35+ s := strings.Repeat("界", 4001)
36+ chunks := ChunkMessage(s, 4000)
37+ if len(chunks) != 2 {
38+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
39+ }
40+ if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
41+ t.Fatalf("first chunk runes = %d, want 4000", got)
42+ }
43+ if got := utf8.RuneCountInString(chunks[1]); got != 1 {
44+ t.Fatalf("second chunk runes = %d, want 1", got)
45+ }
46+}
47+
48+func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
49+ chunks := ChunkMessage("🙂🙂🙂", 2)
50+ if len(chunks) != 2 {
51+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
52+ }
53+ if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
54+ t.Fatalf("chunks = %#v", chunks)
55+ }
56+}
57+
58+func TestIsImageMIME(t *testing.T) {
59+ if !IsImageMIME("image/PNG") || IsImageMIME("application/pdf") {
60+ t.Fatal("mime gate wrong")
61+ }
62+}
63+
64+func TestGuessImageMIME(t *testing.T) {
65+ if GuessImageMIME("a/b.webp") != "image/webp" {
66+ t.Fatal("webp")
67+ }
68+ if GuessImageMIME("photos/file") != "image/jpeg" {
69+ t.Fatal("fallback should be jpeg (telegram re-encodes photos)")
70+ }
71+}
@@ -0,0 +1,71 @@
1+package bot
2+
3+import (
4+ "strings"
5+ "testing"
6+ "unicode/utf8"
7+)
8+
9+func TestChunkMessageShort(t *testing.T) {
10+ got := ChunkMessage("hello", 10)
11+ if len(got) != 1 || got[0] != "hello" {
12+ t.Fatalf("got %#v", got)
13+ }
14+}
15+
16+func TestChunkMessageSplitsOnLines(t *testing.T) {
17+ in := strings.Repeat("aaaa\n", 5) + "bbbb"
18+ for _, c := range ChunkMessage(in, 10) {
19+ if utf8.RuneCountInString(c) > 10 {
20+ t.Fatalf("chunk too long: %q", c)
21+ }
22+ }
23+}
24+
25+func TestChunkMessageHardSplitsLongLine(t *testing.T) {
26+ in := strings.Repeat("x", 25)
27+ got := ChunkMessage(in, 10)
28+ if len(got) != 3 {
29+ t.Fatalf("want 3 chunks, got %d: %#v", len(got), got)
30+ }
31+}
32+
33+// Ported from telegram/bot_test.go to preserve rune-boundary coverage.
34+func TestChunkMessageUsesRuneLimit(t *testing.T) {
35+ s := strings.Repeat("界", 4001)
36+ chunks := ChunkMessage(s, 4000)
37+ if len(chunks) != 2 {
38+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
39+ }
40+ if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
41+ t.Fatalf("first chunk runes = %d, want 4000", got)
42+ }
43+ if got := utf8.RuneCountInString(chunks[1]); got != 1 {
44+ t.Fatalf("second chunk runes = %d, want 1", got)
45+ }
46+}
47+
48+func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
49+ chunks := ChunkMessage("🙂🙂🙂", 2)
50+ if len(chunks) != 2 {
51+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
52+ }
53+ if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
54+ t.Fatalf("chunks = %#v", chunks)
55+ }
56+}
57+
58+func TestIsImageMIME(t *testing.T) {
59+ if !IsImageMIME("image/PNG") || IsImageMIME("application/pdf") {
60+ t.Fatal("mime gate wrong")
61+ }
62+}
63+
64+func TestGuessImageMIME(t *testing.T) {
65+ if GuessImageMIME("a/b.webp") != "image/webp" {
66+ t.Fatal("webp")
67+ }
68+ if GuessImageMIME("photos/file") != "image/jpeg" {
69+ t.Fatal("fallback should be jpeg (telegram re-encodes photos)")
70+ }
71+}
M packages/agent/modes/telegram/adapter.go +3 -3
@@ -178,7 +178,7 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
178178 fmt.Fprintln(stderr(), "telegram: download photo:", err)
179179 }
180180 }
181- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
181+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
182182 if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
183183 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
184184 }
@@ -202,7 +202,7 @@ func (a *Adapter) Send(ctx context.Context, channelID, text string, opts bot.Sen
202202 replyTo = n
203203 }
204204 }
205- for _, chunk := range chunkMessage(text, 4000) {
205+ for _, chunk := range bot.ChunkMessage(text, 4000) {
206206 if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
207207 return err
208208 }
@@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st
252252 return nil, "", err
253253 }
254254 if mime == "" {
255- mime = guessImageMIME(f.FilePath)
255+ mime = bot.GuessImageMIME(f.FilePath)
256256 }
257257 return data, mime, nil
258258 }
@@ -178,7 +178,7 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
178 fmt.Fprintln(stderr(), "telegram: download photo:", err)178 fmt.Fprintln(stderr(), "telegram: download photo:", err)
179 }179 }
180 }180 }
181- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {181+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
182 if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {182 if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
183 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})183 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
184 }184 }
@@ -202,7 +202,7 @@ func (a *Adapter) Send(ctx context.Context, channelID, text string, opts bot.Sen
202 replyTo = n202 replyTo = n
203 }203 }
204 }204 }
205- for _, chunk := range chunkMessage(text, 4000) {205+ for _, chunk := range bot.ChunkMessage(text, 4000) {
206 if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {206 if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
207 return err207 return err
208 }208 }
@@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st
252 return nil, "", err252 return nil, "", err
253 }253 }
254 if mime == "" {254 if mime == "" {
255- mime = guessImageMIME(f.FilePath)255+ mime = bot.GuessImageMIME(f.FilePath)
256 }256 }
257 return data, mime, nil257 return data, mime, nil
258 }258 }
D packages/agent/modes/telegram/bot_test.go +0 -31
@@ -1,31 +0,0 @@
1-package telegram
2-
3-import (
4- "strings"
5- "testing"
6- "unicode/utf8"
7-)
8-
9-func TestChunkMessageUsesRuneLimit(t *testing.T) {
10- s := strings.Repeat("界", 4001)
11- chunks := chunkMessage(s, 4000)
12- if len(chunks) != 2 {
13- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
14- }
15- if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
16- t.Fatalf("first chunk runes = %d, want 4000", got)
17- }
18- if got := utf8.RuneCountInString(chunks[1]); got != 1 {
19- t.Fatalf("second chunk runes = %d, want 1", got)
20- }
21-}
22-
23-func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
24- chunks := chunkMessage("🙂🙂🙂", 2)
25- if len(chunks) != 2 {
26- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
27- }
28- if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
29- t.Fatalf("chunks = %#v", chunks)
30- }
31-}
@@ -1,31 +0,0 @@
1-package telegram
2-
3-import (
4- "strings"
5- "testing"
6- "unicode/utf8"
7-)
8-
9-func TestChunkMessageUsesRuneLimit(t *testing.T) {
10- s := strings.Repeat("界", 4001)
11- chunks := chunkMessage(s, 4000)
12- if len(chunks) != 2 {
13- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
14- }
15- if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
16- t.Fatalf("first chunk runes = %d, want 4000", got)
17- }
18- if got := utf8.RuneCountInString(chunks[1]); got != 1 {
19- t.Fatalf("second chunk runes = %d, want 1", got)
20- }
21-}
22-
23-func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
24- chunks := chunkMessage("🙂🙂🙂", 2)
25- if len(chunks) != 2 {
26- t.Fatalf("len(chunks) = %d, want 2", len(chunks))
27- }
28- if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
29- t.Fatalf("chunks = %#v", chunks)
30- }
31-}
M packages/agent/modes/telegram/bridge.go +4 -3
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
1011 "github.com/patriceckhart/zot/packages/provider"
1112 )
1213
@@ -195,7 +196,7 @@ func (b *Bridge) sendToPaired(text, prefix string) {
195196 if prefix != "" {
196197 text = prefix + text
197198 }
198- for _, chunk := range chunkMessage(text, 4000) {
199+ for _, chunk := range bot.ChunkMessage(text, 4000) {
199200 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
200201 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
201202 return
@@ -368,7 +369,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
368369 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
369370 }
370371 }
371- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
372+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
372373 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
373374 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
374375 }
@@ -397,7 +398,7 @@ func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]b
397398 }
398399 mime := mimeOverride
399400 if mime == "" {
400- mime = guessImageMIME(f.FilePath)
401+ mime = bot.GuessImageMIME(f.FilePath)
401402 }
402403 return data, mime, nil
403404 }
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
10 "github.com/patriceckhart/zot/packages/provider"11 "github.com/patriceckhart/zot/packages/provider"
11 )12 )
12 13
@@ -195,7 +196,7 @@ func (b *Bridge) sendToPaired(text, prefix string) {
195 if prefix != "" {196 if prefix != "" {
196 text = prefix + text197 text = prefix + text
197 }198 }
198- for _, chunk := range chunkMessage(text, 4000) {199+ for _, chunk := range bot.ChunkMessage(text, 4000) {
199 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {200 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
200 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)201 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
201 return202 return
@@ -368,7 +369,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
368 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})369 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
369 }370 }
370 }371 }
371- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {372+ if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
372 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {373 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
373 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})374 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
374 }375 }
@@ -397,7 +398,7 @@ func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]b
397 }398 }
398 mime := mimeOverride399 mime := mimeOverride
399 if mime == "" {400 if mime == "" {
400- mime = guessImageMIME(f.FilePath)401+ mime = bot.GuessImageMIME(f.FilePath)
401 }402 }
402 return data, mime, nil403 return data, mime, nil
403 }404 }
M packages/agent/modes/telegram/daemon.go +9 -56
@@ -1,81 +1,34 @@
11 package telegram
22
33 import (
4- "errors"
5- "fmt"
6- "os"
74 "path/filepath"
8- "strconv"
9- "strings"
105 "time"
6+
7+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
118 )
129
1310 // PIDPath returns the location of the bot's pid file.
14-func PIDPath(zotHome string) string {
15- return filepath.Join(zotHome, "bot.pid")
16-}
11+func PIDPath(zotHome string) string { return filepath.Join(zotHome, "bot.pid") }
1712
1813 // LogPath returns the location of the bot's log file (stdout+stderr
1914 // from a detached `zot bot start`).
20-func LogPath(zotHome string) string {
21- return filepath.Join(zotHome, "logs", "bot.log")
22-}
15+func LogPath(zotHome string) string { return filepath.Join(zotHome, "logs", "bot.log") }
2316
2417 // WritePID persists pid to bot.pid. Overwrites any existing file.
25-func WritePID(zotHome string, pid int) error {
26- p := PIDPath(zotHome)
27- if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28- return err
29- }
30- return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644)
31-}
18+func WritePID(zotHome string, pid int) error { return bot.WritePIDFile(PIDPath(zotHome), pid) }
3219
3320 // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't
3421 // exist. Returns an error for any other read/parse failure.
35-func ReadPID(zotHome string) (int, error) {
36- b, err := os.ReadFile(PIDPath(zotHome))
37- if errors.Is(err, os.ErrNotExist) {
38- return 0, nil
39- }
40- if err != nil {
41- return 0, err
42- }
43- pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
44- if err != nil {
45- return 0, fmt.Errorf("parse pid: %w", err)
46- }
47- return pid, nil
48-}
22+func ReadPID(zotHome string) (int, error) { return bot.ReadPIDFile(PIDPath(zotHome)) }
4923
5024 // RemovePID deletes the pid file if it exists.
51-func RemovePID(zotHome string) error {
52- err := os.Remove(PIDPath(zotHome))
53- if errors.Is(err, os.ErrNotExist) {
54- return nil
55- }
56- return err
57-}
25+func RemovePID(zotHome string) error { return bot.RemovePIDFile(PIDPath(zotHome)) }
5826
5927 // IsRunning returns (pid, true) if a live process with the recorded
6028 // pid exists, or (pid, false) if the pid file points to a dead process.
6129 // Stale pid files are left in place; the caller may remove them.
62-func IsRunning(zotHome string) (int, bool, error) {
63- pid, err := ReadPID(zotHome)
64- if err != nil {
65- return 0, false, err
66- }
67- if pid <= 0 {
68- return 0, false, nil
69- }
70- alive, err := processAlive(pid)
71- if err != nil {
72- return pid, false, nil
73- }
74- return pid, alive, nil
75-}
30+func IsRunning(zotHome string) (int, bool, error) { return bot.IsRunningAt(PIDPath(zotHome)) }
7631
7732 // StopProcess asks pid to exit and waits up to graceful for it to stop,
7833 // then escalates to a forced kill. Returns nil if the process is gone.
79-func StopProcess(pid int, graceful time.Duration) error {
80- return stopProcess(pid, graceful)
81-}
34+func StopProcess(pid int, graceful time.Duration) error { return bot.StopProcess(pid, graceful) }
@@ -1,81 +1,34 @@
1 package telegram1 package telegram
2 2
3 import (3 import (
4- "errors"
5- "fmt"
6- "os"
7 "path/filepath"4 "path/filepath"
8- "strconv"
9- "strings"
10 "time"5 "time"
6+
7+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
11 )8 )
12 9
13 // PIDPath returns the location of the bot's pid file.10 // PIDPath returns the location of the bot's pid file.
14-func PIDPath(zotHome string) string {11+func PIDPath(zotHome string) string { return filepath.Join(zotHome, "bot.pid") }
15- return filepath.Join(zotHome, "bot.pid")
16-}
17 12
18 // LogPath returns the location of the bot's log file (stdout+stderr13 // LogPath returns the location of the bot's log file (stdout+stderr
19 // from a detached `zot bot start`).14 // from a detached `zot bot start`).
20-func LogPath(zotHome string) string {15+func LogPath(zotHome string) string { return filepath.Join(zotHome, "logs", "bot.log") }
21- return filepath.Join(zotHome, "logs", "bot.log")
22-}
23 16
24 // WritePID persists pid to bot.pid. Overwrites any existing file.17 // WritePID persists pid to bot.pid. Overwrites any existing file.
25-func WritePID(zotHome string, pid int) error {18+func WritePID(zotHome string, pid int) error { return bot.WritePIDFile(PIDPath(zotHome), pid) }
26- p := PIDPath(zotHome)
27- if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28- return err
29- }
30- return os.WriteFile(p, []byte(strconv.Itoa(pid)+"\n"), 0o644)
31-}
32 19
33 // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't20 // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't
34 // exist. Returns an error for any other read/parse failure.21 // exist. Returns an error for any other read/parse failure.
35-func ReadPID(zotHome string) (int, error) {22+func ReadPID(zotHome string) (int, error) { return bot.ReadPIDFile(PIDPath(zotHome)) }
36- b, err := os.ReadFile(PIDPath(zotHome))
37- if errors.Is(err, os.ErrNotExist) {
38- return 0, nil
39- }
40- if err != nil {
41- return 0, err
42- }
43- pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
44- if err != nil {
45- return 0, fmt.Errorf("parse pid: %w", err)
46- }
47- return pid, nil
48-}
49 23
50 // RemovePID deletes the pid file if it exists.24 // RemovePID deletes the pid file if it exists.
51-func RemovePID(zotHome string) error {25+func RemovePID(zotHome string) error { return bot.RemovePIDFile(PIDPath(zotHome)) }
52- err := os.Remove(PIDPath(zotHome))
53- if errors.Is(err, os.ErrNotExist) {
54- return nil
55- }
56- return err
57-}
58 26
59 // IsRunning returns (pid, true) if a live process with the recorded27 // IsRunning returns (pid, true) if a live process with the recorded
60 // pid exists, or (pid, false) if the pid file points to a dead process.28 // pid exists, or (pid, false) if the pid file points to a dead process.
61 // Stale pid files are left in place; the caller may remove them.29 // Stale pid files are left in place; the caller may remove them.
62-func IsRunning(zotHome string) (int, bool, error) {30+func IsRunning(zotHome string) (int, bool, error) { return bot.IsRunningAt(PIDPath(zotHome)) }
63- pid, err := ReadPID(zotHome)
64- if err != nil {
65- return 0, false, err
66- }
67- if pid <= 0 {
68- return 0, false, nil
69- }
70- alive, err := processAlive(pid)
71- if err != nil {
72- return pid, false, nil
73- }
74- return pid, alive, nil
75-}
76 31
77 // StopProcess asks pid to exit and waits up to graceful for it to stop,32 // StopProcess asks pid to exit and waits up to graceful for it to stop,
78 // then escalates to a forced kill. Returns nil if the process is gone.33 // then escalates to a forced kill. Returns nil if the process is gone.
79-func StopProcess(pid int, graceful time.Duration) error {34+func StopProcess(pid int, graceful time.Duration) error { return bot.StopProcess(pid, graceful) }
80- return stopProcess(pid, graceful)
81-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close