Modularized
This commit is contained in:
@@ -41,7 +41,6 @@ go version # Should show 1.19 or higher
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create installation directory
|
# Create installation directory
|
||||||
sudo mkdir -p /opt/sneedchat-bridge
|
|
||||||
cd /opt/sneedchat-bridge
|
cd /opt/sneedchat-bridge
|
||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
52
config/config.go
Normal file
52
config/config.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DiscordBotToken string
|
||||||
|
DiscordChannelID string
|
||||||
|
DiscordGuildID string
|
||||||
|
DiscordWebhookURL string
|
||||||
|
SneedchatRoomID int
|
||||||
|
BridgeUsername string
|
||||||
|
BridgePassword string
|
||||||
|
BridgeUserID int
|
||||||
|
DiscordPingUserID string
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(envFile string) (*Config, error) {
|
||||||
|
if err := godotenv.Load(envFile); err != nil {
|
||||||
|
log.Printf("Warning: error loading %s: %v", envFile, err)
|
||||||
|
}
|
||||||
|
cfg := &Config{
|
||||||
|
DiscordBotToken: os.Getenv("DISCORD_BOT_TOKEN"),
|
||||||
|
DiscordChannelID: os.Getenv("DISCORD_CHANNEL_ID"),
|
||||||
|
DiscordGuildID: os.Getenv("DISCORD_GUILD_ID"),
|
||||||
|
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
||||||
|
BridgeUsername: os.Getenv("BRIDGE_USERNAME"),
|
||||||
|
BridgePassword: os.Getenv("BRIDGE_PASSWORD"),
|
||||||
|
DiscordPingUserID: os.Getenv("DISCORD_PING_USER_ID"),
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID, err := strconv.Atoi(os.Getenv("SNEEDCHAT_ROOM_ID"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid SNEEDCHAT_ROOM_ID: %w", err)
|
||||||
|
}
|
||||||
|
cfg.SneedchatRoomID = roomID
|
||||||
|
|
||||||
|
if v := os.Getenv("BRIDGE_USER_ID"); v != "" {
|
||||||
|
cfg.BridgeUserID, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if os.Getenv("DEBUG") == "1" || os.Getenv("DEBUG") == "true" {
|
||||||
|
cfg.Debug = true
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
302
cookie/fetcher.go
Normal file
302
cookie/fetcher.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CookieRetryDelay = 5 * time.Second
|
||||||
|
MaxCookieRetryDelay = 60 * time.Second
|
||||||
|
CookieRefreshEvery = 4 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type RefreshService struct {
|
||||||
|
username, password, domain string
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
cookieMu sync.RWMutex
|
||||||
|
currentCookie string
|
||||||
|
|
||||||
|
readyOnce sync.Once
|
||||||
|
readyCh chan struct{}
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRefreshService(username, password, domain string) *RefreshService {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
tr := &http.Transport{
|
||||||
|
// Force HTTP/1.1 (avoid ALPN h2 differences)
|
||||||
|
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: tr,
|
||||||
|
}
|
||||||
|
return &RefreshService{
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
domain: domain,
|
||||||
|
client: client,
|
||||||
|
readyCh: make(chan struct{}),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) Start() {
|
||||||
|
r.wg.Add(1)
|
||||||
|
go r.loop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) Stop() {
|
||||||
|
close(r.stopCh)
|
||||||
|
r.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) WaitForCookie() { <-r.readyCh }
|
||||||
|
|
||||||
|
func (r *RefreshService) GetCurrentCookie() string {
|
||||||
|
r.cookieMu.RLock()
|
||||||
|
defer r.cookieMu.RUnlock()
|
||||||
|
return r.currentCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) loop() {
|
||||||
|
defer r.wg.Done()
|
||||||
|
|
||||||
|
log.Println("🔑 Fetching initial cookie...")
|
||||||
|
c, err := r.FetchFreshCookie()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to acquire initial cookie: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.cookieMu.Lock()
|
||||||
|
r.currentCookie = c
|
||||||
|
r.cookieMu.Unlock()
|
||||||
|
r.readyOnce.Do(func() { close(r.readyCh) })
|
||||||
|
log.Println("✅ Initial cookie acquired")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) FetchFreshCookie() (string, error) {
|
||||||
|
attempt := 0
|
||||||
|
delay := CookieRetryDelay
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.stopCh:
|
||||||
|
return "", fmt.Errorf("stopped")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++
|
||||||
|
if attempt > 1 {
|
||||||
|
log.Printf("🔄 Cookie fetch retry attempt %d (waiting %v)...", attempt, delay)
|
||||||
|
time.Sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
if delay > MaxCookieRetryDelay {
|
||||||
|
delay = MaxCookieRetryDelay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := r.attemptFetchCookie()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "xf_user=") {
|
||||||
|
log.Printf("✅ Successfully fetched fresh cookie with xf_user (attempt %d)", attempt)
|
||||||
|
r.cookieMu.Lock()
|
||||||
|
r.currentCookie = c
|
||||||
|
r.cookieMu.Unlock()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
log.Printf("❌ Cookie fetch attempt %d missing xf_user — retrying...", attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) attemptFetchCookie() (string, error) {
|
||||||
|
// Step 1: KiwiFlare
|
||||||
|
log.Println("Step 1: Checking for KiwiFlare challenge...")
|
||||||
|
clearance, err := r.getClearanceToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("clearance token error: %w", err)
|
||||||
|
}
|
||||||
|
if clearance != "" {
|
||||||
|
log.Println("✅ KiwiFlare challenge solved")
|
||||||
|
log.Println("⏳ Waiting 2 seconds for cookie propagation...")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: GET /login
|
||||||
|
log.Println("Step 2: Fetching login page...")
|
||||||
|
loginURL := fmt.Sprintf("https://%s/login/", r.domain)
|
||||||
|
req, _ := http.NewRequest("GET", loginURL, nil)
|
||||||
|
req.Header.Set("User-Agent", randomUserAgent())
|
||||||
|
req.Header.Set("Cache-Control", "no-cache")
|
||||||
|
req.Header.Set("Pragma", "no-cache")
|
||||||
|
req.URL.RawQuery = fmt.Sprintf("r=%d", rand.Intn(1_000_000))
|
||||||
|
|
||||||
|
resp, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get login page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
log.Printf("→ Using protocol for login page: %s", resp.Proto)
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
bodyStr := string(body)
|
||||||
|
|
||||||
|
log.Println("⏳ Waiting 1 second before processing login page...")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Step 3: Extract CSRF
|
||||||
|
log.Println("Step 3: Extracting CSRF token...")
|
||||||
|
var csrf string
|
||||||
|
for _, pat := range []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`<html[^>]*data-csrf=["']([^"']+)["']`),
|
||||||
|
regexp.MustCompile(`name="_xfToken" value="([^"]+)"`),
|
||||||
|
regexp.MustCompile(`data-csrf=["']([^"']+)["']`),
|
||||||
|
regexp.MustCompile(`"csrf":"([^"]+)"`),
|
||||||
|
regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`),
|
||||||
|
} {
|
||||||
|
if m := pat.FindStringSubmatch(bodyStr); len(m) >= 2 {
|
||||||
|
csrf = m[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if csrf == "" {
|
||||||
|
log.Printf("⚠️ CSRF token not found. Partial HTML:\n%s", bodyStr[:min(800, len(bodyStr))])
|
||||||
|
return "", fmt.Errorf("CSRF token not found in login page")
|
||||||
|
}
|
||||||
|
log.Printf("✅ Found CSRF token: %s...", csrf[:min(10, len(csrf))])
|
||||||
|
|
||||||
|
// Step 4: POST /login/login
|
||||||
|
log.Println("Step 4: Submitting login credentials...")
|
||||||
|
postURL := fmt.Sprintf("https://%s/login/login", r.domain)
|
||||||
|
form := url.Values{
|
||||||
|
"_xfToken": {csrf},
|
||||||
|
"_xfRequestUri": {"/"},
|
||||||
|
"_xfWithData": {"1"},
|
||||||
|
"login": {r.username},
|
||||||
|
"password": {r.password},
|
||||||
|
"_xfRedirect": {fmt.Sprintf("https://%s/", r.domain)},
|
||||||
|
"remember": {"1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure GET cookies are kept
|
||||||
|
cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain))
|
||||||
|
if resp.Cookies() != nil {
|
||||||
|
r.client.Jar.SetCookies(cookieURL, resp.Cookies())
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq, _ := http.NewRequest("POST", postURL, strings.NewReader(form.Encode()))
|
||||||
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
postReq.Header.Set("User-Agent", randomUserAgent())
|
||||||
|
postReq.Header.Set("Referer", loginURL)
|
||||||
|
postReq.Header.Set("Origin", fmt.Sprintf("https://%s", r.domain))
|
||||||
|
postReq.Header.Set("X-XF-Token", csrf)
|
||||||
|
postReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
postReq.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
postReq.Header.Set("Accept-Encoding", "gzip, deflate") // avoid br
|
||||||
|
|
||||||
|
loginResp, err := r.client.Do(postReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("login POST failed: %w", err)
|
||||||
|
}
|
||||||
|
defer loginResp.Body.Close()
|
||||||
|
log.Printf("Login response status: %d", loginResp.StatusCode)
|
||||||
|
|
||||||
|
// Follow a single redirect (XenForo usually sets xf_user on redirect target)
|
||||||
|
if loginResp.StatusCode >= 300 && loginResp.StatusCode < 400 {
|
||||||
|
if loc := loginResp.Header.Get("Location"); loc != "" {
|
||||||
|
log.Printf("Following redirect to %s to check for xf_user...", loc)
|
||||||
|
url2 := loc
|
||||||
|
if !strings.HasPrefix(loc, "http") {
|
||||||
|
url2 = fmt.Sprintf("https://%s%s", r.domain, loc)
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
if fr, err := r.client.Get(url2); err == nil {
|
||||||
|
fr.Body.Close()
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode response (gzip)
|
||||||
|
var reader io.ReadCloser
|
||||||
|
if loginResp.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
gz, ge := gzip.NewReader(loginResp.Body)
|
||||||
|
if ge == nil {
|
||||||
|
reader = gz
|
||||||
|
defer gz.Close()
|
||||||
|
} else {
|
||||||
|
reader = io.NopCloser(loginResp.Body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reader = io.NopCloser(loginResp.Body)
|
||||||
|
}
|
||||||
|
respHTML, _ := io.ReadAll(reader)
|
||||||
|
if strings.Contains(string(respHTML), `data-logged-in="false"`) {
|
||||||
|
log.Println("⚠️ HTML indicates still logged out (data-logged-in=false)")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
return r.retryWithFreshCSRF()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize cookie domains and compose cookie string
|
||||||
|
cookies := r.client.Jar.Cookies(cookieURL)
|
||||||
|
for _, c := range cookies {
|
||||||
|
c.Domain = strings.TrimPrefix(c.Domain, ".")
|
||||||
|
}
|
||||||
|
r.client.Jar.SetCookies(cookieURL, cookies)
|
||||||
|
|
||||||
|
want := map[string]bool{
|
||||||
|
"xf_user": true,
|
||||||
|
"xf_toggle": true,
|
||||||
|
"xf_csrf": true,
|
||||||
|
"xf_session": true,
|
||||||
|
"sssg_clearance": true,
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
hasUser := false
|
||||||
|
for _, c := range cookies {
|
||||||
|
if want[c.Name] {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value))
|
||||||
|
if c.Name == "xf_user" {
|
||||||
|
hasUser = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasUser {
|
||||||
|
return "", fmt.Errorf("xf_user cookie missing — authentication failed, will retry")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomUserAgent() string {
|
||||||
|
agents := []string{
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
||||||
|
}
|
||||||
|
return agents[rand.Intn(len(agents))]
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
78
cookie/retry.go
Normal file
78
cookie/retry.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *RefreshService) retryWithFreshCSRF() (string, error) {
|
||||||
|
loginURL := fmt.Sprintf("https://%s/login/", r.domain)
|
||||||
|
resp, err := r.client.Get(loginURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to refetch login page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
re := regexp.MustCompile(`name="_xfToken" value="([^"]+)"`)
|
||||||
|
m := re.FindSubmatch(body)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return "", fmt.Errorf("csrf retry token not found")
|
||||||
|
}
|
||||||
|
csrf := string(m[1])
|
||||||
|
log.Printf("✅ Retry CSRF token: %.10s...", csrf)
|
||||||
|
|
||||||
|
postURL := fmt.Sprintf("https://%s/login/login", r.domain)
|
||||||
|
form := url.Values{
|
||||||
|
"login": {r.username},
|
||||||
|
"password": {r.password},
|
||||||
|
"_xfToken": {csrf},
|
||||||
|
"_xfRedirect": {"/"},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("POST", postURL, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", randomUserAgent())
|
||||||
|
req.Header.Set("Referer", loginURL)
|
||||||
|
req.Header.Set("Origin", fmt.Sprintf("https://%s", r.domain))
|
||||||
|
req.Header.Set("X-XF-Token", csrf)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||||
|
|
||||||
|
resp2, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("retry POST failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
|
var reader io.ReadCloser
|
||||||
|
if resp2.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
gz, ge := gzip.NewReader(resp2.Body)
|
||||||
|
if ge == nil {
|
||||||
|
reader = gz
|
||||||
|
defer gz.Close()
|
||||||
|
} else {
|
||||||
|
reader = io.NopCloser(resp2.Body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reader = io.NopCloser(resp2.Body)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(reader)
|
||||||
|
if strings.Contains(string(b), `data-logged-in="true"`) {
|
||||||
|
log.Println("✅ Retry indicates logged in successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieURL, _ := url.Parse(fmt.Sprintf("https://%s/", r.domain))
|
||||||
|
for _, c := range r.client.Jar.Cookies(cookieURL) {
|
||||||
|
if c.Name == "xf_user" {
|
||||||
|
log.Printf("✅ Successfully fetched fresh cookie with xf_user: %.12s...", c.Value)
|
||||||
|
// Rebuild cookie header with known-good set (reuse attemptFetchCookie’s logic if you want)
|
||||||
|
return "xf_user=" + c.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("retry still missing xf_user cookie")
|
||||||
|
}
|
||||||
138
cookie/solver.go
Normal file
138
cookie/solver.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *RefreshService) getClearanceToken() (string, error) {
|
||||||
|
baseURL := fmt.Sprintf("https://%s/", r.domain)
|
||||||
|
req, _ := http.NewRequest("GET", baseURL, nil)
|
||||||
|
req.Header.Set("User-Agent", randomUserAgent())
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
resp, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
// Detect challenge (several patterns)
|
||||||
|
patterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`<html[^>]*id=["']sssg["'][^>]*data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`),
|
||||||
|
regexp.MustCompile(`<html[^>]*id=["']sssg["'][^>]*data-sssg-difficulty=["'](\d+)["'][^>]*data-sssg-challenge=["']([^"']+)["']`),
|
||||||
|
regexp.MustCompile(`data-sssg-challenge=["']([^"']+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`),
|
||||||
|
}
|
||||||
|
var salt string
|
||||||
|
var difficulty int
|
||||||
|
found := false
|
||||||
|
for i, p := range patterns {
|
||||||
|
if m := p.FindStringSubmatch(string(body)); len(m) >= 3 {
|
||||||
|
if i == 1 {
|
||||||
|
difficulty, _ = strconv.Atoi(m[1])
|
||||||
|
salt = m[2]
|
||||||
|
} else {
|
||||||
|
salt = m[1]
|
||||||
|
difficulty, _ = strconv.Atoi(m[2])
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found || difficulty == 0 || salt == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Solving KiwiFlare challenge (difficulty=%d)", difficulty)
|
||||||
|
time.Sleep(time.Duration(500+rand.Intn(750)) * time.Millisecond)
|
||||||
|
|
||||||
|
nonce, err := r.solvePoW(salt, difficulty)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Duration(700+rand.Intn(900)) * time.Millisecond)
|
||||||
|
|
||||||
|
submitURL := fmt.Sprintf("https://%s/.sssg/api/answer", r.domain)
|
||||||
|
form := url.Values{"a": {salt}, "b": {nonce}}
|
||||||
|
post, _ := http.NewRequest("POST", submitURL, strings.NewReader(form.Encode()))
|
||||||
|
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
post.Header.Set("User-Agent", randomUserAgent())
|
||||||
|
post.Header.Set("Origin", baseURL)
|
||||||
|
post.Header.Set("Referer", baseURL)
|
||||||
|
|
||||||
|
resp2, err := r.client.Do(post)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
|
// Some deployments return JSON like {"auth":"..."}
|
||||||
|
var result map[string]any
|
||||||
|
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
||||||
|
|
||||||
|
time.Sleep(time.Duration(1200+rand.Intn(800)) * time.Millisecond)
|
||||||
|
|
||||||
|
cookieURL, _ := url.Parse(baseURL)
|
||||||
|
for _, c := range r.client.Jar.Cookies(cookieURL) {
|
||||||
|
if c.Name == "sssg_clearance" {
|
||||||
|
log.Printf("✅ KiwiFlare clearance cookie confirmed: %s...", c.Value[:min(10, len(c.Value))])
|
||||||
|
return c.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := result["auth"].(string); ok && v != "" {
|
||||||
|
// Fallback: manually add
|
||||||
|
r.client.Jar.SetCookies(cookieURL, []*http.Cookie{{
|
||||||
|
Name: "sssg_clearance",
|
||||||
|
Value: v,
|
||||||
|
Path: "/",
|
||||||
|
Domain: r.domain,
|
||||||
|
}})
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("clearance cookie missing after solve")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RefreshService) solvePoW(salt string, difficulty int) (string, error) {
|
||||||
|
start := time.Now()
|
||||||
|
bytes := difficulty / 8
|
||||||
|
bits := difficulty % 8
|
||||||
|
|
||||||
|
for nonce := rand.Int63(); ; nonce++ {
|
||||||
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%s%d", salt, nonce)))
|
||||||
|
ok := true
|
||||||
|
for i := 0; i < bytes; i++ {
|
||||||
|
if sum[i] != 0 {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok && bits > 0 && bytes < len(sum) {
|
||||||
|
mask := byte(0xFF << (8 - bits))
|
||||||
|
if sum[bytes]&mask != 0 {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
delay := time.Duration(2+rand.Intn(3))*time.Second - time.Since(start)
|
||||||
|
if delay > 0 {
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", nonce), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
discord/bridge.go
Normal file
60
discord/bridge.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package discord
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"local/sneedchatbridge/config"
|
||||||
|
"local/sneedchatbridge/sneed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
cfg *config.Config
|
||||||
|
session *discordgo.Session
|
||||||
|
sneed *sneed.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBridge(cfg *config.Config, sneedClient *sneed.Client) (*Bridge, error) {
|
||||||
|
s, err := discordgo.New("Bot " + cfg.DiscordBotToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := &Bridge{cfg: cfg, session: s, sneed: sneedClient}
|
||||||
|
s.AddHandler(b.onReady)
|
||||||
|
s.AddHandler(b.onMessage)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Start() error {
|
||||||
|
b.session.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
|
||||||
|
if err := b.session.Open(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Stop() {
|
||||||
|
b.session.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onReady(s *discordgo.Session, r *discordgo.Ready) {
|
||||||
|
log.Printf("🤖 Discord bot ready: %s (%s)", r.User.Username, r.User.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||||
|
if m.Author == nil || m.Author.Bot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.ChannelID != b.cfg.DiscordChannelID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Simple pass-through to Sneedchat (extend with attachments, mapping, etc., as in your original)
|
||||||
|
if ok := b.sneed.Send(m.Content); !ok {
|
||||||
|
s.ChannelMessageSend(m.ChannelID, "⚠️ Sneedchat appears offline. Message not sent.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("📤 Discord → Sneedchat: %s: %s", m.Author.Username, m.Content)
|
||||||
|
// Basic echo confirmation
|
||||||
|
_, _ = s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("✅ Sent to Sneedchat: %s", m.Content))
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module sneedchat-discord-bridge
|
module local/sneedchatbridge
|
||||||
|
|
||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
|
|||||||
88
main.go
Normal file
88
main.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"local/sneedchatbridge/config"
|
||||||
|
"local/sneedchatbridge/cookie"
|
||||||
|
"local/sneedchatbridge/discord"
|
||||||
|
"local/sneedchatbridge/sneed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
envFile := ".env"
|
||||||
|
// allow: ./bin --env /path/to/.env
|
||||||
|
for i, a := range os.Args {
|
||||||
|
if a == "--env" && i+1 < len(os.Args) {
|
||||||
|
envFile = os.Args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(envFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Using .env file: %s", envFile)
|
||||||
|
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
|
||||||
|
log.Printf("Bridge username: %s", cfg.BridgeUsername)
|
||||||
|
|
||||||
|
// Cookie service
|
||||||
|
cookieSvc := cookie.NewRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st")
|
||||||
|
cookieSvc.Start()
|
||||||
|
log.Println("⏳ Waiting for initial cookie...")
|
||||||
|
cookieSvc.WaitForCookie()
|
||||||
|
|
||||||
|
initialCookie := cookieSvc.GetCurrentCookie()
|
||||||
|
if initialCookie == "" {
|
||||||
|
log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sneedchat client
|
||||||
|
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc)
|
||||||
|
|
||||||
|
// Discord bridge
|
||||||
|
bridge, err := discord.NewBridge(cfg, sneedClient)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create Discord bridge: %v", err)
|
||||||
|
}
|
||||||
|
if err := bridge.Start(); err != nil {
|
||||||
|
log.Fatalf("Failed to start Discord bridge: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("🌉 Discord-Sneedchat Bridge started successfully")
|
||||||
|
|
||||||
|
// Periodic cookie refresh
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(4 * time.Hour)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
log.Println("🔄 Starting automatic cookie refresh")
|
||||||
|
if _, err := cookieSvc.FetchFreshCookie(); err != nil {
|
||||||
|
log.Printf("⚠️ Cookie refresh failed: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Println("✅ Cookie refresh completed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Connect to Sneedchat
|
||||||
|
go func() {
|
||||||
|
if err := sneedClient.Connect(); err != nil {
|
||||||
|
log.Printf("Initial Sneedchat connect failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sig
|
||||||
|
|
||||||
|
log.Println("Shutdown signal received, cleaning up...")
|
||||||
|
bridge.Stop()
|
||||||
|
sneedClient.Disconnect()
|
||||||
|
cookieSvc.Stop()
|
||||||
|
log.Println("Bridge stopped successfully")
|
||||||
|
}
|
||||||
163
sneed/client.go
Normal file
163
sneed/client.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package sneed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"local/sneedchatbridge/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
wsURL string
|
||||||
|
roomID int
|
||||||
|
cookies *cookie.RefreshService
|
||||||
|
|
||||||
|
conn *websocket.Conn
|
||||||
|
connected bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
lastMessage time.Time
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(roomID int, cookieSvc *cookie.RefreshService) *Client {
|
||||||
|
return &Client{
|
||||||
|
wsURL: "wss://kiwifarms.st:9443/chat.ws",
|
||||||
|
roomID: roomID,
|
||||||
|
cookies: cookieSvc,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.connected {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Add("Cookie", c.cookies.GetCurrentCookie())
|
||||||
|
|
||||||
|
log.Printf("Connecting to Sneedchat room %d", c.roomID)
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("websocket connection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.conn = conn
|
||||||
|
c.connected = true
|
||||||
|
c.lastMessage = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.wg.Add(3)
|
||||||
|
go c.readLoop()
|
||||||
|
go c.heartbeatLoop()
|
||||||
|
go c.joinRoom()
|
||||||
|
|
||||||
|
log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) joinRoom() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
c.Send(fmt.Sprintf("/join %d", c.roomID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readLoop() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
defer c.handleDisconnect()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Sneedchat read error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.lastMessage = time.Now()
|
||||||
|
_ = message // plug in your existing JSON handling if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) heartbeatLoop() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
c.mu.RLock()
|
||||||
|
connected := c.connected
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if connected && time.Since(c.lastMessage) > 60*time.Second && conn != nil {
|
||||||
|
_ = conn.WriteMessage(websocket.TextMessage, []byte("/ping"))
|
||||||
|
}
|
||||||
|
case <-c.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Send(s string) bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
conn := c.conn
|
||||||
|
ok := c.connected && conn != nil
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil {
|
||||||
|
log.Printf("Sneedchat write error: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleDisconnect() {
|
||||||
|
select {
|
||||||
|
case <-c.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.connected = false
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
log.Println("🔴 Sneedchat disconnected")
|
||||||
|
time.Sleep(7 * time.Second)
|
||||||
|
_ = c.Connect() // try once; your original had a loop — add if desired
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Disconnect() {
|
||||||
|
close(c.stopCh)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
}
|
||||||
|
c.connected = false
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.wg.Wait()
|
||||||
|
}
|
||||||
17
sneed/types.go
Normal file
17
sneed/types.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package sneed
|
||||||
|
|
||||||
|
type SneedMessage struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
MessageRaw string `json:"message_raw"`
|
||||||
|
MessageEditDate int `json:"message_edit_date"`
|
||||||
|
Author map[string]interface{} `json:"author"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SneedPayload struct {
|
||||||
|
Messages []SneedMessage `json:"messages"`
|
||||||
|
Message *SneedMessage `json:"message"`
|
||||||
|
Delete interface{} `json:"delete"`
|
||||||
|
}
|
||||||
61
utils/bbcode.go
Normal file
61
utils/bbcode.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BBCodeToMarkdown(text string) string {
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||||
|
text = strings.ReplaceAll(text, "\r", "\n")
|
||||||
|
|
||||||
|
text = regexp.MustCompile(`(?i)\[img\](.*?)\[/img\]`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`(?i)\[video\](.*?)\[/video\]`).ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
urlPattern := regexp.MustCompile(`(?i)\[url=(.*?)\](.*?)\[/url\]`)
|
||||||
|
text = urlPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
parts := urlPattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
link := strings.TrimSpace(parts[1])
|
||||||
|
txt := strings.TrimSpace(parts[2])
|
||||||
|
if regexp.MustCompile(`(?i)^https?://`).MatchString(txt) {
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
return "[" + txt + "](" + link + ")"
|
||||||
|
})
|
||||||
|
|
||||||
|
text = regexp.MustCompile(`(?i)\[url\](.*?)\[/url\]`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`(?i)\[(?:b|strong)\](.*?)\[/\s*(?:b|strong)\]`).ReplaceAllString(text, "**$1**")
|
||||||
|
text = regexp.MustCompile(`(?i)\[(?:i|em)\](.*?)\[/\s*(?:i|em)\]`).ReplaceAllString(text, "*$1*")
|
||||||
|
text = regexp.MustCompile(`(?i)\[u\](.*?)\[/\s*u\]`).ReplaceAllString(text, "__$1__")
|
||||||
|
text = regexp.MustCompile(`(?i)\[(?:s|strike)\](.*?)\[/\s*(?:s|strike)\]`).ReplaceAllString(text, "~~$1~~")
|
||||||
|
text = regexp.MustCompile(`(?i)\[code\](.*?)\[/code\]`).ReplaceAllString(text, "`$1`")
|
||||||
|
text = regexp.MustCompile(`(?i)\[(?:php|plain|code=\w+)\](.*?)\[/(?:php|plain|code)\]`).ReplaceAllString(text, "```$1```")
|
||||||
|
|
||||||
|
quotePattern := regexp.MustCompile(`(?i)\[quote\](.*?)\[/quote\]`)
|
||||||
|
text = quotePattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
parts := quotePattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(parts[1])
|
||||||
|
lines := strings.Split(inner, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = "> " + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
text = regexp.MustCompile(`(?i)\[spoiler\](.*?)\[/spoiler\]`).ReplaceAllString(text, "||$1||")
|
||||||
|
text = regexp.MustCompile(`(?i)\[(?:color|size)=.*?\](.*?)\[/\s*(?:color|size)\]`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`(?m)^\[\*\]\s*`).ReplaceAllString(text, "• ")
|
||||||
|
text = regexp.MustCompile(`(?i)\[/?list\]`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`\[/?[A-Za-z0-9\-=_]+\]`).ReplaceAllString(text, "")
|
||||||
|
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
98
utils/boundedmap.go
Normal file
98
utils/boundedmap.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BoundedMap struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
data map[int]interface{}
|
||||||
|
timestamps map[int]time.Time
|
||||||
|
maxSize int
|
||||||
|
maxAge time.Duration
|
||||||
|
keys []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBoundedMap(maxSize int, maxAge time.Duration) *BoundedMap {
|
||||||
|
return &BoundedMap{
|
||||||
|
data: make(map[int]interface{}),
|
||||||
|
timestamps: make(map[int]time.Time),
|
||||||
|
maxSize: maxSize,
|
||||||
|
maxAge: maxAge,
|
||||||
|
keys: make([]int, 0, maxSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *BoundedMap) Set(key int, value interface{}) {
|
||||||
|
bm.mu.Lock()
|
||||||
|
defer bm.mu.Unlock()
|
||||||
|
if _, ok := bm.data[key]; ok {
|
||||||
|
bm.data[key] = value
|
||||||
|
bm.timestamps[key] = time.Now()
|
||||||
|
for i, k := range bm.keys {
|
||||||
|
if k == key {
|
||||||
|
bm.keys = append(bm.keys[:i], bm.keys[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bm.keys = append(bm.keys, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bm.data[key] = value
|
||||||
|
bm.timestamps[key] = time.Now()
|
||||||
|
bm.keys = append(bm.keys, key)
|
||||||
|
if len(bm.data) > bm.maxSize {
|
||||||
|
oldest := bm.keys[0]
|
||||||
|
delete(bm.data, oldest)
|
||||||
|
delete(bm.timestamps, oldest)
|
||||||
|
bm.keys = bm.keys[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *BoundedMap) Get(key int) (interface{}, bool) {
|
||||||
|
bm.mu.RLock()
|
||||||
|
defer bm.mu.RUnlock()
|
||||||
|
v, ok := bm.data[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *BoundedMap) Delete(key int) {
|
||||||
|
bm.mu.Lock()
|
||||||
|
defer bm.mu.Unlock()
|
||||||
|
delete(bm.data, key)
|
||||||
|
delete(bm.timestamps, key)
|
||||||
|
for i, k := range bm.keys {
|
||||||
|
if k == key {
|
||||||
|
bm.keys = append(bm.keys[:i], bm.keys[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *BoundedMap) CleanupOldEntries() int {
|
||||||
|
bm.mu.Lock()
|
||||||
|
defer bm.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
removed := 0
|
||||||
|
for key, ts := range bm.timestamps {
|
||||||
|
if now.Sub(ts) > bm.maxAge {
|
||||||
|
delete(bm.data, key)
|
||||||
|
delete(bm.timestamps, key)
|
||||||
|
for i, k := range bm.keys {
|
||||||
|
if k == key {
|
||||||
|
bm.keys = append(bm.keys[:i], bm.keys[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bm *BoundedMap) Len() int {
|
||||||
|
bm.mu.RLock()
|
||||||
|
defer bm.mu.RUnlock()
|
||||||
|
return len(bm.data)
|
||||||
|
}
|
||||||
8
utils/helpers.go
Normal file
8
utils/helpers.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
func Min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user