381 lines
7.4 KiB
Go
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"
|
|
}
|