diff --git a/internal/delivery/telegramsink/correction.go b/internal/delivery/telegramsink/correction.go new file mode 100644 index 0000000..c09d6de --- /dev/null +++ b/internal/delivery/telegramsink/correction.go @@ -0,0 +1,101 @@ +// correction.go — the correction gesture as it appears in the chat (V-637). +// Two taps at most: "не то" opens the seven intents, and one of them writes the +// label. The web's version of the same gesture is cmd/mavweb/chat.go. +package telegramsink + +import ( + "fmt" + "strconv" + "strings" +) + +// CorrectionTargets — the intents a correction may name, in the order the +// buttons are drawn. It mirrors the seven the web offers, and it is a closed +// list for the same reason: V-632 fits prototypes from the label table, and a +// label nothing can score is worse than no label. +var CorrectionTargets = []string{"fact", "note", "reminder", "query", "act", "chat", "system"} + +// correctionKeyboard — the one gesture beside the reply. Nothing when the turn +// did not persist: a button that cannot name a row would report a failure the +// owner cannot act on. +func (p *Poller) correctionKeyboard(traceID int64) *inlineKeyboard { + if traceID <= 0 || p.correct == nil { + return nil + } + return &inlineKeyboard{Rows: [][]inlineButton{{ + {Text: "не то", Data: fmt.Sprintf("%s%d", prefixAsk, traceID)}, + }}} +} + +// targetKeyboard — the seven intents, plus the cheap half kept reachable. He +// opened the row without knowing he had to name something, and closing it with +// no way out would price the negative he was willing to give. +func targetKeyboard(traceID int64) *inlineKeyboard { + var rows [][]inlineButton + row := []inlineButton{} + for _, t := range CorrectionTargets { + row = append(row, inlineButton{Text: t, Data: fmt.Sprintf("%s%d:%s", prefixTarget, traceID, t)}) + if len(row) == 4 { + rows, row = append(rows, row), nil + } + } + if len(row) > 0 { + rows = append(rows, row) + } + return &inlineKeyboard{Rows: append(rows, []inlineButton{ + {Text: "просто неверно", Data: fmt.Sprintf("%s%d:", prefixTarget, traceID)}, + })} +} + +// Callback data is capped at 64 bytes by telegram, so it carries the trace id +// and the target and nothing else. +const ( + prefixAsk = "w:" + prefixTarget = "t:" +) + +type callbackKind int + +const ( + callbackUnknown callbackKind = iota + callbackAskTarget + callbackTarget +) + +// parseCallback reads button data. An unparseable id, or a target that is not +// one of the seven, is callbackUnknown — the data came off the wire, and a +// label the fitting code cannot score is worse than no label. +func parseCallback(data string) (traceID int64, target string, kind callbackKind) { + switch { + case strings.HasPrefix(data, prefixAsk): + id, err := strconv.ParseInt(strings.TrimPrefix(data, prefixAsk), 10, 64) + if err != nil || id <= 0 { + return 0, "", callbackUnknown + } + return id, "", callbackAskTarget + case strings.HasPrefix(data, prefixTarget): + rest := strings.TrimPrefix(data, prefixTarget) + idPart, target, ok := strings.Cut(rest, ":") + if !ok { + return 0, "", callbackUnknown + } + id, err := strconv.ParseInt(idPart, 10, 64) + if err != nil || id <= 0 { + return 0, "", callbackUnknown + } + if target != "" && !isCorrectionTarget(target) { + return 0, "", callbackUnknown + } + return id, target, callbackTarget + } + return 0, "", callbackUnknown +} + +func isCorrectionTarget(s string) bool { + for _, t := range CorrectionTargets { + if t == s { + return true + } + } + return false +} diff --git a/internal/delivery/telegramsink/correction_test.go b/internal/delivery/telegramsink/correction_test.go new file mode 100644 index 0000000..b0c89f0 --- /dev/null +++ b/internal/delivery/telegramsink/correction_test.go @@ -0,0 +1,30 @@ +package telegramsink + +import "testing" + +// Button data comes off the wire. An unparseable id or an intent that is not one +// of the seven must not reach the label table V-632 fits prototypes from. +func TestParseCallbackRejectsWhatCannotBeALabel(t *testing.T) { + for _, data := range []string{ + "", "nonsense", "w:", "w:0", "w:-3", "w:abc", + "t:77", "t:0:note", "t:abc:note", "t:77:погода", "t:77:fact:extra", + } { + if _, _, kind := parseCallback(data); kind != callbackUnknown { + t.Errorf("%q was accepted, want callbackUnknown", data) + } + } + if id, target, kind := parseCallback("t:77:reminder"); id != 77 || target != "reminder" || kind != callbackTarget { + t.Errorf("got %d %q %v, want the reminder correction", id, target, kind) + } +} + +// Every intent the web offers has a button here, so a new intent cannot exist +// with no way to correct a chat turn into it. +func TestIntakeTargetsAreTheSeven(t *testing.T) { + if len(CorrectionTargets) != 7 { + t.Fatalf("%d targets, want the seven public intents", len(CorrectionTargets)) + } + if isCorrectionTarget("") { + t.Error("empty is the absence of a target, not one of them") + } +} diff --git a/internal/delivery/telegramsink/telegramsink.go b/internal/delivery/telegramsink/telegramsink.go index bbf157b..b4016ee 100644 --- a/internal/delivery/telegramsink/telegramsink.go +++ b/internal/delivery/telegramsink/telegramsink.go @@ -72,6 +72,13 @@ type Config struct { // Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the // tick loop. Timeout time.Duration + + // Intake — read the chat as well as write to it (V-637). Off by default, + // like the search and weather blocks: a bot that only pushes cannot be + // talked into anything, and turning that off has to stay a deletion. When + // set, a message from ChatID becomes a turn and its reply carries the + // correction gesture. ChatID is the only accepted sender. + Intake bool `json:"intake,omitempty"` } // Sink — implements delivery.Sink via the telegram bot sendMessage API. one @@ -130,6 +137,11 @@ type sendMessageReq struct { Text string `json:"text"` DisableNotification bool `json:"disable_notification"` // false = ring (always — these are alarms) ProtectContent bool `json:"protect_content"` // true = no forwarding out of chat + + // ReplyMarkup — the inline keyboard, used only by the intake half (V-637): + // a reply to a turn he typed carries the correction gesture. nil on every + // push the sink sends, and omitted from the wire when nil. + ReplyMarkup *inlineKeyboard `json:"reply_markup,omitempty"` } // telegramResp — the shape telegram returns. ok=false on logical error with