Compare commits

...

10 Commits

Author SHA1 Message Date
cottongin
1831b0e923 Remove compiled binaries from tracking
Untrack 12 Mach-O binaries that were committed to the repo and add
them to .gitignore. These can be rebuilt from source with go build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:53:08 -05:00
cottongin
8eeefcc81c Move PROJECT_STATUS.txt to docs/
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:46:40 -05:00
cottongin
78accf403d Remove test artifacts from tracking
Untrack har_operations_full.txt, roomcode GIFs, and test_upload.gif.
Add them to .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:45:54 -05:00
cottongin
db284d0677 Move troubleshooting and implementation docs to docs/
Relocate 30 non-essential .md files (investigation notes, fix summaries,
implementation details, status reports) from the project root into docs/
to reduce clutter. Core operational docs (README, quickstart guides,
configuration references) remain in the root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:40:46 -05:00
cottongin
3b7a139606 Remove sensitive files from tracking and add example config
Add matterbridge.toml, test configs, HAR captures, and JWT-containing
debug files to .gitignore. Provide matterbridge.toml.example with
placeholder values for safe onboarding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:35:51 -05:00
cottongin
fd42ac0e7c working state 2026-02-07 12:37:21 -05:00
cottongin
1e0cb63b1c tweaks to websocket 2026-02-07 00:06:20 -05:00
cottongin
673c8025ee nailed it 2.0 2025-11-02 16:49:12 -05:00
cottongin
9262ae79dd nailed it 2025-11-02 16:48:18 -05:00
cottongin
f764519a30 nailed it 2025-11-02 16:04:03 -05:00
64 changed files with 1086 additions and 625 deletions

35
.gitignore vendored
View File

@@ -1,6 +1,18 @@
# Binaries
matterbridge
test-kosmi
capture-auth
monitor-ws
test-image-upload
test-long-title
test-proper-roomcodes
test-roomcode-image
test-session
test-upload
test-websocket
test-websocket-direct
cmd/kosmi-client/kosmi-clien
cmd/kosmi-client/kosmi-client
*.exe
*.dll
*.so
@@ -33,9 +45,29 @@ Thumbs.db
# Config files with secrets
matterbridge.toml.local
matterbridge.toml
test-token-config.toml
*.har
*.har.txt
*.har.gz
auth-data.json
definitely_logged_*.json
loggedin_maybe.json
loggedout_maybe.json
# Debug/capture files with JWT tokens
QUICK_START_TOKEN.md
loggedout_websocket_messages.md
logging_IN_attempt_but_loggedout_websocket_messages.md
logging_IN_attempt_but_now_logged_IN_websocket_messages.md
*.secret.toml
.env
# Test/sample generated files
har_operations_full.txt
roomcode_*.gif
test_upload.gif
# Logs
*.log
logs/
@@ -48,3 +80,6 @@ build/
.examples/
chat-summaries/
bin/
# Persistent data directory (contains cached tokens)
data/

View File

@@ -3,8 +3,20 @@ FROM golang:1.23-alpine
WORKDIR /app
# Install only essential dependencies
RUN apk add --no-cache ca-certificates
# Install essential dependencies and Chromium for authentication
# Chromium is needed for email/password authentication via browser automation
RUN apk add --no-cache \
ca-certificates \
chromium \
chromium-chromedriver \
nss \
freetype \
harfbuzz \
ttf-freefont
# Set environment variables for Chromium
ENV CHROME_BIN=/usr/bin/chromium-browser \
CHROME_PATH=/usr/lib/chromium/
# Copy go mod files
COPY go.mod go.sum ./

181
TOKEN_PERSISTENCE.md Normal file
View File

@@ -0,0 +1,181 @@
# Token Persistence Guide
## Overview
The Kosmi bridge now caches JWT authentication tokens to avoid repeated browser automation on every startup. The token is stored in a local directory that persists across Docker container rebuilds and restarts.
## How It Works
### Token Cache Location
The token cache is stored in a file called `kosmi_token_cache.json` in the following locations:
- **Docker (Production)**: `./data/kosmi_token_cache.json` (mounted from your host machine)
- **Local Development**: `~/.matterbridge/kosmi_token_cache.json`
### Token Cache Structure
The cache file contains:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"email": "your-email@example.com",
"expires_at": "2026-11-02T15:56:23Z",
"saved_at": "2025-11-02T15:56:23Z"
}
```
### Token Lifecycle
1. **On Startup**: The bridge checks for a cached token
- If found and valid, it uses the cached token (no browser automation needed)
- If expired or expiring within 7 days, it performs fresh authentication
- If not found, it performs fresh authentication
2. **Token Expiry**: Kosmi JWT tokens expire after 1 year
- The bridge automatically refreshes tokens that expire within 7 days
- You'll see a log message indicating how long until the token expires
3. **Token Storage**: After successful authentication, the token is saved to the cache file
- File permissions are set to `0600` (read/write for owner only)
- The cache directory is created automatically if it doesn't exist
## Docker Configuration
### Volume Mount
The `docker-compose.yml` includes a volume mount for persistent storage:
```yaml
volumes:
- ./data:/app/data:z
```
This mounts the `./data` directory from your host machine into the container at `/app/data`.
### Environment Variable
The container sets the `MATTERBRIDGE_DATA_DIR` environment variable:
```yaml
environment:
- MATTERBRIDGE_DATA_DIR=/app/data
```
This tells the bridge where to store persistent data like the token cache.
## Usage
### First Run
On the first run with email/password configured:
1. The bridge will launch a headless browser
2. Authenticate with Kosmi using your credentials
3. Extract and cache the JWT token
4. Save it to `./data/kosmi_token_cache.json`
You'll see logs like:
```
level=info msg="No cached token found, performing authentication..."
level=info msg="Starting browser automation for authentication..."
level=info msg="💾 Token cached (expires in 8760h)"
```
### Subsequent Runs
On subsequent runs (container restarts, rebuilds, etc.):
1. The bridge checks the cached token
2. If valid, uses it immediately (no browser needed)
3. Connects to Kosmi in seconds
You'll see logs like:
```
level=info msg="✅ Using cached token (expires in 8736h)"
```
### Token Refresh
When the token is close to expiring (within 7 days):
1. The bridge automatically performs fresh authentication
2. Updates the cached token
3. Continues normal operation
You'll see logs like:
```
level=info msg="Cached token expires soon (2025-11-09T15:56:23Z), will refresh"
level=info msg="Starting browser automation for authentication..."
level=info msg="💾 Token cached (expires in 8760h)"
```
## File Structure
After running with authentication, your directory structure will look like:
```
irc-kosmi-relay/
├── data/ # Persistent data directory
│ └── kosmi_token_cache.json # Cached JWT token
├── docker-compose.yml
├── matterbridge.toml
└── ...
```
## Troubleshooting
### Token Cache Not Persisting
If the token cache doesn't persist across container restarts:
1. Check that the `./data` directory exists and is writable
2. Verify the volume mount in `docker-compose.yml` is correct
3. Check container logs for permission errors
### Force Token Refresh
To force a fresh authentication (e.g., if credentials changed):
```bash
# Stop the container
docker-compose down
# Remove the cached token
rm ./data/kosmi_token_cache.json
# Start the container
docker-compose up -d
```
### Check Token Status
To view the current cached token:
```bash
cat ./data/kosmi_token_cache.json | jq .
```
This will show you:
- When the token was saved
- When it expires
- Which email it's associated with
## Security Notes
- The token cache file has restricted permissions (`0600`) for security
- The token is a JWT that expires after 1 year
- The cache file is stored locally and never transmitted
- If you commit your code to version control, add `data/` to `.gitignore`
## Benefits
1. **Faster Startup**: No browser automation on every restart (saves 10-15 seconds)
2. **Reduced Resource Usage**: No need to launch Chromium on every startup
3. **Persistence**: Token survives container rebuilds, restarts, and host reboots
4. **Automatic Refresh**: Token is automatically refreshed before expiry
5. **Local Storage**: Token is stored on your host machine, not in the container

View File

@@ -129,8 +129,19 @@ func (m *Manager) startWebSocketClient(messageCallback func(string)) error {
// Get EnableRoomCodeImage setting from config (defaults to false)
enableRoomCodeImage := m.config.Viper().GetBool("jackbox.EnableRoomCodeImage")
// Get configurable delays for room code broadcast
imageDelay := time.Duration(m.config.Viper().GetInt("jackbox.RoomCodeImageDelay")) * time.Second
plaintextDelay := time.Duration(m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay")) * time.Second
if plaintextDelay == 0 {
plaintextDelay = 29 * time.Second // default
}
m.log.Infof("Room code delays: imageDelay=%v, plaintextDelay=%v (raw config: image=%d, plaintext=%d)",
imageDelay, plaintextDelay,
m.config.Viper().GetInt("jackbox.RoomCodeImageDelay"),
m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay"))
// Create WebSocket client (pass the API client for vote tracking)
m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, m.client, enableRoomCodeImage, m.log)
m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, m.client, enableRoomCodeImage, imageDelay, plaintextDelay, m.log)
// Connect to WebSocket
if err := m.wsClient.Connect(); err != nil {

View File

@@ -305,10 +305,10 @@ func GenerateRoomCodeImage(roomCode, gameTitle string) ([]byte, error) {
}
// Animation parameters
initialPauseFrames := 25 // Initial pause before animation starts (2.5 seconds at 10fps)
fadeFrames := 10 // Number of frames for fade-in (1 second at 10fps)
pauseFrames := 30 // Frames to pause between characters (3 seconds at 10fps)
frameDelay := 10 // 10/100 second = 0.1s per frame (10 fps)
initialPauseFrames := 1 // Initial pause before animation starts (2.5 seconds at 10fps)
fadeFrames := 10 // Number of frames for fade-in (1 second at 10fps)
pauseFrames := 30 // Frames to pause between characters (3 seconds at 10fps)
frameDelay := 10 // 10/100 second = 0.1s per frame (10 fps)
// Helper function to draw a frame and convert to paletted
drawFrame := func(charIndex int, fadeProgress float64) *image.Paletted {

View File

@@ -24,8 +24,10 @@ type WebSocketClient struct {
stopChan chan struct{}
connected bool
authenticated bool
subscribedSession int
enableRoomCodeImage bool // Whether to upload room code images to Kosmi
subscribedSession int
enableRoomCodeImage bool // Whether to upload room code images to Kosmi
roomCodeImageDelay time.Duration // Delay before sending image announcement
roomCodePlaintextDelay time.Duration // Delay before sending plaintext room code
}
// WebSocket message types
@@ -67,17 +69,19 @@ type GameAddedData struct {
}
// NewWebSocketClient creates a new WebSocket client
func NewWebSocketClient(apiURL, token string, messageCallback func(string), apiClient *Client, enableRoomCodeImage bool, log *logrus.Entry) *WebSocketClient {
func NewWebSocketClient(apiURL, token string, messageCallback func(string), apiClient *Client, enableRoomCodeImage bool, roomCodeImageDelay, roomCodePlaintextDelay time.Duration, log *logrus.Entry) *WebSocketClient {
return &WebSocketClient{
apiURL: apiURL,
token: token,
messageCallback: messageCallback,
apiClient: apiClient,
enableRoomCodeImage: enableRoomCodeImage,
log: log,
reconnectDelay: 1 * time.Second,
maxReconnect: 30 * time.Second,
stopChan: make(chan struct{}),
apiURL: apiURL,
token: token,
messageCallback: messageCallback,
apiClient: apiClient,
enableRoomCodeImage: enableRoomCodeImage,
roomCodeImageDelay: roomCodeImageDelay,
roomCodePlaintextDelay: roomCodePlaintextDelay,
log: log,
reconnectDelay: 1 * time.Second,
maxReconnect: 30 * time.Second,
stopChan: make(chan struct{}),
}
}
@@ -306,7 +310,7 @@ func (c *WebSocketClient) handleGameAdded(data json.RawMessage) {
if gameData.Game.RoomCode != "" {
if c.enableRoomCodeImage {
// Try to upload room code image (for Kosmi) - image contains all info
c.broadcastWithRoomCodeImage(gameData.Game.Title, gameData.Game.RoomCode)
c.broadcastWithRoomCodeImage(message, gameData.Game.Title, gameData.Game.RoomCode)
} else {
// Use IRC text formatting (fallback)
roomCodeText := fmt.Sprintf(" - Room Code \x02\x11%s\x0F", gameData.Game.RoomCode)
@@ -324,7 +328,8 @@ func (c *WebSocketClient) handleGameAdded(data json.RawMessage) {
// broadcastWithRoomCodeImage generates, uploads, and broadcasts a room code image
// The image contains all the information (game title, room code, etc.)
func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string) {
// The message parameter should contain the full game announcement including any vote results
func (c *WebSocketClient) broadcastWithRoomCodeImage(message, gameTitle, roomCode string) {
c.log.Infof("🎨 Starting room code image generation and upload for: %s - %s", gameTitle, roomCode)
// Generate room code image (animated GIF) with game title embedded
@@ -333,7 +338,7 @@ func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string)
if err != nil {
c.log.Errorf("❌ Failed to generate room code image: %v", err)
// Fallback to plain text (no IRC formatting codes for Kosmi)
fallbackMessage := fmt.Sprintf("🎮 Coming up next: %s - Room Code %s", gameTitle, roomCode)
fallbackMessage := fmt.Sprintf("%s - Room Code %s", message, roomCode)
if c.messageCallback != nil {
c.messageCallback(fallbackMessage)
}
@@ -349,7 +354,7 @@ func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string)
if err != nil {
c.log.Errorf("❌ Failed to upload room code image: %v", err)
// Fallback to plain text (no IRC formatting codes for Kosmi)
fallbackMessage := fmt.Sprintf("🎮 Coming up next: %s - Room Code %s", gameTitle, roomCode)
fallbackMessage := fmt.Sprintf("%s - Room Code %s", message, roomCode)
if c.messageCallback != nil {
c.messageCallback(fallbackMessage)
}
@@ -358,9 +363,13 @@ func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string)
c.log.Infof("✅ Step 2 complete: Uploaded to %s", imageURL)
// Now that upload succeeded, send the full announcement with game title and URL
// Now that upload succeeded, send the full announcement with the message and URL
if c.roomCodeImageDelay > 0 {
c.log.Infof("⏳ Step 3: Waiting %v before broadcasting game announcement...", c.roomCodeImageDelay)
time.Sleep(c.roomCodeImageDelay)
}
c.log.Infof("📢 Step 3: Broadcasting game announcement with URL...")
fullMessage := fmt.Sprintf("🎮 Coming up next: %s %s", gameTitle, imageURL)
fullMessage := fmt.Sprintf("%s %s", message, imageURL)
if c.messageCallback != nil {
c.messageCallback(fullMessage)
c.log.Infof("✅ Step 3 complete: Game announcement sent with URL")
@@ -368,17 +377,18 @@ func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string)
c.log.Error("❌ Step 3 failed: messageCallback is nil")
}
// Send the plaintext room code after 19 seconds (to sync with animation completion)
// Send the plaintext room code after configured delay (to sync with animation completion)
// Capture callback and logger in closure
callback := c.messageCallback
logger := c.log
plainRoomCode := roomCode // Capture room code for plain text message
plaintextDelay := c.roomCodePlaintextDelay
c.log.Infof("⏰ Step 4: Starting 19-second timer goroutine for plaintext room code...")
c.log.Infof("⏰ Step 4: Starting %v timer goroutine for plaintext room code...", plaintextDelay)
go func() {
logger.Infof("⏰ [Goroutine started] Waiting 19 seconds before sending plaintext room code: %s", plainRoomCode)
time.Sleep(19 * time.Second)
logger.Infof("⏰ [19 seconds elapsed] Now sending plaintext room code...")
logger.Infof("⏰ [Goroutine started] Waiting %v before sending plaintext room code: %s", plaintextDelay, plainRoomCode)
time.Sleep(plaintextDelay)
logger.Infof("⏰ [%v elapsed] Now sending plaintext room code...", plaintextDelay)
if callback != nil {
// Send just the room code in plaintext (for easy copy/paste)
@@ -391,7 +401,7 @@ func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string)
}
}()
c.log.Infof("✅ Step 4 complete: Goroutine launched, will fire in 19 seconds")
c.log.Infof("✅ Step 4 complete: Goroutine launched, will fire in %v", plaintextDelay)
}
// handleSessionEnded processes session.ended events
@@ -544,4 +554,3 @@ func (c *WebSocketClient) IsSubscribed() bool {
defer c.mu.Unlock()
return c.subscribedSession > 0
}

View File

@@ -2,129 +2,55 @@ package bkosmi
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/chromedp/chromedp"
"github.com/sirupsen/logrus"
)
const (
// Check token expiry 7 days before it expires
tokenExpiryCheckBuffer = 7 * 24 * time.Hour
)
// BrowserAuthManager handles automated browser-based authentication
type BrowserAuthManager struct {
email string
password string
token string
tokenExpiry time.Time
log *logrus.Entry
lastCheckTime time.Time
checkInterval time.Duration
}
// NewBrowserAuthManager creates a new browser-based authentication manager
func NewBrowserAuthManager(email, password string, log *logrus.Entry) *BrowserAuthManager {
return &BrowserAuthManager{
email: email,
password: password,
log: log,
checkInterval: 24 * time.Hour, // Check daily for token expiry
}
}
// GetToken returns a valid token, obtaining a new one via browser if needed
func (b *BrowserAuthManager) GetToken() (string, error) {
// Check if we need to obtain or refresh the token
if b.token == "" || b.shouldRefreshToken() {
b.log.Info("Obtaining authentication token via browser automation...")
if err := b.loginViaBrowser(); err != nil {
return "", &AuthError{
Op: "browser_login",
Reason: "failed to obtain token via browser",
Err: err,
}
}
}
return b.token, nil
}
// shouldRefreshToken checks if the token needs to be refreshed
func (b *BrowserAuthManager) shouldRefreshToken() bool {
// No token yet
if b.token == "" {
return true
}
// Token expired or about to expire
if time.Now().After(b.tokenExpiry.Add(-tokenExpiryCheckBuffer)) {
b.log.Info("Token expired or expiring soon, will refresh")
return true
}
// Periodic check (daily) to verify token is still valid
if time.Since(b.lastCheckTime) > b.checkInterval {
b.log.Debug("Performing periodic token validity check")
b.lastCheckTime = time.Now()
// For now, we trust the expiry time. Could add a validation check here.
}
return false
}
// loginViaBrowser uses chromedp to automate login and extract token
func (b *BrowserAuthManager) loginViaBrowser() error {
// Set up Chrome options
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
)
// Create allocator context
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()
// loginWithChromedp uses browser automation to log in and extract the JWT token.
// This is the proven implementation that successfully authenticates users.
func loginWithChromedp(email, password string, log *logrus.Entry) (string, error) {
log.Info("Starting browser automation for authentication...")
// Create context with timeout
ctx, cancel := chromedp.NewContext(allocCtx)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// Set a reasonable timeout for the entire login process
ctx, cancel = context.WithTimeout(ctx, 90*time.Second)
// Set up chromedp options (headless mode)
opts := []chromedp.ExecAllocatorOption{
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.DisableGPU,
chromedp.NoSandbox,
chromedp.Headless,
}
// Create allocator context
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
defer cancel()
// Create browser context with no logging to suppress cookie errors
ctx, cancel = chromedp.NewContext(allocCtx, chromedp.WithLogf(func(string, ...interface{}) {}))
defer cancel()
var token string
// Run the browser automation tasks
// Run the automation tasks
err := chromedp.Run(ctx,
// Navigate to Kosmi
chromedp.Navigate("https://app.kosmi.io"),
// Wait for page to load completely
chromedp.WaitReady("body"),
chromedp.Sleep(2*time.Second),
chromedp.Sleep(3*time.Second),
// Click Login button (find by text content using JS with error handling)
// Find and click Login button
chromedp.ActionFunc(func(ctx context.Context) error {
// First, log what buttons we can see
var buttonTexts []string
chromedp.Evaluate(`
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
`, &buttonTexts).Do(ctx)
b.log.Debugf("Found buttons: %v", buttonTexts)
log.Debug("Looking for Login button...")
var found bool
if err := chromedp.Evaluate(`
(() => {
const buttons = Array.from(document.querySelectorAll('button'));
// Try both "Login" and "Log in"
const btn = buttons.find(el => {
const text = el.textContent.trim();
return text === 'Login' || text === 'Log in';
@@ -139,27 +65,22 @@ func (b *BrowserAuthManager) loginViaBrowser() error {
return err
}
if !found {
return fmt.Errorf("Login button not found (found buttons: %v)", buttonTexts)
return fmt.Errorf("Login button not found")
}
log.Debug("✓ Clicked Login button")
return nil
}),
// Wait for login modal
// Wait and click "Login with Email"
chromedp.Sleep(3*time.Second),
// Click "Login with Email" button
chromedp.ActionFunc(func(ctx context.Context) error {
// Log what buttons we can see now
var buttonTexts []string
chromedp.Evaluate(`
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
`, &buttonTexts).Do(ctx)
b.log.Debugf("After clicking Log in, found buttons: %v", buttonTexts)
log.Debug("Looking for 'Login with Email' button...")
var found bool
if err := chromedp.Evaluate(`
(() => {
const btn = Array.from(document.querySelectorAll('button')).find(el => el.textContent.includes('Email'));
const btn = Array.from(document.querySelectorAll('button')).find(el =>
el.textContent.includes('Email')
);
if (btn) {
btn.click();
return true;
@@ -170,293 +91,85 @@ func (b *BrowserAuthManager) loginViaBrowser() error {
return err
}
if !found {
return fmt.Errorf("Login with Email button not found (found buttons: %v)", buttonTexts)
return fmt.Errorf("'Login with Email' button not found")
}
log.Debug("✓ Clicked 'Login with Email' button")
return nil
}),
// Wait for email form
// Wait for form and fill credentials
chromedp.Sleep(3*time.Second),
chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debug("Waiting for password input...")
return nil
}),
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debug("Password input found, preparing to fill form...")
return nil
}),
// Click on the email input to focus it
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
chromedp.Sleep(200*time.Millisecond),
// Type email character by character
chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debugf("Typing email: %s", b.email)
return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, b.email, chromedp.ByQuery).Do(ctx)
}),
chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery),
chromedp.Sleep(500*time.Millisecond),
// Click on the password input to focus it
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
chromedp.Sleep(200*time.Millisecond),
// Type password character by character
chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debugf("Typing password (length: %d)", len(b.password))
return chromedp.SendKeys(`input[type="password"]`, b.password, chromedp.ByQuery).Do(ctx)
}),
// Verify password was filled correctly
chromedp.ActionFunc(func(ctx context.Context) error {
var actualLength int
chromedp.Evaluate(`
(() => {
const passwordInput = document.querySelector('input[type="password"]');
return passwordInput ? passwordInput.value.length : 0;
})()
`, &actualLength).Do(ctx)
b.log.Debugf("Password filled (actual length: %d, expected: %d)", actualLength, len(b.password))
if actualLength != len(b.password) {
return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(b.password))
}
return nil
}),
chromedp.SendKeys(`input[type="password"]`, password+"\n", chromedp.ByQuery),
chromedp.Sleep(500*time.Millisecond),
// Wait a moment for form validation
chromedp.Sleep(1*time.Second),
// Click the login submit button (be very specific)
// Wait for login to complete - check for modal to close
chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debug("Attempting to click submit button...")
var result string
if err := chromedp.Evaluate(`
(() => {
const buttons = Array.from(document.querySelectorAll('button'));
log.Debug("Waiting for login to complete...")
// Wait for the login modal to disappear (indicates successful login)
maxAttempts := 30 // 15 seconds total
for i := 0; i < maxAttempts; i++ {
time.Sleep(500 * time.Millisecond)
// Find the submit button in the login form
// It should be visible, enabled, and contain "Login" but not be the main nav button
const submitBtn = buttons.find(el => {
const text = el.textContent.trim();
const isLoginBtn = text === 'Login' || text.startsWith('Login');
const isEnabled = !el.disabled;
const isVisible = el.offsetParent !== null;
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null;
// Check if login modal is gone (successful login)
var modalGone bool
chromedp.Evaluate(`
(() => {
// Check if the email/password form is still visible
const emailInput = document.querySelector('input[placeholder*="Email"], input[placeholder*="Username"]');
const passwordInput = document.querySelector('input[type="password"]');
return !emailInput && !passwordInput;
})()
`, &modalGone).Do(ctx)
return isLoginBtn && isEnabled && isVisible && isInForm;
});
if (submitBtn) {
submitBtn.click();
return 'CLICKED: ' + submitBtn.textContent.trim();
if modalGone {
log.Debug("✓ Login modal closed")
// Modal is gone, wait a bit more for token to be set
time.Sleep(2 * time.Second)
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
if token != "" {
log.Info("✅ Authentication successful")
return nil
}
}
return 'NOT_FOUND';
})()
`, &result).Do(ctx); err != nil {
return err
// Check for error messages
var errorText string
chromedp.Evaluate(`
(() => {
const errorEl = document.querySelector('[role="alert"], .error, .error-message');
return errorEl ? errorEl.textContent.trim() : '';
})()
`, &errorText).Do(ctx)
if errorText != "" && errorText != "null" {
return fmt.Errorf("login failed: %s", errorText)
}
}
b.log.Debugf("Submit button result: %s", result)
if result == "NOT_FOUND" {
return fmt.Errorf("Login submit button not found or not clickable")
// Timeout - get whatever token is there
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
if token == "" {
return fmt.Errorf("login timeout: no token found")
}
b.log.Debug("Submit button clicked")
return nil
}),
// Wait for login to complete (page will reload/redirect)
chromedp.Sleep(5*time.Second),
// Check if login succeeded by looking for error messages
chromedp.ActionFunc(func(ctx context.Context) error {
var errorText string
chromedp.Evaluate(`
(() => {
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
return errorEl ? errorEl.textContent : '';
})()
`, &errorText).Do(ctx)
if errorText != "" {
return fmt.Errorf("login failed with error: %s", errorText)
}
b.log.Debug("No error messages found, checking token...")
return nil
}),
// Extract token from localStorage
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
// Verify the token is not anonymous by checking if user info exists
chromedp.ActionFunc(func(ctx context.Context) error {
var userInfo string
chromedp.Evaluate(`
(() => {
try {
const token = localStorage.getItem('token');
if (!token) return 'NO_TOKEN';
// Decode JWT payload (middle part)
const parts = token.split('.');
if (parts.length !== 3) return 'INVALID_TOKEN';
const payload = JSON.parse(atob(parts[1]));
return JSON.stringify({
sub: payload.sub,
typ: payload.typ,
isAnon: payload.sub ? false : true
});
} catch (e) {
return 'ERROR: ' + e.message;
}
})()
`, &userInfo).Do(ctx)
b.log.Debugf("Token info from browser: %s", userInfo)
log.Warn("⚠ Login timeout but token was found")
return nil
}),
)
if err != nil {
return fmt.Errorf("browser automation failed: %w", err)
return "", fmt.Errorf("browser automation failed: %w", err)
}
if token == "" {
return fmt.Errorf("no token found in localStorage after login")
return "", fmt.Errorf("failed to extract token from localStorage")
}
b.token = token
b.log.Infof("✅ Successfully obtained token via browser automation")
b.log.Infof(" Email used: %s", b.email)
b.log.Infof(" Token (first 50 chars): %s...", token[:min(50, len(token))])
b.log.Infof(" Token (last 50 chars): ...%s", token[max(0, len(token)-50):])
// Parse token to get expiry
if err := b.parseTokenExpiry(); err != nil {
b.log.Warnf("Failed to parse token expiry: %v", err)
// Default to 1 year if we can't parse
b.tokenExpiry = time.Now().Add(365 * 24 * time.Hour)
}
b.lastCheckTime = time.Now()
expiresIn := time.Until(b.tokenExpiry)
b.log.Infof("Token expires in: %v", expiresIn.Round(24*time.Hour))
return nil
return token, nil
}
// parseTokenExpiry extracts the expiry time from the JWT token
func (b *BrowserAuthManager) parseTokenExpiry() error {
// JWT format: header.payload.signature
parts := strings.Split(b.token, ".")
if len(parts) != 3 {
return fmt.Errorf("invalid JWT format")
}
// Decode the payload (base64url without padding)
payload := parts[1]
// Add padding if needed
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
// Replace URL-safe characters
payload = strings.ReplaceAll(payload, "-", "+")
payload = strings.ReplaceAll(payload, "_", "/")
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return fmt.Errorf("failed to decode JWT payload: %w", err)
}
// Parse JSON
var claims struct {
Exp int64 `json:"exp"`
Sub string `json:"sub"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(decoded, &claims); err != nil {
return fmt.Errorf("failed to parse JWT claims: %w", err)
}
if claims.Exp == 0 {
return fmt.Errorf("no expiry in token")
}
b.tokenExpiry = time.Unix(claims.Exp, 0)
b.log.Infof(" Token user ID (sub): %s", claims.Sub)
b.log.Infof(" Token type (typ): %s", claims.Typ)
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// IsAuthenticated checks if we have a valid token
func (b *BrowserAuthManager) IsAuthenticated() bool {
return b.token != "" && time.Now().Before(b.tokenExpiry)
}
// GetUserID returns the user ID from the token (if available)
func (b *BrowserAuthManager) GetUserID() string {
if b.token == "" {
return ""
}
parts := strings.Split(b.token, ".")
if len(parts) != 3 {
return ""
}
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
payload = strings.ReplaceAll(payload, "-", "+")
payload = strings.ReplaceAll(payload, "_", "/")
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return ""
}
var claims struct {
Sub string `json:"sub"`
}
if err := json.Unmarshal(decoded, &claims); err != nil {
return ""
}
return claims.Sub
}

View File

@@ -34,6 +34,7 @@ const (
type GraphQLWSClient struct {
roomURL string
roomID string
token string // JWT token (can be empty for anonymous)
log *logrus.Entry
conn *websocket.Conn
messageCallback func(*NewMessagePayload)
@@ -50,10 +51,11 @@ type WSMessage struct {
}
// NewGraphQLWSClient creates a new native WebSocket client
func NewGraphQLWSClient(roomURL, roomID string, log *logrus.Entry) *GraphQLWSClient {
func NewGraphQLWSClient(roomURL, roomID, token string, log *logrus.Entry) *GraphQLWSClient {
return &GraphQLWSClient{
roomURL: roomURL,
roomID: roomID,
token: token,
log: log,
done: make(chan struct{}),
}
@@ -63,11 +65,18 @@ func NewGraphQLWSClient(roomURL, roomID string, log *logrus.Entry) *GraphQLWSCli
func (c *GraphQLWSClient) Connect() error {
c.log.Info("Connecting to Kosmi via native WebSocket")
// Step 1: Get anonymous token
c.log.Debug("Getting anonymous token...")
token, err := c.getAnonymousToken()
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
// Step 1: Get token (use provided or get anonymous)
var token string
if c.token != "" {
c.log.Debug("Using provided authentication token")
token = c.token
} else {
c.log.Debug("Getting anonymous token...")
var err error
token, err = c.getAnonymousToken()
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
}
// Step 2: Connect to WebSocket

View File

@@ -31,6 +31,7 @@ type Bkosmi struct {
roomID string
roomURL string
connected bool
authDone bool // Signals that authentication is complete (like IRC bridge)
msgChannel chan config.Message
jackboxClient *jackbox.Client
}
@@ -63,8 +64,42 @@ func (b *Bkosmi) Connect() error {
b.roomID = roomID
b.Log.Infof("Extracted room ID: %s", b.roomID)
// Create GraphQL WebSocket client (pure Go, no Playwright!)
b.client = NewGraphQLWSClient(b.roomURL, b.roomID, b.Log)
// Check if we need authentication
email := b.GetString("Email")
password := b.GetString("Password")
var token string
if email != "" && password != "" {
// Try to load cached token first
cachedToken, err := loadTokenCache(email, b.Log)
if err != nil {
b.Log.Warnf("Failed to load token cache: %v", err)
}
if cachedToken != nil {
// Use cached token
token = cachedToken.Token
} else {
// No valid cache, authenticate with browser
b.Log.Info("Authenticating with email/password...")
token, err = loginWithChromedp(email, password, b.Log)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
b.Log.Info("✅ Authentication successful")
// Save token to cache
if err := saveTokenCache(token, email, b.Log); err != nil {
b.Log.Warnf("Failed to cache token: %v", err)
}
}
} else {
b.Log.Info("No credentials provided, using anonymous access")
// token will be empty, client will get anonymous token
}
// Create GraphQL WebSocket client with token
b.client = NewGraphQLWSClient(b.roomURL, b.roomID, token, b.Log)
// Register message handler
b.client.OnMessage(b.handleIncomingMessage)
@@ -75,6 +110,7 @@ func (b *Bkosmi) Connect() error {
}
b.connected = true
b.authDone = true // Signal that authentication is complete
b.Log.Info("Successfully connected to Kosmi")
return nil
@@ -98,9 +134,18 @@ func (b *Bkosmi) Disconnect() error {
// JoinChannel joins a Kosmi room (no-op as we connect to a specific room)
func (b *Bkosmi) JoinChannel(channel config.ChannelInfo) error {
// Wait for authentication to complete before proceeding
// This ensures the WebSocket connection is fully established (like IRC bridge)
for {
if b.authDone {
break
}
time.Sleep(time.Second)
}
// 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)
b.Log.Debugf("Channel ready: %s (connected via room URL)", channel.Name)
return nil
}

197
bridge/kosmi/token_cache.go Normal file
View File

@@ -0,0 +1,197 @@
package bkosmi
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sirupsen/logrus"
)
// TokenCache represents a cached JWT token with metadata
type TokenCache struct {
Token string `json:"token"`
Email string `json:"email"` // To verify it's for the right account
ExpiresAt time.Time `json:"expires_at"` // When the token expires
SavedAt time.Time `json:"saved_at"` // When we cached it
}
const (
tokenCacheFile = "kosmi_token_cache.json"
// Refresh token if it expires within this window
tokenRefreshBuffer = 7 * 24 * time.Hour // 1 week before expiry
)
// getTokenCachePath returns the path to the token cache file
// Prioritizes MATTERBRIDGE_DATA_DIR env var, falls back to ~/.matterbridge
func getTokenCachePath() (string, error) {
var cacheDir string
// Check for environment variable (for Docker volumes)
if dataDir := os.Getenv("MATTERBRIDGE_DATA_DIR"); dataDir != "" {
cacheDir = dataDir
} else {
// Fall back to home directory for local development
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
cacheDir = filepath.Join(homeDir, ".matterbridge")
}
// Create cache directory if it doesn't exist
if err := os.MkdirAll(cacheDir, 0700); err != nil {
return "", fmt.Errorf("failed to create cache directory: %w", err)
}
return filepath.Join(cacheDir, tokenCacheFile), nil
}
// parseJWTExpiry extracts the expiry time from a JWT token
func parseJWTExpiry(token string) (time.Time, error) {
// JWT format: header.payload.signature
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, fmt.Errorf("invalid JWT format")
}
// Decode the payload (second part)
payload := parts[1]
// JWT uses base64url encoding, which may not have padding
// Add padding if needed
if len(payload)%4 != 0 {
payload += strings.Repeat("=", 4-len(payload)%4)
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
// Try standard base64 if URL encoding fails
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
}
}
// Parse the JSON payload
var claims struct {
Exp int64 `json:"exp"` // Expiry time as Unix timestamp
}
if err := json.Unmarshal(decoded, &claims); err != nil {
return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
}
if claims.Exp == 0 {
return time.Time{}, fmt.Errorf("no expiry time in JWT")
}
return time.Unix(claims.Exp, 0), nil
}
// loadTokenCache loads a cached token from disk
func loadTokenCache(email string, log *logrus.Entry) (*TokenCache, error) {
cachePath, err := getTokenCachePath()
if err != nil {
return nil, fmt.Errorf("failed to get cache path: %w", err)
}
// Check if cache file exists
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
return nil, nil // No cache file, not an error
}
// Read cache file
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, fmt.Errorf("failed to read cache file: %w", err)
}
// Parse cache
var cache TokenCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, fmt.Errorf("failed to parse cache file: %w", err)
}
// Verify it's for the correct email
if cache.Email != email {
log.Debugf("Cached token is for different email (%s vs %s), ignoring", cache.Email, email)
return nil, nil
}
// Check if token is expired or close to expiring
now := time.Now()
if now.After(cache.ExpiresAt) {
log.Info("Cached token has expired")
return nil, nil
}
if now.After(cache.ExpiresAt.Add(-tokenRefreshBuffer)) {
log.Infof("Cached token expires soon (%s), will refresh", cache.ExpiresAt.Format(time.RFC3339))
return nil, nil
}
timeUntilExpiry := cache.ExpiresAt.Sub(now)
log.Infof("✅ Using cached token (expires in %s)", timeUntilExpiry.Round(24*time.Hour))
return &cache, nil
}
// saveTokenCache saves a token to disk
func saveTokenCache(token, email string, log *logrus.Entry) error {
cachePath, err := getTokenCachePath()
if err != nil {
return fmt.Errorf("failed to get cache path: %w", err)
}
// Parse token expiry
expiresAt, err := parseJWTExpiry(token)
if err != nil {
log.Warnf("Failed to parse token expiry, will not cache: %v", err)
return nil // Don't fail the connection, just skip caching
}
// Create cache structure
cache := TokenCache{
Token: token,
Email: email,
ExpiresAt: expiresAt,
SavedAt: time.Now(),
}
// Marshal to JSON
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal cache: %w", err)
}
// Write to file with restricted permissions
if err := os.WriteFile(cachePath, data, 0600); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}
timeUntilExpiry := expiresAt.Sub(time.Now())
log.Infof("💾 Token cached (expires in %s)", timeUntilExpiry.Round(24*time.Hour))
return nil
}
// clearTokenCache removes the cached token
func clearTokenCache(log *logrus.Entry) error {
cachePath, err := getTokenCachePath()
if err != nil {
return fmt.Errorf("failed to get cache path: %w", err)
}
if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove cache file: %w", err)
}
log.Debug("Token cache cleared")
return nil
}

Binary file not shown.

View File

@@ -0,0 +1,16 @@
module github.com/erikfredericks/get-kosmi-token
go 1.21
require github.com/chromedp/chromedp v0.9.2
require (
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
golang.org/x/sys v0.11.0 // indirect
)

View File

@@ -0,0 +1,23 @@
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

276
cmd/get-kosmi-token/main.go Normal file
View File

@@ -0,0 +1,276 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/chromedp/chromedp"
)
// Standalone script to extract JWT token from Kosmi using browser automation
// Usage: go run get-token.go --email "email@email.com" --password "password"
var (
emailFlag = flag.String("email", "", "Email for Kosmi login")
passwordFlag = flag.String("password", "", "Password for Kosmi login")
headlessFlag = flag.Bool("headless", true, "Run browser in headless mode")
verboseFlag = flag.Bool("verbose", false, "Enable verbose logging")
)
func main() {
flag.Parse()
if *emailFlag == "" || *passwordFlag == "" {
log.Fatal("Error: --email and --password are required")
}
// Set up logging
if !*verboseFlag {
log.SetOutput(os.Stderr)
}
log.Println("Starting browser automation to extract JWT token...")
log.Printf("Email: %s", *emailFlag)
log.Printf("Headless mode: %v", *headlessFlag)
token, err := extractToken(*emailFlag, *passwordFlag, *headlessFlag)
if err != nil {
log.Fatalf("Failed to extract token: %v", err)
}
// Output token to stdout (so it can be captured)
fmt.Println(token)
if *verboseFlag {
log.Printf("✓ Successfully extracted token (length: %d)", len(token))
}
}
func extractToken(email, password string, headless bool) (string, error) {
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// Set up chromedp options
opts := []chromedp.ExecAllocatorOption{
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.DisableGPU,
chromedp.NoSandbox,
}
if headless {
opts = append(opts, chromedp.Headless)
}
// Create allocator context
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
defer cancel()
// Create browser context - suppress cookie errors unless verbose
logFunc := func(string, ...interface{}) {}
if *verboseFlag {
logFunc = log.Printf
}
ctx, cancel = chromedp.NewContext(allocCtx, chromedp.WithLogf(logFunc))
defer cancel()
var token string
// Run the automation tasks
err := chromedp.Run(ctx,
// Step 1: Navigate to Kosmi
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("→ Navigating to https://app.kosmi.io")
return chromedp.Navigate("https://app.kosmi.io").Do(ctx)
}),
// Step 2: Wait for page to load
chromedp.WaitReady("body"),
chromedp.Sleep(3*time.Second),
// Step 3: Find and click Login button
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("→ Looking for Login button...")
// Log all buttons we can see
if *verboseFlag {
var buttonTexts []string
chromedp.Evaluate(`
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
`, &buttonTexts).Do(ctx)
log.Printf(" Found buttons: %v", buttonTexts)
}
var found bool
if err := chromedp.Evaluate(`
(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const btn = buttons.find(el => {
const text = el.textContent.trim();
return text === 'Login' || text === 'Log in';
});
if (btn) {
btn.click();
return true;
}
return false;
})()
`, &found).Do(ctx); err != nil {
return err
}
if !found {
return fmt.Errorf("Login button not found")
}
log.Println("✓ Clicked Login button")
return nil
}),
// Step 4: Wait for login modal
chromedp.Sleep(3*time.Second),
// Step 5: Click "Login with Email" button
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("→ Looking for 'Login with Email' button...")
if *verboseFlag {
var buttonTexts []string
chromedp.Evaluate(`
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
`, &buttonTexts).Do(ctx)
log.Printf(" Found buttons: %v", buttonTexts)
}
var found bool
if err := chromedp.Evaluate(`
(() => {
const btn = Array.from(document.querySelectorAll('button')).find(el =>
el.textContent.includes('Email')
);
if (btn) {
btn.click();
return true;
}
return false;
})()
`, &found).Do(ctx); err != nil {
return err
}
if !found {
return fmt.Errorf("'Login with Email' button not found")
}
log.Println("✓ Clicked 'Login with Email' button")
return nil
}),
// Step 6: Wait for email form
chromedp.Sleep(3*time.Second),
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
// Step 7: Fill in email
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("→ Filling email field...")
return nil
}),
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
chromedp.Sleep(200*time.Millisecond),
chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery),
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("✓ Email entered")
return nil
}),
chromedp.Sleep(500*time.Millisecond),
// Step 8: Fill in password
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("→ Filling password field...")
return nil
}),
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
chromedp.Sleep(200*time.Millisecond),
chromedp.SendKeys(`input[type="password"]`, password+"\n", chromedp.ByQuery),
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("✓ Password entered and form submitted")
return nil
}),
chromedp.Sleep(500*time.Millisecond),
// Step 10: Wait for login to complete - check for modal to close
chromedp.ActionFunc(func(ctx context.Context) error {
log.Println("→ Waiting for login to complete...")
// Wait for the login modal to disappear (indicates successful login)
maxAttempts := 30 // 15 seconds total
for i := 0; i < maxAttempts; i++ {
time.Sleep(500 * time.Millisecond)
// Check if login modal is gone (successful login)
var modalGone bool
chromedp.Evaluate(`
(() => {
// Check if the email/password form is still visible
const emailInput = document.querySelector('input[placeholder*="Email"], input[placeholder*="Username"]');
const passwordInput = document.querySelector('input[type="password"]');
return !emailInput && !passwordInput;
})()
`, &modalGone).Do(ctx)
if modalGone {
log.Println("✓ Login modal closed - login successful")
// Modal is gone, wait a bit more for token to be set
time.Sleep(2 * time.Second)
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
if token != "" {
log.Println("✓ Authenticated token received")
return nil
}
}
// Check for error messages
var errorText string
chromedp.Evaluate(`
(() => {
const errorEl = document.querySelector('[role="alert"], .error, .error-message');
return errorEl ? errorEl.textContent.trim() : '';
})()
`, &errorText).Do(ctx)
if errorText != "" && errorText != "null" {
return fmt.Errorf("login failed: %s", errorText)
}
}
// Timeout - get whatever token is there
log.Println("⚠ Login timeout - extracting current token")
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
if token == "" {
return fmt.Errorf("login timeout: no token found")
}
log.Println("✓ Token extracted (may be anonymous)")
return nil
}),
)
if err != nil {
return "", fmt.Errorf("browser automation failed: %w", err)
}
if token == "" {
return "", fmt.Errorf("failed to extract token from localStorage")
}
return token, nil
}

Binary file not shown.

Binary file not shown.

View File

@@ -14,12 +14,16 @@ services:
- ./matterbridge.toml:/app/matterbridge.toml:ro,z
# Optional: Mount a directory for logs
- ./logs:/app/logs:z
# Mount data directory for persistent token cache
- ./data:/app/data:z
# If you need to expose any ports (e.g., for API or webhooks)
# ports:
# - "4242:4242"
environment:
# Optional: Set timezone
- TZ=America/New_York
# Data directory for persistent storage (token cache, etc.)
- MATTERBRIDGE_DATA_DIR=/app/data
# Optional: Set memory limits (much lower now without browser!)
# mem_limit: 128m
# mem_reservation: 64m

View File

@@ -1,200 +0,0 @@
[0] Type: connection_init
[2] Type: subscribe
Operation: SettingsQuery
Query:
[3] Type: subscribe
Operation: ExtendedCurrentUserQuery
Query:
[4] Type: subscribe
Operation: UserRoomQuery
Query:
[5] Type: subscribe
Operation: WebRTCIceServerQuery
Query:
[6] Type: subscribe
Query:
[16] Type: subscribe
Operation: CurrentUserQuery4
Query:
[20] Type: subscribe
Operation: NotificationQuery1
Query:
[21] Type: subscribe
Operation: MessageList
Query:
[22] Type: subscribe
Operation: EventMutation
Query:
[23] Type: subscribe
Operation: ServerTimeQuery
Query:
[24] Type: subscribe
Operation: JoinRoom
Query:
[25] Type: subscribe
Operation: NewNotificationSubscription
Query:
[26] Type: subscribe
Operation: NewPublicNotificationSubscription
Query:
[27] Type: subscribe
Operation: MessageListUpdate
Query:
[28] Type: subscribe
Operation: OnNewPrivateMessage
Query:
[29] Type: subscribe
Operation: PrivateMessageDeletedSubsciption
Query:
[30] Type: subscribe
Operation: RemoveRoomSubscription
Query:
[31] Type: subscribe
Operation: RoomlistRoomUpdateSubscription
Query:
[32] Type: subscribe
Operation: OnFriendListUpdate
Query:
[33] Type: subscribe
Operation: SubscriptionUpdateSubscription
Query:
[34] Type: subscribe
Operation: SettingsSubscription
Query:
[41] Type: subscribe
Operation: GetRunningApp
Query:
[42] Type: subscribe
Operation: RoomRootQuery
Query:
[43] Type: subscribe
Operation: WithGetMembers
Query:
[44] Type: subscribe
Operation: GetSpacesState
Query:
[45] Type: subscribe
Operation: RoomChatQuery
Query:
[46] Type: subscribe
Operation: LinkedMembers
Query:
[47] Type: subscribe
Operation: mediaPlayerStateQuery1
Query:
[48] Type: subscribe
Operation: MediaPlayerSubtitlesQuery
Query:
[49] Type: subscribe
Operation: MediaSoupStateQuery
Query:
[50] Type: subscribe
Operation: ToolbarMetadataQuery
Query:
[51] Type: subscribe
Operation: MessageReaders
Query:
[56] Type: subscribe
Operation: UserTyping
Query:
[57] Type: subscribe
Operation: RoomDisconnect
Query:
[58] Type: subscribe
Operation: MemberJoins
Query:
[59] Type: subscribe
Operation: MemberLeaves
Query:
[60] Type: subscribe
Operation: SetRole2
Query:
[61] Type: subscribe
Operation: NewMessageSubscription
Query:
[62] Type: subscribe
Operation: MessageDeletedSubscription
Query:
[63] Type: subscribe
Operation: ClearMessagesSubscription
Query:
[64] Type: subscribe
Operation: ReactionAdded
Query:
[65] Type: subscribe
Operation: ReactionRemoved
Query:
[66] Type: subscribe
Operation: MessageEditedSubscription
Query:
[67] Type: subscribe
Operation: OnSpacesStateUpdate
Query:
[68] Type: subscribe
Operation: NewMemberSubscription
Query:
[69] Type: subscribe
Operation: MemberUnlinksSubscription
Query:
[70] Type: subscribe
Operation: SetRole
Query:
[71] Type: subscribe
Operation: OnMediaSoupUpdateState
Query:
[75] Type: subscribe
Operation: StartAppSubscription
Query:
[76] Type: subscribe
Operation: MetadataUpdates
Query:
[83] Type: subscribe
Operation: OnMediaPlayerUpdateState
Query:
[84] Type: subscribe
Operation: OnMediaPlayerUpdateSubtitles
Query:
[98] Type: subscribe
Operation: ServerTimeQuery
Query:
[102] Type: subscribe
Operation: Login
Query:
[0] Type: connection_init
[2] Type: subscribe
Operation: LeaveRoom
Query:
[3] Type: subscribe
Operation: SettingsQuery
Query:
[4] Type: subscribe
Operation: ExtendedCurrentUserQuery
Query:
[5] Type: subscribe
Operation: UserRoomQuery
Query:
[6] Type: subscribe
Operation: WebRTCIceServerQuery
Query:
[7] Type: subscribe
Query:
[14] Type: subscribe
Operation: NotificationQuery3
Query:
[15] Type: subscribe
Operation: MessageList
Query:
[16] Type: subscribe
Operation: NotificationQuery2
Query:
[22] Type: subscribe
Operation: CurrentUserQuery4
Query:
[32] Type: subscribe
Operation: NotificationQuery1
Query:
[33] Type: subscribe
Operation: MessageList
Query:
[34] Type: subscribe
Operation: EventMutation

130
matterbridge.toml.example Normal file
View File

@@ -0,0 +1,130 @@
# Matterbridge configuration for Kosmi <-> IRC relay
#
# IMPORTANT: Copy this file to matterbridge.toml and update values before running:
# 1. Change the Kosmi RoomURL to your room
# 2. Change the IRC server and channel to your IRC network
# 3. Set your bot's nickname
# 4. Configure NickServ authentication if needed
# 5. Set Jackbox credentials if using that integration
###################################################################
# Kosmi section
###################################################################
[kosmi]
[kosmi.hyperspaceout]
# Kosmi room URL (required)
# Format: https://app.kosmi.io/room/@roomname or https://app.kosmi.io/room/roomid
RoomURL="https://app.kosmi.io/room/@yourroom"
# Optional: Email/password authentication
# If not provided, will use anonymous access
# Note: Requires Chrome/Chromium installed for browser automation
Email=""
Password=""
# How to format usernames from other bridges
RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
###################################################################
# IRC section
###################################################################
[irc]
[irc.zeronode]
# IRC server to connect to (change this to your IRC server)
Server="irc.libera.chat:6697"
# Your bot's nickname (change this)
Nick="kosmi-relay"
# How to format usernames from other bridges
RemoteNickFormat="[{PROTOCOL}] <{NICK}> "
# Enable TLS (recommended for public servers)
UseTLS=true
# For TLS, use port 6697:
# Server="irc.libera.chat:6697"
# UseTLS=true
# Skip TLS verification (only for self-signed certs, not recommended)
SkipTLSVerify=false
# NickServ authentication (optional but recommended)
# Register your nick on the IRC network first, then uncomment:
#NickServNick="nickserv"
#NickServPassword="your_password"
# Alternative: Use SASL authentication (more secure)
#UseSASL=true
#NickServUsername="kosmi-relay"
#NickServPassword="your_password"
# Channels to auto-join on connect (optional)
Channels=["#your-channel"]
# Enable debug logging for IRC (optional)
Debug=false
###################################################################
# Gateway configuration
###################################################################
# This connects the Kosmi room to the IRC channel
[[gateway]]
name="kosmi-irc-gateway"
enable=true
# Kosmi side
[[gateway.inout]]
account="kosmi.hyperspaceout"
channel="main" # Kosmi uses a single "main" channel per room
# IRC side
[[gateway.inout]]
account="irc.zeronode"
channel="#your-channel"
###################################################################
# Jackbox Game Picker API Integration
###################################################################
[jackbox]
# Enable Jackbox integration for vote detection and game notifications
Enabled=false
# Jackbox API URL
APIURL="https://your-jackbox-api.example.com"
# Admin password for API authentication
AdminPassword=""
# Use WebSocket for real-time game notifications (recommended)
# Set to false to use webhooks instead
UseWebSocket=true
# Webhook configuration (only needed if UseWebSocket=false)
# Webhook server port (for receiving game notifications)
WebhookPort=3001
# Webhook secret for signature verification
WebhookSecret=""
# Enable room code image upload for Kosmi chat
# When enabled, generates a PNG image of the room code and attempts to upload it
# Falls back to plain text if upload fails or is not supported
EnableRoomCodeImage=true
# Delay in seconds before sending the image+announcement message (default: 0)
RoomCodeImageDelay=28
# Delay in seconds before sending the plaintext room code follow-up (default: 29)
RoomCodePlaintextDelay=22
###################################################################
# General settings
###################################################################
[general]
# Show join/leave messages
ShowJoinPart=false
# Remote nick format (how nicks from other bridges are displayed)
#RemoteNickFormat="[{PROTOCOL}] <{NICK}> "

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB