201 lines
6.5 KiB
Go
201 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// MatrixError is a parsed Matrix CS-API error body ({errcode, error}).
|
|
type MatrixError struct {
|
|
StatusCode int
|
|
ErrCode string `json:"errcode"`
|
|
Err string `json:"error"`
|
|
RetryAfterMs int `json:"retry_after_ms"`
|
|
}
|
|
|
|
func (e *MatrixError) Error() string {
|
|
return fmt.Sprintf("matrix %d %s: %s", e.StatusCode, e.ErrCode, e.Err)
|
|
}
|
|
|
|
// MatrixClient is a thin plaintext CS-API client that authenticates as an
|
|
// appservice: every request carries the non-expiring `as_token` plus a
|
|
// `?user_id=` identity assertion so the homeserver treats it as the bot user.
|
|
// No crypto store — Vojo rooms are unencrypted by default.
|
|
type MatrixClient struct {
|
|
base string
|
|
asToken string
|
|
asUserID string
|
|
http *http.Client
|
|
txnSeq atomic.Uint64
|
|
}
|
|
|
|
func NewMatrixClient(base, asToken, asUserID string) *MatrixClient {
|
|
return &MatrixClient{
|
|
base: base,
|
|
asToken: asToken,
|
|
asUserID: asUserID,
|
|
http: &http.Client{Timeout: 60 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *MatrixClient) nextTxnID() string {
|
|
return "aibot-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" +
|
|
strconv.FormatUint(c.txnSeq.Add(1), 36)
|
|
}
|
|
|
|
// do issues a CS-API request as the appservice user and decodes JSON into out.
|
|
// Non-2xx responses are returned as *MatrixError.
|
|
func (c *MatrixClient) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
buf, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
reader = bytes.NewReader(buf)
|
|
}
|
|
|
|
if query == nil {
|
|
query = url.Values{}
|
|
}
|
|
// Appservice identity assertion: act as the bot user (spec §Identity
|
|
// assertion). The as_token authenticates the appservice; user_id selects
|
|
// which namespaced user we are acting as.
|
|
query.Set("user_id", c.asUserID)
|
|
|
|
u := c.base + path + "?" + query.Encode()
|
|
req, err := http.NewRequestWithContext(ctx, method, u, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.asToken)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
mErr := &MatrixError{StatusCode: resp.StatusCode}
|
|
_ = json.Unmarshal(data, mErr) // best-effort; body may not be JSON
|
|
return mErr
|
|
}
|
|
if out != nil && len(data) > 0 {
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("decode response from %s: %w", path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Whoami confirms the as_token + user_id resolves to BOT_MXID (startup check).
|
|
func (c *MatrixClient) Whoami(ctx context.Context) (string, error) {
|
|
var out struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/_matrix/client/v3/account/whoami", nil, nil, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.UserID, nil
|
|
}
|
|
|
|
func (c *MatrixClient) JoinRoom(ctx context.Context, roomID string) error {
|
|
return c.do(ctx, http.MethodPost, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", nil, struct{}{}, nil)
|
|
}
|
|
|
|
func (c *MatrixClient) LeaveRoom(ctx context.Context, roomID string) error {
|
|
return c.do(ctx, http.MethodPost, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", nil, struct{}{}, nil)
|
|
}
|
|
|
|
// SendEvent PUTs a message event with a unique txn id and returns its event id.
|
|
func (c *MatrixClient) SendEvent(ctx context.Context, roomID, evType string, content any) (string, error) {
|
|
path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
|
|
url.PathEscape(roomID), url.PathEscape(evType), url.PathEscape(c.nextTxnID()))
|
|
var out struct {
|
|
EventID string `json:"event_id"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPut, path, nil, content, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.EventID, nil
|
|
}
|
|
|
|
// SetDisplayName sets the bot user's profile display name (F23). Idempotent.
|
|
func (c *MatrixClient) SetDisplayName(ctx context.Context, name string) error {
|
|
path := "/_matrix/client/v3/profile/" + url.PathEscape(c.asUserID) + "/displayname"
|
|
return c.do(ctx, http.MethodPut, path, nil, map[string]any{"displayname": name}, nil)
|
|
}
|
|
|
|
// SendTyping sets or clears the bot user's typing indicator in a room. The
|
|
// homeserver broadcasts m.typing, which clients render as "… is typing"; the
|
|
// timeout (ms) applies only when starting and is omitted when clearing.
|
|
func (c *MatrixClient) SendTyping(ctx context.Context, roomID string, typing bool, timeoutMs int) error {
|
|
path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/typing/" + url.PathEscape(c.asUserID)
|
|
body := map[string]any{"typing": typing}
|
|
if typing {
|
|
body["timeout"] = timeoutMs
|
|
}
|
|
return c.do(ctx, http.MethodPut, path, nil, body, nil)
|
|
}
|
|
|
|
// RoomEncrypted checks live encryption state (F15 — never a join-time snapshot).
|
|
// A 404/M_NOT_FOUND means no m.room.encryption state → unencrypted.
|
|
func (c *MatrixClient) RoomEncrypted(ctx context.Context, roomID string) (bool, error) {
|
|
path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.encryption/"
|
|
err := c.do(ctx, http.MethodGet, path, nil, nil, &struct{}{})
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if mErr, ok := err.(*MatrixError); ok && (mErr.StatusCode == http.StatusNotFound || mErr.ErrCode == "M_NOT_FOUND") {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// RoomMembership returns joined+invited counts and the set of homeservers that
|
|
// have a member present (joined or invited). Used both to classify a room as a
|
|
// 1:1 (F3) and to enforce that the bot only stays in rooms hosted entirely on
|
|
// allowed servers — appservice transactions carry no room summary, so this reads
|
|
// /members. The member is identified by the event's state_key (the sender is
|
|
// whoever *set* the membership, which may differ).
|
|
func (c *MatrixClient) RoomMembership(ctx context.Context, roomID string) (joined, invited int, servers map[string]bool, err error) {
|
|
path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/members"
|
|
var out struct {
|
|
Chunk []Event `json:"chunk"`
|
|
}
|
|
if err = c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
servers = make(map[string]bool)
|
|
for i := range out.Chunk {
|
|
e := &out.Chunk[i]
|
|
if e.StateKey == nil {
|
|
continue
|
|
}
|
|
switch e.membershipOf() {
|
|
case "join":
|
|
joined++
|
|
servers[serverOf(*e.StateKey)] = true
|
|
case "invite":
|
|
invited++
|
|
servers[serverOf(*e.StateKey)] = true
|
|
}
|
|
}
|
|
return joined, invited, servers, nil
|
|
}
|