Add /tldr: a private overview of what you missed in a channel
/tldr [since] summarizes the messages in the channel it is run in that the caller has not been summarized yet, using a small local model on Ollama, and replies ephemerally. The answer streams into the reply as it is written. - Per-user, per-channel cursor (tldr_cursors) so each run picks up where the last left off; `since:` (e.g. 6h, 2d, max 7d) peeks without moving it. First use covers the last 24h, and a stale cursor never reaches back more than 7 days. - Only summarizes channels both the caller and the bot can read. - The transcript drops bots, system messages, reactions and filler, turns links, GIFs, images and files into short tags ([link: site], [image], [file: name], preview titles), marks replies, and writes a date marker where the chat paused for 3h or more so the model can keep topics apart. - The prompt asks for one bullet per conversation, oldest first, with a worked example; it was tuned against a synthetic chat and a real export with qwen3:4b-instruct-2507 (the default model). - One model call at a time across the bot, one /tldr per user. - docker-compose: Ollama service (CPU only, 8g memory cap: 6g killed the runner mid-request), model pull on startup. Needs the tldr_cursors migration (velox-dashboard-api 000016). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b1ea5f17e3
commit
7ae1fa5747
21 files changed
+2423
-2
No files matched your search
@@ -0,0 +1,673 @@
|
||||
package tldr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func TestParseSince(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
err bool
|
||||
}{
|
||||
{"30m", 30 * time.Minute, false},
|
||||
{"6h", 6 * time.Hour, false},
|
||||
{"2d", 48 * time.Hour, false},
|
||||
{" 2D ", 48 * time.Hour, false},
|
||||
{"30d", maxWindow, false},
|
||||
{"9999h", maxWindow, false},
|
||||
{"0h", 0, true},
|
||||
{"", 0, true},
|
||||
{"1h30m", 0, true},
|
||||
{"yesterday", 0, true},
|
||||
{"-5m", 0, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ParseSince(c.in)
|
||||
if (err != nil) != c.err {
|
||||
t.Errorf("ParseSince(%q) err = %v, want err=%v", c.in, err, c.err)
|
||||
continue
|
||||
}
|
||||
if !c.err && got != c.want {
|
||||
t.Errorf("ParseSince(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnowflakeFromTime(t *testing.T) {
|
||||
// Known pairing: a snowflake's timestamp is (id >> 22) + discord epoch.
|
||||
ts := time.UnixMilli(1700000000000)
|
||||
id, err := strconv.ParseInt(SnowflakeFromTime(ts), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (id >> 22) + discordEpochMs; got != 1700000000000 {
|
||||
t.Errorf("round trip = %d", got)
|
||||
}
|
||||
if SnowflakeFromTime(time.UnixMilli(0)) != "0" {
|
||||
t.Errorf("pre-epoch time should clamp to 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIDs(t *testing.T) {
|
||||
// Lexical comparison would get "9" vs "10" wrong.
|
||||
if CompareIDs("9", "10") >= 0 || CompareIDs("10", "9") <= 0 || CompareIDs("5", "5") != 0 {
|
||||
t.Error("numeric comparison broken")
|
||||
}
|
||||
}
|
||||
|
||||
func msg(id, author, content string) *discordgo.Message {
|
||||
return &discordgo.Message{
|
||||
ID: id,
|
||||
Content: content,
|
||||
Author: &discordgo.User{ID: author, Username: author},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondense(t *testing.T) {
|
||||
bot := msg("1", "bot", "hi")
|
||||
bot.Author.Bot = true
|
||||
system := msg("2", "a", "joined")
|
||||
system.Type = discordgo.MessageTypeGuildMemberJoin
|
||||
attachOnly := msg("3", "a", "")
|
||||
attachOnly.Attachments = []*discordgo.MessageAttachment{{}}
|
||||
withAttach := msg("4", "a", "olha a foto")
|
||||
withAttach.Attachments = []*discordgo.MessageAttachment{{}}
|
||||
mention := msg("5", "a", "hey <@42> and <@!42>\n\nlook")
|
||||
mention.Mentions = []*discordgo.User{{ID: "42", Username: "bob", GlobalName: "Bob"}}
|
||||
long := msg("6", "a", strings.Repeat("x", 900))
|
||||
|
||||
for name, m := range map[string]*discordgo.Message{
|
||||
"bot": bot,
|
||||
"system": system,
|
||||
"blank": msg("7", "a", " \n "),
|
||||
"attachment only": attachOnly,
|
||||
"emoji only": msg("8", "a", "👍🔥"),
|
||||
"custom emoji": msg("9", "a", "<:pog:123456789>"),
|
||||
"link only": msg("10", "a", "https://example.com/some/very/long/path?x=1"),
|
||||
"filler ok": msg("11", "a", "Ok!"),
|
||||
"filler lol": msg("12", "a", "lol"),
|
||||
"filler kkk": msg("13", "a", "kkkk"),
|
||||
} {
|
||||
if _, _, ok := condense(m); ok {
|
||||
t.Errorf("%s should be dropped", name)
|
||||
}
|
||||
}
|
||||
|
||||
check := func(m *discordgo.Message, want string) {
|
||||
t.Helper()
|
||||
_, got, ok := condense(m)
|
||||
if !ok || got != want {
|
||||
t.Errorf("condense(%q) = %q, %v; want %q", m.Content, got, ok, want)
|
||||
}
|
||||
}
|
||||
check(msg("20", "a", "sim"), "sim") // short answers can be votes, keep them
|
||||
check(msg("21", "a", "olha isto https://www.example.com/x?y=1 fixe"), "olha isto [link: example.com] fixe")
|
||||
check(msg("22", "a", "vejam <https://youtu.be/abc>."), "vejam [link: youtu.be].")
|
||||
check(msg("23", "a", "haha https://tenor.com/view/cat-123 mesmo"), "haha [gif] mesmo")
|
||||
check(msg("24", "a", "foto https://cdn.discordapp.com/a/b/c.PNG?ex=1 ok"), "foto [image] ok")
|
||||
check(withAttach, "olha a foto [attachment]")
|
||||
check(mention, "hey @Bob and @Bob look")
|
||||
|
||||
_, got, _ := condense(long)
|
||||
if r := []rune(got); len(r) != maxPerMsgChars+1 {
|
||||
t.Errorf("long message = %d runes, want %d", len(r), maxPerMsgChars+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptHasExample(t *testing.T) {
|
||||
// The prompt that was benchmarked is the instructions followed by one worked example.
|
||||
if !strings.HasPrefix(systemPrompt, instructions+"\n\nExample.\nMessages:\n") || !strings.Contains(systemPrompt, "Overview:\n- ") {
|
||||
t.Error("system prompt should be the instructions plus the worked example")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondenseRichContent(t *testing.T) {
|
||||
check := func(m *discordgo.Message, want string) {
|
||||
t.Helper()
|
||||
_, got, ok := condense(m)
|
||||
if !ok || got != want {
|
||||
t.Errorf("condense(%q) = %q, %v; want %q", m.Content, got, ok, want)
|
||||
}
|
||||
}
|
||||
dropped := func(name string, m *discordgo.Message) {
|
||||
t.Helper()
|
||||
if _, got, ok := condense(m); ok {
|
||||
t.Errorf("%s should be dropped, got %q", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
photo := msg("1", "a", "olha")
|
||||
photo.Attachments = []*discordgo.MessageAttachment{{Filename: "IMG_1.jpg", ContentType: "image/jpeg"}, {Filename: "x.gif"}}
|
||||
check(photo, "olha [image] [gif]")
|
||||
|
||||
photoOnly := msg("2", "a", "")
|
||||
photoOnly.Attachments = []*discordgo.MessageAttachment{{Filename: "IMG_1.jpg", ContentType: "image/jpeg"}}
|
||||
dropped("photo only", photoOnly)
|
||||
|
||||
doc := msg("3", "a", "")
|
||||
doc.Attachments = []*discordgo.MessageAttachment{{Filename: "orçamento-setembro.pdf", ContentType: "application/pdf"}}
|
||||
check(doc, "[file: orçamento-setembro.pdf]")
|
||||
|
||||
video := msg("4", "a", "vejam https://youtu.be/abc")
|
||||
video.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeVideo, Title: " Como renovar certificados TLS "}}
|
||||
check(video, `vejam [link: youtu.be] (preview: "Como renovar certificados TLS")`)
|
||||
|
||||
linkOnly := msg("5", "a", "https://youtu.be/abc")
|
||||
linkOnly.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeVideo, Title: "Tutorial de Grafana"}}
|
||||
check(linkOnly, `[link: youtu.be] (preview: "Tutorial de Grafana")`)
|
||||
|
||||
gif := msg("6", "a", "https://tenor.com/view/cat-123")
|
||||
gif.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeGifv, Title: "cat"}}
|
||||
dropped("gif only", gif)
|
||||
|
||||
ref := msg("7", "Ana", "")
|
||||
ref.Author.GlobalName = "Ana"
|
||||
reply := msg("8", "Rui", "concordo")
|
||||
reply.Type = discordgo.MessageTypeReply
|
||||
reply.ReferencedMessage = ref
|
||||
check(reply, "(reply to Ana) concordo")
|
||||
|
||||
selfReply := msg("9", "Ana", "e mais uma coisa")
|
||||
selfReply.Type = discordgo.MessageTypeReply
|
||||
selfReply.ReferencedMessage = ref
|
||||
check(selfReply, "e mais uma coisa")
|
||||
|
||||
deleted := msg("10", "Rui", "ok mas porquê")
|
||||
deleted.Type = discordgo.MessageTypeReply
|
||||
check(deleted, "ok mas porquê")
|
||||
}
|
||||
|
||||
func TestBuildTranscriptMergesAuthors(t *testing.T) {
|
||||
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "dev", Messages: []*discordgo.Message{
|
||||
msg("1", "Ana", "primeira"),
|
||||
msg("2", "Ana", "segunda"),
|
||||
msg("3", "Rui", "lol"), // filler between two Ana messages is dropped
|
||||
msg("4", "Ana", "terceira"),
|
||||
msg("5", "Rui", "resposta"),
|
||||
}}})
|
||||
|
||||
want := "Ana: primeira / segunda / terceira\nRui: resposta\n"
|
||||
if len(tr.Chunks) != 1 || tr.Chunks[0] != want {
|
||||
t.Errorf("chunk = %q, want %q", tr.Chunks[0], want)
|
||||
}
|
||||
if tr.Messages != 4 || tr.LastProcessed["1"] != "5" {
|
||||
t.Errorf("messages=%d cursor=%s", tr.Messages, tr.LastProcessed["1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscriptCutsLongRunsOfOneAuthor(t *testing.T) {
|
||||
var msgs []*discordgo.Message
|
||||
for i := 1; i <= 10; i++ {
|
||||
msgs = append(msgs, msg(strconv.Itoa(i), "a", strings.Repeat("w", 300)))
|
||||
}
|
||||
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "c", Messages: msgs}})
|
||||
for _, line := range strings.Split(tr.Chunks[0], "\n") {
|
||||
if len(line) > maxMergedChars+20 {
|
||||
t.Errorf("merged line is %d chars, over the %d cap", len(line), maxMergedChars)
|
||||
}
|
||||
}
|
||||
if tr.Messages != 10 {
|
||||
t.Errorf("messages = %d", tr.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLanguage(t *testing.T) {
|
||||
pt := "o deploy de ontem partiu o login que nao tinha corrido, vou ver para ti mas já está feito. foi uma coisa de pelo menos duas horas, são horas de parar"
|
||||
en := "the deploy broke the login and that is the thing we saw, you are right that it was the cause of this and it is for the team to fix with the rest"
|
||||
if got := detectLanguage([]string{pt}); got != "Portuguese" {
|
||||
t.Errorf("pt = %q", got)
|
||||
}
|
||||
if got := detectLanguage([]string{en}); got != "English" {
|
||||
t.Errorf("en = %q", got)
|
||||
}
|
||||
if got := detectLanguage([]string{"ok", "lol"}); got != "" {
|
||||
t.Errorf("too little text should be unsure, got %q", got)
|
||||
}
|
||||
if got := detectLanguage([]string{pt + " " + en}); got != "" {
|
||||
t.Errorf("mixed text should be unsure, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizePromptNamesDetectedLanguage(t *testing.T) {
|
||||
f := &fakeSummarizer{}
|
||||
pt := strings.Repeat("o deploy de ontem partiu o login que nao tinha corrido, vou ver para ti mas já está feito. ", 3)
|
||||
if _, err := Summarize(context.Background(), f, []string{pt}, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(f.users[0], "Write the overview in Portuguese.") {
|
||||
t.Errorf("user prompt should name Portuguese: %q", f.users[0][len(f.users[0])-80:])
|
||||
}
|
||||
f = &fakeSummarizer{}
|
||||
if _, err := Summarize(context.Background(), f, []string{"ok"}, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(f.users[0], "same language as the messages") {
|
||||
t.Error("an unsure guess should fall back to the generic language line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscriptCursorAndChunks(t *testing.T) {
|
||||
bot := msg("11", "bot", "ignored")
|
||||
bot.Author.Bot = true
|
||||
batches := []ChannelBatch{
|
||||
{ID: "100", Name: "general", Messages: []*discordgo.Message{msg("10", "a", "one"), bot}},
|
||||
{ID: "200", Name: "dev", Messages: []*discordgo.Message{msg("20", "b", "two")}},
|
||||
}
|
||||
tr := BuildTranscript(batches)
|
||||
|
||||
if tr.Messages != 2 || tr.Channels != 2 || tr.Truncated || len(tr.Chunks) != 1 {
|
||||
t.Fatalf("unexpected transcript: %+v", tr)
|
||||
}
|
||||
// The skipped bot message still counts as processed so it never resurfaces.
|
||||
if tr.LastProcessed["100"] != "11" || tr.LastProcessed["200"] != "20" {
|
||||
t.Errorf("cursors = %v", tr.LastProcessed)
|
||||
}
|
||||
want := "## #general\na: one\n## #dev\nb: two\n"
|
||||
if tr.Chunks[0] != want {
|
||||
t.Errorf("chunk = %q, want %q", tr.Chunks[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscriptSingleChannelHasNoHeaderAndSplitsWithinBudget(t *testing.T) {
|
||||
var msgs []*discordgo.Message
|
||||
line := strings.Repeat("y", 400)
|
||||
for i := 1; i <= 100; i++ {
|
||||
msgs = append(msgs, msg(strconv.Itoa(i), "a", line))
|
||||
}
|
||||
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "busy", Messages: msgs}})
|
||||
|
||||
if len(tr.Chunks) < 2 {
|
||||
t.Fatalf("expected several chunks, got %d", len(tr.Chunks))
|
||||
}
|
||||
for idx, c := range tr.Chunks {
|
||||
if len(c) > chunkBudgetChars {
|
||||
t.Errorf("chunk %d is %d chars, over budget", idx, len(c))
|
||||
}
|
||||
if strings.Contains(c, "## #") {
|
||||
t.Errorf("chunk %d has a channel header, a single channel should not", idx)
|
||||
}
|
||||
}
|
||||
if tr.Messages != 100 || tr.Truncated {
|
||||
t.Errorf("all 100 should fit: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscriptSeveralChannelsRepeatHeadersPerChunk(t *testing.T) {
|
||||
var one, two []*discordgo.Message
|
||||
line := strings.Repeat("y", 400)
|
||||
for i := 1; i <= 60; i++ {
|
||||
one = append(one, msg(strconv.Itoa(i), "a", line))
|
||||
two = append(two, msg(strconv.Itoa(1000+i), "b", line))
|
||||
}
|
||||
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "one", Messages: one}, {ID: "2", Name: "two", Messages: two}})
|
||||
|
||||
if len(tr.Chunks) < 2 {
|
||||
t.Fatalf("expected several chunks, got %d", len(tr.Chunks))
|
||||
}
|
||||
for idx, c := range tr.Chunks {
|
||||
if !strings.HasPrefix(c, "## #") {
|
||||
t.Errorf("chunk %d does not start with a channel header", idx)
|
||||
}
|
||||
if len(c) > chunkBudgetChars {
|
||||
t.Errorf("chunk %d is %d chars, over budget", idx, len(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscriptTruncatesAndHoldsCursor(t *testing.T) {
|
||||
line := strings.Repeat("z", 480)
|
||||
var msgs []*discordgo.Message
|
||||
for i := 1; i <= 400; i++ { // ~190k chars, far over the 90k total budget
|
||||
msgs = append(msgs, msg(strconv.Itoa(i), "a", line))
|
||||
}
|
||||
later := msg("999", "b", "never reached")
|
||||
tr := BuildTranscript([]ChannelBatch{
|
||||
{ID: "1", Name: "a", Messages: msgs},
|
||||
{ID: "2", Name: "b", Messages: []*discordgo.Message{later}},
|
||||
})
|
||||
|
||||
if !tr.Truncated || len(tr.Chunks) > maxChunks {
|
||||
t.Fatalf("expected truncation within %d chunks: truncated=%v chunks=%d", maxChunks, tr.Truncated, len(tr.Chunks))
|
||||
}
|
||||
last := tr.LastProcessed["1"]
|
||||
if n, _ := strconv.Atoi(last); n != tr.Messages {
|
||||
t.Errorf("cursor %s should sit exactly at the last included message (%d)", last, tr.Messages)
|
||||
}
|
||||
if _, ok := tr.LastProcessed["2"]; ok {
|
||||
t.Error("a channel after the cut-off must not get a cursor")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeFetcher struct {
|
||||
all []*discordgo.Message // ascending
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) ChannelMessages(_ string, limit int, _, afterID, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) {
|
||||
f.calls++
|
||||
var out []*discordgo.Message
|
||||
for _, m := range f.all {
|
||||
if CompareIDs(m.ID, afterID) > 0 {
|
||||
out = append(out, m)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Discord hands each page back newest first.
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestFetchAfterPagesInOrder(t *testing.T) {
|
||||
f := &fakeFetcher{}
|
||||
for i := 1; i <= 250; i++ {
|
||||
f.all = append(f.all, msg(strconv.Itoa(i), "a", "m"))
|
||||
}
|
||||
|
||||
got, err := FetchAfter(f, "c", "0", 1000)
|
||||
if err != nil || len(got) != 250 || f.calls != 3 {
|
||||
t.Fatalf("len=%d calls=%d err=%v", len(got), f.calls, err)
|
||||
}
|
||||
for i, m := range got {
|
||||
if m.ID != strconv.Itoa(i+1) {
|
||||
t.Fatalf("out of order at %d: %s", i, m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
f.calls = 0
|
||||
got, _ = FetchAfter(f, "c", "0", 120)
|
||||
if len(got) != 120 || got[119].ID != "120" || f.calls != 2 {
|
||||
t.Errorf("limit not honoured: len=%d last=%s calls=%d", len(got), got[len(got)-1].ID, f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSummarizer struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
users []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSummarizer) Chat(_ context.Context, system, user string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, system)
|
||||
f.users = append(f.users, user)
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return fmt.Sprintf("summary-%d", len(f.calls)), nil
|
||||
}
|
||||
|
||||
func TestSummarizeSingleAndMerge(t *testing.T) {
|
||||
f := &fakeSummarizer{}
|
||||
out, err := Summarize(context.Background(), f, []string{"a"}, nil, nil)
|
||||
if err != nil || out != "summary-1" || len(f.calls) != 1 {
|
||||
t.Fatalf("single chunk: %q %v calls=%d", out, err, len(f.calls))
|
||||
}
|
||||
|
||||
f = &fakeSummarizer{}
|
||||
var steps [][2]int
|
||||
out, err = Summarize(context.Background(), f, []string{"a", "b", "c"}, func(done, total int) {
|
||||
steps = append(steps, [2]int{done, total})
|
||||
}, nil)
|
||||
if err != nil || len(f.calls) != 4 || out != "summary-4" {
|
||||
t.Fatalf("map-reduce: %q %v calls=%d", out, err, len(f.calls))
|
||||
}
|
||||
if f.calls[3] != mergePrompt || f.calls[0] != systemPrompt {
|
||||
t.Error("final call should use the merge prompt, the rest the summary prompt")
|
||||
}
|
||||
if len(steps) != 4 || steps[0] != [2]int{0, 4} || steps[3] != [2]int{3, 4} {
|
||||
t.Errorf("progress = %v", steps)
|
||||
}
|
||||
|
||||
if _, err := Summarize(context.Background(), &fakeSummarizer{err: errors.New("boom")}, []string{"a"}, nil, nil); err == nil {
|
||||
t.Error("model errors must propagate")
|
||||
}
|
||||
if _, err := Summarize(context.Background(), f, nil, nil, nil); err == nil {
|
||||
t.Error("empty input must error")
|
||||
}
|
||||
}
|
||||
|
||||
const readPerms = int64(discordgo.PermissionViewChannel | discordgo.PermissionReadMessageHistory)
|
||||
|
||||
type fakeDiscord struct {
|
||||
*fakeFetcher
|
||||
botPerms int64
|
||||
fetched map[string]string // channel ID -> the "after" it was first fetched with
|
||||
fetchErr error
|
||||
}
|
||||
|
||||
func newFakeDiscord(msgs ...*discordgo.Message) *fakeDiscord {
|
||||
return &fakeDiscord{fakeFetcher: &fakeFetcher{all: msgs}, botPerms: readPerms, fetched: map[string]string{}}
|
||||
}
|
||||
|
||||
func (d *fakeDiscord) ChannelMessages(ch string, limit int, b, after, a string, o ...discordgo.RequestOption) ([]*discordgo.Message, error) {
|
||||
if _, seen := d.fetched[ch]; !seen {
|
||||
d.fetched[ch] = after
|
||||
}
|
||||
if d.fetchErr != nil {
|
||||
return nil, d.fetchErr
|
||||
}
|
||||
return d.fakeFetcher.ChannelMessages(ch, limit, b, after, a, o...)
|
||||
}
|
||||
|
||||
func (d *fakeDiscord) UserChannelPermissions(string, string, ...discordgo.RequestOption) (int64, error) {
|
||||
return d.botPerms, nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
cursors map[int64]int64
|
||||
saved map[int64]int64
|
||||
}
|
||||
|
||||
func (s *fakeStore) GetCursors(context.Context, int64, int64) (map[int64]int64, error) {
|
||||
return s.cursors, nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) SetCursors(_ context.Context, _, _ int64, c map[int64]int64) error {
|
||||
s.saved = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func req(since time.Duration, now time.Time) Request {
|
||||
return Request{GuildID: "1", ChannelID: "10", UserID: "7", BotID: "bot", UserPerms: readPerms, Since: since, Now: now}
|
||||
}
|
||||
|
||||
func TestRunSummarizesOnlyTheRequestedChannel(t *testing.T) {
|
||||
now := time.Now()
|
||||
oldID := SnowflakeFromTime(now.Add(-30 * 24 * time.Hour)) // far older than the 7d floor
|
||||
recent, _ := strconv.ParseInt(SnowflakeFromTime(now.Add(-time.Hour)), 10, 64)
|
||||
d := newFakeDiscord(
|
||||
msg(strconv.FormatInt(recent+1, 10), "a", "deploy is broken"),
|
||||
msg(strconv.FormatInt(recent+2, 10), "b", "fixing it now"),
|
||||
)
|
||||
staleCursor, _ := strconv.ParseInt(oldID, 10, 64)
|
||||
// The user also has a cursor in another channel, which must be left alone.
|
||||
store := &fakeStore{cursors: map[int64]int64{10: staleCursor, 99: 12345}}
|
||||
sum := &fakeSummarizer{}
|
||||
|
||||
res, err := Run(context.Background(), d, store, sum, req(0, now), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Empty || res.Messages != 2 || res.Summary != "summary-1" {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if len(d.fetched) != 1 {
|
||||
t.Errorf("fetched channels = %v, want only channel 10", d.fetched)
|
||||
}
|
||||
if got := d.fetched["10"]; CompareIDs(got, SnowflakeFromTime(now.Add(-maxWindow))) < 0 {
|
||||
t.Errorf("stale cursor reached back past the 7-day floor: %s", got)
|
||||
}
|
||||
if len(store.saved) != 1 || store.saved[10] != recent+2 {
|
||||
t.Errorf("cursors saved = %v, want only channel 10 -> %d", store.saved, recent+2)
|
||||
}
|
||||
if strings.Contains(sum.users[0], "## #") {
|
||||
t.Error("a single-channel prompt should carry no channel header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefusesWhenTheUserCannotReadTheChannel(t *testing.T) {
|
||||
d := newFakeDiscord(msg("1", "a", "secret plans"))
|
||||
sum := &fakeSummarizer{}
|
||||
r := req(0, time.Now())
|
||||
r.UserPerms = discordgo.PermissionViewChannel // can see the channel but not its history
|
||||
|
||||
_, err := Run(context.Background(), d, &fakeStore{}, sum, r, nil, nil)
|
||||
if !errors.Is(err, ErrUserCannotRead) {
|
||||
t.Fatalf("err = %v, want ErrUserCannotRead", err)
|
||||
}
|
||||
if len(d.fetched) != 0 || len(sum.calls) != 0 {
|
||||
t.Errorf("nothing should be fetched or summarized: fetched=%v calls=%d", d.fetched, len(sum.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefusesWhenTheBotCannotReadTheChannel(t *testing.T) {
|
||||
d := newFakeDiscord(msg("1", "a", "hello"))
|
||||
d.botPerms = discordgo.PermissionViewChannel
|
||||
|
||||
_, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil)
|
||||
if !errors.Is(err, ErrBotCannotRead) || len(d.fetched) != 0 {
|
||||
t.Errorf("err=%v fetched=%v", err, d.fetched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMapsADiscord403ToBotCannotRead(t *testing.T) {
|
||||
d := newFakeDiscord()
|
||||
d.fetchErr = &discordgo.RESTError{Response: &http.Response{StatusCode: http.StatusForbidden}}
|
||||
|
||||
_, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil)
|
||||
if !errors.Is(err, ErrBotCannotRead) {
|
||||
t.Errorf("err = %v, want ErrBotCannotRead", err)
|
||||
}
|
||||
|
||||
d.fetchErr = errors.New("network down")
|
||||
if _, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil); err == nil || errors.Is(err, ErrBotCannotRead) {
|
||||
t.Errorf("other fetch errors must not look like a permission problem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSinceModeLeavesCursorsAlone(t *testing.T) {
|
||||
now := time.Now()
|
||||
recent, _ := strconv.ParseInt(SnowflakeFromTime(now.Add(-time.Minute)), 10, 64)
|
||||
d := newFakeDiscord(msg(strconv.FormatInt(recent+1, 10), "a", "hello"))
|
||||
store := &fakeStore{}
|
||||
|
||||
res, err := Run(context.Background(), d, store, &fakeSummarizer{}, req(time.Hour, now), nil, nil)
|
||||
if err != nil || res.Empty {
|
||||
t.Fatalf("res=%+v err=%v", res, err)
|
||||
}
|
||||
if store.saved != nil {
|
||||
t.Errorf("since mode must not move cursors, saved %v", store.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNothingNew(t *testing.T) {
|
||||
res, err := Run(context.Background(), newFakeDiscord(), &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil)
|
||||
if err != nil || !res.Empty {
|
||||
t.Fatalf("res=%+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStreamer records whether each call went through Chat or ChatStream.
|
||||
type fakeStreamer struct {
|
||||
mu sync.Mutex
|
||||
log []string
|
||||
}
|
||||
|
||||
func (f *fakeStreamer) Chat(context.Context, string, string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.log = append(f.log, "chat")
|
||||
return "partial", nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamer) ChatStream(_ context.Context, _, _ string, onText func(string)) (string, error) {
|
||||
f.mu.Lock()
|
||||
f.log = append(f.log, "stream")
|
||||
f.mu.Unlock()
|
||||
for _, s := range []string{"he", "hello", "hello world"} {
|
||||
if onText != nil {
|
||||
onText(s)
|
||||
}
|
||||
}
|
||||
return "hello world", nil
|
||||
}
|
||||
|
||||
func TestSummarizeStreamsTheSingleChunk(t *testing.T) {
|
||||
f := &fakeStreamer{}
|
||||
var seen []string
|
||||
out, err := Summarize(context.Background(), f, []string{"a"}, nil, func(s string) { seen = append(seen, s) })
|
||||
if err != nil || out != "hello world" {
|
||||
t.Fatalf("out=%q err=%v", out, err)
|
||||
}
|
||||
if strings.Join(f.log, ",") != "stream" || len(seen) != 3 || seen[2] != "hello world" {
|
||||
t.Errorf("log=%v seen=%v", f.log, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStreamsOnlyTheFinalMerge(t *testing.T) {
|
||||
f := &fakeStreamer{}
|
||||
var seen []string
|
||||
out, err := Summarize(context.Background(), f, []string{"a", "b", "c"}, nil, func(s string) { seen = append(seen, s) })
|
||||
if err != nil || out != "hello world" {
|
||||
t.Fatalf("out=%q err=%v", out, err)
|
||||
}
|
||||
// The three per-chunk passes are intermediate notes and must not stream.
|
||||
if strings.Join(f.log, ",") != "chat,chat,chat,stream" || len(seen) != 3 {
|
||||
t.Errorf("log=%v seen=%v", f.log, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeWithoutPartialUsesPlainChat(t *testing.T) {
|
||||
f := &fakeStreamer{}
|
||||
out, err := Summarize(context.Background(), f, []string{"a"}, nil, nil)
|
||||
if err != nil || out != "partial" || strings.Join(f.log, ",") != "chat" {
|
||||
t.Errorf("out=%q err=%v log=%v", out, err, f.log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeFallsBackWhenSummarizerCannotStream(t *testing.T) {
|
||||
called := false
|
||||
out, err := Summarize(context.Background(), &fakeSummarizer{}, []string{"a"}, nil, func(string) { called = true })
|
||||
if err != nil || out != "summary-1" || called {
|
||||
t.Errorf("out=%q err=%v partialCalled=%v", out, err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottle(t *testing.T) {
|
||||
clock := time.Unix(1000, 0)
|
||||
now := func() time.Time { return clock }
|
||||
var got []string
|
||||
f := throttle(2*time.Second, now, func(s string) { got = append(got, s) })
|
||||
|
||||
f("a") // first call always passes
|
||||
clock = clock.Add(time.Second)
|
||||
f("b") // too soon
|
||||
clock = clock.Add(time.Second)
|
||||
f("c") // 2s since the last edit
|
||||
clock = clock.Add(500 * time.Millisecond)
|
||||
f("d") // too soon again
|
||||
|
||||
if strings.Join(got, "") != "ac" {
|
||||
t.Errorf("edits = %v, want [a c]", got)
|
||||
}
|
||||
if throttle(time.Second, now, nil) != nil {
|
||||
t.Error("a nil callback should stay nil")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user