Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions adapters/vk/longpoll.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ type messageObject struct {
ConversationMessageID int64 `json:"conversation_message_id"` //nolint:tagliatelle // VK API uses snake_case.
Date int64 `json:"date"`
Attachments []attachment `json:"attachments"`
// ReplyMessage is the message this one replies to; FwdMessages are forwarded
// messages. Both are quoted originals the user may be referring to. The
// self-referential pointer/slice is fine — only one level is ever read (nested
// reply_message/fwd_messages are not traversed).
ReplyMessage *messageObject `json:"reply_message"` //nolint:tagliatelle // VK API uses snake_case.
FwdMessages []messageObject `json:"fwd_messages"` //nolint:tagliatelle // VK API uses snake_case.
}

// messageEventObject is the object of a message_event update (a callback button
Expand Down
71 changes: 71 additions & 0 deletions adapters/vk/receiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,11 @@ func (r *Receiver) onMessageNew(ctx context.Context, msg messageObject) {
if text == "" {
return
}
// Fold any quoted/replied-to/forwarded original into the prompt so the run sees
// the context the user is referring to, not just their new text. QuotedPrompt is
// a strict no-op when there is no quote, so a normal message stays unchanged.
qAuthor, qText := quotedContext(msg)
text = chat.QuotedPrompt(qAuthor, qText, text)
r.svc.Handle(ctx, chatIDStr(peerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text)
}

Expand Down Expand Up @@ -492,6 +497,10 @@ func (r *Receiver) handleVoice(ctx context.Context, msg messageObject, att *audi
r.notify(ctx, msg.PeerID, "Sorry, I couldn't make out that voice message.")
return
}
// VK voice does not flow through the text path, so fold any quoted original in
// here too, keeping voice replies uniform with text/media replies.
qAuthor, qText := quotedContext(msg)
text = chat.QuotedPrompt(qAuthor, qText, text)
r.svc.Handle(ctx, chatIDStr(msg.PeerID), msg.FromID, msgIDStr(msg.ConversationMessageID), text)
}

Expand All @@ -505,6 +514,8 @@ func (r *Receiver) handleDocument(ctx context.Context, msg messageObject, doc *d
return
}
prompt := chat.DocumentPrompt(savedPath, caption)
qAuthor, qText := quotedContext(msg)
prompt = chat.QuotedPrompt(qAuthor, qText, prompt)
r.svc.HandleMedia(ctx, chatIDStr(msg.PeerID), msg.FromID, msgIDStr(msg.ConversationMessageID), prompt, nil)
}

Expand All @@ -523,6 +534,8 @@ func (r *Receiver) handlePhoto(ctx context.Context, msg messageObject, ph *photo
return
}
prompt := chat.PhotoPrompt(savedPath, caption)
qAuthor, qText := quotedContext(msg)
prompt = chat.QuotedPrompt(qAuthor, qText, prompt)
images, err := chat.LoadPhotoImage(savedPath)
if err != nil {
r.logger.Warn("vk: load photo image failed; path-only", "peer_id", msg.PeerID, "error", err)
Expand Down Expand Up @@ -651,3 +664,61 @@ func largestPhoto(sizes []photoSize) *photoSize {
}
return best
}

// quotedAssistantLabel names a community/bot's own quoted message; quotedUserLabel
// is the coarse label for a human participant. Both keep the folded context author
// coarse — VK gives us only a from_id, and we deliberately avoid an extra users.get
// call to resolve a name.
const (
quotedAssistantLabel = "the assistant"
quotedUserLabel = "a participant"
)

// quotedContext extracts a quoted original from a VK message so it can be folded
// into the prompt (via chat.QuotedPrompt). It prefers the replied-to message
// (reply_message), else the first forwarded message (fwd_messages), reading ONE
// level only — nested reply/forward chains are not traversed. The quoted text is the
// source message's text, or a generic "[media]" placeholder when that message
// carries only attachments, so the reference is never silently dropped. An empty
// text means the message is neither a reply nor a forward, which keeps a normal
// message byte-for-byte unchanged. The author is coarse (no users.get lookup): a
// community/bot source (from_id < 0) is the assistant, a human (from_id > 0) is a
// participant, and an unknown source yields an empty label.
func quotedContext(msg messageObject) (author, text string) {
quoted, ok := firstQuoted(msg)
if !ok {
return "", ""
}
text = strings.TrimSpace(quoted.Text)
if text == "" && len(quoted.Attachments) > 0 {
text = "[media]"
}
return quotedAuthorLabel(quoted.FromID), text
}

// firstQuoted returns the single quoted source of a message — the replied-to
// message if present, else the first forwarded message — and whether one exists.
// It intentionally does not recurse into the quoted message's own reply/forwards.
func firstQuoted(msg messageObject) (messageObject, bool) {
if msg.ReplyMessage != nil {
return *msg.ReplyMessage, true
}
if len(msg.FwdMessages) > 0 {
return msg.FwdMessages[0], true
}
return messageObject{}, false
}

// quotedAuthorLabel maps a quoted message's from_id to a coarse author label with
// no users.get lookup: a negative id (a community / the bot) is the assistant, a
// positive id is a human participant, and zero is unknown (empty).
func quotedAuthorLabel(fromID int64) string {
switch {
case fromID < 0:
return quotedAssistantLabel
case fromID > 0:
return quotedUserLabel
default:
return ""
}
}
128 changes: 128 additions & 0 deletions adapters/vk/receiver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,134 @@ func TestReceiverMessageNewFromAllowedUser(t *testing.T) {
}
}

// TestReceiverFoldsReplyMessage: a reply_message is folded into the text prompt so
// the run sees the quoted original plus the user's new text.
func TestReceiverFoldsReplyMessage(t *testing.T) {
svc := &fakeService{}
r := newTestReceiver(svc, &fakeNotice{}, false, nil)

r.dispatch(context.Background(), msgNewUpdate(t, messageObject{
FromID: 42, PeerID: 200, Text: "и что с этим", ConversationMessageID: 10,
ReplyMessage: &messageObject{FromID: 42, Text: "Проверил функционал Git Runners"},
}))

if len(svc.handleCalls) != 1 {
t.Fatalf("Handle calls = %d, want 1", len(svc.handleCalls))
}
got := svc.handleCalls[0].prompt
if !strings.Contains(got, "Проверил функционал Git Runners") {
t.Errorf("prompt %q missing the quoted reply original", got)
}
if !strings.Contains(got, "и что с этим") {
t.Errorf("prompt %q missing the user's new text", got)
}
if !strings.Contains(got, quotedUserLabel) {
t.Errorf("prompt %q missing the participant author label", got)
}
}

// TestReceiverFoldsForwardedMessage: with no reply_message, the FIRST fwd_messages
// entry is used as the quoted original. A community source (from_id < 0) is labeled
// as the assistant.
func TestReceiverFoldsForwardedMessage(t *testing.T) {
svc := &fakeService{}
r := newTestReceiver(svc, &fakeNotice{}, false, nil)

r.dispatch(context.Background(), msgNewUpdate(t, messageObject{
FromID: 42, PeerID: 200, Text: "look at this", ConversationMessageID: 11,
FwdMessages: []messageObject{
{FromID: -100, Text: "forwarded original"},
{FromID: 42, Text: "second one, ignored"},
},
}))

if len(svc.handleCalls) != 1 {
t.Fatalf("Handle calls = %d, want 1", len(svc.handleCalls))
}
got := svc.handleCalls[0].prompt
if !strings.Contains(got, "forwarded original") || !strings.Contains(got, "look at this") {
t.Errorf("prompt %q missing the forwarded original or the user text", got)
}
if strings.Contains(got, "second one, ignored") {
t.Errorf("prompt %q folded more than the first forwarded message", got)
}
if !strings.Contains(got, quotedAssistantLabel) {
t.Errorf("prompt %q should label the community-sourced quote as the assistant", got)
}
}

// TestReceiverMediaOnlyReplyPlaceholder: a reply_message that carries only an
// attachment (no text) yields a generic "[media]" reference rather than being
// silently dropped.
func TestReceiverMediaOnlyReplyPlaceholder(t *testing.T) {
svc := &fakeService{}
r := newTestReceiver(svc, &fakeNotice{}, false, nil)

r.dispatch(context.Background(), msgNewUpdate(t, messageObject{
FromID: 42, PeerID: 200, Text: "про это", ConversationMessageID: 12,
ReplyMessage: &messageObject{
FromID: 42,
Attachments: []attachment{{Type: "photo", Photo: &photoAttachment{}}},
},
}))

if len(svc.handleCalls) != 1 {
t.Fatalf("Handle calls = %d, want 1", len(svc.handleCalls))
}
got := svc.handleCalls[0].prompt
if !strings.Contains(got, "[media]") {
t.Errorf("prompt %q missing the [media] placeholder for a media-only reply", got)
}
if !strings.Contains(got, "про это") {
t.Errorf("prompt %q missing the user's new text", got)
}
}

// TestReceiverNoReplyPromptUnchanged: a message with neither reply_message nor
// fwd_messages is submitted byte-for-byte unchanged (the QuotedPrompt no-op).
func TestReceiverNoReplyPromptUnchanged(t *testing.T) {
svc := &fakeService{}
r := newTestReceiver(svc, &fakeNotice{}, false, nil)

const userText = "just a normal message"
r.dispatch(context.Background(), msgNewUpdate(t, messageObject{
FromID: 42, PeerID: 200, Text: userText, ConversationMessageID: 13,
}))

if len(svc.handleCalls) != 1 {
t.Fatalf("Handle calls = %d, want 1", len(svc.handleCalls))
}
if svc.handleCalls[0].prompt != userText {
t.Errorf("non-reply prompt = %q, want it byte-for-byte unchanged (%q)", svc.handleCalls[0].prompt, userText)
}
}

// TestReceiverFoldsMediaOnlyForward: a forwarded message that carries only an
// attachment (no text) yields the generic [media] reference, the same as a media-only
// reply — so the forwarded reference is never silently dropped.
func TestReceiverFoldsMediaOnlyForward(t *testing.T) {
svc := &fakeService{}
r := newTestReceiver(svc, &fakeNotice{}, false, nil)

r.dispatch(context.Background(), msgNewUpdate(t, messageObject{
FromID: 42, PeerID: 200, Text: "смотри", ConversationMessageID: 14,
FwdMessages: []messageObject{
{FromID: 42, Attachments: []attachment{{Type: "doc", Doc: &docAttachment{}}}},
},
}))

if len(svc.handleCalls) != 1 {
t.Fatalf("Handle calls = %d, want 1", len(svc.handleCalls))
}
got := svc.handleCalls[0].prompt
if !strings.Contains(got, "[media]") {
t.Errorf("prompt %q missing the [media] placeholder for a media-only forward", got)
}
if !strings.Contains(got, "смотри") {
t.Errorf("prompt %q missing the user's new text", got)
}
}

func TestReceiverIgnoresDisallowedAndCommunityMessages(t *testing.T) {
svc := &fakeService{}
r := newTestReceiver(svc, &fakeNotice{}, false, nil)
Expand Down
74 changes: 74 additions & 0 deletions cmd/flock-telegram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,13 @@ func handleMessage(ctx context.Context, deps messageDeps, b *bot.Bot, msg *model
return
}

// Fold any quoted/replied-to original into the prompt so the run sees the
// context the user is referring to, not just their new text (voice is covered
// too: its transcript became `text` above). QuotedPrompt is a strict no-op when
// there is no quote, so a non-reply message stays byte-for-byte identical.
qAuthor, qText := quotedContext(b, msg)
text = chat.QuotedPrompt(qAuthor, qText, text)

// Submit hands the work to the chat's own goroutine and is effectively
// non-blocking until that chat's queue buffer fills (then it applies
// back-pressure); during shutdown the job is cleanly dropped. Either way no
Expand Down Expand Up @@ -630,6 +637,8 @@ func handleDocument(
return
}
prompt := chat.DocumentPrompt(savedPath, msg.Caption)
qAuthor, qText := quotedContext(b, msg)
prompt = chat.QuotedPrompt(qAuthor, qText, prompt)
submitMedia(ctx, service, msg, prompt, nil, isEdit)
}

Expand Down Expand Up @@ -660,6 +669,8 @@ func handlePhoto(
return
}
prompt := chat.PhotoPrompt(savedPath, msg.Caption)
qAuthor, qText := quotedContext(b, msg)
prompt = chat.QuotedPrompt(qAuthor, qText, prompt)
images, err := chat.LoadPhotoImage(savedPath)
if err != nil {
// Vision attach failed (e.g. unreadable file); fall back to a path-only run
Expand Down Expand Up @@ -725,6 +736,69 @@ func botID(b *bot.Bot) int64 {
return b.ID()
}

// assistantAuthorLabel names the bot's own earlier message when it is the quoted
// original, so the folded context reads as coming from the assistant rather than a
// human participant.
const assistantAuthorLabel = "the assistant"

// quotedContext extracts the quoted/replied-to original from a message so it can be
// folded into the prompt (via chat.QuotedPrompt). It returns an empty text when the
// message is not a reply/quote, which keeps a normal message byte-for-byte
// unchanged. The quoted text prefers the exact highlighted portion (msg.Quote.Text),
// then the replied-to message's text, then its caption, then a generic "[media]"
// placeholder for any reply with no usable text — so the reference is never silently
// dropped. The author is derived from the replied-to message's sender (empty for a
// quote-only selection with no sender), labeled as the assistant when it is the
// bot's own message.
func quotedContext(b *bot.Bot, msg *models.Message) (author, text string) {
if msg == nil {
return "", ""
}
reply := msg.ReplyToMessage
switch {
case msg.Quote != nil && strings.TrimSpace(msg.Quote.Text) != "":
text = msg.Quote.Text
case reply != nil && reply.Text != "":
text = reply.Text
case reply != nil && reply.Caption != "":
text = reply.Caption
case reply != nil:
// A reply to a message with no text or caption — a photo, file, voice, poll,
// location, and so on: keep a generic reference so the quote is never dropped.
text = "[media]"
}
if reply != nil {
author = quotedAuthorLabel(b, reply.From)
}
return author, text
}

// quotedAuthorLabel derives a short author label for a replied-to message's sender.
// Only the bot's OWN message (the same identity check as replyToBot: from.ID ==
// botID) is labeled as the assistant; any other sender — a human or a third-party
// bot — yields "FirstName (@username)" (or whichever part is present), and an unknown
// sender yields an empty label.
func quotedAuthorLabel(b *bot.Bot, from *models.User) string {
if from == nil {
return ""
}
if id := botID(b); id != 0 && from.ID == id {
return assistantAuthorLabel
}
name := strings.TrimSpace(from.FirstName)
username := strings.TrimSpace(from.Username)
switch {
case name != "" && username != "":
return name + " (@" + username + ")"
case name != "":
return name
case username != "":
return "@" + username
default:
return ""
}
}

// toGateEntities maps Telegram message entities to the transport-agnostic
// chat.Entity used by the gate, keeping only the mention kinds it cares
// about.
Expand Down
12 changes: 7 additions & 5 deletions cmd/flock-telegram/media_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ type mediaTestHarness struct {
srvCleanup func()
}

func newMediaHarness(t *testing.T, maxUpload int64) *mediaTestHarness {
func newMediaHarness(t *testing.T) *mediaTestHarness {
t.Helper()
var downloads int32
const content = "FILECONTENT-1234567890"
Expand Down Expand Up @@ -121,7 +121,9 @@ func newMediaHarness(t *testing.T, maxUpload int64) *mediaTestHarness {

logger := slog.New(slog.DiscardHandler)
uploadsRoot := t.TempDir()
up := telegram.NewUploader(telegram.NewBotFileSource(b), srv.Client(), &fakeUploads{root: uploadsRoot}, maxUpload, logger)
// 0 lets NewUploader apply its default download cap; the harness never exercises
// the size limit, so no test needs to vary it.
up := telegram.NewUploader(telegram.NewBotFileSource(b), srv.Client(), &fakeUploads{root: uploadsRoot}, 0, logger)

runner := &recordingRunner{}
disp := dispatch.New(2)
Expand All @@ -146,7 +148,7 @@ func (h *mediaTestHarness) downloadCount() int32 { return atomic.LoadInt32(h.dow
// mention-gate rejects (group chat, mention required, no @mention) must perform
// ZERO downloads and ZERO submits.
func TestMediaGateRejectionZeroWork(t *testing.T) {
h := newMediaHarness(t, 0)
h := newMediaHarness(t)
defer h.srvCleanup()

cfg := config.Config{
Expand Down Expand Up @@ -177,7 +179,7 @@ func TestMediaGateRejectionZeroWork(t *testing.T) {
// TestDocumentRoutingSubmitsPromptWithPath is AC1: an accepted document upload is
// downloaded once and submitted with a prompt referencing the saved path.
func TestDocumentRoutingSubmitsPromptWithPath(t *testing.T) {
h := newMediaHarness(t, 0)
h := newMediaHarness(t)
defer h.srvCleanup()

cfg := config.Config{AllowedUsers: []int64{10}}
Expand Down Expand Up @@ -215,7 +217,7 @@ func TestDocumentRoutingSubmitsPromptWithPath(t *testing.T) {
// AND attached as a vision image block, with the prompt referencing the saved
// path; the largest photo size is chosen.
func TestPhotoRoutingAttachesImageAndPath(t *testing.T) {
h := newMediaHarness(t, 0)
h := newMediaHarness(t)
defer h.srvCleanup()

cfg := config.Config{AllowedUsers: []int64{10}}
Expand Down
Loading