working v1

This commit is contained in:
cottongin
2025-10-31 16:17:04 -04:00
parent e41402a963
commit 020daea391
71 changed files with 14793 additions and 1 deletions

135
bridge/bridge.go Normal file
View 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
View 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
View 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
}

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

View 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"
}

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

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

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

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

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