Add Telegram file upload directive

This commit is contained in:
Codex
2026-06-08 01:44:48 +00:00
parent 739b6cd870
commit ac8d5c2803
3 changed files with 108 additions and 9 deletions

View File

@@ -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:
`<!-- telegram-file {"path":"<absolute-local-file-path>","caption":"<optional caption>"} -->`
Rules:
- Replace `<absolute-local-file-path>` 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.

View File

@@ -26,11 +26,12 @@ const (
resumeThreadPageSize = 8 resumeThreadPageSize = 8
assistantStreamEditInterval = 1200 * time.Millisecond assistantStreamEditInterval = 1200 * time.Millisecond
assistantStreamInitialRunes = 24 assistantStreamInitialRunes = 24
telegramFileDirectiveStart = "<!-- telegram-file "
telegramPhotoDirectiveStart = "<!-- telegram-photo " telegramPhotoDirectiveStart = "<!-- telegram-photo "
telegramThreadRenameDirectiveStart = "<!-- codex-thread-rename " telegramThreadRenameDirectiveStart = "<!-- codex-thread-rename "
telegramThreadCWDDirectiveStart = "<!-- codex-thread-cwd " telegramThreadCWDDirectiveStart = "<!-- codex-thread-cwd "
telegramDirectiveEnd = " -->" telegramDirectiveEnd = " -->"
telegramPhotoCaptionLimit = 1024 telegramCaptionLimit = 1024
pictureMediaGroupLimit = 10 pictureMediaGroupLimit = 10
) )
@@ -54,11 +55,17 @@ type Bot struct {
type assistantMessageSegment struct { type assistantMessageSegment struct {
Text string Text string
File *assistantFileDirective
Photo *assistantPhotoDirective Photo *assistantPhotoDirective
ThreadRename *assistantThreadRenameDirective ThreadRename *assistantThreadRenameDirective
ThreadCWD *assistantThreadCWDDirective ThreadCWD *assistantThreadCWDDirective
} }
type assistantFileDirective struct {
Path string `json:"path"`
Caption string `json:"caption,omitempty"`
}
type assistantPhotoDirective struct { type assistantPhotoDirective struct {
Path string `json:"path"` Path string `json:"path"`
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
@@ -2580,6 +2587,11 @@ func splitAssistantMessageSegments(text string) []assistantMessageSegment {
for _, line := range strings.SplitAfter(text, "\n") { for _, line := range strings.SplitAfter(text, "\n") {
body := strings.TrimSuffix(line, "\n") body := strings.TrimSuffix(line, "\n")
body = strings.TrimSuffix(body, "\r") 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 { if directive, ok := parseAssistantPhotoDirectiveLine(body); ok {
flushVisible() flushVisible()
segments = append(segments, assistantMessageSegment{Photo: &directive}) segments = append(segments, assistantMessageSegment{Photo: &directive})
@@ -2631,6 +2643,22 @@ func parseAssistantThreadCWDDirectiveLine(line string) (assistantThreadCWDDirect
return directive, true 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) { func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, bool) {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) { 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 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 segment.Photo != nil {
if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil { if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil {
b.logger.Printf("send assistant photo: %v", err) 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) 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 { func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive assistantPhotoDirective) error {
path := strings.TrimSpace(directive.Path) path := strings.TrimSpace(directive.Path)
if path == "" { if path == "" {
@@ -2736,22 +2791,22 @@ func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive as
if err != nil { if err != nil {
return fmt.Errorf("read %s: %v", filepath.Base(path), err) 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 { if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil {
return fmt.Errorf("send %s: %v", filepath.Base(path), err) return fmt.Errorf("send %s: %v", filepath.Base(path), err)
} }
return nil return nil
} }
func truncateTelegramPhotoCaption(caption string) string { func truncateTelegramCaption(caption string) string {
runes := []rune(caption) runes := []rune(caption)
if len(runes) <= telegramPhotoCaptionLimit { if len(runes) <= telegramCaptionLimit {
return caption return caption
} }
if telegramPhotoCaptionLimit <= 3 { if telegramCaptionLimit <= 3 {
return string(runes[:telegramPhotoCaptionLimit]) return string(runes[:telegramCaptionLimit])
} }
return string(runes[:telegramPhotoCaptionLimit-3]) + "..." return string(runes[:telegramCaptionLimit-3]) + "..."
} }
func assistantStreamPreview(text string) string { func assistantStreamPreview(text string) string {
@@ -2768,7 +2823,9 @@ func assistantStreamPreview(text string) string {
} }
func isAssistantDirectiveStart(line string) bool { 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, strings.TrimSpace(telegramPhotoDirectiveStart)) ||
strings.HasPrefix(line, telegramThreadRenameDirectiveStart) || strings.HasPrefix(line, telegramThreadRenameDirectiveStart) ||
strings.HasPrefix(line, strings.TrimSpace(telegramThreadRenameDirectiveStart)) || strings.HasPrefix(line, strings.TrimSpace(telegramThreadRenameDirectiveStart)) ||

View File

@@ -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<!-- telegram-file {\"path\":%q,\"caption\":\"report\"} -->\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) { func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
text := "<!-- telegram-photo not-json -->" text := "<!-- telegram-photo not-json -->"
segments := splitAssistantMessageSegments(text) segments := splitAssistantMessageSegments(text)
@@ -261,7 +279,7 @@ func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
} }
func TestAssistantStreamPreviewHidesDirectives(t *testing.T) { func TestAssistantStreamPreviewHidesDirectives(t *testing.T) {
text := "before\n<!-- telegram-photo {\"path\":\"/workspace/photo.jpg\"} -->\nafter\n<!-- codex-thread-rename " text := "before\n<!-- telegram-file {\"path\":\"/workspace/report.pdf\"} -->\n<!-- telegram-photo {\"path\":\"/workspace/photo.jpg\"} -->\nafter\n<!-- codex-thread-rename "
got := assistantStreamPreview(text) got := assistantStreamPreview(text)
want := "before\nafter\n" want := "before\nafter\n"
if got != want { if got != want {