Files
2025-11-18 02:07:08 -05:00

381 lines
7.4 KiB
Go

package matrix
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"local/sneedchatbridge/config"
"local/sneedchatbridge/sneed"
"local/sneedchatbridge/utils"
)
//
// RoomEvent — internal Matrix event representation
//
type RoomEvent struct {
RoomID string
EventID string
Sender string
Type string
Content map[string]interface{}
Unsigned map[string]interface{}
Redacts string
}
//
// Bridge structure
//
type Bridge struct {
cfg *config.Config
sneedc *sneed.Client
httpClient *http.Client
roomID string
muOutbound sync.Mutex
outboundSent []map[string]interface{} // recent outbound messages for echo suppression
muMap sync.Mutex
idMap map[int]int // Matrix synthetic ID → Sneed message ID
sneedTo map[int]int // Sneed message ID → Matrix synthetic ID
muGhost sync.Mutex
remoteUserToGhost map[int]string // user_id → MXID
}
//
// Constructor
//
func NewBridge(cfg *config.Config, sneedc *sneed.Client) *Bridge {
b := &Bridge{
cfg: cfg,
sneedc: sneedc,
httpClient: &http.Client{Timeout: 10 * time.Second},
// roomID must come from Synapse: invite your appservice bot into the room
roomID: cfg.MatrixRoomID,
idMap: make(map[int]int),
sneedTo: make(map[int]int),
remoteUserToGhost: make(map[int]string),
outboundSent: make([]map[string]interface{}, 0, 64),
}
// Wire Sneedchat callbacks
sneedc.OnMessage = b.onSneedMessage
sneedc.OnEdit = b.onSneedEdit
sneedc.OnDelete = b.onSneedDelete
return b
}
//
// MATRIX → SNEEDCHAT ENTRYPOINT
//
func (b *Bridge) HandleMatrixEvent(ev RoomEvent) {
// ignore our own appservice ghost users
if strings.HasSuffix(ev.Sender, ":"+b.cfg.MatrixGhostUserDomain) {
return
}
switch ev.Type {
case "m.room.message":
b.handleMatrixMessage(ev)
case "m.room.redaction":
b.handleMatrixRedaction(ev)
}
}
//
// MATRIX → SNEEDCHAT Message
//
func (b *Bridge) handleMatrixMessage(ev RoomEvent) {
body, _ := ev.Content["body"].(string)
body = utils.CleanSpaces(body)
if body == "" {
return
}
// Track outbound for echo suppression
b.trackOutbound(ev.EventID, body, time.Now())
// If only image link
if utils.IsImageURL(body) {
wrapped := utils.WrapImageForSneed(body)
b.sneedc.Say(wrapped)
return
}
// Otherwise text
b.sneedc.Say(body)
}
//
// MATRIX → SNEEDCHAT Redaction
//
func (b *Bridge) handleMatrixRedaction(ev RoomEvent) {
raw := ev.Redacts
if raw == "" {
return
}
synthID, ok := b.eventIDToInt(raw)
if !ok {
return
}
b.muMap.Lock()
sneedID, ok := b.idMap[synthID]
b.muMap.Unlock()
if ok {
b.sneedc.Say(fmt.Sprintf("/delete %d", sneedID))
}
}
//
// Synthetic ID converter
//
func (b *Bridge) eventIDToInt(evID string) (int, bool) {
evID = strings.TrimPrefix(evID, "$")
if len(evID) < 8 {
return 0, false
}
i, err := strconv.Atoi(evID[:8])
return i, err == nil
}
//
// Outbound tracking to avoid echoing Matrix messages back
//
func (b *Bridge) trackOutbound(eventID string, content string, ts time.Time) {
sid, ok := b.eventIDToInt(eventID)
if !ok {
return
}
b.muOutbound.Lock()
defer b.muOutbound.Unlock()
b.outboundSent = append(b.outboundSent, map[string]interface{}{
"synthetic_id": sid,
"content": content,
"ts": ts,
})
// prune entries older than 60 seconds
cut := time.Now().Add(-60 * time.Second)
pruned := b.outboundSent[:0]
for _, e := range b.outboundSent {
if e["ts"].(time.Time).After(cut) {
pruned = append(pruned, e)
}
}
b.outboundSent = pruned
}
//
// SNEEDCHAT → MATRIX Message
//
func (b *Bridge) onSneedMessage(msgID int, userID int, username string, content string) {
mxid := b.makeGhostMXID(username, userID)
body := utils.BBCodeToMarkdown(content)
if err := b.sendMatrixMessage(mxid, b.roomID, body, msgID); err != nil {
log.Printf("❌ Error sending Matrix message: %v", err)
}
}
//
// SNEEDCHAT → MATRIX Edit
//
func (b *Bridge) onSneedEdit(msgID int, userID int, newText string) {
b.muMap.Lock()
synthID, ok := b.sneedTo[msgID]
b.muMap.Unlock()
if !ok {
return
}
mxEventID := fmt.Sprintf("$%08d:sneed", synthID)
body := utils.BBCodeToMarkdown(newText)
_ = b.sendMatrixEdit(b.roomID, mxEventID, body)
}
//
// SNEEDCHAT → MATRIX Delete
//
func (b *Bridge) onSneedDelete(msgID int, userID int) {
b.muMap.Lock()
synthID, ok := b.sneedTo[msgID]
b.muMap.Unlock()
if !ok {
return
}
mxEventID := fmt.Sprintf("$%08d:sneed", synthID)
_ = b.sendMatrixRedaction(b.roomID, mxEventID)
}
//
// GHOST USER GENERATION (Collision-Safe)
//
func (b *Bridge) makeGhostMXID(username string, userID int) string {
b.muGhost.Lock()
defer b.muGhost.Unlock()
// Already assigned?
if mx, ok := b.remoteUserToGhost[userID]; ok {
return mx
}
base := utils.NormalizeUsername(username)
domain := b.cfg.MatrixGhostUserDomain
prefix := b.cfg.MatrixGhostUserPrefix
// Try @base
mxid := fmt.Sprintf("@%s%s:%s", prefix, base, domain)
if !b.mxidInUse(mxid) {
b.remoteUserToGhost[userID] = mxid
return mxid
}
// Try suffixes
for i := 2; i < 10000; i++ {
candidate := fmt.Sprintf("@%s%s_%d:%s", prefix, base, i, domain)
if !b.mxidInUse(candidate) {
b.remoteUserToGhost[userID] = candidate
return candidate
}
}
// Fallback
fallback := fmt.Sprintf("@%suid_%d:%s", prefix, userID, domain)
b.remoteUserToGhost[userID] = fallback
return fallback
}
func (b *Bridge) mxidInUse(mxid string) bool {
for _, x := range b.remoteUserToGhost {
if x == mxid {
return true
}
}
return false
}
//
// MATRIX SEND HELPERS
//
func (b *Bridge) sendMatrixMessage(mxid, roomID, body string, sneedID int) error {
url := fmt.Sprintf(
"%s/_matrix/client/r0/rooms/%s/send/m.room.message/%d?access_token=%s",
b.getHS(),
roomID,
time.Now().UnixNano(),
b.cfg.MatrixAppserviceToken,
)
payload := map[string]interface{}{
"msgtype": "m.text",
"body": body,
"sender": mxid,
}
return b.httpPutJSON(url, payload)
}
func (b *Bridge) sendMatrixEdit(roomID, targetEventID, newBody string) error {
url := fmt.Sprintf(
"%s/_matrix/client/r0/rooms/%s/send/m.room.message/%d?access_token=%s",
b.getHS(),
roomID,
time.Now().UnixNano(),
b.cfg.MatrixAppserviceToken,
)
payload := map[string]interface{}{
"msgtype": "m.text",
"body": newBody,
"m.new_content": map[string]interface{}{
"msgtype": "m.text",
"body": newBody,
},
"m.relates_to": map[string]interface{}{
"rel_type": "m.replace",
"event_id": targetEventID,
},
}
return b.httpPutJSON(url, payload)
}
func (b *Bridge) sendMatrixRedaction(roomID, targetEventID string) error {
url := fmt.Sprintf(
"%s/_matrix/client/r0/rooms/%s/redact/%s/%d?access_token=%s",
b.getHS(),
roomID,
targetEventID,
time.Now().UnixNano(),
b.cfg.MatrixAppserviceToken,
)
payload := map[string]interface{}{
"reason": "Deleted on Sneedchat",
}
return b.httpPutJSON(url, payload)
}
//
// HTTP PUT helper
//
func (b *Bridge) httpPutJSON(url string, payload map[string]interface{}) error {
data, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("Matrix returned HTTP %d", resp.StatusCode)
}
return nil
}
//
// Homeserver URL (static unless you add config)
//
func (b *Bridge) getHS() string {
return "http://localhost:8008"
}