mirror of
https://github.com/tiennm99/openai-status-bot.git
synced 2026-07-14 13:04:45 +00:00
c119386073
Fixed-index truncation could split an HTML tag or entity or drop a closing tag, making Telegram reject the whole message with 'can't parse entities'. Cut on a newline boundary where tags are balanced, reserve room for a truncation notice, and strip any trailing partial markup.
213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
package bot
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
|
"github.com/tiennm99/openai-status-bot/internal/poller"
|
|
)
|
|
|
|
const statusURL = "https://status.openai.com/"
|
|
|
|
const helpText = `<b>OpenAI Status Bot</b>
|
|
|
|
/start - subscribe this chat or topic
|
|
/stop - unsubscribe this chat or topic
|
|
/status [component] - show current OpenAI status
|
|
/components - show all component statuses
|
|
/subscribe <incident|component|all> - set notification types
|
|
/subscribe component <name|id|all> - filter component updates
|
|
/history [count] - show recent incidents, default 5, max 10
|
|
/uptime - show component health overview
|
|
/info - show chat and subscription details
|
|
/help - show this help`
|
|
|
|
func formatStatus(summary openai.Summary) string {
|
|
lines := []string{
|
|
"<b>OpenAI Status</b>",
|
|
"",
|
|
fmt.Sprintf("Overall: <code>%s</code>", escape(summary.Status.Description)),
|
|
}
|
|
|
|
duplicates := poller.DuplicateComponentNames(summary.Components)
|
|
degraded := make([]string, 0)
|
|
for _, component := range summary.Components {
|
|
if component.Group || component.Status == "operational" {
|
|
continue
|
|
}
|
|
degraded = append(degraded, fmt.Sprintf("- %s: <code>%s</code>", escape(poller.ComponentLabel(component, duplicates[component.Name])), escape(poller.StatusLabel(component.Status))))
|
|
}
|
|
|
|
if len(degraded) == 0 {
|
|
lines = append(lines, "", "All listed components are operational.")
|
|
} else {
|
|
lines = append(lines, "", "Affected components:")
|
|
lines = append(lines, degraded...)
|
|
}
|
|
lines = append(lines, "", fmt.Sprintf("<a href=\"%s\">View full status page</a>", statusURL))
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatComponentStatus(component openai.Component, duplicate bool) string {
|
|
return fmt.Sprintf(
|
|
"<b>%s</b>\n\nStatus: <code>%s</code>\nLast change: %s UTC\n\n<a href=\"%s\">View full status page</a>",
|
|
escape(poller.ComponentLabel(component, duplicate)),
|
|
escape(poller.StatusLabel(component.Status)),
|
|
escape(formatTime(component.UpdatedAt)),
|
|
statusURL,
|
|
)
|
|
}
|
|
|
|
func formatComponents(summary openai.Summary) string {
|
|
lines := []string{"<b>OpenAI Components</b>", ""}
|
|
duplicates := poller.DuplicateComponentNames(summary.Components)
|
|
for _, component := range summary.Components {
|
|
if component.Group {
|
|
continue
|
|
}
|
|
lines = append(lines, fmt.Sprintf("- %s: <code>%s</code>", escape(poller.ComponentLabel(component, duplicates[component.Name])), escape(poller.StatusLabel(component.Status))))
|
|
}
|
|
return truncateMessage(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func formatHistory(incidents []openai.Incident, count int) string {
|
|
if len(incidents) == 0 {
|
|
return "No recent incidents found."
|
|
}
|
|
if count > len(incidents) {
|
|
count = len(incidents)
|
|
}
|
|
|
|
lines := []string{"<b>Recent OpenAI Incidents</b>", ""}
|
|
for i := 0; i < count; i++ {
|
|
incident := incidents[i]
|
|
link := incident.Shortlink
|
|
if link == "" && incident.ID != "" {
|
|
link = statusURL + "incidents/" + incident.ID
|
|
}
|
|
entry := fmt.Sprintf(
|
|
"%d. <b>[%s]</b> %s\n Created: %s UTC\n Status: <code>%s</code>",
|
|
i+1,
|
|
escape(strings.ToUpper(emptyDefault(incident.Impact, "unknown"))),
|
|
escape(incident.Name),
|
|
escape(formatDate(incident.CreatedAt)),
|
|
escape(poller.StatusLabel(incident.Status)),
|
|
)
|
|
if link != "" {
|
|
entry += fmt.Sprintf("\n <a href=\"%s\">Details</a>", escape(link))
|
|
}
|
|
lines = append(lines, entry)
|
|
}
|
|
lines = append(lines, "", fmt.Sprintf("<a href=\"%shistory\">View full history</a>", statusURL))
|
|
return truncateMessage(strings.Join(lines, "\n\n"))
|
|
}
|
|
|
|
func formatUptime(summary openai.Summary) string {
|
|
lines := []string{"<b>OpenAI Component Health</b>", ""}
|
|
duplicates := poller.DuplicateComponentNames(summary.Components)
|
|
for _, component := range summary.Components {
|
|
if component.Group {
|
|
continue
|
|
}
|
|
lines = append(lines, fmt.Sprintf(
|
|
"%s\n Status: <code>%s</code>\n Last change: %s UTC",
|
|
escape(poller.ComponentLabel(component, duplicates[component.Name])),
|
|
escape(poller.StatusLabel(component.Status)),
|
|
escape(formatTime(component.UpdatedAt)),
|
|
))
|
|
}
|
|
lines = append(lines, "", "Uptime percentage is not available from the public Statuspage API.")
|
|
lines = append(lines, fmt.Sprintf("<a href=\"%s\">View full status page</a>", statusURL))
|
|
return truncateMessage(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func parseHistoryCount(fields []string) int {
|
|
const (
|
|
defaultCount = 5
|
|
maxCount = 10
|
|
)
|
|
if len(fields) < 2 {
|
|
return defaultCount
|
|
}
|
|
count, err := strconv.Atoi(fields[1])
|
|
if err != nil || count < 1 {
|
|
return defaultCount
|
|
}
|
|
if count > maxCount {
|
|
return maxCount
|
|
}
|
|
return count
|
|
}
|
|
|
|
const (
|
|
telegramMessageLimit = 3900
|
|
truncationNotice = "\n\n<i>… truncated</i>"
|
|
)
|
|
|
|
// truncateMessage trims an HTML-formatted message to Telegram's length limit.
|
|
// Every formatter assembles messages from newline-delimited fragments whose
|
|
// HTML tags are individually balanced, so cutting on a newline boundary keeps
|
|
// the markup valid. A naive mid-string cut could split a tag (<co|de>) or an
|
|
// entity (&am|p;), or drop a closing tag, making Telegram reject the entire
|
|
// message with "can't parse entities".
|
|
func truncateMessage(value string) string {
|
|
runes := []rune(value)
|
|
if len(runes) <= telegramMessageLimit {
|
|
return value
|
|
}
|
|
budget := telegramMessageLimit - len([]rune(truncationNotice))
|
|
truncated := string(runes[:budget])
|
|
if idx := strings.LastIndexByte(truncated, '\n'); idx >= 0 {
|
|
truncated = truncated[:idx]
|
|
} else {
|
|
truncated = dropPartialMarkup(truncated)
|
|
}
|
|
return strings.TrimRight(truncated, "\n ") + truncationNotice
|
|
}
|
|
|
|
// dropPartialMarkup removes a trailing incomplete HTML tag or entity left by a
|
|
// hard cut, so the result never ends inside "<...>" or "&...;". Only reached
|
|
// for a single fragment longer than the whole limit, which the formatters do
|
|
// not produce in practice.
|
|
func dropPartialMarkup(s string) string {
|
|
if lt := strings.LastIndexByte(s, '<'); lt > strings.LastIndexByte(s, '>') {
|
|
s = s[:lt]
|
|
}
|
|
if amp := strings.LastIndexByte(s, '&'); amp > strings.LastIndexByte(s, ';') {
|
|
s = s[:amp]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func formatDate(value string) string {
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return emptyDefault(value, "unknown")
|
|
}
|
|
return parsed.UTC().Format("Jan 2, 2006")
|
|
}
|
|
|
|
func formatTime(value string) string {
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return emptyDefault(value, "unknown")
|
|
}
|
|
return parsed.UTC().Format("Jan 2, 2006 15:04")
|
|
}
|
|
|
|
func emptyDefault(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func escape(value string) string {
|
|
return html.EscapeString(value)
|
|
}
|