working v1
This commit is contained in:
135
bridge/bridge.go
Normal file
135
bridge/bridge.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Bridger interface {
|
||||
Send(msg config.Message) (string, error)
|
||||
Connect() error
|
||||
JoinChannel(channel config.ChannelInfo) error
|
||||
Disconnect() error
|
||||
}
|
||||
|
||||
type Bridge struct {
|
||||
Bridger
|
||||
*sync.RWMutex
|
||||
|
||||
Name string
|
||||
Account string
|
||||
Protocol string
|
||||
Channels map[string]config.ChannelInfo
|
||||
Joined map[string]bool
|
||||
ChannelMembers *config.ChannelMembers
|
||||
Log *logrus.Entry
|
||||
Config config.Config
|
||||
General *config.Protocol
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
*Bridge
|
||||
|
||||
Remote chan config.Message
|
||||
}
|
||||
|
||||
// Factory is the factory function to create a bridge
|
||||
type Factory func(*Config) Bridger
|
||||
|
||||
func New(bridge *config.Bridge) *Bridge {
|
||||
accInfo := strings.Split(bridge.Account, ".")
|
||||
if len(accInfo) != 2 {
|
||||
log.Fatalf("config failure, account incorrect: %s", bridge.Account)
|
||||
}
|
||||
|
||||
protocol := accInfo[0]
|
||||
name := accInfo[1]
|
||||
|
||||
return &Bridge{
|
||||
RWMutex: new(sync.RWMutex),
|
||||
Channels: make(map[string]config.ChannelInfo),
|
||||
Name: name,
|
||||
Protocol: protocol,
|
||||
Account: bridge.Account,
|
||||
Joined: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) JoinChannels() error {
|
||||
return b.joinChannels(b.Channels, b.Joined)
|
||||
}
|
||||
|
||||
// SetChannelMembers sets the newMembers to the bridge ChannelMembers
|
||||
func (b *Bridge) SetChannelMembers(newMembers *config.ChannelMembers) {
|
||||
b.Lock()
|
||||
b.ChannelMembers = newMembers
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) joinChannels(channels map[string]config.ChannelInfo, exists map[string]bool) error {
|
||||
for ID, channel := range channels {
|
||||
if !exists[ID] {
|
||||
b.Log.Infof("%s: joining %s (ID: %s)", b.Account, channel.Name, ID)
|
||||
time.Sleep(time.Duration(b.GetInt("JoinDelay")) * time.Millisecond)
|
||||
err := b.JoinChannel(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists[ID] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) GetConfigKey(key string) string {
|
||||
return b.Account + "." + key
|
||||
}
|
||||
|
||||
func (b *Bridge) IsKeySet(key string) bool {
|
||||
return b.Config.IsKeySet(b.GetConfigKey(key)) || b.Config.IsKeySet("general."+key)
|
||||
}
|
||||
|
||||
func (b *Bridge) GetBool(key string) bool {
|
||||
val, ok := b.Config.GetBool(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetBool("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetInt(key string) int {
|
||||
val, ok := b.Config.GetInt(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetInt("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetString(key string) string {
|
||||
val, ok := b.Config.GetString(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetString("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetStringSlice(key string) []string {
|
||||
val, ok := b.Config.GetStringSlice(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetStringSlice("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetStringSlice2D(key string) [][]string {
|
||||
val, ok := b.Config.GetStringSlice2D(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetStringSlice2D("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
442
bridge/config/config.go
Normal file
442
bridge/config/config.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
EventJoinLeave = "join_leave"
|
||||
EventTopicChange = "topic_change"
|
||||
EventFailure = "failure"
|
||||
EventFileFailureSize = "file_failure_size"
|
||||
EventAvatarDownload = "avatar_download"
|
||||
EventRejoinChannels = "rejoin_channels"
|
||||
EventUserAction = "user_action"
|
||||
EventMsgDelete = "msg_delete"
|
||||
EventFileDelete = "file_delete"
|
||||
EventAPIConnected = "api_connected"
|
||||
EventUserTyping = "user_typing"
|
||||
EventGetChannelMembers = "get_channel_members"
|
||||
EventNoticeIRC = "notice_irc"
|
||||
)
|
||||
|
||||
const ParentIDNotFound = "msg-parent-not-found"
|
||||
|
||||
type Message struct {
|
||||
Text string `json:"text"`
|
||||
Channel string `json:"channel"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"userid"` // userid on the bridge
|
||||
Avatar string `json:"avatar"`
|
||||
Account string `json:"account"`
|
||||
Event string `json:"event"`
|
||||
Protocol string `json:"protocol"`
|
||||
Gateway string `json:"gateway"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
Extra map[string][]interface{}
|
||||
}
|
||||
|
||||
func (m Message) ParentNotFound() bool {
|
||||
return m.ParentID == ParentIDNotFound
|
||||
}
|
||||
|
||||
func (m Message) ParentValid() bool {
|
||||
return m.ParentID != "" && !m.ParentNotFound()
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
Name string
|
||||
Data *[]byte
|
||||
Comment string
|
||||
URL string
|
||||
Size int64
|
||||
Avatar bool
|
||||
SHA string
|
||||
NativeID string
|
||||
}
|
||||
|
||||
type ChannelInfo struct {
|
||||
Name string
|
||||
Account string
|
||||
Direction string
|
||||
ID string
|
||||
SameChannel map[string]bool
|
||||
Options ChannelOptions
|
||||
}
|
||||
|
||||
type ChannelMember struct {
|
||||
Username string
|
||||
Nick string
|
||||
UserID string
|
||||
ChannelID string
|
||||
ChannelName string
|
||||
}
|
||||
|
||||
type ChannelMembers []ChannelMember
|
||||
|
||||
type Protocol struct {
|
||||
AllowMention []string // discord
|
||||
AuthCode string // steam
|
||||
BindAddress string // mattermost, slack // DEPRECATED
|
||||
Buffer int // api
|
||||
Charset string // irc
|
||||
ClientID string // msteams
|
||||
ColorNicks bool // only irc for now
|
||||
Debug bool // general
|
||||
DebugLevel int // only for irc now
|
||||
DisableWebPagePreview bool // telegram
|
||||
EditSuffix string // mattermost, slack, discord, telegram, gitter
|
||||
EditDisable bool // mattermost, slack, discord, telegram, gitter
|
||||
HTMLDisable bool // matrix
|
||||
IconURL string // mattermost, slack
|
||||
IgnoreFailureOnStart bool // general
|
||||
IgnoreNicks string // all protocols
|
||||
IgnoreMessages string // all protocols
|
||||
Jid string // xmpp
|
||||
JoinDelay string // all protocols
|
||||
Label string // all protocols
|
||||
Login string // mattermost, matrix
|
||||
LogFile string // general
|
||||
MediaDownloadBlackList []string
|
||||
MediaDownloadPath string // Basically MediaServerUpload, but instead of uploading it, just write it to a file on the same server.
|
||||
MediaDownloadSize int // all protocols
|
||||
MediaServerDownload string
|
||||
MediaServerUpload string
|
||||
MediaConvertTgs string // telegram
|
||||
MediaConvertWebPToPNG bool // telegram
|
||||
MessageDelay int // IRC, time in millisecond to wait between messages
|
||||
MessageFormat string // telegram
|
||||
MessageLength int // IRC, max length of a message allowed
|
||||
MessageQueue int // IRC, size of message queue for flood control
|
||||
MessageSplit bool // IRC, split long messages with newlines on MessageLength instead of clipping
|
||||
MessageSplitMaxCount int // discord, split long messages into at most this many messages instead of clipping (MessageLength=1950 cannot be configured)
|
||||
Muc string // xmpp
|
||||
MxID string // matrix
|
||||
Name string // all protocols
|
||||
Nick string // all protocols
|
||||
NickFormatter string // mattermost, slack
|
||||
NickServNick string // IRC
|
||||
NickServUsername string // IRC
|
||||
NickServPassword string // IRC
|
||||
NicksPerRow int // mattermost, slack
|
||||
NoHomeServerSuffix bool // matrix
|
||||
NoSendJoinPart bool // all protocols
|
||||
NoTLS bool // mattermost, xmpp
|
||||
Password string // IRC,mattermost,XMPP,matrix
|
||||
PrefixMessagesWithNick bool // mattemost, slack
|
||||
PreserveThreading bool // slack
|
||||
Protocol string // all protocols
|
||||
QuoteDisable bool // telegram
|
||||
QuoteFormat string // telegram
|
||||
QuoteLengthLimit int // telegram
|
||||
RealName string // IRC
|
||||
RejoinDelay int // IRC
|
||||
ReplaceMessages [][]string // all protocols
|
||||
ReplaceNicks [][]string // all protocols
|
||||
RemoteNickFormat string // all protocols
|
||||
RunCommands []string // IRC
|
||||
Server string // IRC,mattermost,XMPP,discord,matrix
|
||||
SessionFile string // msteams,whatsapp
|
||||
ShowJoinPart bool // all protocols
|
||||
ShowTopicChange bool // slack
|
||||
ShowUserTyping bool // slack
|
||||
ShowEmbeds bool // discord
|
||||
SkipTLSVerify bool // IRC, mattermost
|
||||
SkipVersionCheck bool // mattermost
|
||||
StripNick bool // all protocols
|
||||
StripMarkdown bool // irc
|
||||
SyncTopic bool // slack
|
||||
TengoModifyMessage string // general
|
||||
Team string // mattermost, keybase
|
||||
TeamID string // msteams
|
||||
TenantID string // msteams
|
||||
Token string // gitter, slack, discord, api, matrix
|
||||
Topic string // zulip
|
||||
URL string // mattermost, slack // DEPRECATED
|
||||
UseAPI bool // mattermost, slack
|
||||
UseLocalAvatar []string // discord
|
||||
UseSASL bool // IRC
|
||||
UseTLS bool // IRC
|
||||
UseDiscriminator bool // discord
|
||||
UseFirstName bool // telegram
|
||||
UseUserName bool // discord, matrix, mattermost
|
||||
UseInsecureURL bool // telegram
|
||||
UserName string // IRC
|
||||
VerboseJoinPart bool // IRC
|
||||
WebhookBindAddress string // mattermost, slack
|
||||
WebhookURL string // mattermost, slack
|
||||
}
|
||||
|
||||
type ChannelOptions struct {
|
||||
Key string // irc, xmpp
|
||||
WebhookURL string // discord
|
||||
Topic string // zulip
|
||||
}
|
||||
|
||||
type Bridge struct {
|
||||
Account string
|
||||
Channel string
|
||||
Options ChannelOptions
|
||||
SameChannel bool
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
Name string
|
||||
Enable bool
|
||||
In []Bridge
|
||||
Out []Bridge
|
||||
InOut []Bridge
|
||||
}
|
||||
|
||||
type Tengo struct {
|
||||
InMessage string
|
||||
Message string
|
||||
RemoteNickFormat string
|
||||
OutMessage string
|
||||
}
|
||||
|
||||
type SameChannelGateway struct {
|
||||
Name string
|
||||
Enable bool
|
||||
Channels []string
|
||||
Accounts []string
|
||||
}
|
||||
|
||||
type BridgeValues struct {
|
||||
API map[string]Protocol
|
||||
IRC map[string]Protocol
|
||||
Mattermost map[string]Protocol
|
||||
Matrix map[string]Protocol
|
||||
Slack map[string]Protocol
|
||||
SlackLegacy map[string]Protocol
|
||||
Steam map[string]Protocol
|
||||
Gitter map[string]Protocol
|
||||
XMPP map[string]Protocol
|
||||
Discord map[string]Protocol
|
||||
Telegram map[string]Protocol
|
||||
Rocketchat map[string]Protocol
|
||||
SSHChat map[string]Protocol
|
||||
WhatsApp map[string]Protocol // TODO is this struct used? Search for "SlackLegacy" for example didn't return any results
|
||||
Zulip map[string]Protocol
|
||||
Keybase map[string]Protocol
|
||||
Mumble map[string]Protocol
|
||||
General Protocol
|
||||
Tengo Tengo
|
||||
Gateway []Gateway
|
||||
SameChannelGateway []SameChannelGateway
|
||||
}
|
||||
|
||||
type Config interface {
|
||||
Viper() *viper.Viper
|
||||
BridgeValues() *BridgeValues
|
||||
IsKeySet(key string) bool
|
||||
GetBool(key string) (bool, bool)
|
||||
GetInt(key string) (int, bool)
|
||||
GetString(key string) (string, bool)
|
||||
GetStringSlice(key string) ([]string, bool)
|
||||
GetStringSlice2D(key string) ([][]string, bool)
|
||||
}
|
||||
|
||||
type config struct {
|
||||
sync.RWMutex
|
||||
|
||||
logger *logrus.Entry
|
||||
v *viper.Viper
|
||||
cv *BridgeValues
|
||||
}
|
||||
|
||||
// NewConfig instantiates a new configuration based on the specified configuration file path.
|
||||
func NewConfig(rootLogger *logrus.Logger, cfgfile string) Config {
|
||||
logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"})
|
||||
|
||||
viper.SetConfigFile(cfgfile)
|
||||
input, err := ioutil.ReadFile(cfgfile)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to read configuration file: %#v", err)
|
||||
}
|
||||
|
||||
cfgtype := detectConfigType(cfgfile)
|
||||
mycfg := newConfigFromString(logger, input, cfgtype)
|
||||
if mycfg.cv.General.LogFile != "" {
|
||||
logfile, err := os.OpenFile(mycfg.cv.General.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err == nil {
|
||||
logger.Info("Opening log file ", mycfg.cv.General.LogFile)
|
||||
rootLogger.Out = logfile
|
||||
} else {
|
||||
logger.Warn("Failed to open ", mycfg.cv.General.LogFile)
|
||||
}
|
||||
}
|
||||
if mycfg.cv.General.MediaDownloadSize == 0 {
|
||||
mycfg.cv.General.MediaDownloadSize = 1000000
|
||||
}
|
||||
viper.WatchConfig()
|
||||
viper.OnConfigChange(func(e fsnotify.Event) {
|
||||
logger.Println("Config file changed:", e.Name)
|
||||
})
|
||||
return mycfg
|
||||
}
|
||||
|
||||
// detectConfigType detects JSON and YAML formats, defaults to TOML.
|
||||
func detectConfigType(cfgfile string) string {
|
||||
fileExt := filepath.Ext(cfgfile)
|
||||
switch fileExt {
|
||||
case ".json":
|
||||
return "json"
|
||||
case ".yaml", ".yml":
|
||||
return "yaml"
|
||||
}
|
||||
return "toml"
|
||||
}
|
||||
|
||||
// NewConfigFromString instantiates a new configuration based on the specified string.
|
||||
func NewConfigFromString(rootLogger *logrus.Logger, input []byte) Config {
|
||||
logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"})
|
||||
return newConfigFromString(logger, input, "toml")
|
||||
}
|
||||
|
||||
func newConfigFromString(logger *logrus.Entry, input []byte, cfgtype string) *config {
|
||||
viper.SetConfigType(cfgtype)
|
||||
viper.SetEnvPrefix("matterbridge")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadConfig(bytes.NewBuffer(input)); err != nil {
|
||||
logger.Fatalf("Failed to parse the configuration: %s", err)
|
||||
}
|
||||
|
||||
cfg := &BridgeValues{}
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
logger.Fatalf("Failed to load the configuration: %s", err)
|
||||
}
|
||||
return &config{
|
||||
logger: logger,
|
||||
v: viper.GetViper(),
|
||||
cv: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) BridgeValues() *BridgeValues {
|
||||
return c.cv
|
||||
}
|
||||
|
||||
func (c *config) Viper() *viper.Viper {
|
||||
return c.v
|
||||
}
|
||||
|
||||
func (c *config) IsKeySet(key string) bool {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetBool(key string) (bool, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetBool(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetInt(key string) (int, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetInt(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetString(key string) (string, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetString(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetStringSlice(key string) ([]string, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetStringSlice(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetStringSlice2D(key string) ([][]string, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
res, ok := c.v.Get(key).([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var result [][]string
|
||||
for _, entry := range res {
|
||||
result2 := []string{}
|
||||
for _, entry2 := range entry.([]interface{}) {
|
||||
result2 = append(result2, entry2.(string))
|
||||
}
|
||||
result = append(result, result2)
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func GetIconURL(msg *Message, iconURL string) string {
|
||||
info := strings.Split(msg.Account, ".")
|
||||
protocol := info[0]
|
||||
name := info[1]
|
||||
iconURL = strings.Replace(iconURL, "{NICK}", msg.Username, -1)
|
||||
iconURL = strings.Replace(iconURL, "{BRIDGE}", name, -1)
|
||||
iconURL = strings.Replace(iconURL, "{PROTOCOL}", protocol, -1)
|
||||
return iconURL
|
||||
}
|
||||
|
||||
type TestConfig struct {
|
||||
Config
|
||||
|
||||
Overrides map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *TestConfig) IsKeySet(key string) bool {
|
||||
_, ok := c.Overrides[key]
|
||||
return ok || c.Config.IsKeySet(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetBool(key string) (bool, bool) {
|
||||
val, ok := c.Overrides[key]
|
||||
if ok {
|
||||
return val.(bool), true
|
||||
}
|
||||
return c.Config.GetBool(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetInt(key string) (int, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.(int), true
|
||||
}
|
||||
return c.Config.GetInt(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetString(key string) (string, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.(string), true
|
||||
}
|
||||
return c.Config.GetString(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetStringSlice(key string) ([]string, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.([]string), true
|
||||
}
|
||||
return c.Config.GetStringSlice(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetStringSlice2D(key string) ([][]string, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.([][]string), true
|
||||
}
|
||||
return c.Config.GetStringSlice2D(key)
|
||||
}
|
||||
287
bridge/helper/helper.go
Normal file
287
bridge/helper/helper.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DownloadFile downloads the given non-authenticated URL.
|
||||
func DownloadFile(url string) (*[]byte, error) {
|
||||
return DownloadFileAuth(url, "")
|
||||
}
|
||||
|
||||
// DownloadFileAuth downloads the given URL using the specified authentication token.
|
||||
func DownloadFileAuth(url string, auth string) (*[]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if auth != "" {
|
||||
req.Header.Add("Authorization", auth)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(&buf, resp.Body)
|
||||
data := buf.Bytes()
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// DownloadFileAuthRocket downloads the given URL using the specified Rocket user ID and authentication token.
|
||||
func DownloadFileAuthRocket(url, token, userID string) (*[]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
req.Header.Add("X-Auth-Token", token)
|
||||
req.Header.Add("X-User-Id", userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = io.Copy(&buf, resp.Body)
|
||||
data := buf.Bytes()
|
||||
return &data, err
|
||||
}
|
||||
|
||||
// GetSubLines splits messages in newline-delimited lines. If maxLineLength is
|
||||
// specified as non-zero GetSubLines will also clip long lines to the maximum
|
||||
// length and insert a warning marker that the line was clipped.
|
||||
//
|
||||
// TODO: The current implementation has the inconvenient that it disregards
|
||||
// word boundaries when splitting but this is hard to solve without potentially
|
||||
// breaking formatting and other stylistic effects.
|
||||
func GetSubLines(message string, maxLineLength int, clippingMessage string) []string {
|
||||
if clippingMessage == "" {
|
||||
clippingMessage = " <clipped message>"
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(message), "\n") {
|
||||
if line == "" {
|
||||
// Prevent sending empty messages, so we'll skip this line
|
||||
// if it has no content.
|
||||
continue
|
||||
}
|
||||
|
||||
if maxLineLength == 0 || len([]byte(line)) <= maxLineLength {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// !!! WARNING !!!
|
||||
// Before touching the splitting logic below please ensure that you PROPERLY
|
||||
// understand how strings, runes and range loops over strings work in Go.
|
||||
// A good place to start is to read https://blog.golang.org/strings. :-)
|
||||
var splitStart int
|
||||
var startOfPreviousRune int
|
||||
for i := range line {
|
||||
if i-splitStart > maxLineLength-len([]byte(clippingMessage)) {
|
||||
lines = append(lines, line[splitStart:startOfPreviousRune]+clippingMessage)
|
||||
splitStart = startOfPreviousRune
|
||||
}
|
||||
startOfPreviousRune = i
|
||||
}
|
||||
// This last append is safe to do without looking at the remaining byte-length
|
||||
// as we assume that the byte-length of the last rune will never exceed that of
|
||||
// the byte-length of the clipping message.
|
||||
lines = append(lines, line[splitStart:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// HandleExtra manages the supplementary details stored inside a message's 'Extra' field map.
|
||||
func HandleExtra(msg *config.Message, general *config.Protocol) []config.Message {
|
||||
extra := msg.Extra
|
||||
rmsg := []config.Message{}
|
||||
for _, f := range extra[config.EventFileFailureSize] {
|
||||
fi := f.(config.FileInfo)
|
||||
text := fmt.Sprintf("file %s too big to download (%#v > allowed size: %#v)", fi.Name, fi.Size, general.MediaDownloadSize)
|
||||
rmsg = append(rmsg, config.Message{
|
||||
Text: text,
|
||||
Username: "<system> ",
|
||||
Channel: msg.Channel,
|
||||
Account: msg.Account,
|
||||
})
|
||||
}
|
||||
return rmsg
|
||||
}
|
||||
|
||||
// GetAvatar constructs a URL for a given user-avatar if it is available in the cache.
|
||||
func GetAvatar(av map[string]string, userid string, general *config.Protocol) string {
|
||||
if sha, ok := av[userid]; ok {
|
||||
return general.MediaServerDownload + "/" + sha + "/" + userid + ".png"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleDownloadSize checks a specified filename against the configured download blacklist
|
||||
// and checks a specified file-size against the configure limit.
|
||||
func HandleDownloadSize(logger *logrus.Entry, msg *config.Message, name string, size int64, general *config.Protocol) error {
|
||||
// check blacklist here
|
||||
for _, entry := range general.MediaDownloadBlackList {
|
||||
if entry != "" {
|
||||
re, err := regexp.Compile(entry)
|
||||
if err != nil {
|
||||
logger.Errorf("incorrect regexp %s for %s", entry, msg.Account)
|
||||
continue
|
||||
}
|
||||
if re.MatchString(name) {
|
||||
return fmt.Errorf("Matching blacklist %s. Not downloading %s", entry, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Debugf("Trying to download %#v with size %#v", name, size)
|
||||
if int(size) > general.MediaDownloadSize {
|
||||
msg.Event = config.EventFileFailureSize
|
||||
msg.Extra[msg.Event] = append(msg.Extra[msg.Event], config.FileInfo{
|
||||
Name: name,
|
||||
Comment: msg.Text,
|
||||
Size: size,
|
||||
})
|
||||
return fmt.Errorf("File %#v to large to download (%#v). MediaDownloadSize is %#v", name, size, general.MediaDownloadSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleDownloadData adds the data for a remote file into a Matterbridge gateway message.
|
||||
func HandleDownloadData(logger *logrus.Entry, msg *config.Message, name, comment, url string, data *[]byte, general *config.Protocol) {
|
||||
HandleDownloadData2(logger, msg, name, "", comment, url, data, general)
|
||||
}
|
||||
|
||||
// HandleDownloadData adds the data for a remote file into a Matterbridge gateway message.
|
||||
func HandleDownloadData2(logger *logrus.Entry, msg *config.Message, name, id, comment, url string, data *[]byte, general *config.Protocol) {
|
||||
var avatar bool
|
||||
logger.Debugf("Download OK %#v %#v", name, len(*data))
|
||||
if msg.Event == config.EventAvatarDownload {
|
||||
avatar = true
|
||||
}
|
||||
msg.Extra["file"] = append(msg.Extra["file"], config.FileInfo{
|
||||
Name: name,
|
||||
Data: data,
|
||||
URL: url,
|
||||
Comment: comment,
|
||||
Avatar: avatar,
|
||||
NativeID: id,
|
||||
})
|
||||
}
|
||||
|
||||
var emptyLineMatcher = regexp.MustCompile("\n+")
|
||||
|
||||
// RemoveEmptyNewLines collapses consecutive newline characters into a single one and
|
||||
// trims any preceding or trailing newline characters as well.
|
||||
func RemoveEmptyNewLines(msg string) string {
|
||||
return emptyLineMatcher.ReplaceAllString(strings.Trim(msg, "\n"), "\n")
|
||||
}
|
||||
|
||||
// ClipMessage trims a message to the specified length if it exceeds it and adds a warning
|
||||
// to the message in case it does so.
|
||||
func ClipMessage(text string, length int, clippingMessage string) string {
|
||||
if clippingMessage == "" {
|
||||
clippingMessage = " <clipped message>"
|
||||
}
|
||||
|
||||
if len(text) > length {
|
||||
text = text[:length-len(clippingMessage)]
|
||||
for len(text) > 0 {
|
||||
if r, _ := utf8.DecodeLastRuneInString(text); r == utf8.RuneError {
|
||||
text = text[:len(text)-1]
|
||||
// Note: DecodeLastRuneInString only returns the constant value "1" in
|
||||
// case of an error. We do not yet know whether the last rune is now
|
||||
// actually valid. Example: "€" is 0xE2 0x82 0xAC. If we happen to split
|
||||
// the string just before 0xAC, and go back only one byte, that would
|
||||
// leave us with a string that ends in the byte 0xE2, which is not a valid
|
||||
// rune, so we need to try again.
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
text += clippingMessage
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func ClipOrSplitMessage(text string, length int, clippingMessage string, splitMax int) []string {
|
||||
var msgParts []string
|
||||
remainingText := text
|
||||
// Invariant of this splitting loop: No text is lost (msgParts+remainingText is the original text),
|
||||
// and all parts is guaranteed to satisfy the length requirement.
|
||||
for len(msgParts) < splitMax-1 && len(remainingText) > length {
|
||||
// Decision: The text needs to be split (again).
|
||||
var chunk string
|
||||
wasted := 0
|
||||
// The longest UTF-8 encoding of a valid rune is 4 bytes (0xF4 0x8F 0xBF 0xBF, encoding U+10FFFF),
|
||||
// so we should never need to waste 4 or more bytes at a time.
|
||||
for wasted < 4 && wasted < length {
|
||||
chunk = remainingText[:length-wasted]
|
||||
if r, _ := utf8.DecodeLastRuneInString(chunk); r == utf8.RuneError {
|
||||
wasted += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Note: At this point, "chunk" might still be invalid, if "text" is very broken.
|
||||
msgParts = append(msgParts, chunk)
|
||||
remainingText = remainingText[len(chunk):]
|
||||
}
|
||||
msgParts = append(msgParts, ClipMessage(remainingText, length, clippingMessage))
|
||||
return msgParts
|
||||
}
|
||||
|
||||
// ParseMarkdown takes in an input string as markdown and parses it to html
|
||||
func ParseMarkdown(input string) string {
|
||||
extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode
|
||||
markdownParser := parser.NewWithExtensions(extensions)
|
||||
renderer := html.NewRenderer(html.RendererOptions{
|
||||
Flags: 0,
|
||||
})
|
||||
parsedMarkdown := markdown.ToHTML([]byte(input), markdownParser, renderer)
|
||||
res := string(parsedMarkdown)
|
||||
res = strings.TrimPrefix(res, "<p>")
|
||||
res = strings.TrimSuffix(res, "</p>\n")
|
||||
return res
|
||||
}
|
||||
|
||||
// ConvertWebPToPNG converts input data (which should be WebP format) to PNG format
|
||||
func ConvertWebPToPNG(data *[]byte) error {
|
||||
r := bytes.NewReader(*data)
|
||||
m, err := webp.Decode(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var output []byte
|
||||
w := bytes.NewBuffer(output)
|
||||
if err := png.Encode(w, m); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = w.Bytes()
|
||||
return nil
|
||||
}
|
||||
238
bridge/helper/helper_test.go
Normal file
238
bridge/helper/helper_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testLineLength = 64
|
||||
|
||||
var lineSplittingTestCases = map[string]struct {
|
||||
input string
|
||||
splitOutput []string
|
||||
nonSplitOutput []string
|
||||
}{
|
||||
"Short single-line message": {
|
||||
input: "short",
|
||||
splitOutput: []string{"short"},
|
||||
nonSplitOutput: []string{"short"},
|
||||
},
|
||||
"Long single-line message": {
|
||||
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
splitOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipis <clipped message>",
|
||||
"cing elit, sed do eiusmod tempor incididunt ut <clipped message>",
|
||||
" labore et dolore magna aliqua.",
|
||||
},
|
||||
nonSplitOutput: []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."},
|
||||
},
|
||||
"Short multi-line message": {
|
||||
input: "I\ncan't\nget\nno\nsatisfaction!",
|
||||
splitOutput: []string{
|
||||
"I",
|
||||
"can't",
|
||||
"get",
|
||||
"no",
|
||||
"satisfaction!",
|
||||
},
|
||||
nonSplitOutput: []string{
|
||||
"I",
|
||||
"can't",
|
||||
"get",
|
||||
"no",
|
||||
"satisfaction!",
|
||||
},
|
||||
},
|
||||
"Long multi-line message": {
|
||||
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n" +
|
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n" +
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n" +
|
||||
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
splitOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipis <clipped message>",
|
||||
"cing elit, sed do eiusmod tempor incididunt ut <clipped message>",
|
||||
" labore et dolore magna aliqua.",
|
||||
"Ut enim ad minim veniam, quis nostrud exercita <clipped message>",
|
||||
"tion ullamco laboris nisi ut aliquip ex ea com <clipped message>",
|
||||
"modo consequat.",
|
||||
"Duis aute irure dolor in reprehenderit in volu <clipped message>",
|
||||
"ptate velit esse cillum dolore eu fugiat nulla <clipped message>",
|
||||
" pariatur.",
|
||||
"Excepteur sint occaecat cupidatat non proident <clipped message>",
|
||||
", sunt in culpa qui officia deserunt mollit an <clipped message>",
|
||||
"im id est laborum.",
|
||||
},
|
||||
nonSplitOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
|
||||
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
},
|
||||
},
|
||||
"Message ending with new-line.": {
|
||||
input: "Newline ending\n",
|
||||
splitOutput: []string{"Newline ending"},
|
||||
nonSplitOutput: []string{"Newline ending"},
|
||||
},
|
||||
"Long message containing UTF-8 multi-byte runes": {
|
||||
input: "不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說",
|
||||
splitOutput: []string{
|
||||
"不布人個我此而及單石業喜資富下 <clipped message>",
|
||||
"我河下日沒一我臺空達的常景便物 <clipped message>",
|
||||
"沒為……子大我別名解成?生賣的 <clipped message>",
|
||||
"全直黑,我自我結毛分洲了世當, <clipped message>",
|
||||
"是政福那是東;斯說",
|
||||
},
|
||||
nonSplitOutput: []string{"不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說"},
|
||||
},
|
||||
"Long message, clip three-byte rune after two bytes": {
|
||||
input: "x 人人生而自由,在尊嚴和權利上一律平等。 他們都具有理性和良知,應該以兄弟情誼的精神對待彼此。",
|
||||
splitOutput: []string{
|
||||
"x 人人生而自由,在尊嚴和權利上 <clipped message>",
|
||||
"一律平等。 他們都具有理性和良知 <clipped message>",
|
||||
",應該以兄弟情誼的精神對待彼此。",
|
||||
},
|
||||
nonSplitOutput: []string{"x 人人生而自由,在尊嚴和權利上一律平等。 他們都具有理性和良知,應該以兄弟情誼的精神對待彼此。"},
|
||||
},
|
||||
}
|
||||
|
||||
func TestGetSubLines(t *testing.T) {
|
||||
for testname, testcase := range lineSplittingTestCases {
|
||||
splitLines := GetSubLines(testcase.input, testLineLength, "")
|
||||
assert.Equalf(t, testcase.splitOutput, splitLines, "'%s' testcase should give expected lines with splitting.", testname)
|
||||
for _, splitLine := range splitLines {
|
||||
byteLength := len([]byte(splitLine))
|
||||
assert.True(t, byteLength <= testLineLength, "Splitted line '%s' of testcase '%s' should not exceed the maximum byte-length (%d vs. %d).", splitLine, testcase, byteLength, testLineLength)
|
||||
}
|
||||
|
||||
nonSplitLines := GetSubLines(testcase.input, 0, "")
|
||||
assert.Equalf(t, testcase.nonSplitOutput, nonSplitLines, "'%s' testcase should give expected lines without splitting.", testname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertWebPToPNG(t *testing.T) {
|
||||
if os.Getenv("LOCAL_TEST") == "" {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
input, err := ioutil.ReadFile("test.webp")
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
d := &input
|
||||
err = ConvertWebPToPNG(d)
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile("test.png", *d, 0o644) // nolint:gosec
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
var clippingOrSplittingTestCases = map[string]struct {
|
||||
inputText string
|
||||
clipSplitLength int
|
||||
clippingMessage string
|
||||
splitMax int
|
||||
expectedOutput []string
|
||||
}{
|
||||
"Short single-line message, split 3": {
|
||||
inputText: "short",
|
||||
clipSplitLength: 20,
|
||||
clippingMessage: "?!?!",
|
||||
splitMax: 3,
|
||||
expectedOutput: []string{"short"},
|
||||
},
|
||||
"Short single-line message, split 1": {
|
||||
inputText: "short",
|
||||
clipSplitLength: 20,
|
||||
clippingMessage: "?!?!",
|
||||
splitMax: 1,
|
||||
expectedOutput: []string{"short"},
|
||||
},
|
||||
"Short single-line message, split 0": {
|
||||
// Mainly check that we don't crash.
|
||||
inputText: "short",
|
||||
clipSplitLength: 20,
|
||||
clippingMessage: "?!?!",
|
||||
splitMax: 0,
|
||||
expectedOutput: []string{"short"},
|
||||
},
|
||||
"Long single-line message, noclip": {
|
||||
inputText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
clipSplitLength: 50,
|
||||
clippingMessage: "?!?!",
|
||||
splitMax: 10,
|
||||
expectedOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing",
|
||||
" elit, sed do eiusmod tempor incididunt ut labore ",
|
||||
"et dolore magna aliqua.",
|
||||
},
|
||||
},
|
||||
"Long single-line message, noclip tight": {
|
||||
inputText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
clipSplitLength: 50,
|
||||
clippingMessage: "?!?!",
|
||||
splitMax: 3,
|
||||
expectedOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing",
|
||||
" elit, sed do eiusmod tempor incididunt ut labore ",
|
||||
"et dolore magna aliqua.",
|
||||
},
|
||||
},
|
||||
"Long single-line message, clip custom": {
|
||||
inputText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
clipSplitLength: 50,
|
||||
clippingMessage: "?!?!",
|
||||
splitMax: 2,
|
||||
expectedOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing",
|
||||
" elit, sed do eiusmod tempor incididunt ut lab?!?!",
|
||||
},
|
||||
},
|
||||
"Long single-line message, clip built-in": {
|
||||
inputText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
clipSplitLength: 50,
|
||||
clippingMessage: "",
|
||||
splitMax: 2,
|
||||
expectedOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing",
|
||||
" elit, sed do eiusmod tempor inc <clipped message>",
|
||||
},
|
||||
},
|
||||
"Short multi-line message": {
|
||||
inputText: "I\ncan't\nget\nno\nsatisfaction!",
|
||||
clipSplitLength: 50,
|
||||
clippingMessage: "",
|
||||
splitMax: 2,
|
||||
expectedOutput: []string{"I\ncan't\nget\nno\nsatisfaction!"},
|
||||
},
|
||||
"Long message containing UTF-8 multi-byte runes": {
|
||||
inputText: "人人生而自由,在尊嚴和權利上一律平等。 他們都具有理性和良知,應該以兄弟情誼的精神對待彼此。",
|
||||
clipSplitLength: 50,
|
||||
clippingMessage: "",
|
||||
splitMax: 10,
|
||||
expectedOutput: []string{
|
||||
"人人生而自由,在尊嚴和權利上一律", // Note: only 48 bytes!
|
||||
"平等。 他們都具有理性和良知,應該", // Note: only 49 bytes!
|
||||
"以兄弟情誼的精神對待彼此。",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestClipOrSplitMessage(t *testing.T) {
|
||||
for testname, testcase := range clippingOrSplittingTestCases {
|
||||
actualOutput := ClipOrSplitMessage(testcase.inputText, testcase.clipSplitLength, testcase.clippingMessage, testcase.splitMax)
|
||||
assert.Equalf(t, testcase.expectedOutput, actualOutput, "'%s' testcase should give expected lines with clipping+splitting.", testname)
|
||||
for _, splitLine := range testcase.expectedOutput {
|
||||
byteLength := len([]byte(splitLine))
|
||||
assert.True(t, byteLength <= testcase.clipSplitLength, "Splitted line '%s' of testcase '%s' should not exceed the maximum byte-length (%d vs. %d).", splitLine, testname, testcase.clipSplitLength, byteLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
bridge/helper/libtgsconverter.go
Normal file
35
bridge/helper/libtgsconverter.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build cgolottie
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Benau/tgsconverter/libtgsconverter"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func CanConvertTgsToX() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertTgsToX convert input data (which should be tgs format) to any format supported by libtgsconverter
|
||||
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
|
||||
options := libtgsconverter.NewConverterOptions()
|
||||
options.SetExtension(outputFormat)
|
||||
blob, err := libtgsconverter.ImportFromData(*data, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run libtgsconverter.ImportFromData: %s", err.Error())
|
||||
}
|
||||
|
||||
*data = blob
|
||||
return nil
|
||||
}
|
||||
|
||||
func SupportsFormat(format string) bool {
|
||||
return libtgsconverter.SupportsExtension(format)
|
||||
}
|
||||
|
||||
func LottieBackend() string {
|
||||
return "libtgsconverter"
|
||||
}
|
||||
90
bridge/helper/lottie_convert.go
Normal file
90
bridge/helper/lottie_convert.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build !cgolottie
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// CanConvertTgsToX Checks whether the external command necessary for ConvertTgsToX works.
|
||||
func CanConvertTgsToX() error {
|
||||
// We depend on the fact that `lottie_convert.py --help` has exit status 0.
|
||||
// Hyrum's Law predicted this, and Murphy's Law predicts that this will break eventually.
|
||||
// However, there is no alternative like `lottie_convert.py --is-properly-installed`
|
||||
cmd := exec.Command("lottie_convert.py", "--help")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ConvertTgsToWebP convert input data (which should be tgs format) to WebP format
|
||||
// This relies on an external command, which is ugly, but works.
|
||||
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
|
||||
// lottie can't handle input from a pipe, so write to a temporary file:
|
||||
tmpInFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-input-*.tgs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpInFileName := tmpInFile.Name()
|
||||
defer func() {
|
||||
if removeErr := os.Remove(tmpInFileName); removeErr != nil {
|
||||
logger.Errorf("Could not delete temporary (input) file %s: %v", tmpInFileName, removeErr)
|
||||
}
|
||||
}()
|
||||
// lottie can handle writing to a pipe, but there is no way to do that platform-independently.
|
||||
// "/dev/stdout" won't work on Windows, and "-" upsets Cairo for some reason. So we need another file:
|
||||
tmpOutFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-output-*.data")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpOutFileName := tmpOutFile.Name()
|
||||
defer func() {
|
||||
if removeErr := os.Remove(tmpOutFileName); removeErr != nil {
|
||||
logger.Errorf("Could not delete temporary (output) file %s: %v", tmpOutFileName, removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, writeErr := tmpInFile.Write(*data); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
// Must close before calling lottie to avoid data races:
|
||||
if closeErr := tmpInFile.Close(); closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// Call lottie to transform:
|
||||
cmd := exec.Command("lottie_convert.py", "--input-format", "lottie", "--output-format", outputFormat, tmpInFileName, tmpOutFileName)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
// NB: lottie writes progress into to stderr in all cases.
|
||||
_, stderr := cmd.Output()
|
||||
if stderr != nil {
|
||||
// 'stderr' already contains some parts of Stderr, because it was set to 'nil'.
|
||||
return stderr
|
||||
}
|
||||
dataContents, err := ioutil.ReadFile(tmpOutFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*data = dataContents
|
||||
return nil
|
||||
}
|
||||
|
||||
func SupportsFormat(format string) bool {
|
||||
switch format {
|
||||
case "png":
|
||||
fallthrough
|
||||
case "webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func LottieBackend() string {
|
||||
return "lottie_convert.py"
|
||||
}
|
||||
32
bridge/irc/charset.go
Normal file
32
bridge/irc/charset.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package birc
|
||||
|
||||
import (
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/japanese"
|
||||
"golang.org/x/text/encoding/korean"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
var encoders = map[string]encoding.Encoding{
|
||||
"utf-8": unicode.UTF8,
|
||||
"iso-2022-jp": japanese.ISO2022JP,
|
||||
"big5": traditionalchinese.Big5,
|
||||
"gbk": simplifiedchinese.GBK,
|
||||
"euc-kr": korean.EUCKR,
|
||||
"gb2312": simplifiedchinese.HZGB2312,
|
||||
"shift-jis": japanese.ShiftJIS,
|
||||
"euc-jp": japanese.EUCJP,
|
||||
"gb18030": simplifiedchinese.GB18030,
|
||||
}
|
||||
|
||||
func toUTF8(from string, input string) string {
|
||||
enc, ok := encoders[from]
|
||||
if !ok {
|
||||
return input
|
||||
}
|
||||
|
||||
res, _ := enc.NewDecoder().String(input)
|
||||
return res
|
||||
}
|
||||
279
bridge/irc/handlers.go
Normal file
279
bridge/irc/handlers.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package birc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/lrstanley/girc"
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
"github.com/saintfish/chardet"
|
||||
|
||||
// We need to import the 'data' package as an implicit dependency.
|
||||
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
|
||||
_ "github.com/paulrosania/go-charset/data"
|
||||
)
|
||||
|
||||
func (b *Birc) handleCharset(msg *config.Message) error {
|
||||
if b.GetString("Charset") != "" {
|
||||
switch b.GetString("Charset") {
|
||||
case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp":
|
||||
msg.Text = toUTF8(b.GetString("Charset"), msg.Text)
|
||||
default:
|
||||
buf := new(bytes.Buffer)
|
||||
w, err := charset.NewWriter(b.GetString("Charset"), buf)
|
||||
if err != nil {
|
||||
b.Log.Errorf("charset to utf-8 conversion failed: %s", err)
|
||||
return err
|
||||
}
|
||||
fmt.Fprint(w, msg.Text)
|
||||
w.Close()
|
||||
msg.Text = buf.String()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFiles returns true if we have handled the files, otherwise return false
|
||||
func (b *Birc) handleFiles(msg *config.Message) bool {
|
||||
if msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
for _, rmsg := range helper.HandleExtra(msg, b.General) {
|
||||
b.Local <- rmsg
|
||||
}
|
||||
if len(msg.Extra["file"]) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.Comment != "" {
|
||||
msg.Text += fi.Comment + " : "
|
||||
}
|
||||
if fi.URL != "" {
|
||||
msg.Text = fi.URL
|
||||
if fi.Comment != "" {
|
||||
msg.Text = fi.Comment + " : " + fi.URL
|
||||
}
|
||||
}
|
||||
b.Local <- config.Message{Text: msg.Text, Username: msg.Username, Channel: msg.Channel, Event: msg.Event}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Birc) handleInvite(client *girc.Client, event girc.Event) {
|
||||
if len(event.Params) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
channel := event.Params[1]
|
||||
|
||||
b.Log.Debugf("got invite for %s", channel)
|
||||
|
||||
if _, ok := b.channels[channel]; ok {
|
||||
b.i.Cmd.Join(channel)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleJoinPart(client *girc.Client, event girc.Event) {
|
||||
if len(event.Params) == 0 {
|
||||
b.Log.Debugf("handleJoinPart: empty Params? %#v", event)
|
||||
return
|
||||
}
|
||||
channel := strings.ToLower(event.Params[0])
|
||||
if event.Command == "KICK" && event.Params[1] == b.Nick {
|
||||
b.Log.Infof("Got kicked from %s by %s", channel, event.Source.Name)
|
||||
time.Sleep(time.Duration(b.GetInt("RejoinDelay")) * time.Second)
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: channel, Account: b.Account, Event: config.EventRejoinChannels}
|
||||
return
|
||||
}
|
||||
if event.Command == "QUIT" {
|
||||
if event.Source.Name == b.Nick && strings.Contains(event.Last(), "Ping timeout") {
|
||||
b.Log.Infof("%s reconnecting ..", b.Account)
|
||||
b.Remote <- config.Message{Username: "system", Text: "reconnect", Channel: channel, Account: b.Account, Event: config.EventFailure}
|
||||
return
|
||||
}
|
||||
}
|
||||
if event.Source.Name != b.Nick {
|
||||
if b.GetBool("nosendjoinpart") {
|
||||
return
|
||||
}
|
||||
msg := config.Message{Username: "system", Text: event.Source.Name + " " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave}
|
||||
if b.GetBool("verbosejoinpart") {
|
||||
b.Log.Debugf("<= Sending verbose JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
msg = config.Message{Username: "system", Text: event.Source.Name + " (" + event.Source.Ident + "@" + event.Source.Host + ") " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave}
|
||||
} else {
|
||||
b.Log.Debugf("<= Sending JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
}
|
||||
b.Log.Debugf("<= Message is %#v", msg)
|
||||
b.Remote <- msg
|
||||
return
|
||||
}
|
||||
b.Log.Debugf("handle %#v", event)
|
||||
}
|
||||
|
||||
func (b *Birc) handleNewConnection(client *girc.Client, event girc.Event) {
|
||||
b.Log.Debug("Registering callbacks")
|
||||
i := b.i
|
||||
b.Nick = event.Params[0]
|
||||
|
||||
b.Log.Debug("Clearing handlers before adding in case of BNC reconnect")
|
||||
i.Handlers.Clear("PRIVMSG")
|
||||
i.Handlers.Clear("CTCP_ACTION")
|
||||
i.Handlers.Clear(girc.RPL_TOPICWHOTIME)
|
||||
i.Handlers.Clear(girc.NOTICE)
|
||||
i.Handlers.Clear("JOIN")
|
||||
i.Handlers.Clear("PART")
|
||||
i.Handlers.Clear("QUIT")
|
||||
i.Handlers.Clear("KICK")
|
||||
i.Handlers.Clear("INVITE")
|
||||
|
||||
i.Handlers.AddBg("PRIVMSG", b.handlePrivMsg)
|
||||
i.Handlers.Add(girc.RPL_TOPICWHOTIME, b.handleTopicWhoTime)
|
||||
i.Handlers.AddBg(girc.NOTICE, b.handleNotice)
|
||||
i.Handlers.AddBg("JOIN", b.handleJoinPart)
|
||||
i.Handlers.AddBg("PART", b.handleJoinPart)
|
||||
i.Handlers.AddBg("QUIT", b.handleJoinPart)
|
||||
i.Handlers.AddBg("KICK", b.handleJoinPart)
|
||||
i.Handlers.Add("INVITE", b.handleInvite)
|
||||
}
|
||||
|
||||
func (b *Birc) handleNickServ() {
|
||||
if !b.GetBool("UseSASL") && b.GetString("NickServNick") != "" && b.GetString("NickServPassword") != "" {
|
||||
b.Log.Debugf("Sending identify to nickserv %s", b.GetString("NickServNick"))
|
||||
b.i.Cmd.Message(b.GetString("NickServNick"), "IDENTIFY "+b.GetString("NickServPassword"))
|
||||
}
|
||||
if strings.EqualFold(b.GetString("NickServNick"), "Q@CServe.quakenet.org") {
|
||||
b.Log.Debugf("Authenticating %s against %s", b.GetString("NickServUsername"), b.GetString("NickServNick"))
|
||||
b.i.Cmd.Message(b.GetString("NickServNick"), "AUTH "+b.GetString("NickServUsername")+" "+b.GetString("NickServPassword"))
|
||||
}
|
||||
// give nickserv some slack
|
||||
time.Sleep(time.Second * 5)
|
||||
b.authDone = true
|
||||
}
|
||||
|
||||
func (b *Birc) handleNotice(client *girc.Client, event girc.Event) {
|
||||
if strings.Contains(event.String(), "This nickname is registered") && event.Source.Name == b.GetString("NickServNick") {
|
||||
b.handleNickServ()
|
||||
} else {
|
||||
b.handlePrivMsg(client, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleOther(client *girc.Client, event girc.Event) {
|
||||
if b.GetInt("DebugLevel") == 1 {
|
||||
if event.Command != "CLIENT_STATE_UPDATED" &&
|
||||
event.Command != "CLIENT_GENERAL_UPDATED" {
|
||||
b.Log.Debugf("%#v", event.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
switch event.Command {
|
||||
case "372", "375", "376", "250", "251", "252", "253", "254", "255", "265", "266", "002", "003", "004", "005":
|
||||
return
|
||||
}
|
||||
b.Log.Debugf("%#v", event.String())
|
||||
}
|
||||
|
||||
func (b *Birc) handleOtherAuth(client *girc.Client, event girc.Event) {
|
||||
b.handleNickServ()
|
||||
b.handleRunCommands()
|
||||
// we are now fully connected
|
||||
// only send on first connection
|
||||
if b.FirstConnection {
|
||||
b.connected <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
|
||||
if b.skipPrivMsg(event) {
|
||||
return
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
Username: event.Source.Name,
|
||||
Channel: strings.ToLower(event.Params[0]),
|
||||
Account: b.Account,
|
||||
UserID: event.Source.Ident + "@" + event.Source.Host,
|
||||
}
|
||||
|
||||
b.Log.Debugf("== Receiving PRIVMSG: %s %s %#v", event.Source.Name, event.Last(), event)
|
||||
|
||||
// set action event
|
||||
if ok, ctcp := event.IsCTCP(); ok {
|
||||
if ctcp.Command != girc.CTCP_ACTION {
|
||||
b.Log.Debugf("dropping user ctcp, command: %s", ctcp.Command)
|
||||
return
|
||||
}
|
||||
rmsg.Event = config.EventUserAction
|
||||
}
|
||||
|
||||
// set NOTICE event
|
||||
if event.Command == "NOTICE" {
|
||||
rmsg.Event = config.EventNoticeIRC
|
||||
}
|
||||
|
||||
// strip action, we made an event if it was an action
|
||||
rmsg.Text += event.StripAction()
|
||||
|
||||
// start detecting the charset
|
||||
mycharset := b.GetString("Charset")
|
||||
if mycharset == "" {
|
||||
// detect what were sending so that we convert it to utf-8
|
||||
detector := chardet.NewTextDetector()
|
||||
result, err := detector.DetectBest([]byte(rmsg.Text))
|
||||
if err != nil {
|
||||
b.Log.Infof("detection failed for rmsg.Text: %#v", rmsg.Text)
|
||||
return
|
||||
}
|
||||
b.Log.Debugf("detected %s confidence %#v", result.Charset, result.Confidence)
|
||||
mycharset = result.Charset
|
||||
// if we're not sure, just pick ISO-8859-1
|
||||
if result.Confidence < 80 {
|
||||
mycharset = "ISO-8859-1"
|
||||
}
|
||||
}
|
||||
switch mycharset {
|
||||
case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp":
|
||||
rmsg.Text = toUTF8(b.GetString("Charset"), rmsg.Text)
|
||||
default:
|
||||
r, err := charset.NewReader(mycharset, strings.NewReader(rmsg.Text))
|
||||
if err != nil {
|
||||
b.Log.Errorf("charset to utf-8 conversion failed: %s", err)
|
||||
return
|
||||
}
|
||||
output, _ := ioutil.ReadAll(r)
|
||||
rmsg.Text = string(output)
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", event.Params[0], b.Account)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *Birc) handleRunCommands() {
|
||||
for _, cmd := range b.GetStringSlice("RunCommands") {
|
||||
cmd = strings.ReplaceAll(cmd, "{BOTNICK}", b.Nick)
|
||||
if err := b.i.Cmd.SendRaw(cmd); err != nil {
|
||||
b.Log.Errorf("RunCommands %s failed: %s", cmd, err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleTopicWhoTime(client *girc.Client, event girc.Event) {
|
||||
parts := strings.Split(event.Params[2], "!")
|
||||
t, err := strconv.ParseInt(event.Params[3], 10, 64)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Invalid time stamp: %s", event.Params[3])
|
||||
}
|
||||
user := parts[0]
|
||||
if len(parts) > 1 {
|
||||
user += " [" + parts[1] + "]"
|
||||
}
|
||||
b.Log.Debugf("%s: Topic set by %s [%s]", event.Command, user, time.Unix(t, 0))
|
||||
}
|
||||
415
bridge/irc/irc.go
Normal file
415
bridge/irc/irc.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package birc
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/lrstanley/girc"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
|
||||
// We need to import the 'data' package as an implicit dependency.
|
||||
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
|
||||
_ "github.com/paulrosania/go-charset/data"
|
||||
)
|
||||
|
||||
type Birc struct {
|
||||
i *girc.Client
|
||||
Nick string
|
||||
names map[string][]string
|
||||
connected chan error
|
||||
Local chan config.Message // local queue for flood control
|
||||
FirstConnection, authDone bool
|
||||
MessageDelay, MessageQueue, MessageLength int
|
||||
channels map[string]bool
|
||||
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Birc{}
|
||||
b.Config = cfg
|
||||
b.Nick = b.GetString("Nick")
|
||||
b.names = make(map[string][]string)
|
||||
b.connected = make(chan error)
|
||||
b.channels = make(map[string]bool)
|
||||
|
||||
if b.GetInt("MessageDelay") == 0 {
|
||||
b.MessageDelay = 1300
|
||||
} else {
|
||||
b.MessageDelay = b.GetInt("MessageDelay")
|
||||
}
|
||||
if b.GetInt("MessageQueue") == 0 {
|
||||
b.MessageQueue = 30
|
||||
} else {
|
||||
b.MessageQueue = b.GetInt("MessageQueue")
|
||||
}
|
||||
if b.GetInt("MessageLength") == 0 {
|
||||
b.MessageLength = 400
|
||||
} else {
|
||||
b.MessageLength = b.GetInt("MessageLength")
|
||||
}
|
||||
b.FirstConnection = true
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Birc) Command(msg *config.Message) string {
|
||||
if msg.Text == "!users" {
|
||||
b.i.Handlers.Add(girc.RPL_NAMREPLY, b.storeNames)
|
||||
b.i.Handlers.Add(girc.RPL_ENDOFNAMES, b.endNames)
|
||||
b.i.Cmd.SendRaw("NAMES " + msg.Channel) //nolint:errcheck
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Birc) Connect() error {
|
||||
if b.GetBool("UseSASL") && b.GetString("TLSClientCertificate") != "" {
|
||||
return errors.New("you can't enable SASL and TLSClientCertificate at the same time")
|
||||
}
|
||||
|
||||
b.Local = make(chan config.Message, b.MessageQueue+10)
|
||||
b.Log.Infof("Connecting %s", b.GetString("Server"))
|
||||
|
||||
i, err := b.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.GetBool("UseSASL") {
|
||||
i.Config.SASL = &girc.SASLPlain{
|
||||
User: b.GetString("NickServNick"),
|
||||
Pass: b.GetString("NickServPassword"),
|
||||
}
|
||||
}
|
||||
|
||||
i.Handlers.Add(girc.RPL_WELCOME, b.handleNewConnection)
|
||||
i.Handlers.Add(girc.RPL_ENDOFMOTD, b.handleOtherAuth)
|
||||
i.Handlers.Add(girc.ERR_NOMOTD, b.handleOtherAuth)
|
||||
i.Handlers.Add(girc.ALL_EVENTS, b.handleOther)
|
||||
b.i = i
|
||||
|
||||
go b.doConnect()
|
||||
|
||||
err = <-b.connected
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed %s", err)
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
b.FirstConnection = false
|
||||
if b.GetInt("DebugLevel") == 0 {
|
||||
i.Handlers.Clear(girc.ALL_EVENTS)
|
||||
}
|
||||
go b.doSend()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Birc) Disconnect() error {
|
||||
b.i.Close()
|
||||
close(b.Local)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Birc) JoinChannel(channel config.ChannelInfo) error {
|
||||
b.channels[channel.Name] = true
|
||||
// need to check if we have nickserv auth done before joining channels
|
||||
for {
|
||||
if b.authDone {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
if channel.Options.Key != "" {
|
||||
b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name)
|
||||
b.i.Cmd.JoinKey(channel.Name, channel.Options.Key)
|
||||
} else {
|
||||
b.i.Cmd.Join(channel.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Birc) Send(msg config.Message) (string, error) {
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// we can be in between reconnects #385
|
||||
if !b.i.IsConnected() {
|
||||
b.Log.Error("Not connected to server, dropping message")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Execute a command
|
||||
if strings.HasPrefix(msg.Text, "!") {
|
||||
b.Command(&msg)
|
||||
}
|
||||
|
||||
// convert to specified charset
|
||||
if err := b.handleCharset(&msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// handle files, return if we're done here
|
||||
if ok := b.handleFiles(&msg); ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var msgLines []string
|
||||
if b.GetBool("StripMarkdown") {
|
||||
msg.Text = stripmd.Strip(msg.Text)
|
||||
}
|
||||
|
||||
if b.GetBool("MessageSplit") {
|
||||
msgLines = helper.GetSubLines(msg.Text, b.MessageLength, b.GetString("MessageClipped"))
|
||||
} else {
|
||||
msgLines = helper.GetSubLines(msg.Text, 0, b.GetString("MessageClipped"))
|
||||
}
|
||||
for i := range msgLines {
|
||||
if len(b.Local) >= b.MessageQueue {
|
||||
b.Log.Debugf("flooding, dropping message (queue at %d)", len(b.Local))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
msg.Text = msgLines[i]
|
||||
b.Local <- msg
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Birc) doConnect() {
|
||||
for {
|
||||
if err := b.i.Connect(); err != nil {
|
||||
b.Log.Errorf("disconnect: error: %s", err)
|
||||
if b.FirstConnection {
|
||||
b.connected <- err
|
||||
return
|
||||
}
|
||||
} else {
|
||||
b.Log.Info("disconnect: client requested quit")
|
||||
}
|
||||
b.Log.Info("reconnecting in 30 seconds...")
|
||||
time.Sleep(30 * time.Second)
|
||||
b.i.Handlers.Clear(girc.RPL_WELCOME)
|
||||
b.i.Handlers.Add(girc.RPL_WELCOME, func(client *girc.Client, event girc.Event) {
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: "", Account: b.Account, Event: config.EventRejoinChannels}
|
||||
// set our correct nick on reconnect if necessary
|
||||
b.Nick = event.Source.Name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
|
||||
func sanitizeNick(nick string) string {
|
||||
sanitize := func(r rune) rune {
|
||||
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}
|
||||
return strings.Map(sanitize, nick)
|
||||
}
|
||||
|
||||
func (b *Birc) doSend() {
|
||||
rate := time.Millisecond * time.Duration(b.MessageDelay)
|
||||
throttle := time.NewTicker(rate)
|
||||
for msg := range b.Local {
|
||||
<-throttle.C
|
||||
username := msg.Username
|
||||
// Optional support for the proposed RELAYMSG extension, described at
|
||||
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
|
||||
// nolint:nestif
|
||||
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
|
||||
b.GetBool("UseRelayMsg") {
|
||||
username = sanitizeNick(username)
|
||||
text := msg.Text
|
||||
|
||||
// Work around girc chomping leading commas on single word messages?
|
||||
if strings.HasPrefix(text, ":") && !strings.ContainsRune(text, ' ') {
|
||||
text = ":" + text
|
||||
}
|
||||
|
||||
if msg.Event == config.EventUserAction {
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
|
||||
} else {
|
||||
b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
if b.GetBool("Colornicks") {
|
||||
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
|
||||
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
|
||||
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username)
|
||||
}
|
||||
switch msg.Event {
|
||||
case config.EventUserAction:
|
||||
b.i.Cmd.Action(msg.Channel, username+msg.Text)
|
||||
case config.EventNoticeIRC:
|
||||
b.Log.Debugf("Sending notice to channel %s", msg.Channel)
|
||||
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
|
||||
default:
|
||||
b.Log.Debugf("Sending to channel %s", msg.Channel)
|
||||
b.i.Cmd.Message(msg.Channel, username+msg.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateInput validates the server/port/nick configuration. Returns a *girc.Client if successful
|
||||
func (b *Birc) getClient() (*girc.Client, error) {
|
||||
server, portstr, err := net.SplitHostPort(b.GetString("Server"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.Atoi(portstr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := b.GetString("UserName")
|
||||
if user == "" {
|
||||
user = b.GetString("Nick")
|
||||
}
|
||||
// fix strict user handling of girc
|
||||
for !girc.IsValidUser(user) {
|
||||
if len(user) == 1 || len(user) == 0 {
|
||||
user = "matterbridge"
|
||||
break
|
||||
}
|
||||
user = user[1:]
|
||||
}
|
||||
realName := b.GetString("RealName")
|
||||
if realName == "" {
|
||||
realName = b.GetString("Nick")
|
||||
}
|
||||
|
||||
debug := ioutil.Discard
|
||||
if b.GetInt("DebugLevel") == 2 {
|
||||
debug = b.Log.Writer()
|
||||
}
|
||||
|
||||
pingDelay, err := time.ParseDuration(b.GetString("pingdelay"))
|
||||
if err != nil || pingDelay == 0 {
|
||||
pingDelay = time.Minute
|
||||
}
|
||||
|
||||
b.Log.Debugf("setting pingdelay to %s", pingDelay)
|
||||
|
||||
tlsConfig, err := b.getTLSConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i := girc.New(girc.Config{
|
||||
Server: server,
|
||||
ServerPass: b.GetString("Password"),
|
||||
Port: port,
|
||||
Nick: b.GetString("Nick"),
|
||||
User: user,
|
||||
Name: realName,
|
||||
SSL: b.GetBool("UseTLS"),
|
||||
Bind: b.GetString("Bind"),
|
||||
TLSConfig: tlsConfig,
|
||||
PingDelay: pingDelay,
|
||||
// skip gIRC internal rate limiting, since we have our own throttling
|
||||
AllowFlood: true,
|
||||
Debug: debug,
|
||||
SupportedCaps: map[string][]string{"overdrivenetworks.com/relaymsg": nil, "draft/relaymsg": nil},
|
||||
})
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (b *Birc) endNames(client *girc.Client, event girc.Event) {
|
||||
channel := event.Params[1]
|
||||
sort.Strings(b.names[channel])
|
||||
maxNamesPerPost := (300 / b.nicksPerRow()) * b.nicksPerRow()
|
||||
for len(b.names[channel]) > maxNamesPerPost {
|
||||
b.Remote <- config.Message{
|
||||
Username: b.Nick, Text: b.formatnicks(b.names[channel][0:maxNamesPerPost]),
|
||||
Channel: channel, Account: b.Account,
|
||||
}
|
||||
b.names[channel] = b.names[channel][maxNamesPerPost:]
|
||||
}
|
||||
b.Remote <- config.Message{
|
||||
Username: b.Nick, Text: b.formatnicks(b.names[channel]),
|
||||
Channel: channel, Account: b.Account,
|
||||
}
|
||||
b.names[channel] = nil
|
||||
b.i.Handlers.Clear(girc.RPL_NAMREPLY)
|
||||
b.i.Handlers.Clear(girc.RPL_ENDOFNAMES)
|
||||
}
|
||||
|
||||
func (b *Birc) skipPrivMsg(event girc.Event) bool {
|
||||
// Our nick can be changed
|
||||
b.Nick = b.i.GetNick()
|
||||
|
||||
// freenode doesn't send 001 as first reply
|
||||
if event.Command == "NOTICE" && len(event.Params) != 2 {
|
||||
return true
|
||||
}
|
||||
// don't forward queries to the bot
|
||||
if event.Params[0] == b.Nick {
|
||||
return true
|
||||
}
|
||||
// don't forward message from ourself
|
||||
if event.Source != nil {
|
||||
if event.Source.Name == b.Nick {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// don't forward messages we sent via RELAYMSG
|
||||
if relayedNick, ok := event.Tags.Get("draft/relaymsg"); ok && relayedNick == b.Nick {
|
||||
return true
|
||||
}
|
||||
// This is the old name of the cap sent in spoofed messages; I've kept this in
|
||||
// for compatibility reasons
|
||||
if relayedNick, ok := event.Tags.Get("relaymsg"); ok && relayedNick == b.Nick {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Birc) nicksPerRow() int {
|
||||
return 4
|
||||
}
|
||||
|
||||
func (b *Birc) storeNames(client *girc.Client, event girc.Event) {
|
||||
channel := event.Params[2]
|
||||
b.names[channel] = append(
|
||||
b.names[channel],
|
||||
strings.Split(strings.TrimSpace(event.Last()), " ")...)
|
||||
}
|
||||
|
||||
func (b *Birc) formatnicks(nicks []string) string {
|
||||
return strings.Join(nicks, ", ") + " currently on IRC"
|
||||
}
|
||||
|
||||
func (b *Birc) getTLSConfig() (*tls.Config, error) {
|
||||
server, _, _ := net.SplitHostPort(b.GetString("server"))
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: b.GetBool("skiptlsverify"), //nolint:gosec
|
||||
ServerName: server,
|
||||
}
|
||||
|
||||
if filename := b.GetString("TLSClientCertificate"); filename != "" {
|
||||
cert, err := tls.LoadX509KeyPair(filename, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
return tlsConfig, nil
|
||||
}
|
||||
612
bridge/kosmi/chromedp_client.go
Normal file
612
bridge/kosmi/chromedp_client.go
Normal file
@@ -0,0 +1,612 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/input"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ChromeDPClient manages a headless Chrome instance to connect to Kosmi
|
||||
type ChromeDPClient struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
roomURL string
|
||||
log *logrus.Entry
|
||||
messageHandlers []func(*NewMessagePayload)
|
||||
mu sync.RWMutex
|
||||
connected bool
|
||||
}
|
||||
|
||||
// NewChromeDPClient creates a new ChromeDP-based Kosmi client
|
||||
func NewChromeDPClient(roomURL string, log *logrus.Entry) *ChromeDPClient {
|
||||
return &ChromeDPClient{
|
||||
roomURL: roomURL,
|
||||
log: log,
|
||||
messageHandlers: []func(*NewMessagePayload){},
|
||||
}
|
||||
}
|
||||
|
||||
// Connect launches Chrome and navigates to the Kosmi room
|
||||
func (c *ChromeDPClient) Connect() error {
|
||||
c.log.Info("Launching headless Chrome for Kosmi connection")
|
||||
|
||||
// Create Chrome context with flags to avoid headless detection
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", false), // Enable GPU to look more real
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.Flag("disable-blink-features", "AutomationControlled"), // Hide automation
|
||||
chromedp.Flag("disable-infobars", true),
|
||||
chromedp.Flag("window-size", "1920,1080"),
|
||||
chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
)
|
||||
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
|
||||
c.ctx = ctx
|
||||
c.cancel = func() {
|
||||
cancel()
|
||||
allocCancel()
|
||||
}
|
||||
|
||||
// Inject anti-detection scripts and WebSocket hook BEFORE any navigation
|
||||
c.log.Info("Injecting anti-detection and WebSocket interceptor...")
|
||||
if err := c.injectAntiDetection(); err != nil {
|
||||
return fmt.Errorf("failed to inject anti-detection: %w", err)
|
||||
}
|
||||
if err := c.injectWebSocketHookBeforeLoad(); err != nil {
|
||||
return fmt.Errorf("failed to inject WebSocket hook: %w", err)
|
||||
}
|
||||
|
||||
// Now navigate to the room
|
||||
c.log.Infof("Navigating to Kosmi room: %s", c.roomURL)
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(c.roomURL),
|
||||
chromedp.WaitReady("body"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to navigate to room: %w", err)
|
||||
}
|
||||
|
||||
c.log.Info("Page loaded, checking if hook is active...")
|
||||
|
||||
// Verify the hook is installed
|
||||
var hookInstalled bool
|
||||
if err := chromedp.Run(ctx, chromedp.Evaluate(`window.__KOSMI_WS_HOOK_INSTALLED__ === true`, &hookInstalled)); err != nil {
|
||||
c.log.Warnf("Could not verify hook installation: %v", err)
|
||||
} else if hookInstalled {
|
||||
c.log.Info("✓ WebSocket hook confirmed installed")
|
||||
} else {
|
||||
c.log.Warn("✗ WebSocket hook not detected!")
|
||||
}
|
||||
|
||||
// Wait a moment for WebSocket to connect
|
||||
c.log.Info("Waiting for WebSocket connection...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Check if we've captured any WebSocket connections
|
||||
var wsConnected string
|
||||
checkScript := `
|
||||
(function() {
|
||||
if (window.__KOSMI_WS_CONNECTED__) {
|
||||
return 'WebSocket connection intercepted';
|
||||
}
|
||||
return 'No WebSocket connection detected yet';
|
||||
})();
|
||||
`
|
||||
if err := chromedp.Run(ctx, chromedp.Evaluate(checkScript, &wsConnected)); err == nil {
|
||||
c.log.Infof("Status: %s", wsConnected)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("Successfully connected to Kosmi via Chrome")
|
||||
|
||||
// Start console log listener (for debugging)
|
||||
go c.listenToConsole()
|
||||
|
||||
// Start message listener
|
||||
go c.listenForMessages()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectAntiDetection injects scripts to hide automation/headless detection
|
||||
func (c *ChromeDPClient) injectAntiDetection() error {
|
||||
script := `
|
||||
// Override navigator.webdriver
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => false,
|
||||
});
|
||||
|
||||
// Override plugins
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [1, 2, 3, 4, 5],
|
||||
});
|
||||
|
||||
// Override languages
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en'],
|
||||
});
|
||||
|
||||
// Chrome runtime
|
||||
window.chrome = {
|
||||
runtime: {},
|
||||
};
|
||||
|
||||
// Permissions
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications' ?
|
||||
Promise.resolve({ state: Notification.permission }) :
|
||||
originalQuery(parameters)
|
||||
);
|
||||
|
||||
console.log('[Kosmi Bridge] Anti-detection scripts injected');
|
||||
`
|
||||
|
||||
return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
_, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx)
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
// injectWebSocketHookBeforeLoad uses CDP to inject script before any page scripts run
|
||||
func (c *ChromeDPClient) injectWebSocketHookBeforeLoad() error {
|
||||
// Get the WebSocket hook script
|
||||
script := c.getWebSocketHookScript()
|
||||
|
||||
// Use chromedp.ActionFunc to access the CDP directly
|
||||
return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// Use Page.addScriptToEvaluateOnNewDocument to inject before page load
|
||||
// This is the proper way to inject scripts that run before page JavaScript
|
||||
_, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx)
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
// getWebSocketHookScript returns the WebSocket interception script
|
||||
func (c *ChromeDPClient) getWebSocketHookScript() string {
|
||||
return `
|
||||
(function() {
|
||||
if (window.__KOSMI_WS_HOOK_INSTALLED__) {
|
||||
console.log('[Kosmi Bridge] WebSocket hook already installed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original WebSocket constructor
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
|
||||
// Store messages in a queue
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
|
||||
// Hook WebSocket constructor
|
||||
window.WebSocket = function(url, protocols) {
|
||||
const socket = new OriginalWebSocket(url, protocols);
|
||||
|
||||
// Check if this is Kosmi's GraphQL WebSocket
|
||||
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
||||
console.log('[Kosmi Bridge] WebSocket hook active for:', url);
|
||||
window.__KOSMI_WS_CONNECTED__ = true;
|
||||
|
||||
// Method 1: Hook addEventListener
|
||||
const originalAddEventListener = socket.addEventListener.bind(socket);
|
||||
socket.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
const wrappedListener = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[Kosmi Bridge] Message intercepted:', data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data,
|
||||
source: 'addEventListener'
|
||||
});
|
||||
} catch (e) {
|
||||
// Not JSON, skip
|
||||
}
|
||||
|
||||
// Call original listener
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return originalAddEventListener(type, wrappedListener, options);
|
||||
}
|
||||
return originalAddEventListener(type, listener, options);
|
||||
};
|
||||
|
||||
// Method 2: Intercept onmessage property setter
|
||||
let realOnMessage = null;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(WebSocket.prototype, 'onmessage');
|
||||
|
||||
Object.defineProperty(socket, 'onmessage', {
|
||||
get: function() {
|
||||
return realOnMessage;
|
||||
},
|
||||
set: function(handler) {
|
||||
realOnMessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[Kosmi Bridge] Message via onmessage:', data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data,
|
||||
source: 'onmessage'
|
||||
});
|
||||
} catch (e) {
|
||||
// Not JSON, skip
|
||||
}
|
||||
|
||||
// ALWAYS call original handler
|
||||
if (handler) {
|
||||
handler.call(socket, event);
|
||||
}
|
||||
};
|
||||
|
||||
// Set it on the underlying WebSocket
|
||||
if (descriptor && descriptor.set) {
|
||||
descriptor.set.call(socket, realOnMessage);
|
||||
}
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
console.log('[Kosmi Bridge] WebSocket hooks installed');
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
// Preserve the original constructor properties
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
|
||||
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
|
||||
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
|
||||
|
||||
window.__KOSMI_WS_HOOK_INSTALLED__ = true;
|
||||
console.log('[Kosmi Bridge] WebSocket hook installed successfully');
|
||||
})();
|
||||
`
|
||||
}
|
||||
|
||||
// listenToConsole captures console logs from the browser
|
||||
func (c *ChromeDPClient) listenToConsole() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
var logs string
|
||||
script := `
|
||||
(function() {
|
||||
if (!window.__KOSMI_CONSOLE_LOGS__) {
|
||||
window.__KOSMI_CONSOLE_LOGS__ = [];
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
console.log = function(...args) {
|
||||
window.__KOSMI_CONSOLE_LOGS__.push({type: 'log', message: args.join(' ')});
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
console.warn = function(...args) {
|
||||
window.__KOSMI_CONSOLE_LOGS__.push({type: 'warn', message: args.join(' ')});
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
console.error = function(...args) {
|
||||
window.__KOSMI_CONSOLE_LOGS__.push({type: 'error', message: args.join(' ')});
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
const logs = window.__KOSMI_CONSOLE_LOGS__;
|
||||
window.__KOSMI_CONSOLE_LOGS__ = [];
|
||||
return JSON.stringify(logs);
|
||||
})();
|
||||
`
|
||||
|
||||
if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &logs)); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if logs != "" && logs != "[]" {
|
||||
var logEntries []struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(logs), &logEntries); err == nil {
|
||||
for _, entry := range logEntries {
|
||||
// Only show Kosmi Bridge logs
|
||||
if strings.Contains(entry.Message, "[Kosmi Bridge]") {
|
||||
switch entry.Type {
|
||||
case "error":
|
||||
c.log.Errorf("Browser: %s", entry.Message)
|
||||
case "warn":
|
||||
c.log.Warnf("Browser: %s", entry.Message)
|
||||
default:
|
||||
c.log.Debugf("Browser: %s", entry.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listenForMessages continuously polls for new messages from the queue
|
||||
func (c *ChromeDPClient) listenForMessages() {
|
||||
c.log.Info("Starting message listener")
|
||||
ticker := time.NewTicker(500 * time.Millisecond) // Poll every 500ms
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
c.log.Info("Message listener stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.pollMessages(); err != nil {
|
||||
c.log.Errorf("Error polling messages: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollMessages retrieves and processes messages from the queue
|
||||
func (c *ChromeDPClient) pollMessages() error {
|
||||
var messagesJSON string
|
||||
|
||||
// Get and clear the message queue
|
||||
script := `
|
||||
(function() {
|
||||
const messages = window.__KOSMI_MESSAGE_QUEUE__ || [];
|
||||
const count = messages.length;
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
|
||||
if (count > 0) {
|
||||
console.log('[Kosmi Bridge] Polling found', count, 'messages');
|
||||
}
|
||||
|
||||
return JSON.stringify(messages);
|
||||
})();
|
||||
`
|
||||
|
||||
if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &messagesJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if messagesJSON == "" || messagesJSON == "[]" {
|
||||
return nil // No messages
|
||||
}
|
||||
|
||||
c.log.Debugf("Retrieved %d bytes of message data", len(messagesJSON))
|
||||
|
||||
// Parse messages
|
||||
var messages []struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
|
||||
c.log.Errorf("Failed to parse messages JSON: %v", err)
|
||||
c.log.Debugf("Raw JSON: %s", messagesJSON)
|
||||
return fmt.Errorf("failed to parse messages: %w", err)
|
||||
}
|
||||
|
||||
c.log.Infof("Processing %d messages from queue", len(messages))
|
||||
|
||||
// Process each message
|
||||
for i, msg := range messages {
|
||||
c.log.Debugf("Processing message %d/%d from source: %s", i+1, len(messages), msg.Source)
|
||||
c.processMessage(msg.Data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processMessage handles a single GraphQL message
|
||||
func (c *ChromeDPClient) processMessage(data json.RawMessage) {
|
||||
// Parse as GraphQL message
|
||||
var gqlMsg struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &gqlMsg); err != nil {
|
||||
c.log.Debugf("Failed to parse GraphQL message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Only process "next" or "data" type messages
|
||||
if gqlMsg.Type != "next" && gqlMsg.Type != "data" {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the payload
|
||||
var payload NewMessagePayload
|
||||
if err := json.Unmarshal(gqlMsg.Payload, &payload); err != nil {
|
||||
c.log.Debugf("Failed to parse message payload: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a newMessage event
|
||||
if payload.Data.NewMessage.Body == "" {
|
||||
return // Not a message event
|
||||
}
|
||||
|
||||
// Call all registered handlers
|
||||
c.mu.RLock()
|
||||
handlers := c.messageHandlers
|
||||
c.mu.RUnlock()
|
||||
|
||||
for _, handler := range handlers {
|
||||
handler(&payload)
|
||||
}
|
||||
}
|
||||
|
||||
// OnMessage registers a handler for incoming messages
|
||||
func (c *ChromeDPClient) OnMessage(handler func(*NewMessagePayload)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.messageHandlers = append(c.messageHandlers, handler)
|
||||
}
|
||||
|
||||
// SendMessage sends a message to the Kosmi room
|
||||
func (c *ChromeDPClient) SendMessage(text string) error {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Wait for the chat input to be available (with timeout)
|
||||
// Kosmi uses a contenteditable div with role="textbox"
|
||||
var inputFound bool
|
||||
for i := 0; i < 50; i++ {
|
||||
// Simple check without string replacement issues
|
||||
checkScript := `
|
||||
(function() {
|
||||
const input = document.querySelector('div[role="textbox"][contenteditable="true"]');
|
||||
return input !== null;
|
||||
})();
|
||||
`
|
||||
|
||||
if err := chromedp.Run(c.ctx, chromedp.Evaluate(checkScript, &inputFound)); err != nil {
|
||||
return fmt.Errorf("failed to check for chat input: %w", err)
|
||||
}
|
||||
|
||||
if inputFound {
|
||||
c.log.Infof("Chat input found after %d attempts (%.1f seconds)", i, float64(i)*0.1)
|
||||
break
|
||||
}
|
||||
|
||||
// Log progress periodically
|
||||
if i == 0 || i == 10 || i == 25 || i == 49 {
|
||||
c.log.Debugf("Still waiting for chat input... attempt %d/50", i+1)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !inputFound {
|
||||
// Diagnostic: Get element counts directly
|
||||
var diagInfo struct {
|
||||
Textareas int `json:"textareas"`
|
||||
Contenteditable int `json:"contenteditable"`
|
||||
Textboxes int `json:"textboxes"`
|
||||
}
|
||||
|
||||
diagScript := `
|
||||
(function() {
|
||||
return {
|
||||
textareas: document.querySelectorAll('textarea').length,
|
||||
contenteditable: document.querySelectorAll('[contenteditable="true"]').length,
|
||||
textboxes: document.querySelectorAll('[role="textbox"]').length
|
||||
};
|
||||
})();
|
||||
`
|
||||
|
||||
if err := chromedp.Run(c.ctx, chromedp.Evaluate(diagScript, &diagInfo)); err == nil {
|
||||
c.log.Errorf("Diagnostic: textareas=%d, contenteditable=%d, textboxes=%d",
|
||||
diagInfo.Textareas, diagInfo.Contenteditable, diagInfo.Textboxes)
|
||||
}
|
||||
|
||||
c.log.Error("Chat input not found after 5 seconds")
|
||||
return fmt.Errorf("chat input not available after timeout")
|
||||
}
|
||||
|
||||
// Send the message using ChromeDP's native SendKeys
|
||||
// This is more reliable than dispatching events manually and mimics actual user input
|
||||
selector := `div[role="textbox"][contenteditable="true"]`
|
||||
|
||||
// First, clear the input and type the message
|
||||
err := chromedp.Run(c.ctx,
|
||||
chromedp.Focus(selector),
|
||||
chromedp.Evaluate(`document.querySelector('div[role="textbox"][contenteditable="true"]').textContent = ''`, nil),
|
||||
chromedp.SendKeys(selector, text),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to type message: %w", err)
|
||||
}
|
||||
|
||||
// Small delay for React to process
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Send Enter key using CDP Input API directly
|
||||
// This is closer to what Playwright does and should trigger all the right events
|
||||
err = chromedp.Run(c.ctx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// Send keyDown for Enter
|
||||
if err := input.DispatchKeyEvent(input.KeyDown).
|
||||
WithKey("Enter").
|
||||
WithCode("Enter").
|
||||
WithNativeVirtualKeyCode(13).
|
||||
WithWindowsVirtualKeyCode(13).
|
||||
Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send keyUp for Enter
|
||||
return input.DispatchKeyEvent(input.KeyUp).
|
||||
WithKey("Enter").
|
||||
WithCode("Enter").
|
||||
WithNativeVirtualKeyCode(13).
|
||||
WithWindowsVirtualKeyCode(13).
|
||||
Do(ctx)
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message via SendKeys: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debugf("Sent message: %s", text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the Chrome instance
|
||||
func (c *ChromeDPClient) Close() error {
|
||||
c.log.Info("Closing ChromeDP client")
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is connected
|
||||
func (c *ChromeDPClient) IsConnected() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// escapeJSString escapes a string for use in JavaScript
|
||||
func escapeJSString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
391
bridge/kosmi/graphql.go
Normal file
391
bridge/kosmi/graphql.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GraphQL WebSocket message types
|
||||
const (
|
||||
typeConnectionInit = "connection_init"
|
||||
typeConnectionAck = "connection_ack"
|
||||
typeConnectionError = "connection_error"
|
||||
typeConnectionKeepAlive = "ka"
|
||||
typeStart = "start"
|
||||
typeData = "data"
|
||||
typeError = "error"
|
||||
typeComplete = "complete"
|
||||
typeStop = "stop"
|
||||
typeNext = "next"
|
||||
)
|
||||
|
||||
// GraphQLMessage represents a GraphQL WebSocket message
|
||||
type GraphQLMessage struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// NewMessagePayload represents the payload structure for new messages
|
||||
type NewMessagePayload struct {
|
||||
Data struct {
|
||||
NewMessage struct {
|
||||
Body string `json:"body"`
|
||||
Time int64 `json:"time"`
|
||||
User struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
} `json:"newMessage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// GraphQLClient manages the WebSocket connection to Kosmi's GraphQL API
|
||||
type GraphQLClient struct {
|
||||
conn *websocket.Conn
|
||||
url string
|
||||
roomID string
|
||||
log *logrus.Entry
|
||||
subscriptionID string
|
||||
mu sync.RWMutex
|
||||
connected bool
|
||||
reconnectDelay time.Duration
|
||||
messageHandlers []func(*NewMessagePayload)
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewGraphQLClient creates a new GraphQL WebSocket client
|
||||
func NewGraphQLClient(url, roomID string, log *logrus.Entry) *GraphQLClient {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &GraphQLClient{
|
||||
url: url,
|
||||
roomID: roomID,
|
||||
log: log,
|
||||
reconnectDelay: 5 * time.Second,
|
||||
messageHandlers: []func(*NewMessagePayload){},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the WebSocket connection and performs the GraphQL handshake
|
||||
func (c *GraphQLClient) Connect() error {
|
||||
c.log.Infof("Connecting to Kosmi GraphQL WebSocket: %s", c.url)
|
||||
|
||||
// Set up WebSocket dialer with graphql-ws subprotocol
|
||||
dialer := websocket.Dialer{
|
||||
Subprotocols: []string{"graphql-ws"},
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
conn, resp, err := dialer.Dial(c.url, http.Header{})
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
c.log.Errorf("WebSocket dial failed with status %d: %v", resp.StatusCode, err)
|
||||
}
|
||||
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("WebSocket connection established")
|
||||
|
||||
// Send connection_init message
|
||||
initMsg := GraphQLMessage{
|
||||
Type: typeConnectionInit,
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
|
||||
if err := c.writeMessage(initMsg); err != nil {
|
||||
return fmt.Errorf("failed to send connection_init: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debug("Sent connection_init message")
|
||||
|
||||
// Wait for connection_ack
|
||||
if err := c.waitForConnectionAck(); err != nil {
|
||||
return fmt.Errorf("failed to receive connection_ack: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("GraphQL WebSocket handshake completed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForConnectionAck waits for the connection_ack message
|
||||
func (c *GraphQLClient) waitForConnectionAck() error {
|
||||
c.mu.RLock()
|
||||
conn := c.conn
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Set a timeout for the ack
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
defer conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
var msg GraphQLMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debugf("Received message type: %s", msg.Type)
|
||||
|
||||
switch msg.Type {
|
||||
case typeConnectionAck:
|
||||
c.log.Info("Received connection_ack")
|
||||
return nil
|
||||
case typeConnectionError:
|
||||
return fmt.Errorf("connection error: %s", string(msg.Payload))
|
||||
case typeConnectionKeepAlive:
|
||||
c.log.Debug("Received keep-alive")
|
||||
// Continue waiting for ack
|
||||
default:
|
||||
c.log.Warnf("Unexpected message type during handshake: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToMessages subscribes to new messages in the room
|
||||
func (c *GraphQLClient) SubscribeToMessages() error {
|
||||
c.mu.Lock()
|
||||
if !c.connected {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
c.subscriptionID = "newMessage-1"
|
||||
c.mu.Unlock()
|
||||
|
||||
// GraphQL subscription query for new messages
|
||||
query := fmt.Sprintf(`
|
||||
subscription {
|
||||
newMessage(roomId: "%s") {
|
||||
body
|
||||
time
|
||||
user {
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
`, c.roomID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"query": query,
|
||||
"variables": map[string]interface{}{},
|
||||
}
|
||||
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal subscription payload: %w", err)
|
||||
}
|
||||
|
||||
msg := GraphQLMessage{
|
||||
ID: c.subscriptionID,
|
||||
Type: typeStart,
|
||||
Payload: payloadJSON,
|
||||
}
|
||||
|
||||
if err := c.writeMessage(msg); err != nil {
|
||||
return fmt.Errorf("failed to send subscription: %w", err)
|
||||
}
|
||||
|
||||
c.log.Infof("Subscribed to messages in room: %s", c.roomID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnMessage registers a handler for incoming messages
|
||||
func (c *GraphQLClient) OnMessage(handler func(*NewMessagePayload)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.messageHandlers = append(c.messageHandlers, handler)
|
||||
}
|
||||
|
||||
// Listen starts listening for messages from the WebSocket
|
||||
func (c *GraphQLClient) Listen() {
|
||||
c.log.Info("Starting message listener")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
c.log.Info("Message listener stopped")
|
||||
return
|
||||
default:
|
||||
c.mu.RLock()
|
||||
conn := c.conn
|
||||
c.mu.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
c.log.Warn("Connection is nil, stopping listener")
|
||||
return
|
||||
}
|
||||
|
||||
var msg GraphQLMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
c.log.Errorf("WebSocket error: %v", err)
|
||||
}
|
||||
c.log.Warn("Connection closed, stopping listener")
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
c.handleMessage(&msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage processes incoming GraphQL messages
|
||||
func (c *GraphQLClient) handleMessage(msg *GraphQLMessage) {
|
||||
c.log.Debugf("Received GraphQL message type: %s", msg.Type)
|
||||
|
||||
switch msg.Type {
|
||||
case typeNext, typeData:
|
||||
// Parse the message payload
|
||||
var payload NewMessagePayload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
c.log.Errorf("Failed to parse message payload: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call all registered handlers
|
||||
c.mu.RLock()
|
||||
handlers := c.messageHandlers
|
||||
c.mu.RUnlock()
|
||||
|
||||
for _, handler := range handlers {
|
||||
handler(&payload)
|
||||
}
|
||||
|
||||
case typeConnectionKeepAlive:
|
||||
c.log.Debug("Received keep-alive")
|
||||
|
||||
case typeError:
|
||||
c.log.Errorf("GraphQL error: %s", string(msg.Payload))
|
||||
|
||||
case typeComplete:
|
||||
c.log.Infof("Subscription %s completed", msg.ID)
|
||||
|
||||
default:
|
||||
c.log.Debugf("Unhandled message type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends a message to the Kosmi room
|
||||
func (c *GraphQLClient) SendMessage(text string) error {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// GraphQL mutation to send a message
|
||||
mutation := fmt.Sprintf(`
|
||||
mutation {
|
||||
sendMessage(roomId: "%s", body: "%s") {
|
||||
id
|
||||
}
|
||||
}
|
||||
`, c.roomID, escapeGraphQLString(text))
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"query": mutation,
|
||||
"variables": map[string]interface{}{},
|
||||
}
|
||||
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal mutation payload: %w", err)
|
||||
}
|
||||
|
||||
msg := GraphQLMessage{
|
||||
ID: fmt.Sprintf("sendMessage-%d", time.Now().UnixNano()),
|
||||
Type: typeStart,
|
||||
Payload: payloadJSON,
|
||||
}
|
||||
|
||||
if err := c.writeMessage(msg); err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debugf("Sent message: %s", text)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMessage writes a GraphQL message to the WebSocket
|
||||
func (c *GraphQLClient) writeMessage(msg GraphQLMessage) error {
|
||||
c.mu.RLock()
|
||||
conn := c.conn
|
||||
c.mu.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("connection is nil")
|
||||
}
|
||||
|
||||
return conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
// Close closes the WebSocket connection
|
||||
func (c *GraphQLClient) Close() error {
|
||||
c.log.Info("Closing GraphQL client")
|
||||
|
||||
c.cancel()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
// Send close message
|
||||
closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")
|
||||
c.conn.WriteMessage(websocket.CloseMessage, closeMsg)
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
|
||||
c.connected = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is connected
|
||||
func (c *GraphQLClient) IsConnected() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// escapeGraphQLString escapes special characters in GraphQL strings
|
||||
func escapeGraphQLString(s string) string {
|
||||
// Replace special characters that need escaping in GraphQL
|
||||
jsonBytes, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
// Remove surrounding quotes from JSON string
|
||||
if len(jsonBytes) >= 2 {
|
||||
return string(jsonBytes[1 : len(jsonBytes)-1])
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
481
bridge/kosmi/hybrid_client.go
Normal file
481
bridge/kosmi/hybrid_client.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// HybridClient uses ChromeDP for auth/cookies and GraphQL for sending messages
|
||||
type HybridClient struct {
|
||||
roomURL string
|
||||
roomID string
|
||||
log *logrus.Entry
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
httpClient *http.Client
|
||||
messageCallback func(*NewMessagePayload)
|
||||
connected bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewHybridClient creates a new hybrid client
|
||||
func NewHybridClient(roomURL string, log *logrus.Entry) *HybridClient {
|
||||
return &HybridClient{
|
||||
roomURL: roomURL,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect launches Chrome, gets cookies, and sets up GraphQL client
|
||||
func (c *HybridClient) Connect() error {
|
||||
c.log.Info("Launching Chrome to obtain session cookies")
|
||||
|
||||
// Extract room ID
|
||||
roomID, err := extractRoomID(c.roomURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract room ID: %w", err)
|
||||
}
|
||||
c.roomID = roomID
|
||||
|
||||
// Create Chrome context with anti-detection
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", false),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.Flag("disable-blink-features", "AutomationControlled"),
|
||||
chromedp.Flag("disable-infobars", true),
|
||||
chromedp.Flag("window-size", "1920,1080"),
|
||||
chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
)
|
||||
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
|
||||
c.ctx = ctx
|
||||
c.cancel = func() {
|
||||
cancel()
|
||||
allocCancel()
|
||||
}
|
||||
|
||||
// Inject scripts to run on every new document BEFORE creating any pages
|
||||
// This ensures they run BEFORE any page JavaScript
|
||||
c.log.Info("Injecting scripts to run on every page load...")
|
||||
|
||||
antiDetectionScript := `
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
window.chrome = { runtime: {} };
|
||||
`
|
||||
|
||||
wsHookScript := c.getWebSocketHookScript()
|
||||
|
||||
// Use Page.addScriptToEvaluateOnNewDocument via CDP
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
_, err := page.AddScriptToEvaluateOnNewDocument(antiDetectionScript).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add anti-detection script: %w", err)
|
||||
}
|
||||
|
||||
_, err = page.AddScriptToEvaluateOnNewDocument(wsHookScript).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add WebSocket hook script: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to inject scripts: %w", err)
|
||||
}
|
||||
|
||||
// Now navigate to the room - scripts will run before page JS
|
||||
c.log.Infof("Navigating to Kosmi room: %s", c.roomURL)
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(c.roomURL),
|
||||
chromedp.WaitReady("body"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to navigate to room: %w", err)
|
||||
}
|
||||
|
||||
// Wait for page to load and WebSocket to connect
|
||||
c.log.Info("Waiting for page to load and WebSocket to connect...")
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Check if WebSocket is connected
|
||||
var wsStatus map[string]interface{}
|
||||
checkScript := `
|
||||
(function() {
|
||||
return {
|
||||
hookInstalled: !!window.__KOSMI_WS_HOOK_INSTALLED__,
|
||||
wsFound: !!window.__KOSMI_WS__,
|
||||
wsConnected: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState === WebSocket.OPEN : false,
|
||||
wsState: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState : -1
|
||||
};
|
||||
})();
|
||||
`
|
||||
if err := chromedp.Run(ctx, chromedp.Evaluate(checkScript, &wsStatus)); err == nil {
|
||||
c.log.Infof("WebSocket status: %+v", wsStatus)
|
||||
}
|
||||
|
||||
// Get cookies from the browser
|
||||
c.log.Info("Extracting cookies from browser session...")
|
||||
cookies, err := c.getCookies()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get cookies: %w", err)
|
||||
}
|
||||
|
||||
c.log.Infof("Obtained %d cookies from browser", len(cookies))
|
||||
|
||||
// Set up HTTP client with cookies
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
|
||||
c.httpClient = &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Add cookies to the jar
|
||||
u, _ := url.Parse("https://engine.kosmi.io")
|
||||
c.httpClient.Jar.SetCookies(u, cookies)
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("Successfully connected - browser session established with cookies")
|
||||
|
||||
// Start message listener (using WebSocket hook in browser)
|
||||
go c.listenForMessages()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCookies extracts cookies from the Chrome session
|
||||
func (c *HybridClient) getCookies() ([]*http.Cookie, error) {
|
||||
var cookiesData []map[string]interface{}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
return document.cookie.split(';').map(c => {
|
||||
const parts = c.trim().split('=');
|
||||
return {
|
||||
name: parts[0],
|
||||
value: parts.slice(1).join('=')
|
||||
};
|
||||
});
|
||||
})();
|
||||
`
|
||||
|
||||
if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &cookiesData)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cookies := make([]*http.Cookie, 0, len(cookiesData))
|
||||
for _, cd := range cookiesData {
|
||||
if name, ok := cd["name"].(string); ok {
|
||||
if value, ok := cd["value"].(string); ok {
|
||||
cookies = append(cookies, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// injectAntiDetection injects anti-detection scripts
|
||||
func (c *HybridClient) injectAntiDetection() error {
|
||||
script := `
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
window.chrome = { runtime: {} };
|
||||
`
|
||||
|
||||
return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return chromedp.Evaluate(script, nil).Do(ctx)
|
||||
}))
|
||||
}
|
||||
|
||||
// injectWebSocketHook injects the WebSocket interception script
|
||||
func (c *HybridClient) injectWebSocketHook() error {
|
||||
script := c.getWebSocketHookScript()
|
||||
|
||||
return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return chromedp.Evaluate(script, nil).Do(ctx)
|
||||
}))
|
||||
}
|
||||
|
||||
// getWebSocketHookScript returns the WebSocket hook JavaScript
|
||||
func (c *HybridClient) getWebSocketHookScript() string {
|
||||
return `
|
||||
(function() {
|
||||
if (window.__KOSMI_WS_HOOK_INSTALLED__) return;
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
window.__KOSMI_WS__ = null; // Store reference to the WebSocket
|
||||
|
||||
window.WebSocket = function(url, protocols) {
|
||||
const socket = new OriginalWebSocket(url, protocols);
|
||||
|
||||
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
||||
window.__KOSMI_WS_CONNECTED__ = true;
|
||||
window.__KOSMI_WS__ = socket; // Store the WebSocket reference
|
||||
|
||||
const originalAddEventListener = socket.addEventListener.bind(socket);
|
||||
socket.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
const wrappedListener = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data
|
||||
});
|
||||
} catch (e) {}
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return originalAddEventListener(type, wrappedListener, options);
|
||||
}
|
||||
return originalAddEventListener(type, listener, options);
|
||||
};
|
||||
|
||||
let realOnMessage = null;
|
||||
Object.defineProperty(socket, 'onmessage', {
|
||||
get: function() { return realOnMessage; },
|
||||
set: function(handler) {
|
||||
realOnMessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data
|
||||
});
|
||||
} catch (e) {}
|
||||
if (handler) { handler.call(socket, event); }
|
||||
};
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
|
||||
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
|
||||
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
|
||||
|
||||
window.__KOSMI_WS_HOOK_INSTALLED__ = true;
|
||||
})();
|
||||
`
|
||||
}
|
||||
|
||||
// listenForMessages polls for messages from the WebSocket queue
|
||||
func (c *HybridClient) listenForMessages() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Poll for messages
|
||||
var messages []struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (!window.__KOSMI_MESSAGE_QUEUE__) return [];
|
||||
const messages = window.__KOSMI_MESSAGE_QUEUE__.slice();
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
return messages;
|
||||
})();
|
||||
`
|
||||
|
||||
if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &messages)); err != nil {
|
||||
<-ticker.C
|
||||
continue
|
||||
}
|
||||
|
||||
if len(messages) > 0 {
|
||||
c.log.Infof("Processing %d messages from queue", len(messages))
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
c.processMessage(msg.Data)
|
||||
}
|
||||
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
// processMessage processes a WebSocket message
|
||||
func (c *HybridClient) processMessage(data map[string]interface{}) {
|
||||
msgType, ok := data["type"].(string)
|
||||
if !ok || msgType != "next" {
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := data["payload"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
dataField, ok := payload["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
newMessage, ok := dataField["newMessage"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"newMessage": newMessage,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msgPayload NewMessagePayload
|
||||
if err := json.Unmarshal(jsonBytes, &msgPayload); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(&msgPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends a message via WebSocket using browser automation
|
||||
func (c *HybridClient) SendMessage(text string) error {
|
||||
c.log.Infof("SendMessage called with text: %s", text)
|
||||
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
c.log.Error("SendMessage: not connected")
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
ctx := c.ctx
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.log.Infof("Sending message to room %s via WebSocket", c.roomID)
|
||||
|
||||
// Escape the text for JavaScript
|
||||
escapedText := strings.ReplaceAll(text, `\`, `\\`)
|
||||
escapedText = strings.ReplaceAll(escapedText, `"`, `\"`)
|
||||
escapedText = strings.ReplaceAll(escapedText, "\n", `\n`)
|
||||
|
||||
// JavaScript to send message via WebSocket
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
// Find the Kosmi WebSocket
|
||||
if (!window.__KOSMI_WS__) {
|
||||
return { success: false, error: "WebSocket not found" };
|
||||
}
|
||||
|
||||
const ws = window.__KOSMI_WS__;
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
return { success: false, error: "WebSocket not open, state: " + ws.readyState };
|
||||
}
|
||||
|
||||
// GraphQL-WS message format
|
||||
const message = {
|
||||
id: "send-" + Date.now(),
|
||||
type: "start",
|
||||
payload: {
|
||||
query: "mutation SendMessage($body: String!, $roomID: ID!) { sendMessage(body: $body, roomID: $roomID) { id body time user { id username displayName } } }",
|
||||
variables: {
|
||||
body: "%s",
|
||||
roomID: "%s"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
ws.send(JSON.stringify(message));
|
||||
return { success: true, message: "Sent via WebSocket" };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
})();
|
||||
`, escapedText, c.roomID)
|
||||
|
||||
var result map[string]interface{}
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Evaluate(script, &result),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.log.Errorf("Failed to execute send script: %v", err)
|
||||
return fmt.Errorf("failed to execute send script: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debugf("Send result: %+v", result)
|
||||
|
||||
if success, ok := result["success"].(bool); !ok || !success {
|
||||
errorMsg := "unknown error"
|
||||
if errStr, ok := result["error"].(string); ok {
|
||||
errorMsg = errStr
|
||||
}
|
||||
c.log.Errorf("Failed to send message: %s", errorMsg)
|
||||
return fmt.Errorf("failed to send message: %s", errorMsg)
|
||||
}
|
||||
|
||||
c.log.Infof("✅ Successfully sent message via WebSocket: %s", text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnMessage sets the callback for new messages
|
||||
func (c *HybridClient) OnMessage(callback func(*NewMessagePayload)) {
|
||||
c.messageCallback = callback
|
||||
}
|
||||
|
||||
// Disconnect closes the browser and cleans up
|
||||
func (c *HybridClient) Disconnect() error {
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("Closing hybrid client")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
218
bridge/kosmi/kosmi.go
Normal file
218
bridge/kosmi/kosmi.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWebSocketURL = "wss://engine.kosmi.io/gql-ws"
|
||||
)
|
||||
|
||||
// KosmiClient interface for different client implementations
|
||||
type KosmiClient interface {
|
||||
Connect() error
|
||||
Disconnect() error
|
||||
SendMessage(text string) error
|
||||
OnMessage(callback func(*NewMessagePayload))
|
||||
IsConnected() bool
|
||||
}
|
||||
|
||||
// Bkosmi represents the Kosmi bridge
|
||||
type Bkosmi struct {
|
||||
*bridge.Config
|
||||
client KosmiClient
|
||||
roomID string
|
||||
roomURL string
|
||||
connected bool
|
||||
msgChannel chan config.Message
|
||||
}
|
||||
|
||||
// New creates a new Kosmi bridge instance
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bkosmi{
|
||||
Config: cfg,
|
||||
msgChannel: make(chan config.Message, 100),
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Connect establishes connection to the Kosmi room
|
||||
func (b *Bkosmi) Connect() error {
|
||||
b.Log.Info("Connecting to Kosmi")
|
||||
|
||||
// Get room URL from config
|
||||
b.roomURL = b.GetString("RoomURL")
|
||||
if b.roomURL == "" {
|
||||
return fmt.Errorf("RoomURL is required in configuration")
|
||||
}
|
||||
|
||||
// Extract room ID from URL
|
||||
roomID, err := extractRoomID(b.roomURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract room ID from URL %s: %w", b.roomURL, err)
|
||||
}
|
||||
b.roomID = roomID
|
||||
b.Log.Infof("Extracted room ID: %s", b.roomID)
|
||||
|
||||
// Create Native client (Playwright establishes WebSocket, we control it directly)
|
||||
b.client = NewNativeClient(b.roomURL, b.roomID, b.Log)
|
||||
|
||||
// Register message handler
|
||||
b.client.OnMessage(b.handleIncomingMessage)
|
||||
|
||||
// Connect to Kosmi
|
||||
if err := b.client.Connect(); err != nil {
|
||||
return fmt.Errorf("failed to connect to Kosmi: %w", err)
|
||||
}
|
||||
|
||||
b.connected = true
|
||||
b.Log.Info("Successfully connected to Kosmi")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the connection to Kosmi
|
||||
func (b *Bkosmi) Disconnect() error {
|
||||
b.Log.Info("Disconnecting from Kosmi")
|
||||
|
||||
if b.client != nil {
|
||||
if err := b.client.Disconnect(); err != nil {
|
||||
b.Log.Errorf("Error closing Kosmi client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
close(b.msgChannel)
|
||||
b.connected = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinChannel joins a Kosmi room (no-op as we connect to a specific room)
|
||||
func (b *Bkosmi) JoinChannel(channel config.ChannelInfo) error {
|
||||
// Kosmi doesn't have a concept of joining channels after connection
|
||||
// The room is specified in the configuration and joined on Connect()
|
||||
b.Log.Infof("Channel %s is already connected via room URL", channel.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message to Kosmi
|
||||
func (b *Bkosmi) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Sending message to Kosmi: %#v", msg)
|
||||
|
||||
// Ignore delete messages
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Check if we're connected
|
||||
if !b.connected || b.client == nil {
|
||||
b.Log.Error("Not connected to Kosmi, dropping message")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// The gateway already formatted the username with RemoteNickFormat
|
||||
// So msg.Username contains the formatted string like "[irc] <cottongin>"
|
||||
// Just send: username + text
|
||||
formattedMsg := fmt.Sprintf("%s%s", msg.Username, msg.Text)
|
||||
|
||||
// Send message to Kosmi
|
||||
if err := b.client.SendMessage(formattedMsg); err != nil {
|
||||
b.Log.Errorf("Failed to send message to Kosmi: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// handleIncomingMessage processes messages received from Kosmi
|
||||
func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
|
||||
// Extract message details
|
||||
body := payload.Data.NewMessage.Body
|
||||
username := payload.Data.NewMessage.User.DisplayName
|
||||
if username == "" {
|
||||
username = payload.Data.NewMessage.User.Username
|
||||
}
|
||||
if username == "" {
|
||||
username = "Unknown"
|
||||
}
|
||||
|
||||
timestamp := time.Unix(payload.Data.NewMessage.Time, 0)
|
||||
|
||||
b.Log.Infof("Received message from Kosmi: [%s] %s: %s", timestamp.Format(time.RFC3339), username, body)
|
||||
|
||||
// Check if this is our own message (to avoid echo)
|
||||
// Messages we send have [irc] prefix (from RemoteNickFormat)
|
||||
if strings.HasPrefix(body, "[irc]") {
|
||||
b.Log.Debug("Ignoring our own echoed message (has [irc] prefix)")
|
||||
return
|
||||
}
|
||||
|
||||
// Create Matterbridge message
|
||||
// Use "main" as the channel name for gateway matching
|
||||
// Don't add prefix here - let the gateway's RemoteNickFormat handle it
|
||||
rmsg := config.Message{
|
||||
Username: username,
|
||||
Text: body,
|
||||
Channel: "main",
|
||||
Account: b.Account,
|
||||
UserID: username,
|
||||
Timestamp: timestamp,
|
||||
Protocol: "kosmi",
|
||||
}
|
||||
|
||||
// Send to Matterbridge
|
||||
b.Log.Debugf("Forwarding to Matterbridge channel=%s account=%s: %s", rmsg.Channel, rmsg.Account, rmsg.Text)
|
||||
|
||||
if b.Remote == nil {
|
||||
b.Log.Error("Remote channel is nil! Cannot forward message")
|
||||
return
|
||||
}
|
||||
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
// extractRoomID extracts the room ID from a Kosmi room URL
|
||||
// Supports formats:
|
||||
// - https://app.kosmi.io/room/@roomname
|
||||
// - https://app.kosmi.io/room/roomid
|
||||
func extractRoomID(url string) (string, error) {
|
||||
// Remove trailing slash if present
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
|
||||
// Pattern to match Kosmi room URLs
|
||||
patterns := []string{
|
||||
`https?://app\.kosmi\.io/room/(@?[a-zA-Z0-9_-]+)`,
|
||||
`app\.kosmi\.io/room/(@?[a-zA-Z0-9_-]+)`,
|
||||
`/room/(@?[a-zA-Z0-9_-]+)`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) >= 2 {
|
||||
roomID := matches[1]
|
||||
// Remove @ prefix if present (Kosmi uses both formats)
|
||||
return strings.TrimPrefix(roomID, "@"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// If no pattern matches, assume the entire string is the room ID
|
||||
// This allows for simple room ID configuration
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if lastPart != "" {
|
||||
return strings.TrimPrefix(lastPart, "@"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract room ID from URL: %s", url)
|
||||
}
|
||||
|
||||
521
bridge/kosmi/native_client.go
Normal file
521
bridge/kosmi/native_client.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// NativeClient uses Playwright to establish WebSocket, then interacts directly via JavaScript
|
||||
type NativeClient struct {
|
||||
roomURL string
|
||||
roomID string
|
||||
log *logrus.Entry
|
||||
pw *playwright.Playwright
|
||||
browser playwright.Browser
|
||||
page playwright.Page
|
||||
messageCallback func(*NewMessagePayload)
|
||||
connected bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewNativeClient creates a new native client with Playwright-assisted connection
|
||||
func NewNativeClient(roomURL, roomID string, log *logrus.Entry) *NativeClient {
|
||||
return &NativeClient{
|
||||
roomURL: roomURL,
|
||||
roomID: roomID,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect launches Playwright and establishes the WebSocket connection
|
||||
func (c *NativeClient) Connect() error {
|
||||
c.log.Info("Starting Playwright native client")
|
||||
|
||||
// Launch Playwright
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start Playwright: %w", err)
|
||||
}
|
||||
c.pw = pw
|
||||
|
||||
// Launch browser with resource optimizations
|
||||
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
||||
Headless: playwright.Bool(true),
|
||||
Args: []string{
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
|
||||
// Resource optimizations for reduced CPU/memory usage
|
||||
"--disable-gpu", // No GPU needed for chat
|
||||
"--disable-software-rasterizer", // No rendering needed
|
||||
"--disable-extensions", // No extensions needed
|
||||
"--disable-background-networking", // No background requests
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-breakpad", // No crash reporting
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
"--disable-features=TranslateUI", // No translation UI
|
||||
"--disable-ipc-flooding-protection",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--force-color-profile=srgb",
|
||||
"--metrics-recording-only",
|
||||
"--no-first-run", // Skip first-run tasks
|
||||
"--mute-audio", // No audio needed
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
c.pw.Stop()
|
||||
return fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
c.browser = browser
|
||||
|
||||
// Create context
|
||||
context, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
UserAgent: playwright.String("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
})
|
||||
if err != nil {
|
||||
c.browser.Close()
|
||||
c.pw.Stop()
|
||||
return fmt.Errorf("failed to create context: %w", err)
|
||||
}
|
||||
|
||||
// Create page
|
||||
page, err := context.NewPage()
|
||||
if err != nil {
|
||||
c.browser.Close()
|
||||
c.pw.Stop()
|
||||
return fmt.Errorf("failed to create page: %w", err)
|
||||
}
|
||||
c.page = page
|
||||
|
||||
// Inject WebSocket interceptor
|
||||
c.log.Debug("Injecting WebSocket access layer")
|
||||
if err := c.injectWebSocketAccess(); err != nil {
|
||||
c.Disconnect()
|
||||
return fmt.Errorf("failed to inject WebSocket access: %w", err)
|
||||
}
|
||||
|
||||
// Navigate to room
|
||||
c.log.Infof("Navigating to Kosmi room: %s", c.roomURL)
|
||||
if _, err := page.Goto(c.roomURL, playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded, // Wait for DOM only, not all resources
|
||||
}); err != nil {
|
||||
c.Disconnect()
|
||||
return fmt.Errorf("failed to navigate: %w", err)
|
||||
}
|
||||
|
||||
// Wait for WebSocket to establish
|
||||
c.log.Debug("Waiting for WebSocket connection")
|
||||
if err := c.waitForWebSocket(); err != nil {
|
||||
c.Disconnect()
|
||||
return fmt.Errorf("WebSocket not established: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to room messages
|
||||
c.log.Debugf("Subscribing to messages in room %s", c.roomID)
|
||||
if err := c.subscribeToMessages(); err != nil {
|
||||
c.Disconnect()
|
||||
return fmt.Errorf("failed to subscribe: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("Native client connected successfully")
|
||||
|
||||
// Start message listener
|
||||
go c.listenForMessages()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectWebSocketAccess injects JavaScript that provides direct WebSocket access
|
||||
func (c *NativeClient) injectWebSocketAccess() error {
|
||||
script := `
|
||||
(function() {
|
||||
if (window.__KOSMI_NATIVE_CLIENT__) return;
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.__KOSMI_WS__ = null;
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
window.__KOSMI_READY__ = false;
|
||||
|
||||
// Hook WebSocket constructor to capture the connection
|
||||
window.WebSocket = function(url, protocols) {
|
||||
const socket = new OriginalWebSocket(url, protocols);
|
||||
|
||||
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
||||
window.__KOSMI_WS__ = socket;
|
||||
|
||||
// Hook message handler to queue messages
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
});
|
||||
|
||||
// Mark as ready when connection opens
|
||||
socket.addEventListener('open', () => {
|
||||
window.__KOSMI_READY__ = true;
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
// Preserve WebSocket properties
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
|
||||
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
|
||||
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
|
||||
|
||||
window.__KOSMI_NATIVE_CLIENT__ = true;
|
||||
})();
|
||||
`
|
||||
|
||||
return c.page.AddInitScript(playwright.Script{
|
||||
Content: playwright.String(script),
|
||||
})
|
||||
}
|
||||
|
||||
// waitForWebSocket waits for the WebSocket to be established
|
||||
func (c *NativeClient) waitForWebSocket() error {
|
||||
for i := 0; i < 30; i++ { // 15 seconds max
|
||||
result, err := c.page.Evaluate(`
|
||||
(function() {
|
||||
return {
|
||||
ready: !!window.__KOSMI_READY__,
|
||||
wsExists: !!window.__KOSMI_WS__,
|
||||
wsState: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState : -1
|
||||
};
|
||||
})();
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status := result.(map[string]interface{})
|
||||
ready := status["ready"].(bool)
|
||||
|
||||
if ready {
|
||||
c.log.Info("✅ WebSocket is ready")
|
||||
return nil
|
||||
}
|
||||
|
||||
if i%5 == 0 {
|
||||
c.log.Debugf("Waiting for WebSocket... (attempt %d/30)", i+1)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Errorf("timeout waiting for WebSocket")
|
||||
}
|
||||
|
||||
// subscribeToMessages subscribes to room messages via the WebSocket
|
||||
func (c *NativeClient) subscribeToMessages() error {
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (!window.__KOSMI_WS__ || window.__KOSMI_WS__.readyState !== WebSocket.OPEN) {
|
||||
return { success: false, error: 'WebSocket not ready' };
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
id: 'native-client-subscription',
|
||||
type: 'subscribe',
|
||||
payload: {
|
||||
query: 'subscription { newMessage(roomId: "%s") { body time user { displayName username } } }',
|
||||
variables: {}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
window.__KOSMI_WS__.send(JSON.stringify(subscription));
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
})();
|
||||
`, c.roomID)
|
||||
|
||||
result, err := c.page.Evaluate(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response := result.(map[string]interface{})
|
||||
if success, ok := response["success"].(bool); !ok || !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := response["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("subscription failed: %s", errMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenForMessages continuously polls for new messages
|
||||
func (c *NativeClient) listenForMessages() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if err := c.pollMessages(); err != nil {
|
||||
c.log.Errorf("Error polling messages: %v", err)
|
||||
}
|
||||
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
// pollMessages retrieves and processes messages from the queue
|
||||
func (c *NativeClient) pollMessages() error {
|
||||
result, err := c.page.Evaluate(`
|
||||
(function() {
|
||||
if (!window.__KOSMI_MESSAGE_QUEUE__) return null;
|
||||
if (window.__KOSMI_MESSAGE_QUEUE__.length === 0) return null;
|
||||
const messages = window.__KOSMI_MESSAGE_QUEUE__.slice();
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
return messages;
|
||||
})();
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Early return if no messages (reduces CPU during idle)
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
messagesJSON, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var messages []struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(messagesJSON, &messages); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
c.processMessage(msg.Data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processMessage processes a single WebSocket message
|
||||
func (c *NativeClient) processMessage(data map[string]interface{}) {
|
||||
msgType, ok := data["type"].(string)
|
||||
if !ok || msgType != "next" {
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := data["payload"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
dataField, ok := payload["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
newMessage, ok := dataField["newMessage"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse into our struct
|
||||
jsonBytes, err := json.Marshal(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"newMessage": newMessage,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msgPayload NewMessagePayload
|
||||
if err := json.Unmarshal(jsonBytes, &msgPayload); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(&msgPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends a message by typing into the Kosmi chat input field
|
||||
func (c *NativeClient) SendMessage(text string) error {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.log.Debugf("Sending message to Kosmi: %s", text)
|
||||
|
||||
// Escape the message text for JavaScript
|
||||
textJSON, _ := json.Marshal(text)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(async function() {
|
||||
try {
|
||||
// Try multiple strategies to find the chat input
|
||||
let input = null;
|
||||
|
||||
// Strategy 1: Look for textarea
|
||||
const textareas = document.querySelectorAll('textarea');
|
||||
for (let ta of textareas) {
|
||||
if (ta.offsetParent !== null) { // visible
|
||||
input = ta;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Look for contenteditable
|
||||
if (!input) {
|
||||
const editables = document.querySelectorAll('[contenteditable="true"]');
|
||||
for (let ed of editables) {
|
||||
if (ed.offsetParent !== null) { // visible
|
||||
input = ed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Look for input text
|
||||
if (!input) {
|
||||
const inputs = document.querySelectorAll('input[type="text"]');
|
||||
for (let inp of inputs) {
|
||||
if (inp.offsetParent !== null) { // visible
|
||||
input = inp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
return { success: false, error: 'Could not find any visible input element' };
|
||||
}
|
||||
|
||||
// Set the value based on element type
|
||||
if (input.contentEditable === 'true') {
|
||||
input.textContent = %s;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
input.value = %s;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
// Focus the input
|
||||
input.focus();
|
||||
|
||||
// Wait a tiny bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Find and click the send button, or press Enter
|
||||
const sendButton = document.querySelector('button[type="submit"], button[class*="send" i], button[aria-label*="send" i]');
|
||||
if (sendButton && sendButton.offsetParent !== null) {
|
||||
sendButton.click();
|
||||
} else {
|
||||
// Simulate Enter key press
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
input.dispatchEvent(enterEvent);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
})();
|
||||
`, string(textJSON), string(textJSON))
|
||||
|
||||
result, err := c.page.Evaluate(script)
|
||||
if err != nil {
|
||||
c.log.Errorf("Failed to execute send script: %v", err)
|
||||
return fmt.Errorf("failed to execute send: %w", err)
|
||||
}
|
||||
|
||||
response := result.(map[string]interface{})
|
||||
if success, ok := response["success"].(bool); !ok || !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := response["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
c.log.Errorf("Send failed: %s", errMsg)
|
||||
return fmt.Errorf("send failed: %s", errMsg)
|
||||
}
|
||||
|
||||
c.log.Debug("Successfully sent message to Kosmi")
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnMessage registers a callback for incoming messages
|
||||
func (c *NativeClient) OnMessage(callback func(*NewMessagePayload)) {
|
||||
c.messageCallback = callback
|
||||
}
|
||||
|
||||
// Disconnect closes the Playwright browser
|
||||
func (c *NativeClient) Disconnect() error {
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Debug("Closing Playwright browser")
|
||||
|
||||
if c.browser != nil {
|
||||
c.browser.Close()
|
||||
}
|
||||
|
||||
if c.pw != nil {
|
||||
c.pw.Stop()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is connected
|
||||
func (c *NativeClient) IsConnected() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
347
bridge/kosmi/playwright_client.go
Normal file
347
bridge/kosmi/playwright_client.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// PlaywrightClient manages a Playwright browser instance to connect to Kosmi
|
||||
type PlaywrightClient struct {
|
||||
roomURL string
|
||||
log *logrus.Entry
|
||||
pw *playwright.Playwright
|
||||
browser playwright.Browser
|
||||
page playwright.Page
|
||||
messageCallback func(*NewMessagePayload)
|
||||
connected bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPlaywrightClient creates a new Playwright-based Kosmi client
|
||||
func NewPlaywrightClient(roomURL string, log *logrus.Entry) *PlaywrightClient {
|
||||
return &PlaywrightClient{
|
||||
roomURL: roomURL,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect launches Playwright and navigates to the Kosmi room
|
||||
func (c *PlaywrightClient) Connect() error {
|
||||
c.log.Info("Launching Playwright browser for Kosmi connection")
|
||||
|
||||
// Create Playwright instance (using system Chromium, no install needed)
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start Playwright: %w", err)
|
||||
}
|
||||
c.pw = pw
|
||||
|
||||
// Launch browser using system Chromium
|
||||
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
||||
Headless: playwright.Bool(true),
|
||||
ExecutablePath: playwright.String("/usr/bin/chromium"),
|
||||
Args: []string{
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to launch browser: %w", err)
|
||||
}
|
||||
c.browser = browser
|
||||
|
||||
// Create context and page
|
||||
context, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
UserAgent: playwright.String("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create context: %w", err)
|
||||
}
|
||||
|
||||
page, err := context.NewPage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create page: %w", err)
|
||||
}
|
||||
c.page = page
|
||||
|
||||
// Inject WebSocket hook before navigation
|
||||
c.log.Info("Injecting WebSocket interceptor...")
|
||||
if err := c.injectWebSocketHook(); err != nil {
|
||||
return fmt.Errorf("failed to inject WebSocket hook: %w", err)
|
||||
}
|
||||
|
||||
// Navigate to the room
|
||||
c.log.Infof("Navigating to Kosmi room: %s", c.roomURL)
|
||||
if _, err := page.Goto(c.roomURL, playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateNetworkidle,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to navigate to room: %w", err)
|
||||
}
|
||||
|
||||
// Wait for page to be ready
|
||||
if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{
|
||||
State: playwright.LoadStateNetworkidle,
|
||||
}); err != nil {
|
||||
c.log.Warnf("Page load state warning: %v", err)
|
||||
}
|
||||
|
||||
c.log.Info("Page loaded, waiting for WebSocket connection...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("Successfully connected to Kosmi via Playwright")
|
||||
|
||||
// Start message listener
|
||||
go c.listenForMessages()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectWebSocketHook injects the WebSocket interception script
|
||||
func (c *PlaywrightClient) injectWebSocketHook() error {
|
||||
script := c.getWebSocketHookScript()
|
||||
|
||||
return c.page.AddInitScript(playwright.Script{
|
||||
Content: playwright.String(script),
|
||||
})
|
||||
}
|
||||
|
||||
// getWebSocketHookScript returns the JavaScript to hook WebSocket
|
||||
func (c *PlaywrightClient) getWebSocketHookScript() string {
|
||||
return `
|
||||
(function() {
|
||||
if (window.__KOSMI_WS_HOOK_INSTALLED__) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
|
||||
window.WebSocket = function(url, protocols) {
|
||||
const socket = new OriginalWebSocket(url, protocols);
|
||||
|
||||
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
||||
console.log('[Kosmi Bridge] WebSocket hook active for:', url);
|
||||
window.__KOSMI_WS_CONNECTED__ = true;
|
||||
|
||||
const originalAddEventListener = socket.addEventListener.bind(socket);
|
||||
socket.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
const wrappedListener = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data
|
||||
});
|
||||
} catch (e) {}
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return originalAddEventListener(type, wrappedListener, options);
|
||||
}
|
||||
return originalAddEventListener(type, listener, options);
|
||||
};
|
||||
|
||||
let realOnMessage = null;
|
||||
Object.defineProperty(socket, 'onmessage', {
|
||||
get: function() { return realOnMessage; },
|
||||
set: function(handler) {
|
||||
realOnMessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
window.__KOSMI_MESSAGE_QUEUE__.push({
|
||||
timestamp: Date.now(),
|
||||
data: data
|
||||
});
|
||||
} catch (e) {}
|
||||
if (handler) { handler.call(socket, event); }
|
||||
};
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
|
||||
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
|
||||
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
|
||||
|
||||
window.__KOSMI_WS_HOOK_INSTALLED__ = true;
|
||||
console.log('[Kosmi Bridge] WebSocket hook installed');
|
||||
})();
|
||||
`
|
||||
}
|
||||
|
||||
// listenForMessages polls for new messages from the WebSocket queue
|
||||
func (c *PlaywrightClient) listenForMessages() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Poll for messages
|
||||
result, err := c.page.Evaluate(`
|
||||
(function() {
|
||||
if (!window.__KOSMI_MESSAGE_QUEUE__) return [];
|
||||
const messages = window.__KOSMI_MESSAGE_QUEUE__.slice();
|
||||
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
||||
return messages;
|
||||
})();
|
||||
`)
|
||||
|
||||
if err != nil {
|
||||
c.log.Debugf("Error polling messages: %v", err)
|
||||
<-ticker.C
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse messages
|
||||
var messages []struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(fmt.Sprintf("%v", result)), &messages); err != nil {
|
||||
<-ticker.C
|
||||
continue
|
||||
}
|
||||
|
||||
if len(messages) > 0 {
|
||||
c.log.Infof("Processing %d messages from queue", len(messages))
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
c.processMessage(msg.Data)
|
||||
}
|
||||
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
// processMessage processes a single WebSocket message
|
||||
func (c *PlaywrightClient) processMessage(data map[string]interface{}) {
|
||||
// Check if this is a newMessage subscription event
|
||||
msgType, ok := data["type"].(string)
|
||||
if !ok || msgType != "next" {
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := data["payload"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
dataField, ok := payload["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
newMessage, ok := dataField["newMessage"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse into our struct
|
||||
jsonBytes, err := json.Marshal(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"newMessage": newMessage,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msgPayload NewMessagePayload
|
||||
if err := json.Unmarshal(jsonBytes, &msgPayload); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Call the callback
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(&msgPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends a message to the Kosmi chat
|
||||
func (c *PlaywrightClient) SendMessage(text string) error {
|
||||
c.mu.RLock()
|
||||
if !c.connected {
|
||||
c.mu.RUnlock()
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
selector := `div[role="textbox"][contenteditable="true"]`
|
||||
|
||||
// Wait for the input to be available
|
||||
_, err := c.page.WaitForSelector(selector, playwright.PageWaitForSelectorOptions{
|
||||
Timeout: playwright.Float(5000),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat input not available: %w", err)
|
||||
}
|
||||
|
||||
// Get the input element
|
||||
input := c.page.Locator(selector)
|
||||
|
||||
// Clear and type the message
|
||||
if err := input.Fill(text); err != nil {
|
||||
return fmt.Errorf("failed to fill message: %w", err)
|
||||
}
|
||||
|
||||
// Press Enter to send
|
||||
if err := input.Press("Enter"); err != nil {
|
||||
return fmt.Errorf("failed to press Enter: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debugf("Sent message: %s", text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnMessage sets the callback for new messages
|
||||
func (c *PlaywrightClient) OnMessage(callback func(*NewMessagePayload)) {
|
||||
c.messageCallback = callback
|
||||
}
|
||||
|
||||
// Disconnect closes the browser
|
||||
func (c *PlaywrightClient) Disconnect() error {
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Info("Closing Playwright browser")
|
||||
|
||||
if c.browser != nil {
|
||||
if err := c.browser.Close(); err != nil {
|
||||
c.log.Warnf("Error closing browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.pw != nil {
|
||||
if err := c.pw.Stop(); err != nil {
|
||||
c.log.Warnf("Error stopping Playwright: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user