little update.

This commit is contained in:
saces 2026-01-31 08:13:53 +01:00
parent 4d60e5918d
commit 260386bcac
28 changed files with 1070 additions and 50 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.venv
compose.override.yaml
work

View file

@ -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 <<EOF
cd /libmxclient/build
go mod download
@ -32,11 +35,23 @@ python3 build_ffi.py
ls -la *.so
EOF
FROM docker.io/library/python:${PYTHON_VERSION}-${DEBIAN_VERSION}
FROM docker.io/library/python:${PYTHON_VERSION}-${DEBIAN_VERSION} AS develop
ENV PYTHONUNBUFFERED=1
ENV PIP_ROOT_USER_ACTION=ignore
RUN pip install cffi
RUN apt update
RUN apt -y install libolm3
COPY --from=gobuilder /pygomx-build/libmxclient.so /usr/local/lib/
COPY --from=pybuilder /pygomx/_pygomx.cpython-314-x86_64-linux-gnu.so /usr/local/lib/python3.14/site-packages/
RUN ldconfig
FROM develop AS demobot
WORKDIR /smal
COPY smal /smal
RUN pip install .
WORKDIR /demobot
CMD [ "demobot" ]

View file

@ -3,10 +3,25 @@ monorepos to have the right versions together
libmxclient - golang matrix client library
pygomx - python binding
smal - python matrix lib
echobot - simple bot
docker compose build
run the demobot:
docker compose build demobot
docker compose run --rm demobot smalsetup <matrixid>
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

View file

@ -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:

View file

@ -1,31 +0,0 @@
// Copyright (C) 2026 saces@c-base.org
// SPDX-License-Identifier: AGPL-3.0-only
package main
/*
#include <stdlib.h>
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
}

View file

@ -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
}

View file

@ -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"))
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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.")
}

View file

@ -3,15 +3,88 @@
package main
import (
"context"
"encoding/json"
"fmt"
"mxclientlib/mxapi"
"mxclientlib/mxclient"
"mxclientlib/mxutils"
"unsafe"
"maunium.net/go/mautrix/id"
)
/*
#include <stdlib.h>
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
}

View file

@ -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);
"""
)

View file

@ -1,3 +0,0 @@
Simple Matrix Application Library
=================================

16
smal/README.txt Normal file
View file

@ -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 <matrixid>` in an empty dir.
- start the bot `demobot`.
- simple demo bot. no further configuration required

View file

@ -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"

View file

@ -0,0 +1 @@
from .demobot import main

View file

@ -0,0 +1,4 @@
import sys
from .demobot import main
sys.exit(main())

View file

@ -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()

18
smal/src/smal/app.py Normal file
View file

@ -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__()

34
smal/src/smal/bot.py Normal file
View file

@ -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)

13
smal/src/smal/errors.py Normal file
View file

@ -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)

67
smal/src/smal/pygomx.py Normal file
View file

@ -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)

View file

@ -0,0 +1,4 @@
import sys
from .smalsetup import smalsetup
sys.exit(smalsetup())

View file

@ -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