Skip to content

Commit 9a12efd

Browse files
authored
feat(telegram): inline keyboards, progressive briefing, clickable links (#32)
- Notifications: inline URL buttons to open each item in GitHub - Emails: inline URL buttons to open each email in Gmail - Briefing: edit-in-place progressive update (Loading... → full briefing) - GitHub API URLs converted to HTML URLs for PR/issue links - Max 20 inline buttons per message to stay within Telegram limits
1 parent f9c3347 commit 9a12efd

1 file changed

Lines changed: 107 additions & 3 deletions

File tree

internal/telegram/bot.go

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,13 +228,20 @@ func (tb *Bot) handleNotifications(ctx context.Context, b *bot.Bot, update *mode
228228
}
229229

230230
var sb strings.Builder
231+
var buttons [][]models.InlineKeyboardButton
231232
fmt.Fprintf(&sb, "*Unread Notifications* \\(%d\\)\n\n", len(notifs))
232233
for _, n := range notifs {
233234
emoji := priorityEmoji(n.Priority)
234235
fmt.Fprintf(&sb, "%s *P%d* `%s`\n %s\n _%s_\n\n",
235236
emoji, n.Priority, mdv2.Esc(n.RepoFullName), mdv2.Esc(n.SubjectTitle), mdv2.Esc(n.Reason))
237+
if htmlURL := ghAPIToHTML(n.SubjectURL, n.SubjectType); htmlURL != "" {
238+
label := fmt.Sprintf("%s %s", emoji, truncate(n.SubjectTitle, 30))
239+
buttons = append(buttons, []models.InlineKeyboardButton{
240+
{Text: label, URL: htmlURL},
241+
})
242+
}
236243
}
237-
tb.reply(ctx, b, update, sb.String())
244+
tb.replyWithKeyboard(ctx, b, update, sb.String(), buttons)
238245
}
239246

240247
func (tb *Bot) handleMemory(ctx context.Context, b *bot.Bot, update *models.Update) {
@@ -312,9 +319,40 @@ func (tb *Bot) handleRemind(ctx context.Context, b *bot.Bot, update *models.Upda
312319
}
313320

314321
func (tb *Bot) handleBriefing(ctx context.Context, b *bot.Bot, update *models.Update) {
322+
if update.Message == nil {
323+
return
324+
}
325+
chatID := update.Message.Chat.ID
315326
tb.sendTyping(ctx, update)
327+
328+
// Send initial message, then edit in-place as sections arrive.
329+
sent, err := b.SendMessage(ctx, &bot.SendMessageParams{
330+
ChatID: chatID,
331+
Text: "⏳ Loading briefing\\.\\.\\.",
332+
ParseMode: models.ParseModeMarkdown,
333+
LinkPreviewOptions: &models.LinkPreviewOptions{
334+
IsDisabled: bot.True(),
335+
},
336+
})
337+
if err != nil {
338+
tb.logger.Error("telegram send", "error", err)
339+
return
340+
}
341+
316342
msg := briefing.Generate(ctx, tb.briefingSources)
317-
tb.reply(ctx, b, update, msg)
343+
344+
_, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{
345+
ChatID: chatID,
346+
MessageID: sent.ID,
347+
Text: msg,
348+
ParseMode: models.ParseModeMarkdown,
349+
LinkPreviewOptions: &models.LinkPreviewOptions{
350+
IsDisabled: bot.True(),
351+
},
352+
})
353+
if err != nil {
354+
tb.logger.Error("telegram edit", "error", err)
355+
}
318356
}
319357

320358
// SetBriefingSources configures the data sources for on-demand briefings.
@@ -389,6 +427,7 @@ func (tb *Bot) handleEmails(ctx context.Context, b *bot.Bot, update *models.Upda
389427
}
390428

391429
var sb strings.Builder
430+
var buttons [][]models.InlineKeyboardButton
392431
fmt.Fprintf(&sb, "*Unread Emails* \\(%d total\\)\n\n", count)
393432
for _, e := range emails {
394433
fmt.Fprintf(&sb, "📧 *%s*\n From: %s\n", mdv2.Esc(e.Subject), mdv2.Esc(e.From))
@@ -400,8 +439,16 @@ func (tb *Bot) handleEmails(ctx context.Context, b *bot.Bot, update *models.Upda
400439
fmt.Fprintf(&sb, " _%s_\n", mdv2.Esc(snippet))
401440
}
402441
sb.WriteString("\n")
442+
gmailURL := fmt.Sprintf("https://mail.google.com/mail/u/0/#inbox/%s", e.ID)
443+
label := truncate(e.Subject, 35)
444+
if label == "" {
445+
label = "Open email"
446+
}
447+
buttons = append(buttons, []models.InlineKeyboardButton{
448+
{Text: "📧 " + label, URL: gmailURL},
449+
})
403450
}
404-
tb.reply(ctx, b, update, sb.String())
451+
tb.replyWithKeyboard(ctx, b, update, sb.String(), buttons)
405452
}
406453

407454
func (tb *Bot) handleHelp(ctx context.Context, b *bot.Bot, update *models.Update) {
@@ -457,6 +504,63 @@ func (tb *Bot) reply(ctx context.Context, b *bot.Bot, update *models.Update, tex
457504
}
458505
}
459506

507+
func (tb *Bot) replyWithKeyboard(ctx context.Context, b *bot.Bot, update *models.Update, text string, buttons [][]models.InlineKeyboardButton) {
508+
if update.Message == nil {
509+
return
510+
}
511+
chatID := update.Message.Chat.ID
512+
// Telegram allows max 100 inline buttons. Trim if needed.
513+
if len(buttons) > 20 {
514+
buttons = buttons[:20]
515+
}
516+
var markup *models.InlineKeyboardMarkup
517+
if len(buttons) > 0 {
518+
markup = &models.InlineKeyboardMarkup{InlineKeyboard: buttons}
519+
}
520+
chunks := mdv2.Split(text, telegramMsgMax)
521+
for i, chunk := range chunks {
522+
params := &bot.SendMessageParams{
523+
ChatID: chatID,
524+
Text: chunk,
525+
ParseMode: models.ParseModeMarkdown,
526+
LinkPreviewOptions: &models.LinkPreviewOptions{
527+
IsDisabled: bot.True(),
528+
},
529+
}
530+
// Attach keyboard to last chunk only.
531+
if i == len(chunks)-1 && markup != nil {
532+
params.ReplyMarkup = markup
533+
}
534+
if _, err := b.SendMessage(ctx, params); err != nil {
535+
tb.logger.Error("telegram send", "error", err, "chat_id", chatID)
536+
return
537+
}
538+
}
539+
}
540+
541+
// ghAPIToHTML converts a GitHub API URL to the corresponding HTML URL.
542+
// e.g. https://api.github.com/repos/owner/repo/pulls/123 -> https://github.com/owner/repo/pull/123
543+
func ghAPIToHTML(apiURL, subjectType string) string {
544+
if apiURL == "" {
545+
return ""
546+
}
547+
s := strings.Replace(apiURL, "https://api.github.com/repos/", "https://github.com/", 1)
548+
// API uses "pulls" but HTML uses "pull".
549+
s = strings.Replace(s, "/pulls/", "/pull/", 1)
550+
// API uses "issues" which is the same in HTML.
551+
if s == apiURL {
552+
return "" // couldn't convert
553+
}
554+
return s
555+
}
556+
557+
func truncate(s string, max int) string {
558+
if len(s) <= max {
559+
return s
560+
}
561+
return s[:max] + "…"
562+
}
563+
460564
func priorityEmoji(p int) string {
461565
switch p {
462566
case gh.P0:

0 commit comments

Comments
 (0)