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,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]) + "…"
|
||||
}
|
||||
@@ -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) }
|
||||
Reference in new issue
Block a user