feat(provider): new notification provider: matrix

This commit is contained in:
tim20061
2026-06-15 23:18:48 +08:00
committed by GitHub
parent 3970eef77e
commit 62ad00870b
21 changed files with 528 additions and 6 deletions
@@ -0,0 +1,73 @@
package matrix
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/certimate-go/certimate/pkg/core/notifier"
matrixsdk "github.com/certimate-go/certimate/pkg/sdk3rd/matrix"
)
type NotifierConfig struct {
// Homeserver URL (Element web URL or Matrix homeserver base URL).
// URL homeserver (адрес Element или базовый URL Matrix).
ServerUrl string `json:"serverUrl"`
// User ID (MXID), e.g. @bot:example.org.
// Идентификатор пользователя (MXID), например @bot:example.org.
UserId string `json:"userId"`
// Access token from the homeserver (bot or user).
// Access token с homeserver (бот или пользователь).
AccessToken string `json:"accessToken"`
// Room ID (!room:server) for notifications.
// ID комнаты (!room:server) для уведомлений.
RoomId string `json:"roomId"`
}
type Notifier struct {
config *NotifierConfig
logger *slog.Logger
}
var _ notifier.Provider = (*Notifier)(nil)
func NewNotifier(config *NotifierConfig) (*Notifier, error) {
if config == nil {
return nil, errors.New("the configuration of the notifier provider is nil")
}
return &Notifier{
config: config,
logger: slog.Default(),
}, nil
}
func (n *Notifier) SetLogger(logger *slog.Logger) {
if logger == nil {
n.logger = slog.New(slog.DiscardHandler)
} else {
n.logger = logger
}
}
func (n *Notifier) Notify(ctx context.Context, subject string, message string) (*notifier.NotifyResult, error) {
if n.config.RoomId == "" {
return nil, errors.New("matrix: config `roomId` is required")
}
client, err := matrixsdk.NewClient(n.config.ServerUrl,
matrixsdk.WithUserId(n.config.UserId),
matrixsdk.WithAccessToken(n.config.AccessToken),
)
if err != nil {
return nil, fmt.Errorf("matrix: %w", err)
}
body := fmt.Sprintf("%s\n\n%s", subject, message)
if err := client.SendTextMessageToRoom(ctx, n.config.RoomId, body); err != nil {
return nil, fmt.Errorf("matrix: %w", err)
}
return &notifier.NotifyResult{}, nil
}
@@ -0,0 +1,51 @@
package matrix_test
import (
"testing"
"github.com/certimate-go/certimate/pkg/core/notifier/internal/tester"
impl "github.com/certimate-go/certimate/pkg/core/notifier/providers/matrix"
)
var (
fp = tester.Args("MATRIX_")
fServerUrl string
fUserId string
fAccessToken string
fRoomId string
)
func init() {
fp.DefineString(&fServerUrl, "SERVERURL")
fp.DefineString(&fUserId, "USERID")
fp.DefineString(&fAccessToken, "ACCESSTOKEN")
fp.DefineString(&fRoomId, "ROOMID")
}
/*
Shell command to run this test:
go test -v ./matrix_test.go -args \
--MATRIX_SERVERURL="https://example.com/your-matrix-server" \
--MATRIX_USERID="@bot:example.org" \
--MATRIX_ACCESSTOKEN="your-access-token" \
--MATRIX_ROOMID="!room:example.org"
*/
func TestProvider(t *testing.T) {
fp.Parse()
t.Run("Notify", func(t *testing.T) {
provider, err := impl.NewNotifier(&impl.NotifierConfig{
ServerUrl: fServerUrl,
UserId: fUserId,
AccessToken: fAccessToken,
RoomId: fRoomId,
})
if err != nil {
t.Errorf("err: %+v", err)
return
}
tester.TestNotify(t, provider, tester.TestNotifyArgs{})
})
}
+36
View File
@@ -0,0 +1,36 @@
package matrix
import (
"context"
"fmt"
"net/url"
"strings"
)
// SendText posts an m.room.message event (m.text).
// Отправляет текстовое сообщение в комнату (m.room.message, msgtype m.text).
// REF: https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
func (c *Client) SendTextMessageToRoom(ctx context.Context, roomId, msgBody string) error {
if strings.TrimSpace(roomId) == "" {
return fmt.Errorf("sdkerr: unset roomId")
}
txnId := newTransactionId()
path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
url.PathEscape(roomId), url.PathEscape(txnId))
payload := map[string]any{
"msgtype": "m.text",
"body": msgBody,
}
_, err := c.rc.R().
SetContext(ctx).
SetBody(payload).
Put(path)
if err != nil {
return fmt.Errorf("sdkerr: api error: %w", err)
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package matrix
import (
"fmt"
)
// probeVersions checks that the homeserver exposes the Client-Server API.
// Проверяет доступность homeserver (GET /_matrix/client/versions).
// REF: https://spec.matrix.org/latest/client-server-api/#get_matrixclientversions
func (c *Client) probeVersions() error {
_, err := c.rc.R().Get("/_matrix/client/versions")
if err != nil {
return fmt.Errorf("sdkerr: failed to probe Matrix Client API versions: %w", err)
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package matrix
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/certimate-go/certimate/internal/app"
)
type Client struct {
rc *resty.Client
}
func NewClient(serverUrl string, optFns ...OptionsFunc) (*Client, error) {
opts := &Options{}
for _, fn := range optFns {
fn(opts)
}
if serverUrl == "" {
return nil, fmt.Errorf("sdkerr: unset serverUrl")
}
if _, err := url.Parse(serverUrl); err != nil {
return nil, fmt.Errorf("sdkerr: invalid serverUrl: %w", err)
}
if opts.AccessToken == "" {
return nil, fmt.Errorf("sdkerr: unset accessToken")
}
baseUrl, _ := resolveBaseUrl(strings.TrimSuffix(serverUrl, "/"))
if baseUrl == "" {
baseUrl = serverUrl
}
client := &Client{}
client.rc = resty.New().
SetBaseURL(strings.TrimSuffix(baseUrl, "/")).
SetHeader("Authorization", "Bearer "+opts.AccessToken).
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", app.AppUserAgent).
if err := client.probeVersions(); err != nil {
return nil, err
}
return client, nil
}
func (c *Client) SetTimeout(timeout time.Duration) *Client {
c.rc.SetTimeout(timeout)
return c
}
func (c *Client) SetTLSConfig(config *tls.Config) *Client {
c.rc.SetTLSClientConfig(config)
return c
}
func resolveBaseUrl(serverUrl string) (string, error) {
var wkJSON struct {
Homeserver struct {
BaseURL string `json:"base_url"`
} `json:"m.homeserver"`
}
_, err := resty.New().R().
SetResult(&wkJSON).
Get(serverUrl + "/.well-known/matrix/client")
if err != nil {
return "", fmt.Errorf("failed to discovery Matrix Client API: %w", err)
} else if strings.TrimSpace(wkJSON.Homeserver.BaseURL) != "" {
return wkJSON.Homeserver.BaseURL, nil
} else {
return serverUrl, nil
}
}
func newTransactionId() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return fmt.Sprintf("certimate_%d_%s", time.Now().UnixNano(), hex.EncodeToString(b))
}
+20
View File
@@ -0,0 +1,20 @@
package matrix
type Options struct {
UserId string
AccessToken string
}
type OptionsFunc func(*Options)
func WithUserId(userId string) OptionsFunc {
return func(o *Options) {
o.UserId = userId
}
}
func WithAccessToken(accessToken string) OptionsFunc {
return func(o *Options) {
o.AccessToken = accessToken
}
}