From ac8d5c280351a7e6f65a7a96b6583253acc46745 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 01:44:48 +0000 Subject: [PATCH] Add Telegram file upload directive --- .codex/skills/telegram-file/SKILL.md | 24 +++++++++ internal/telegram/bot.go | 73 +++++++++++++++++++++++++--- internal/telegram/render_test.go | 20 +++++++- 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 .codex/skills/telegram-file/SKILL.md diff --git a/.codex/skills/telegram-file/SKILL.md b/.codex/skills/telegram-file/SKILL.md new file mode 100644 index 0000000..14176a4 --- /dev/null +++ b/.codex/skills/telegram-file/SKILL.md @@ -0,0 +1,24 @@ +--- +name: telegram-file +description: Use when Codex should send, show, upload, or share a local non-image file into the Telegram chat through the bot without calling Telegram tools. +metadata: + short-description: Send Telegram files from assistant output +--- + +# Telegram File + +When asked to send/show/upload/share a local file in Telegram, emit a file directive in normal assistant output. The bot strips the directive and sends the file as a Telegram document. + +Use exactly one directive line per file, outside code fences: + +`` + +Rules: +- Replace `` with an absolute path that the bot container can read. +- The source file may live anywhere on the host, but the bot runs in Docker; before emitting the directive, make sure the file is inside a workspace under the configured playground base directory or another path explicitly mounted into the bot container. +- If the file is outside container-visible paths, copy it into an appropriate workspace-local location first, then use the copied file's absolute path in the directive. +- Do not hardcode machine-specific directories, user names, repository paths, workspace names, or sample filenames in this skill. +- Use `telegram-photo` instead for image files that should appear as Telegram photos rather than generic documents. +- `caption` is optional and should be short; omit the `caption` field when no caption is needed. +- Do not use external Telegram tool calls for this. +- If no usable container-visible file path is known, ask for the path or explain what local file is needed. diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 02a43d0..0f14ac1 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -26,11 +26,12 @@ const ( resumeThreadPageSize = 8 assistantStreamEditInterval = 1200 * time.Millisecond assistantStreamInitialRunes = 24 + telegramFileDirectiveStart = "" - telegramPhotoCaptionLimit = 1024 + telegramCaptionLimit = 1024 pictureMediaGroupLimit = 10 ) @@ -54,11 +55,17 @@ type Bot struct { type assistantMessageSegment struct { Text string + File *assistantFileDirective Photo *assistantPhotoDirective ThreadRename *assistantThreadRenameDirective ThreadCWD *assistantThreadCWDDirective } +type assistantFileDirective struct { + Path string `json:"path"` + Caption string `json:"caption,omitempty"` +} + type assistantPhotoDirective struct { Path string `json:"path"` Caption string `json:"caption,omitempty"` @@ -2580,6 +2587,11 @@ func splitAssistantMessageSegments(text string) []assistantMessageSegment { for _, line := range strings.SplitAfter(text, "\n") { body := strings.TrimSuffix(line, "\n") body = strings.TrimSuffix(body, "\r") + if directive, ok := parseAssistantFileDirectiveLine(body); ok { + flushVisible() + segments = append(segments, assistantMessageSegment{File: &directive}) + continue + } if directive, ok := parseAssistantPhotoDirectiveLine(body); ok { flushVisible() segments = append(segments, assistantMessageSegment{Photo: &directive}) @@ -2631,6 +2643,22 @@ func parseAssistantThreadCWDDirectiveLine(line string) (assistantThreadCWDDirect return directive, true } +func parseAssistantFileDirectiveLine(line string) (assistantFileDirective, bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, telegramFileDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) { + return assistantFileDirective{}, false + } + raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramFileDirectiveStart), telegramDirectiveEnd) + raw = strings.TrimSpace(raw) + var directive assistantFileDirective + if err := json.Unmarshal([]byte(raw), &directive); err != nil { + return assistantFileDirective{}, false + } + directive.Path = strings.TrimSpace(directive.Path) + directive.Caption = strings.TrimSpace(directive.Caption) + return directive, true +} + func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, bool) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) { @@ -2654,6 +2682,14 @@ func (b *Bot) sendAssistantText(ctx context.Context, threadID string, chatID int return err } } + if segment.File != nil { + if err := b.sendAssistantFile(ctx, chatID, *segment.File); err != nil { + b.logger.Printf("send assistant file: %v", err) + if sendErr := b.sendLong(ctx, chatID, "Could not send file: "+err.Error()); sendErr != nil { + return sendErr + } + } + } if segment.Photo != nil { if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil { b.logger.Printf("send assistant photo: %v", err) @@ -2721,6 +2757,25 @@ func (b *Bot) applyAssistantThreadCWD(ctx context.Context, threadID string, dire return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID) } +func (b *Bot) sendAssistantFile(ctx context.Context, chatID int64, directive assistantFileDirective) error { + path := strings.TrimSpace(directive.Path) + if path == "" { + return errors.New("file directive is missing a path") + } + if !filepath.IsAbs(path) { + return fmt.Errorf("file path must be absolute: %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %v", filepath.Base(path), err) + } + caption := truncateTelegramCaption(directive.Caption) + if _, err := b.tg.SendDocumentBytes(ctx, chatID, path, data, caption); err != nil { + return fmt.Errorf("send %s: %v", filepath.Base(path), err) + } + return nil +} + func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive assistantPhotoDirective) error { path := strings.TrimSpace(directive.Path) if path == "" { @@ -2736,22 +2791,22 @@ func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive as if err != nil { return fmt.Errorf("read %s: %v", filepath.Base(path), err) } - caption := truncateTelegramPhotoCaption(directive.Caption) + caption := truncateTelegramCaption(directive.Caption) if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil { return fmt.Errorf("send %s: %v", filepath.Base(path), err) } return nil } -func truncateTelegramPhotoCaption(caption string) string { +func truncateTelegramCaption(caption string) string { runes := []rune(caption) - if len(runes) <= telegramPhotoCaptionLimit { + if len(runes) <= telegramCaptionLimit { return caption } - if telegramPhotoCaptionLimit <= 3 { - return string(runes[:telegramPhotoCaptionLimit]) + if telegramCaptionLimit <= 3 { + return string(runes[:telegramCaptionLimit]) } - return string(runes[:telegramPhotoCaptionLimit-3]) + "..." + return string(runes[:telegramCaptionLimit-3]) + "..." } func assistantStreamPreview(text string) string { @@ -2768,7 +2823,9 @@ func assistantStreamPreview(text string) string { } func isAssistantDirectiveStart(line string) bool { - return strings.HasPrefix(line, telegramPhotoDirectiveStart) || + return strings.HasPrefix(line, telegramFileDirectiveStart) || + strings.HasPrefix(line, strings.TrimSpace(telegramFileDirectiveStart)) || + strings.HasPrefix(line, telegramPhotoDirectiveStart) || strings.HasPrefix(line, strings.TrimSpace(telegramPhotoDirectiveStart)) || strings.HasPrefix(line, telegramThreadRenameDirectiveStart) || strings.HasPrefix(line, strings.TrimSpace(telegramThreadRenameDirectiveStart)) || diff --git a/internal/telegram/render_test.go b/internal/telegram/render_test.go index 67a4bb0..95a79d4 100644 --- a/internal/telegram/render_test.go +++ b/internal/telegram/render_test.go @@ -252,6 +252,24 @@ func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) { } } +func TestSplitAssistantMessageSegmentsWithFileDirective(t *testing.T) { + filePath := filepath.Join(string(filepath.Separator), "workspace", "report.pdf") + text := fmt.Sprintf("before\n\nafter", filePath) + segments := splitAssistantMessageSegments(text) + if len(segments) != 3 { + t.Fatalf("segments = %d, want 3: %#v", len(segments), segments) + } + if segments[0].Text != "before\n" || segments[0].File != nil { + t.Fatalf("unexpected first segment: %#v", segments[0]) + } + if segments[1].File == nil || segments[1].File.Path != filePath || segments[1].File.Caption != "report" { + t.Fatalf("unexpected file segment: %#v", segments[1]) + } + if segments[2].Text != "after" || segments[2].File != nil { + t.Fatalf("unexpected final segment: %#v", segments[2]) + } +} + func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) { text := "" segments := splitAssistantMessageSegments(text) @@ -261,7 +279,7 @@ func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) { } func TestAssistantStreamPreviewHidesDirectives(t *testing.T) { - text := "before\n\nafter\n\n\nafter\n