150 lines
4.0 KiB
Go
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
|
|
}
|