refactor: move protocol-agnostic helpers from telegram/ to bot/
- commit
- 5f6a3d0ca8f2b48e665b0afa8f7d656c402bfa38
- author
- mi-skam · · 2026-07-06 07:55 UTC
- parent
- 15563ff
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe 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 @@ | |||
| 1 | 1 | //go:build !windows | |
| 2 | 2 | ||
| 3 | - | package telegram | |
| 3 | + | package bot | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | //go:build !windows | 1 | //go:build !windows | ||
| 2 | 2 | ||||
| 3 | - | package telegram | 3 | + | 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 @@ | |||
| 1 | 1 | //go:build windows | |
| 2 | 2 | ||
| 3 | - | package telegram | |
| 3 | + | package bot | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | //go:build windows | 1 | //go:build windows | ||
| 2 | 2 | ||||
| 3 | - | package telegram | 3 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | "unicode/utf8" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 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 | |
| 9 | 9 | // boundaries when possible. | |
| 10 | - | func chunkMessage(s string, limit int) []string { | |
| 10 | + | func ChunkMessage(s string, limit int) []string { | |
| 11 | 11 | if limit <= 0 || utf8.RuneCountInString(s) <= limit { | |
| 12 | 12 | return []string{s} | |
| 13 | 13 | } | |
| @@ -63,9 +63,9 @@ func byteIndexAfterRunes(s string, n int) int { | |||
| 63 | 63 | return len(s) | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | // isImageMIME returns true for MIME types the model can probably ingest | |
| 66 | + | // IsImageMIME returns true for MIME types the model can probably ingest | |
| 67 | 67 | // as a vision input. | |
| 68 | - | func isImageMIME(m string) bool { | |
| 68 | + | func IsImageMIME(m string) bool { | |
| 69 | 69 | switch strings.ToLower(m) { | |
| 70 | 70 | case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp": | |
| 71 | 71 | return true | |
| @@ -73,10 +73,10 @@ func isImageMIME(m string) bool { | |||
| 73 | 73 | return false | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 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 | |
| 78 | 78 | // but getFile's file_path may omit the extension. | |
| 79 | - | func guessImageMIME(path string) string { | |
| 79 | + | func GuessImageMIME(path string) string { | |
| 80 | 80 | lower := strings.ToLower(path) | |
| 81 | 81 | switch { | |
| 82 | 82 | case strings.HasSuffix(lower, ".png"): | |
| @@ -1,13 +1,13 @@ | |||||
| 1 | - | package telegram | 1 | + | 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 line | 8 | + | // 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 ingest | 66 | + | // 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 true | 71 | return true | ||
| @@ -73,10 +73,10 @@ func isImageMIME(m string) bool { | |||||
| 73 | return false | 73 | return false | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | // guessImageMIME infers a mime type from a filename suffix. Falls back | 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 | 77 | + | // 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, | |||
| 178 | 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 | 182 | if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil { | |
| 183 | 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 | 202 | replyTo = n | |
| 203 | 203 | } | |
| 204 | 204 | } | |
| 205 | - | for _, chunk := range chunkMessage(text, 4000) { | |
| 205 | + | for _, chunk := range bot.ChunkMessage(text, 4000) { | |
| 206 | 206 | if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil { | |
| 207 | 207 | return err | |
| 208 | 208 | } | |
| @@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st | |||
| 252 | 252 | return nil, "", err | |
| 253 | 253 | } | |
| 254 | 254 | if mime == "" { | |
| 255 | - | mime = guessImageMIME(f.FilePath) | |
| 255 | + | mime = bot.GuessImageMIME(f.FilePath) | |
| 256 | 256 | } | |
| 257 | 257 | return data, mime, nil | |
| 258 | 258 | } | |
| @@ -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 = n | 202 | 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 err | 207 | return err | ||
| 208 | } | 208 | } | ||
| @@ -252,7 +252,7 @@ func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, st | |||||
| 252 | return nil, "", err | 252 | 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, nil | 257 | 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 ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/modes/bot" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -195,7 +196,7 @@ func (b *Bridge) sendToPaired(text, prefix string) { | |||
| 195 | 196 | if prefix != "" { | |
| 196 | 197 | text = prefix + text | |
| 197 | 198 | } | |
| 198 | - | for _, chunk := range chunkMessage(text, 4000) { | |
| 199 | + | for _, chunk := range bot.ChunkMessage(text, 4000) { | |
| 199 | 200 | if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil { | |
| 200 | 201 | fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err) | |
| 201 | 202 | return | |
| @@ -368,7 +369,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 368 | 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 | 373 | if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil { | |
| 373 | 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 | 399 | mime := mimeOverride | |
| 399 | 400 | if mime == "" { | |
| 400 | - | mime = guessImageMIME(f.FilePath) | |
| 401 | + | mime = bot.GuessImageMIME(f.FilePath) | |
| 401 | 402 | } | |
| 402 | 403 | return data, mime, nil | |
| 403 | 404 | } | |
| @@ -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 + text | 197 | 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 | return | 202 | 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 := mimeOverride | 399 | 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, nil | 403 | return data, mime, nil | ||
| 403 | } | 404 | } | ||
M packages/agent/modes/telegram/daemon.go +9 -56
| @@ -1,81 +1,34 @@ | |||
| 1 | 1 | package telegram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | 4 | "path/filepath" | |
| 8 | - | "strconv" | |
| 9 | - | "strings" | |
| 10 | 5 | "time" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/modes/bot" | |
| 11 | 8 | ) | |
| 12 | 9 | ||
| 13 | 10 | // 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") } | |
| 17 | 12 | ||
| 18 | 13 | // LogPath returns the location of the bot's log file (stdout+stderr | |
| 19 | 14 | // 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") } | |
| 23 | 16 | ||
| 24 | 17 | // 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) } | |
| 32 | 19 | ||
| 33 | 20 | // ReadPID returns the pid stored in bot.pid, or 0 if the file doesn't | |
| 34 | 21 | // 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)) } | |
| 49 | 23 | ||
| 50 | 24 | // 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)) } | |
| 58 | 26 | ||
| 59 | 27 | // IsRunning returns (pid, true) if a live process with the recorded | |
| 60 | 28 | // pid exists, or (pid, false) if the pid file points to a dead process. | |
| 61 | 29 | // 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)) } | |
| 76 | 31 | ||
| 77 | 32 | // StopProcess asks pid to exit and waits up to graceful for it to stop, | |
| 78 | 33 | // 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 telegram | 1 | 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+stderr | 13 | // 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't | 20 | // 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 recorded | 27 | // 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 | - | } | |||