Files

150 lines
4.0 KiB
Go

package embed
import (
"context"
"database/sql"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
var urlRegex = regexp.MustCompile(`https?://[^\s<>"{}|\^\[\]]+`)
// Embed represents a stored link preview.
type Embed struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
SiteName string `json:"site_name"`
}
// ExtractURLs returns all HTTP/HTTPS URLs found in text.
func ExtractURLs(text string) []string {
matches := urlRegex.FindAllString(text, -1)
seen := make(map[string]struct{})
var out []string
for _, u := range matches {
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
if len(out) > 5 {
out = out[:5]
}
return out
}
// FetchEmbed retrieves OpenGraph meta tags for a URL.
func FetchEmbed(url string) *Embed {
client := &http.Client{Timeout: 2 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DumpsterBot/1.0)")
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return nil
}
html := string(body)
return &Embed{
URL: url,
Title: ogTag(html, "og:title"),
Description: ogTag(html, "og:description"),
ImageURL: ogTag(html, "og:image"),
SiteName: ogTag(html, "og:site_name"),
}
}
func ogTag(html, property string) string {
// Try property
re := regexp.MustCompile(`<meta[^>]+property=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`)
m := re.FindStringSubmatch(html)
if len(m) > 1 && m[1] != "" {
return strings.TrimSpace(m[1])
}
// Try name
re = regexp.MustCompile(`<meta[^>]+name=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`)
m = re.FindStringSubmatch(html)
if len(m) > 1 && m[1] != "" {
return strings.TrimSpace(m[1])
}
// Fallback for title
if property == "og:title" {
re = regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
m = re.FindStringSubmatch(html)
if len(m) > 1 {
return strings.TrimSpace(m[1])
}
}
return ""
}
// FetchAndStore extracts URLs from content, fetches embeds, and stores them.
func FetchAndStore(db *sql.DB, messageID string, content string) {
urls := ExtractURLs(content)
if len(urls) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, url := range urls {
emb := FetchEmbed(url)
if emb == nil || (emb.Title == "" && emb.Description == "" && emb.ImageURL == "") {
continue
}
_, err := db.ExecContext(ctx, `
INSERT INTO embeds (message_id, url, title, description, image_url, site_name)
VALUES ($1, $2, $3, $4, $5, $6)
`, messageID, emb.URL, emb.Title, emb.Description, emb.ImageURL, emb.SiteName)
if err != nil {
fmt.Printf("failed to store embed: %v\n", err)
}
}
}
// LoadForMessages returns embeds grouped by message_id.
func LoadForMessages(ctx context.Context, db *sql.DB, messageIDs []string) (map[string][]Embed, error) {
result := make(map[string][]Embed)
if len(messageIDs) == 0 {
return result, nil
}
placeholders := make([]string, len(messageIDs))
args := make([]interface{}, len(messageIDs))
for i, id := range messageIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := "SELECT message_id, id, url, title, description, image_url, site_name FROM embeds WHERE message_id IN (" + strings.Join(placeholders, ",") + ")"
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var e Embed
var mid string
if err := rows.Scan(&mid, &e.ID, &e.URL, &e.Title, &e.Description, &e.ImageURL, &e.SiteName); err != nil {
continue
}
result[mid] = append(result[mid], e)
}
return result, nil
}