diff --git a/.gitignore b/.gitignore index 0fdbbf4..4162c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .venv compose.override.yaml +work diff --git a/Containerfile.debian b/Containerfile.debian index d464d77..f79bc54 100644 --- a/Containerfile.debian +++ b/Containerfile.debian @@ -6,6 +6,9 @@ ARG PYTHON_VERSION=3.14 FROM docker.io/library/golang:${GOLANG_VERSION}-${DEBIAN_VERSION} AS gobuilder +RUN apt update +RUN apt -y install libolm-dev + RUN --mount=type=bind,source=./libmxclient/go.mod,target=/libmxclient/build/go.mod --mount=type=bind,source=./libmxclient/go.sum,target=/libmxclient/build/go.sum < +docker compose up -d demobot + +the bot follows each invite (autojoin) and have two commands: + !stop - graceful shutdown + !echo - reply + + +develop: + +for installing/editing things it is run as root inside the container. + + +docker compose build dev docker compose run --rm dev /bin/bash @@ -38,3 +53,7 @@ try to discover from: b'matrix.org' Attempt to discover 'matrix.org' b'{"m.homeserver":{"base_url":"https://matrix-client.matrix.org"},"m.identity_server":{"base_url":"https://vector.im"}}' root@b2f35adb64b0:/smal# + +docker compose build demobot +docker compose run --rm --user $(id -u):$(id -g) -v /local/path/to/data/dir:/demobot demobot smalsetup +docker compose run --rm --user $(id -u):$(id -g) -v /local/path/to/data/dir:/demobot demobot demobot diff --git a/compose.yaml b/compose.yaml index bfb6766..c4ce6a5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,6 +3,21 @@ services: build: context: . dockerfile: Containerfile.debian + target: develop working_dir: /smal volumes: - ./smal:/smal:ro + - ./work:/work + + demobot: + build: + context: . + dockerfile: Containerfile.debian + target: demobot + command: demobot + volumes: + - demobot_data:/demobot + restart: unless-stopped + +volumes: + demobot_data: diff --git a/libmxclient/callback.go b/libmxclient/callback.go deleted file mode 100644 index fd6bfd3..0000000 --- a/libmxclient/callback.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) 2026 saces@c-base.org -// SPDX-License-Identifier: AGPL-3.0-only -package main - -/* -#include -typedef void (*on_event_handler_ptr) (char*); -typedef void (*on_message_handler_ptr) (char*); - -static inline void call_c_on_event_handler(on_event_handler_ptr ptr, char* jsonStr) { - (ptr)(jsonStr); -} - -static inline void call_c_on_message_handler(on_message_handler_ptr ptr, char* jsonStr) { - (ptr)(jsonStr); -} -*/ -import "C" - -var on_event_handler C.on_event_handler_ptr -var on_message_handler C.on_message_handler_ptr - -//export register_on_event_handler -func register_on_event_handler(fn C.on_event_handler_ptr) { - on_event_handler = fn -} - -//export register_on_message_handler -func register_on_message_handler(fn C.on_message_handler_ptr) { - on_message_handler = fn -} diff --git a/libmxclient/determinant/mxpassfile/mxpassfile.go b/libmxclient/determinant/mxpassfile/mxpassfile.go new file mode 100644 index 0000000..6491b8e --- /dev/null +++ b/libmxclient/determinant/mxpassfile/mxpassfile.go @@ -0,0 +1,125 @@ +package mxpassfile + +import ( + "bufio" + "io" + "os" + "strings" +) + +// inspired by https://github.com/jackc/pgpassfile + +// Entry represents a line in a MX passfile. +type Entry struct { + Matrixhost string + Localpart string + Domain string + Token string +} + +// Passfile is the in memory data structure representing a MX passfile. +type Passfile struct { + Entries []*Entry +} + +// ReadPassfile reads the file at path and parses it into a Passfile. +func readPassfile(path string) (*Passfile, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + return ParsePassfile(f) +} + +// ParsePassfile reads r and parses it into a Passfile. +func ParsePassfile(r io.Reader) (*Passfile, error) { + passfile := &Passfile{} + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + entry := parseLine(scanner.Text()) + if entry != nil { + passfile.Entries = append(passfile.Entries, entry) + } + } + + return passfile, scanner.Err() +} + +// parseLine parses a line into an *Entry. It returns nil on comment lines or any other unparsable +// line. +func parseLine(line string) *Entry { + const ( + tmpBackslash = "\r" + tmpPipe = "\n" + ) + + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "#") { + return nil + } + + line = strings.ReplaceAll(line, `\\`, tmpBackslash) + line = strings.ReplaceAll(line, `\|`, tmpPipe) + + parts := strings.Split(line, "|") + if len(parts) != 4 { + return nil + } + + // Unescape escaped colons and backslashes + for i := range parts { + parts[i] = strings.ReplaceAll(parts[i], tmpBackslash, `\`) + parts[i] = strings.ReplaceAll(parts[i], tmpPipe, `|`) + parts[i] = strings.TrimSpace(parts[i]) + } + + return &Entry{ + Matrixhost: parts[0], + Localpart: parts[1], + Domain: parts[2], + Token: parts[3], + } +} + +func isWild(s string) bool { + if s == "" || s == "*" { + return true + } + return false +} + +func superCMP(s1, s2 string) bool { + if isWild(s1) || isWild(s2) { + return true + } + //fmt.Printf("sCMP: '%s' '%s'\n", s1, s2) + return s1 == s2 +} + +// FindPassword finds the password for the provided synapsehost, localpart, and domain. An empty +// string will be returned if no match is found. +func (pf *Passfile) FindPassword(matrixhost, localpart, domain string) string { + for _, e := range pf.Entries { + if (e.Matrixhost == "*" || e.Matrixhost == matrixhost) && + (e.Localpart == "*" || e.Localpart == localpart) && + (e.Domain == "*" || e.Domain == domain) { + return e.Token + } + } + return "" +} + +func (pf *Passfile) FindPasswordFill(matrixhost, localpart, domain string) *Entry { + for _, e := range pf.Entries { + if superCMP(e.Matrixhost, matrixhost) && + superCMP(e.Localpart, localpart) && + superCMP(e.Domain, domain) { + return e + } + } + return nil +} diff --git a/libmxclient/determinant/mxpassfile/mxpassfile_test.go b/libmxclient/determinant/mxpassfile/mxpassfile_test.go new file mode 100644 index 0000000..6056ab8 --- /dev/null +++ b/libmxclient/determinant/mxpassfile/mxpassfile_test.go @@ -0,0 +1,59 @@ +package mxpassfile + +import ( + "bytes" + "strings" + "testing" +) + +func tokenComp(t *testing.T, expected string, value string) { + if value != expected { + t.Fatalf(`token was "%s", expected "%s"`, value, expected) + } +} + +func unescape(s string) string { + s = strings.Replace(s, `\:`, `:`, -1) + s = strings.Replace(s, `\\`, `\`, -1) + return s +} + +var passfile = [][]string{ + {"test1:5432", "larrydb", "larry", "whatstheidea"}, + {"test1:5432", "moedb", "moe", "imbecile"}, + {"test1:5432", "curlydb", "curly", "nyuknyuknyuk"}, + {"test2:5432", "*", "shemp", "heymoe"}, + {"test2:5432", "*", "*", `test\\ing\|er`}, + {"localhost", "*", "*", "sesam"}, + {"test3", "", "", "swordfish"}, // user will be filled later +} + +func TestParsePassFile(t *testing.T) { + buf := bytes.NewBufferString(`# A comment + test1:5432|larrydb|larry|whatstheidea + test1:5432|moedb|moe|imbecile + test1:5432|curlydb|curly|nyuknyuknyuk + test2:5432|*|shemp|heymoe + test2:5432|*|*|test\\ing\|er + localhost|*|*|sesam + `) + + passfile, err := ParsePassfile(buf) + if err != nil { + t.Fatalf(`ParsePassfile returned error: "%v"`, err) + } + + if len(passfile.Entries) != 6 { + t.Fatalf(`passfile.Entries is "%d", expected 6`, len(passfile.Entries)) + } + + tokenComp(t, "whatstheidea", passfile.FindPassword("test1:5432", "larrydb", "larry")) + tokenComp(t, "imbecile", passfile.FindPassword("test1:5432", "moedb", "moe")) + tokenComp(t, `test\ing|er`, passfile.FindPassword("test2:5432", "something", "else")) + tokenComp(t, "sesam", passfile.FindPassword("localhost", "foo", "bare")) + + tokenComp(t, "", passfile.FindPassword("wrong:5432", "larrydb", "larry")) + tokenComp(t, "", passfile.FindPassword("test1:wrong", "larrydb", "larry")) + tokenComp(t, "", passfile.FindPassword("test1:5432", "wrong", "larry")) + tokenComp(t, "", passfile.FindPassword("test1:5432", "larrydb", "wrong")) +} diff --git a/libmxclient/determinant/mxpassfile/readpassfile.go b/libmxclient/determinant/mxpassfile/readpassfile.go new file mode 100644 index 0000000..ee8975b --- /dev/null +++ b/libmxclient/determinant/mxpassfile/readpassfile.go @@ -0,0 +1,9 @@ +//go:build windows +// +build windows + +package mxpassfile + +// ReadPassfile reads the file at path and parses it into a Passfile. +func ReadPassfile(path string) (*Passfile, error) { + return readPassfile(path) +} diff --git a/libmxclient/determinant/mxpassfile/readpassfile_unix.go b/libmxclient/determinant/mxpassfile/readpassfile_unix.go new file mode 100644 index 0000000..b3e3c80 --- /dev/null +++ b/libmxclient/determinant/mxpassfile/readpassfile_unix.go @@ -0,0 +1,22 @@ +//go:build !windows +// +build !windows + +package mxpassfile + +import ( + "errors" + "os" +) + +// ReadPassfile reads the file at path and parses it into a Passfile. +func ReadPassfile(path string) (*Passfile, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return nil, err + } + permissions := fileInfo.Mode().Perm() + if permissions != 0o600 { + return nil, errors.New("To wide permissions, ignore file") + } + return readPassfile(path) +} diff --git a/libmxclient/go.mod b/libmxclient/go.mod index bae72f3..dc34a55 100644 --- a/libmxclient/go.mod +++ b/libmxclient/go.mod @@ -2,18 +2,22 @@ module mxclientlib go 1.24.0 -require maunium.net/go/mautrix v0.26.2 +require ( + github.com/mattn/go-sqlite3 v1.14.33 + github.com/rs/zerolog v1.34.0 + go.mau.fi/util v0.9.5 + maunium.net/go/mautrix v0.26.2 +) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - go.mau.fi/util v0.9.5 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.49.0 // indirect diff --git a/libmxclient/go.sum b/libmxclient/go.sum index 9dd4ebb..fb14199 100644 --- a/libmxclient/go.sum +++ b/libmxclient/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,6 +13,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/libmxclient/mxapi/discover.go b/libmxclient/mxapi/discover.go new file mode 100644 index 0000000..fe5174b --- /dev/null +++ b/libmxclient/mxapi/discover.go @@ -0,0 +1,32 @@ +package mxapi + +import ( + "context" + "encoding/json" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +func Discover(mxid string) (string, error) { + + localpart, hs, err := id.UserID(mxid).ParseAndValidateRelaxed() + if err != nil { + return "", err + } + + wk, err := mautrix.DiscoverClientAPI(context.Background(), hs) + if err != nil { + return "", err + } + if wk != nil { + hs = wk.Homeserver.BaseURL + } + + out, err := json.Marshal(map[string]string{"mxid": mxid, "homeserver": hs, "loginname": localpart}) + if err != nil { + return "", err + } + + return string(out), nil +} diff --git a/libmxclient/mxapi/login.go b/libmxclient/mxapi/login.go new file mode 100644 index 0000000..bb5f41c --- /dev/null +++ b/libmxclient/mxapi/login.go @@ -0,0 +1,56 @@ +package mxapi + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type login_data struct { + Homeserver string `json:"homeserver"` + Mxid string `json:"mxid"` + Loginname string `json:"loginname"` + Password string `json:"password"` +} + +func Login(data string) (string, error) { + var ld login_data + err := json.Unmarshal([]byte(data), &ld) + if err != nil { + return "", err + } + + mauclient, err := mautrix.NewClient(ld.Homeserver, id.UserID(ld.Mxid), "") + if err != nil { + return "", err + } + + deviceName := fmt.Sprintf("smalbot-%d", time.Now().Unix()) + + resp, err := mauclient.Login(context.Background(), &mautrix.ReqLogin{ + Type: "m.login.password", + Identifier: mautrix.UserIdentifier{ + Type: "m.id.user", + User: ld.Loginname, + }, + Password: ld.Password, + DeviceID: id.DeviceID(deviceName), + InitialDeviceDisplayName: deviceName, + StoreCredentials: false, + StoreHomeserverURL: false, + RefreshToken: false, + }) + if err != nil { + return "", err + } + + tpl := "%s | %s | %s | %s\nrecovery | | | %x\nmaster | | | %x\n" + res := fmt.Sprintf(tpl, ld.Homeserver, ld.Loginname, id.UserID(ld.Mxid).Homeserver(), resp.AccessToken, sha256.Sum256([]byte(ld.Password)), sha256.Sum256([]byte(deviceName))) + + return res, nil +} diff --git a/libmxclient/mxclient/client.go b/libmxclient/mxclient/client.go new file mode 100644 index 0000000..4b3d2a7 --- /dev/null +++ b/libmxclient/mxclient/client.go @@ -0,0 +1,193 @@ +// Copyright (C) 2026 saces@c-base.org +// SPDX-License-Identifier: AGPL-3.0-only +package mxclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "mxclientlib/determinant/mxpassfile" + + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "go.mau.fi/util/dbutil" + _ "go.mau.fi/util/dbutil/litestream" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/sqlstatestore" +) + +type MXClient struct { + *mautrix.Client + OnEvent func(string) + OnMessage func(string) +} + +func (mxc *MXClient) _onEvent(ctx context.Context, evt *event.Event) { + if evt.GetStateKey() == mxc.UserID.String() && evt.Content.AsMember().Membership == event.MembershipInvite { + _, err := mxc.JoinRoomByID(ctx, evt.RoomID) + if err == nil { + log.Info(). + Str("room_id", evt.RoomID.String()). + Str("inviter", evt.Sender.String()). + Msg("Joined room after invite") + } else { + log.Error().Err(err). + Str("room_id", evt.RoomID.String()). + Str("inviter", evt.Sender.String()). + Msg("Failed to join room after invite") + } + } else { + fmt.Printf("Got event: %#v\n", evt) + } +} + +func (mxc *MXClient) _onMessage(ctx context.Context, evt *event.Event) { + out, err := json.Marshal(map[string]interface{}{"sender": evt.Sender.String(), + "type": evt.Type.String(), + "id": evt.ID.String(), + "roomid": evt.RoomID.String(), + "content": evt.Content.Raw}) + + if err != nil { + log.Error().Err(err). + Str("id", evt.ID.String()). + Str("inviter", evt.Sender.String()). + Msg("Marshalling error") + return + } + mxc.OnMessage(string(out)) + + /* + log.Info(). + Str("sender", evt.Sender.String()). + Str("type", evt.Type.String()). + Str("id", evt.ID.String()). + Str("roomid", evt.RoomID.String()). + Str("body", evt.Content.AsMessage().Body). + Msg("Received message") + fmt.Printf("Got message: %#v\n", evt) + */ +} + +type sendmessage_data_content struct { + Body string `json:"body"` +} + +type sendmessage_data struct { + RoomId id.RoomID `json:"roomid"` + Type event.Type `json:"type"` + Content sendmessage_data_content `json:"content"` +} + +func (mxc *MXClient) SendRoomMessage(ctx context.Context, data string) (*mautrix.RespSendEvent, error) { + var smd sendmessage_data + err := json.Unmarshal([]byte(data), &smd) + if err != nil { + return nil, err + } + + resp, err := mxc.SendMessageEvent(ctx, smd.RoomId, event.EventMessage, smd.Content) + if err != nil { + return nil, err + } + return resp, nil + +} + +// NewMXClient creates a new Matrix Client ready for syncing +func NewMXClient(homeserverURL string, userID id.UserID, accessToken string) (*MXClient, error) { + client, err := mautrix.NewClient(homeserverURL, userID, accessToken) + if err != nil { + return nil, err + } + + // keep this for the import + client.Log = zerolog.Nop() + // client.Log = zerolog.New(os.Stdout) + // client.SyncTraceLog = true + + resp, err := client.Whoami(context.Background()) + if err != nil { + return nil, err + } + client.DeviceID = resp.DeviceID + + //fmt.Printf("Device ID: %s\n", client.DeviceID) + + rawdb, err := dbutil.NewWithDialect("smalbot.db", "sqlite3") + if err != nil { + return nil, err + } + + //fmt.Println("db is offen.") + + syncer, ok := client.Syncer.(*mautrix.DefaultSyncer) + if !ok { + return nil, errors.New("panic: syncer implementation error") + } + + //mxclient.StateStore = mautrix.NewMemoryStateStore() + + stateStore := sqlstatestore.NewSQLStateStore(rawdb, dbutil.NoopLogger, false) + err = stateStore.Upgrade(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to upgrade state db: %w", err) + } + client.StateStore = stateStore + + pickleKey := []byte("pickle") + + //cryptoStore := crypto.NewMemoryStore(nil) + cryptoStore := crypto.NewSQLCryptoStore(rawdb, dbutil.ZeroLogger(log.With().Str("db_section", "crypto").Logger()), "", "", pickleKey) + err = cryptoStore.DB.Upgrade(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to upgrade crypto db: %w", err) + } + + client.Crypto, err = cryptohelper.NewCryptoHelper(client, pickleKey, cryptoStore) + if err != nil { + return nil, err + } + + err = client.Crypto.Init(context.TODO()) + if err != nil { + return nil, err + } + + client.Store = cryptoStore + + mxclient := &MXClient{client, nil, nil} + + syncer.ParseEventContent = true + syncer.OnEvent(client.StateStoreSyncHandler) + + syncer.OnEventType(event.EventMessage, mxclient._onMessage) + syncer.OnEventType(event.StateMember, mxclient._onEvent) + + return mxclient, nil +} + +func CreateClient(storage_path string, url string, userID string, accessToken string) (*MXClient, error) { + return nil, fmt.Errorf("nope.") +} + +func CreateClientPass(mxpassfile_path string, storage_path string, url string, localpart string, domain string) (*MXClient, error) { + pf, err := mxpassfile.ReadPassfile(mxpassfile_path) + if err != nil { + return nil, err + } + //fmt.Printf("mxpass pf: '%#v'\n", pf) + //fmt.Printf("mxpass find: '%s' '%s' '%s'\n", url, localpart, domain) + e := pf.FindPasswordFill(url, localpart, domain) + if e != nil { + //fmt.Printf("mxpass: %#v\n", e) + return NewMXClient(e.Matrixhost, id.NewUserID(e.Localpart, e.Domain), e.Token) + } + return nil, fmt.Errorf("nope.") +} diff --git a/libmxclient/mxclientlib.go b/libmxclient/mxclientlib.go index 28428e5..bb1ab03 100644 --- a/libmxclient/mxclientlib.go +++ b/libmxclient/mxclientlib.go @@ -3,15 +3,88 @@ package main import ( + "context" + "encoding/json" + "fmt" + "mxclientlib/mxapi" + "mxclientlib/mxclient" "mxclientlib/mxutils" "unsafe" + + "maunium.net/go/mautrix/id" ) /* #include +typedef void (*on_event_handler_ptr) (char*); +typedef void (*on_message_handler_ptr) (char*); + +static inline void call_c_on_event_handler(on_event_handler_ptr ptr, char* jsonStr) { + (ptr)(jsonStr); +} + +static inline void call_c_on_message_handler(on_message_handler_ptr ptr, char* jsonStr) { + (ptr)(jsonStr); +} + */ import "C" +/* +matrix client with c callback +*/ +type CBClient struct { + *mxclient.MXClient + on_event_handler C.on_event_handler_ptr + on_message_handler C.on_message_handler_ptr +} + +func (cli *CBClient) OnEvent(s string) { + cStr := C.CString(s) + defer C.free(unsafe.Pointer(cStr)) + C.call_c_on_event_handler(cli.on_event_handler, cStr) +} + +func (cli *CBClient) OnMessage(s string) { + cStr := C.CString(s) + defer C.free(unsafe.Pointer(cStr)) + C.call_c_on_message_handler(cli.on_message_handler, cStr) +} + +func (cli *CBClient) Set_on_event_handler(fn C.on_event_handler_ptr) { + cli.on_event_handler = fn +} + +func (cli *CBClient) Set_on_message_handler(fn C.on_message_handler_ptr) { + cli.on_message_handler = fn +} + +// NewCClient creates a new Matrix Client ready for syncing +func NewCBClient(homeserverURL string, userID id.UserID, accessToken string) (*CBClient, error) { + client, err := mxclient.NewMXClient(homeserverURL, userID, accessToken) + if err != nil { + return nil, err + } + return &CBClient{client, nil, nil}, nil +} + +/* +account/client management +*/ +var cclients []*CBClient + +func getClient(id int) (*CBClient, error) { + if id < 0 || id >= len(cclients) { + return nil, fmt.Errorf("index out of bounds: '%d'", id) + } + res := cclients[id] + return res, nil +} + +/* +helperse +*/ + //export FreeCString func FreeCString(s *C.char) { C.free(unsafe.Pointer(s)) @@ -20,6 +93,7 @@ func FreeCString(s *C.char) { /* cli tools */ + //export cli_discoverhs func cli_discoverhs(id *C.char) *C.char { mxid := C.GoString(id) @@ -67,15 +141,149 @@ func cli_serverinfo(url *C.char) *C.char { } /* -high api +high level api, supports multiple clients +the same handler can be attached to multiple clients */ -//export createclient -func createclient(url *C.char, userID *C.char, accessToken *C.char) C.int { + +//export apiv0_initialize +func apiv0_initialize() C.int { return 0 } -//export shutdown -func shutdown() C.int { +//export apiv0_deinitialize +func apiv0_deinitialize() C.int { + return 0 +} + +//export apiv0_discover +func apiv0_discover(mxid *C.char) *C.char { + result, err := mxapi.Discover(C.GoString(mxid)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + return C.CString(result) +} + +//export apiv0_login +func apiv0_login(data *C.char) *C.char { + result, err := mxapi.Login(C.GoString(data)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + return C.CString(result) +} + +//export apiv0_createclient +func apiv0_createclient(storage_path *C.char, url *C.char, userID *C.char, accessToken *C.char) *C.char { + mxclient, err := mxclient.CreateClient(C.GoString(storage_path), C.GoString(url), C.GoString(userID), C.GoString(accessToken)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + client := &CBClient{mxclient, nil, nil} + cclients = append(cclients, client) + return C.CString(fmt.Sprintf("{ \"id:\"SUCESS. ID=%d\n", len(cclients))) +} + +//export apiv0_createclient_pass +func apiv0_createclient_pass(mxpassfile_path *C.char, storage_path *C.char, url *C.char, localpart *C.char, domain *C.char) *C.char { + mxclient, err := mxclient.CreateClientPass(C.GoString(mxpassfile_path), C.GoString(storage_path), C.GoString(url), C.GoString(localpart), C.GoString(domain)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + client := &CBClient{mxclient, nil, nil} + mxclient.OnEvent = client.OnEvent + mxclient.OnMessage = client.OnMessage + cclients = append(cclients, client) + out, err := json.Marshal(map[string]int{"id": len(cclients) - 1}) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + s := string(out) + return C.CString(s) +} + +//export apiv0_set_on_event_handler +func apiv0_set_on_event_handler(cid C.int, fn C.on_event_handler_ptr) *C.char { + cli, err := getClient(int(cid)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + + cli.Set_on_event_handler(fn) + return C.CString("SUCCESS.") +} + +//export apiv0_set_on_message_handler +func apiv0_set_on_message_handler(cid C.int, fn C.on_message_handler_ptr) *C.char { + cli, err := getClient(int(cid)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + + cli.Set_on_message_handler(fn) + return C.CString("SUCCESS.") +} + +//export apiv0_startclient +func apiv0_startclient(cid C.int) *C.char { + cli, err := getClient(int(cid)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + + err = cli.Sync() + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + return C.CString("SUCCESS.") +} + +//export apiv0_stopclient +func apiv0_stopclient(cid C.int) *C.char { + cli, err := getClient(int(cid)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + cli.StopSync() + // TODO kill current sync request (client.client.http.cancel() or so) + return C.CString("SUCCESS.") +} + +//export apiv0_sendmessage +func apiv0_sendmessage(cid C.int, data *C.char) *C.char { + cli, err := getClient(int(cid)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + result, err := cli.SendRoomMessage(context.Background(), C.GoString(data)) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + out, err := json.Marshal(result) + if err != nil { + return C.CString(fmt.Sprintf("ERR: %v", err)) + } + s := string(out) + return C.CString(s) +} + +//export apiv0_removeclient +func apiv0_removeclient(cid C.int) C.int { + return 0 +} + +//export apiv0_listclients +func apiv0_listclients() *C.char { + return C.CString("{}") +} + +//export apiv0_getoptions +func apiv0_getoptions(cid C.int) *C.char { + return C.CString("{}") +} + +//export apiv0_setoptions +func apiv0_setoptions(cid C.int, opts *C.char) C.int { return 0 } diff --git a/pygomx/build_ffi.py b/pygomx/build_ffi.py index e80386f..bea8deb 100644 --- a/pygomx/build_ffi.py +++ b/pygomx/build_ffi.py @@ -15,8 +15,6 @@ ffibuilder.cdef( csource=""" typedef void (*on_event_handler_ptr) (char*); typedef void (*on_message_handler_ptr) (char*); - extern void register_on_event_handler(on_event_handler_ptr ptr); - extern void register_on_message_handler(on_message_handler_ptr ptr); extern void FreeCString(char* s); extern char* cli_discoverhs(char* mxid); extern char* cli_mkmxtoken(char* mxid, char* pw); @@ -24,7 +22,21 @@ ffibuilder.cdef( extern char* cli_accountinfo(char* hs, char* accessToken); extern char* cli_clearaccount(char* hs, char* accessToken); extern char* cli_serverinfo(char* url); - extern int createclient(char* url, char* userID, char* accessToken); + extern int apiv0_initialize(); + extern int apiv0_deinitialize(); + extern char* apiv0_discover(char* mxid); + extern char* apiv0_login(char* data); + extern char* apiv0_createclient(char* storage_path, char* hs, char* mxid, char* accessToken); + extern char* apiv0_createclient_pass(char* mxpassfile, char* storage_path, char* hs, char* localpart, char* domain); + extern char* apiv0_set_on_event_handler(int cid, on_event_handler_ptr ptr); + extern char* apiv0_set_on_message_handler(int cid, on_message_handler_ptr ptr); + extern char* apiv0_startclient(int cid); + extern char* apiv0_stopclient(int cid); + extern char* apiv0_sendmessage(int cid, char* data); + extern int apiv0_removeclient(int cid); + extern char* apiv0_listclients(); + extern char* apiv0_getoptions(int cid); + extern int apiv0_setoptions(int cid, char* opts); """ ) diff --git a/smal/README b/smal/README deleted file mode 100644 index 10fdd71..0000000 --- a/smal/README +++ /dev/null @@ -1,3 +0,0 @@ - -Simple Matrix Application Library -================================= diff --git a/smal/README.txt b/smal/README.txt new file mode 100644 index 0000000..6db8081 --- /dev/null +++ b/smal/README.txt @@ -0,0 +1,16 @@ + +Simple Matrix Application Library +================================= + +sub libs: + - pymxutils: matrix cli utils + - smal: the lib itself + - smbl: simplematrixbotlib compat layer experiments (if any) + - demobot: simple demo bot + +install: pip install . + +usage: + - run `smalsetup ` in an empty dir. + - start the bot `demobot`. + - simple demo bot. no further configuration required diff --git a/smal/pyproject.toml b/smal/pyproject.toml index 226cd40..8a45b57 100644 --- a/smal/pyproject.toml +++ b/smal/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.11" description = "smal - simple matrix application library" authors = [{ name = "saces" }] license = "AGPL-3.0-only" -readme = "README" +readme = "README.txt" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python", @@ -19,6 +19,7 @@ classifiers = [ "pymxutils" = "src/pymxutils" "smal" = "src/smal" "smbl" = "src/smbl" +"demobot" = "src/demobot" [project.urls] repository = "https://codeberg.org/saces/pygomx" @@ -34,3 +35,4 @@ mxaccountinfo = "pymxutils.mxutils:accountinfo" mxclearaccount = "pymxutils.mxutils:clearaccount" mxserverinfo = "pymxutils.mxutils:serverinfo" smalsetup = "smal.smalsetup:smalsetup" +demobot = "demobot:main" diff --git a/smal/src/demobot/__init__.py b/smal/src/demobot/__init__.py new file mode 100644 index 0000000..8cf9cb3 --- /dev/null +++ b/smal/src/demobot/__init__.py @@ -0,0 +1 @@ +from .demobot import main diff --git a/smal/src/demobot/__main__.py b/smal/src/demobot/__main__.py new file mode 100644 index 0000000..40ba55d --- /dev/null +++ b/smal/src/demobot/__main__.py @@ -0,0 +1,4 @@ +import sys +from .demobot import main + +sys.exit(main()) diff --git a/smal/src/demobot/demobot.py b/smal/src/demobot/demobot.py new file mode 100644 index 0000000..03dfae2 --- /dev/null +++ b/smal/src/demobot/demobot.py @@ -0,0 +1,74 @@ +import os +from time import time_ns +import logging +import json +from smal.bot import SMALBot +from _pygomx import lib, ffi + +# setup logging, we want timestamps +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s - %(funcName)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) +logger.setLevel(level=logging.INFO) + + +DEFAULT_PREFIX = "!" + + +@ffi.callback("void(char*)") +def on_event(evt): + e = ffi.string(evt) + print("Got an event: ", e) + + +@ffi.callback("void(char*)") +def on_message(msg): + m = ffi.string(msg).decode("utf-8") + + msg = json.loads(m) + + if msg["type"] != "m.room.message": + # not a room message + logger.error(f"not a room message: {msg}") + return + + if msg["sender"] == "get own id from missing code": + # ignore own messages + # for now just do not send valid commands by yourself + logger.info(f"ignore own message: {msg}") + return + + if "msgtype" in msg["content"].keys() and msg["content"]["msgtype"] != "m.text": + # only react to messages, not emotes + logger.debug(f"ignore unknown message type: {msg}") + return + + if msg["content"]["body"] == "!stop": + logger.info(f"stopping the bot") + bot.stop() + return + + if msg["content"]["body"].startswith("!echo"): + logger.error(f"reply to this: {msg}") + + bot.sendmessage(msg["roomid"], "huhu") + + return + + logger.info(f"ignored a message: {msg}") + + +def main(): + # create and run the bot + global bot + bot = SMALBot(DEFAULT_PREFIX) + bot.SetOnEventHandler(on_event) + bot.SetOnMessageHandler(on_message) + bot.run() + + +if __name__ == "__main__": + main() diff --git a/smal/src/smal/app.py b/smal/src/smal/app.py new file mode 100644 index 0000000..2144c91 --- /dev/null +++ b/smal/src/smal/app.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +import logging + +from .pygomx import _MXClient + +logger = logging.getLogger(__name__) + +""" + +""" + + +class SMALApp(_MXClient): + """ """ + + def __init__(self): + super().__init__() diff --git a/smal/src/smal/bot.py b/smal/src/smal/bot.py new file mode 100644 index 0000000..6ee0f29 --- /dev/null +++ b/smal/src/smal/bot.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +import logging +import sys +from typing import Optional + +from .app import SMALApp + +logger = logging.getLogger(__name__) + +""" + +""" + + +class SMALBot(SMALApp): + """ """ + + def __init__(self, sigil: String): + super().__init__() + self._sigil = sigil + + def run(self): + self._sync() + + def stop(self): + self._stopsync() + + def sendmessage(self, roomid, text): + data = {} + data["roomid"] = roomid + data["content"] = {} + data["content"]["body"] = text + + self._sendmessage(data) diff --git a/smal/src/smal/errors.py b/smal/src/smal/errors.py new file mode 100644 index 0000000..38ca664 --- /dev/null +++ b/smal/src/smal/errors.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + + +class APIError(Exception): + """Exception raised for api usage errors. + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message): + self.message = message[4:] + super().__init__(self.message) diff --git a/smal/src/smal/pygomx.py b/smal/src/smal/pygomx.py new file mode 100644 index 0000000..78e691d --- /dev/null +++ b/smal/src/smal/pygomx.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +import logging +from _pygomx import lib, ffi +import json +from .errors import APIError + +logger = logging.getLogger(__name__) + + +class _MXClient: + """ + core binding + """ + + def __init__(self): + super().__init__() + self._createMXClient() + + def _createMXClient(self): + r = lib.apiv0_createclient_pass(b".mxpass", b".", b"*", b"*", b"*") + + result = ffi.string(r) + lib.FreeCString(r) + if result.startswith(b"ERR:"): + raise APIError(result) + + result_dict = json.loads(result) + self.client_id = result_dict["id"] + + def SetOnEventHandler(self, fn): + r = lib.apiv0_set_on_event_handler(self.client_id, fn) + result = ffi.string(r) + lib.FreeCString(r) + if result.startswith(b"ERR:"): + raise APIError(result) + + def SetOnMessageHandler(self, fn): + r = lib.apiv0_set_on_message_handler(self.client_id, fn) + result = ffi.string(r) + lib.FreeCString(r) + if result.startswith(b"ERR:"): + raise APIError(result) + + def _sync(self): + r = lib.apiv0_startclient(self.client_id) + result = ffi.string(r) + lib.FreeCString(r) + # if result.startswith(b"ERR:"): + # raise APIError(result) + print("_sync: ", result) + + def _stopsync(self): + r = lib.apiv0_stopclient(self.client_id) + result = ffi.string(r) + lib.FreeCString(r) + # if result.startswith(b"ERR:"): + # raise APIError(result) + print("_stopsync: ", result) + + def _sendmessage(self, data_dict): + data = json.dumps(data_dict).encode(encoding="utf-8") + r = lib.apiv0_sendmessage(self.client_id, data) + result = ffi.string(r) + lib.FreeCString(r) + # if result.startswith(b"ERR:"): + # raise APIError(result) + print("_sendmessage: ", result) diff --git a/smal/src/smal/smalsetup/__main__.py b/smal/src/smal/smalsetup/__main__.py new file mode 100644 index 0000000..9997fe1 --- /dev/null +++ b/smal/src/smal/smalsetup/__main__.py @@ -0,0 +1,4 @@ +import sys +from .smalsetup import smalsetup + +sys.exit(smalsetup()) diff --git a/smal/src/smal/smalsetup/smalsetup.py b/smal/src/smal/smalsetup/smalsetup.py index eab33ad..d6ba183 100644 --- a/smal/src/smal/smalsetup/smalsetup.py +++ b/smal/src/smal/smalsetup/smalsetup.py @@ -1,2 +1,47 @@ +import sys +import os +import getpass +import json +from _pygomx import lib, ffi + + def smalsetup(): - return 99 + if len(sys.argv) != 2: + print("usage: ", sys.argv[0], " matrixid") + return 1 + + mxid = sys.argv[1].encode(encoding="utf-8") + + r = lib.apiv0_discover(mxid) + result = ffi.string(r).decode("utf-8") + lib.FreeCString(r) + + if result.startswith("ERR:"): + print(result) + return 1 + + result_dict = json.loads(result) + result_dict["password"] = getpass.getpass(prompt="Password: ") + data = json.dumps(result_dict).encode(encoding="utf-8") + + r = lib.apiv0_login(data) + result = ffi.string(r).decode("utf-8") + lib.FreeCString(r) + + if result.startswith("ERR:"): + print(result) + return 1 + + # Set restrictive umask (owner only) + new_umask = 0o077 + old_umask = os.umask(new_umask) + + # Create file with new umask + with open(".mxpass", "w") as f: + f.write(result) + + # Restore original umask + os.umask(old_umask) + + print("login created. start your bot now.") + return 0