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:
FernandoJVideiraandClaude Opus 5.5 committed 2026-10-20 18:46:18 +01:00
1 parent b1ea5f17e3
commit 7ae1fa5747
21 files changed
+2423 -2

No files matched your search

+3
View File
@@ -12,6 +12,7 @@ import (
"velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule"
"velox-bot/internal/commands/timezone"
"velox-bot/internal/commands/tldr"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
@@ -37,6 +38,7 @@ var AllCommands = []*discordgo.ApplicationCommand{
meeting.Meeting,
moderation.Moderation,
timezone.Timezone,
tldr.Tldr,
schedule.Schedule,
projects.Projects,
config.Config,
@@ -58,6 +60,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"meeting": meeting.MeetingHandler,
"moderation": moderation.ModerationHandler,
"timezone": timezone.TimezoneHandler,
"tldr": tldr.TldrHandler,
"schedule": schedule.ScheduleHandler,
"projects": projects.ProjectsHandler,
"play": cmdmusic.PlayHandler,
+6
View File
@@ -77,6 +77,12 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
"`/timezone show` — Show your timezone",
},
{
Name: "TL;DR",
Value: "" +
"`/tldr [since:<30m|6h|2d>]` — Private summary of what you missed in the channel you run it in. " +
"Without `since` it picks up where your last `/tldr` in that channel left off (first time: the past 24h).",
},
{
Name: "Projects",
Value: "" +
+160
View File
@@ -0,0 +1,160 @@
package public
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"velox-bot/internal/db/services"
"velox-bot/internal/tldr"
"github.com/bwmarrin/discordgo"
)
var dmPermission = false
var Tldr = &discordgo.ApplicationCommand{
Name: "tldr",
Description: "Privately summarize what you missed in this channel",
DMPermission: &dmPermission,
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "since",
Description: "Look back this far instead of since your last /tldr (e.g. 30m, 6h, 2d; max 7d)",
Required: false,
},
},
}
const embedColor = 0xFFA500
func TldrHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.GuildID == "" || i.Member == nil || i.Member.User == nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "`/tldr` only works in a server.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
var since time.Duration
var sinceText string
for _, opt := range i.ApplicationCommandData().Options {
if opt.Name != "since" {
continue
}
d, err := tldr.ParseSince(opt.StringValue())
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: err.Error(),
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
since, sinceText = d, strings.ToLower(strings.TrimSpace(opt.StringValue()))
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Reading what you missed…",
Flags: discordgo.MessageFlagsEphemeral,
},
})
edit := func(content string) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &content})
}
userID := i.Member.User.ID
if !tldr.TryStart(userID) {
edit("You already have a summary running, give it a moment.")
return
}
defer tldr.Done(userID)
ctx, cancel := context.WithTimeout(context.Background(), tldr.RunTimeout())
defer cancel()
// While the model writes, show the overview growing instead of leaving the
// "Summarizing…" text up for the whole wait. The final edit below replaces it.
showPartial := func(text string) {
content := ""
embeds := []*discordgo.MessageEmbed{{
Title: "TL;DR",
Description: clip(text, 4000),
Color: embedColor,
Footer: &discordgo.MessageEmbedFooter{Text: "still writing…"},
}}
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &content, Embeds: &embeds})
}
res, err := tldr.Run(ctx, s, services.Global.TLDR, tldr.Default(), tldr.Request{
GuildID: i.GuildID,
ChannelID: i.ChannelID,
UserID: userID,
BotID: s.State.User.ID,
UserPerms: i.Member.Permissions,
Since: since,
}, edit, showPartial)
if errors.Is(err, tldr.ErrUserCannotRead) {
edit("You need to be able to read this channel's message history to summarize it.")
return
}
if errors.Is(err, tldr.ErrBotCannotRead) {
edit("I can't read this channel's history. I need View Channel and Read Message History here.")
return
}
if err != nil {
log.Printf("tldr: guild %s user %s: %v", i.GuildID, userID, err)
edit("I couldn't put a summary together right now. The summarizer may be down or busy, try again in a bit.")
return
}
if res.Empty {
edit("Nothing new to summarize.")
return
}
scope := "since your last /tldr here"
if since > 0 {
scope = "last " + sinceText
}
footer := fmt.Sprintf("%d messages · %s", res.Messages, scope)
if res.Truncated {
if since > 0 {
footer += " · more than fit, the most recent were left out, try a shorter window"
} else {
footer += " · more than fit, run /tldr again for the rest"
}
}
empty := ""
embeds := []*discordgo.MessageEmbed{{
Title: "TL;DR",
Description: clip(res.Summary, 4000),
Color: embedColor,
Footer: &discordgo.MessageEmbedFooter{Text: footer},
}}
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: &empty,
Embeds: &embeds,
})
}
func clip(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n-1]) + "…"
}
+13
View File
@@ -0,0 +1,13 @@
package tldr
import (
"velox-bot/internal/commands/tldr/public"
"github.com/bwmarrin/discordgo"
)
var (
Tldr *discordgo.ApplicationCommand = public.Tldr
)
func TldrHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TldrHandler(s, i) }