Initial Commit
This commit is contained in:
112
utils/bbcode.go
Normal file
112
utils/bbcode.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BBCodeToMarkdown converts simplified Siropu-style BBCode into Matrix-safe Markdown.
|
||||
//
|
||||
// RULES:
|
||||
// - [img]URL[/img] remains an image but without video tagging
|
||||
// - [url] and [url=...] become markdown links
|
||||
// - All [video]...[/video] wrappers are REMOVED entirely (your requirement)
|
||||
// - Bold/italic/underline basic BBCode converted to markdown
|
||||
// - Unknown tags stripped and inner text preserved
|
||||
//
|
||||
func BBCodeToMarkdown(in string) string {
|
||||
if in == "" {
|
||||
return ""
|
||||
}
|
||||
s := in
|
||||
|
||||
// ----------------------------------------------------
|
||||
// STRIP VIDEO TAGS COMPLETELY
|
||||
// ----------------------------------------------------
|
||||
s = stripTagCompletely(s, "video")
|
||||
|
||||
// ----------------------------------------------------
|
||||
// IMAGE TAGS → leave as-is, but sanitize formatting
|
||||
// ----------------------------------------------------
|
||||
s = regexp.MustCompile(`(?i)\[img\](.*?)\[/img\]`).ReplaceAllString(s, "")
|
||||
|
||||
// ----------------------------------------------------
|
||||
// URL TAGS → Markdown links
|
||||
// ----------------------------------------------------
|
||||
// [url]http://x[/url]
|
||||
s = regexp.MustCompile(`(?i)\[url\](.*?)\[/url\]`).ReplaceAllString(s, "[$1]($1)")
|
||||
|
||||
// [url=http://x]label[/url]
|
||||
s = regexp.MustCompile(`(?i)\[url=(.*?)\](.*?)\[/url\]`).ReplaceAllString(s, "[$2]($1)")
|
||||
|
||||
// ----------------------------------------------------
|
||||
// BASIC FORMATTING → Markdown
|
||||
// ----------------------------------------------------
|
||||
replacements := map[*regexp.Regexp]string{
|
||||
regexp.MustCompile(`(?i)\[b\](.*?)\[/b\]`): "**$1**",
|
||||
regexp.MustCompile(`(?i)\[i\](.*?)\[/i\]`): "*$1*",
|
||||
regexp.MustCompile(`(?i)\[u\](.*?)\[/u\]`): "__$1__",
|
||||
regexp.MustCompile(`(?i)\[s\](.*?)\[/s\]`): "~~$1~~",
|
||||
regexp.MustCompile(`(?i)\[quote\](.*?)\[/quote\]`): "> $1",
|
||||
}
|
||||
|
||||
for re, repl := range replacements {
|
||||
s = re.ReplaceAllString(s, repl)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// REMOVE ANY OTHER BBCODE TAGS, KEEP CONTENT
|
||||
// ----------------------------------------------------
|
||||
s = regexp.MustCompile(`(?i)\[(\/?)[a-zA-Z0-9\=\#]+?\]`).ReplaceAllString(s, "")
|
||||
|
||||
// Cleanup whitespace
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// stripTagCompletely removes [tag]...[/tag] entirely, preserving inner text only if desired.
|
||||
// Here we drop everything inside video tags.
|
||||
func stripTagCompletely(s, tag string) string {
|
||||
re := regexp.MustCompile(`(?is)\[` + tag + `(?:=[^\]]*)?\].*?\[\/` + tag + `\]`)
|
||||
return re.ReplaceAllString(s, "")
|
||||
}
|
||||
// IsImageURL determines whether a string looks like an image link.
|
||||
// Used by both Matrix → Sneed and Sneed → Matrix paths.
|
||||
func IsImageURL(u string) bool {
|
||||
u = strings.ToLower(strings.TrimSpace(u))
|
||||
if !(strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// strip query
|
||||
if i := strings.Index(u, "?"); i > 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
|
||||
return strings.HasSuffix(u, ".png") ||
|
||||
strings.HasSuffix(u, ".jpg") ||
|
||||
strings.HasSuffix(u, ".jpeg") ||
|
||||
strings.HasSuffix(u, ".gif") ||
|
||||
strings.HasSuffix(u, ".webp")
|
||||
}
|
||||
|
||||
// WrapImageForSneed produces the BBCode wrapper used for outbound
|
||||
// Matrix → Sneed image messages.
|
||||
//
|
||||
// Example:
|
||||
// input: "https://example.com/img.jpg"
|
||||
// output: "[url=https://example.com/img.jpg][img]https://example.com/img.jpg[/img][/url]"
|
||||
//
|
||||
func WrapImageForSneed(url string) string {
|
||||
if url == "" {
|
||||
return ""
|
||||
}
|
||||
return "[url=" + url + "][img]" + url + "[/img][/url]"
|
||||
}
|
||||
|
||||
// ExtractFirstURL finds the first URL-like token in a message.
|
||||
// Useful for deciding if a message is an image-only post.
|
||||
func ExtractFirstURL(s string) string {
|
||||
re := regexp.MustCompile(`https?://[^\s]+`)
|
||||
found := re.FindString(s)
|
||||
return found
|
||||
}
|
||||
150
utils/boundedmap.go
Normal file
150
utils/boundedmap.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BoundedMap is a size-limited and time-limited map.
|
||||
// Entries automatically expire after a TTL, and older
|
||||
// entries are removed when exceeding MaxSize.
|
||||
type BoundedMap struct {
|
||||
mu sync.Mutex
|
||||
entries map[interface{}]entry
|
||||
MaxSize int
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Value interface{}
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// NewBoundedMap creates a new bounded map with max size and TTL.
|
||||
func NewBoundedMap(maxSize int, ttl time.Duration) *BoundedMap {
|
||||
return &BoundedMap{
|
||||
entries: make(map[interface{}]entry),
|
||||
MaxSize: maxSize,
|
||||
TTL: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// Set stores a key/value pair, replacing old entry if needed.
|
||||
func (b *BoundedMap) Set(key, value interface{}) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.entries[key] = entry{
|
||||
Value: value,
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
// If map is growing too large, evict oldest items.
|
||||
if len(b.entries) > b.MaxSize {
|
||||
b.evictOldest()
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value if it exists and is not expired.
|
||||
func (b *BoundedMap) Get(key interface{}) (interface{}, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
e, ok := b.entries[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Since(e.Created) > b.TTL {
|
||||
delete(b.entries, key)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return e.Value, true
|
||||
}
|
||||
|
||||
// Delete removes an entry.
|
||||
func (b *BoundedMap) Delete(key interface{}) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.entries, key)
|
||||
}
|
||||
|
||||
// CleanupOldEntries removes expired entries and returns number removed.
|
||||
func (b *BoundedMap) CleanupOldEntries() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
count := 0
|
||||
now := time.Now()
|
||||
|
||||
for k, e := range b.entries {
|
||||
if now.Sub(e.Created) > b.TTL {
|
||||
delete(b.entries, k)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
// evictOldest removes the single oldest entry from the map.
|
||||
// Called automatically when the map exceeds MaxSize.
|
||||
func (b *BoundedMap) evictOldest() {
|
||||
if len(b.entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
oldestKey interface{}
|
||||
oldestTS time.Time
|
||||
first = true
|
||||
)
|
||||
|
||||
for k, e := range b.entries {
|
||||
if first {
|
||||
oldestKey = k
|
||||
oldestTS = e.Created
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
if e.Created.Before(oldestTS) {
|
||||
oldestKey = k
|
||||
oldestTS = e.Created
|
||||
}
|
||||
}
|
||||
|
||||
delete(b.entries, oldestKey)
|
||||
}
|
||||
|
||||
// Size returns the current number of live entries.
|
||||
// Note: expired entries are not removed until Get() or CleanupOldEntries().
|
||||
func (b *BoundedMap) Size() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return len(b.entries)
|
||||
}
|
||||
|
||||
// Keys returns a slice of all keys (including expired ones).
|
||||
// Expired keys will be filtered during normal Get/Cleanup operations.
|
||||
func (b *BoundedMap) Keys() []interface{} {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
out := make([]interface{}, 0, len(b.entries))
|
||||
for k := range b.entries {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Values returns all values in the map (including expired ones).
|
||||
func (b *BoundedMap) Values() []interface{} {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
out := make([]interface{}, 0, len(b.entries))
|
||||
for _, e := range b.entries {
|
||||
out = append(out, e.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
143
utils/helpers.go
Normal file
143
utils/helpers.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// STRING HELPERS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// Truncate returns the first N runes of s, safely.
|
||||
func Truncate(s string, n int) string {
|
||||
rs := []rune(s)
|
||||
if len(rs) <= n {
|
||||
return s
|
||||
}
|
||||
return string(rs[:n])
|
||||
}
|
||||
|
||||
// NormalizeUsername lowers and strips unsafe characters.
|
||||
// Used by the Matrix ghost-user generator and Sneed mapping.
|
||||
func NormalizeUsername(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, " ", "_")
|
||||
s = strings.ReplaceAll(s, "@", "")
|
||||
s = strings.ReplaceAll(s, ":", "")
|
||||
s = strings.ReplaceAll(s, "#", "")
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "\\", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// CleanSpaces reduces all whitespace clusters to a single space.
|
||||
func CleanSpaces(s string) string {
|
||||
space := regexp.MustCompile(`\s+`)
|
||||
return space.ReplaceAllString(strings.TrimSpace(s), " ")
|
||||
}
|
||||
|
||||
// StripControlChars removes non-printable or weird control characters.
|
||||
func StripControlChars(s string) string {
|
||||
re := regexp.MustCompile(`[\x00-\x1F\x7F]`)
|
||||
return re.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// URL HELPERS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// NormalizeURL trims and strips unused trailing punctuation.
|
||||
func NormalizeURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if strings.HasSuffix(u, ")") || strings.HasSuffix(u, "]") {
|
||||
u = u[:len(u)-1]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// IsLikelyURL checks for a simple URL pattern.
|
||||
func IsLikelyURL(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// NUMERIC CONVERSIONS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// ToInt attempts to convert any JSON-type number to int.
|
||||
func ToInt(v interface{}) (int, bool) {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t, true
|
||||
case int64:
|
||||
return int(t), true
|
||||
case float64:
|
||||
return int(t), true
|
||||
case string:
|
||||
i, err := strconv.Atoi(t)
|
||||
if err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// JSON SERIALIZATION
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// MustJSON marshals v or returns a placeholder string.
|
||||
func MustJSON(v interface{}) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "<json error>"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// PrettyJSON pretty prints JSON map / slice items.
|
||||
func PrettyJSON(v interface{}) string {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return "<json error>"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
// ---------------------------------------------------------
|
||||
// TIMESTAMP HELPERS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// NowMS returns the current Unix time in milliseconds.
|
||||
func NowMS() int64 {
|
||||
return time.Now().UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
||||
// IsFresher compares two timestamps (ms).
|
||||
// Returns true if tNew is strictly newer than tOld.
|
||||
func IsFresher(tNew, tOld int64) bool {
|
||||
return tNew > tOld
|
||||
}
|
||||
|
||||
// Age returns the duration since a timestamp in ms.
|
||||
func Age(ts int64) time.Duration {
|
||||
return time.Since(time.UnixMilli(ts))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// MESSAGE ID HELPERS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// IsValidMessageID checks that a Sneedchat message_id is safe.
|
||||
func IsValidMessageID(id int) bool {
|
||||
return id > 0 && id < 1_000_000_000
|
||||
}
|
||||
|
||||
// IsValidSyntheticID verifies that bridge synthetic IDs are nonzero.
|
||||
func IsValidSyntheticID(id int) bool {
|
||||
return id > 0
|
||||
}
|
||||
Reference in New Issue
Block a user