Initial Commit

This commit is contained in:
Salastil
2025-11-18 02:07:08 -05:00
parent b3ebf32a75
commit 1c5418edf6
12 changed files with 2379 additions and 0 deletions

171
matrix/appservice_server.go Normal file
View File

@@ -0,0 +1,171 @@
package matrix
import (
"encoding/json"
"log"
"net/http"
"strings"
"local/sneedchatbridge/config"
)
// StartAppserviceServer starts the Appservice HTTP server on the configured
// listen address. Synapse will POST room events here as transactions, and
// GET user queries (ghost user checks).
func (b *Bridge) StartAppserviceServer(cfg *config.Config) error {
mux := http.NewServeMux()
// -----------------------------------------------------------
// /transactions/<txn_id> - Synapse pushes events here
// -----------------------------------------------------------
mux.HandleFunc("/_matrix/appservice/v1/transactions/", func(w http.ResponseWriter, r *http.Request) {
if !b.checkAppserviceAuth(r, cfg.MatrixAppserviceToken) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPut && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
b.handleTransaction(w, r)
})
// -----------------------------------------------------------
// /users/@user:domain - Synapse asks if ghost user is allowed
// -----------------------------------------------------------
mux.HandleFunc("/_matrix/appservice/v1/users/", func(w http.ResponseWriter, r *http.Request) {
if !b.checkAppserviceAuth(r, cfg.MatrixAppserviceToken) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
b.handleUserQuery(w, r, cfg)
})
addr := cfg.AppserviceListenAddr
log.Printf("🟢 Matrix Appservice HTTP server listening on %s", addr)
return http.ListenAndServe(addr, mux)
}
//
// AUTHENTICATION
//
// checkAppserviceAuth enforces authorization using the appservice token
// provided in registration.yaml as hs_token and in .env as MATRIX_APPSERVICE_TOKEN.
func (b *Bridge) checkAppserviceAuth(r *http.Request, token string) bool {
// Try ?access_token=...
if q := r.URL.Query().Get("access_token"); q != "" {
return q == token
}
// Try Authorization: Bearer <token>
if h := r.Header.Get("Authorization"); h != "" {
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ") == token
}
}
return false
}
//
// TRANSACTIONS
//
// handleTransaction receives a list of events from Synapse.
// Synapse POSTs a JSON body like:
// { "events": [ { ... }, { ... } ] }
func (b *Bridge) handleTransaction(w http.ResponseWriter, r *http.Request) {
var payload struct {
Events []map[string]interface{} `json:"events"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
log.Printf("❌ Appservice transaction decode error: %v", err)
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
for _, ev := range payload.Events {
b.routeEvent(ev)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
}
// routeEvent takes a raw Matrix event (map[string]interface{})
// and converts it into a strongly-typed RoomEvent struct,
// then forwards it to the bridge core.
func (b *Bridge) routeEvent(ev map[string]interface{}) {
evType, _ := ev["type"].(string)
roomID, _ := ev["room_id"].(string)
sender, _ := ev["sender"].(string)
eventID, _ := ev["event_id"].(string)
// Extract content if present
content := map[string]interface{}{}
if c, ok := ev["content"].(map[string]interface{}); ok {
content = c
}
// Extract unsigned if present
unsigned := map[string]interface{}{}
if u, ok := ev["unsigned"].(map[string]interface{}); ok {
unsigned = u
}
// Redaction ID if present
redacts, _ := ev["redacts"].(string)
// Assemble our internal event struct
re := RoomEvent{
RoomID: roomID,
EventID: eventID,
Sender: sender,
Type: evType,
Content: content,
Unsigned: unsigned,
Redacts: redacts,
}
b.HandleMatrixEvent(re)
}
//
// USER QUERY ENDPOINT
//
// handleUserQuery answers the Appservice ghost-user existence query.
// Synapse queries:
//
// GET /_matrix/appservice/v1/users/@username:sneedchat.kiwifarms.net
//
// Returning `{}` approves ghost user creation.
// Returning 404 denies.
func (b *Bridge) handleUserQuery(w http.ResponseWriter, r *http.Request, cfg *config.Config) {
path := r.URL.Path
parts := strings.Split(path, "/")
if len(parts) == 0 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
mxid := parts[len(parts)-1]
if mxid == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Allow only the configured ghost-user domain
if !strings.HasSuffix(mxid, ":"+cfg.MatrixGhostUserDomain) {
http.NotFound(w, r)
return
}
// Approve creation
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}

380
matrix/bridge.go Normal file
View File

@@ -0,0 +1,380 @@
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"
}