Compare commits
10 Commits
1cad3cb47f
...
1831b0e923
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1831b0e923
|
||
|
|
8eeefcc81c
|
||
|
|
78accf403d
|
||
|
|
db284d0677
|
||
|
|
3b7a139606
|
||
|
|
fd42ac0e7c | ||
|
|
1e0cb63b1c | ||
|
|
673c8025ee | ||
|
|
9262ae79dd | ||
|
|
f764519a30 |
35
.gitignore
vendored
35
.gitignore
vendored
@@ -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/
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -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
181
TOKEN_PERSISTENCE.md
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -305,7 +305,7 @@ func GenerateRoomCodeImage(roomCode, gameTitle string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// Animation parameters
|
||||
initialPauseFrames := 25 // Initial pause before animation starts (2.5 seconds at 10fps)
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,8 @@ type WebSocketClient struct {
|
||||
authenticated bool
|
||||
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,13 +69,15 @@ 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,
|
||||
roomCodeImageDelay: roomCodeImageDelay,
|
||||
roomCodePlaintextDelay: roomCodePlaintextDelay,
|
||||
log: log,
|
||||
reconnectDelay: 1 * time.Second,
|
||||
maxReconnect: 30 * time.Second,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
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)
|
||||
|
||||
// Check if login modal is gone (successful login)
|
||||
var modalGone bool
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
|
||||
// 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;
|
||||
|
||||
return isLoginBtn && isEnabled && isVisible && isInForm;
|
||||
});
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.click();
|
||||
return 'CLICKED: ' + submitBtn.textContent.trim();
|
||||
}
|
||||
|
||||
return 'NOT_FOUND';
|
||||
// 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;
|
||||
})()
|
||||
`, &result).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
`, &modalGone).Do(ctx)
|
||||
|
||||
b.log.Debugf("Submit button result: %s", result)
|
||||
|
||||
if result == "NOT_FOUND" {
|
||||
return fmt.Errorf("Login submit button not found or not clickable")
|
||||
}
|
||||
|
||||
b.log.Debug("Submit button clicked")
|
||||
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
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Check for error messages
|
||||
var errorText string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
|
||||
return errorEl ? errorEl.textContent : '';
|
||||
const errorEl = document.querySelector('[role="alert"], .error, .error-message');
|
||||
return errorEl ? errorEl.textContent.trim() : '';
|
||||
})()
|
||||
`, &errorText).Do(ctx)
|
||||
|
||||
if errorText != "" {
|
||||
return fmt.Errorf("login failed with error: %s", errorText)
|
||||
if errorText != "" && errorText != "null" {
|
||||
return fmt.Errorf("login failed: %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;
|
||||
// Timeout - get whatever token is there
|
||||
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
|
||||
if token == "" {
|
||||
return fmt.Errorf("login timeout: no token found")
|
||||
}
|
||||
})()
|
||||
`, &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)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
b.lastCheckTime = time.Now()
|
||||
|
||||
expiresIn := time.Until(b.tokenExpiry)
|
||||
b.log.Infof("Token expires in: %v", expiresIn.Round(24*time.Hour))
|
||||
|
||||
return 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +65,19 @@ 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
|
||||
// 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...")
|
||||
token, err := c.getAnonymousToken()
|
||||
var err error
|
||||
token, err = c.getAnonymousToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Connect to WebSocket
|
||||
c.log.Debug("Establishing WebSocket connection...")
|
||||
|
||||
@@ -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
197
bridge/kosmi/token_cache.go
Normal 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
|
||||
}
|
||||
|
||||
BIN
capture-auth
BIN
capture-auth
Binary file not shown.
16
cmd/get-kosmi-token/go.mod
Normal file
16
cmd/get-kosmi-token/go.mod
Normal 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
|
||||
)
|
||||
23
cmd/get-kosmi-token/go.sum
Normal file
23
cmd/get-kosmi-token/go.sum
Normal 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
276
cmd/get-kosmi-token/main.go
Normal 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.
@@ -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
|
||||
|
||||
@@ -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
130
matterbridge.toml.example
Normal 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}> "
|
||||
BIN
monitor-ws
BIN
monitor-ws
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.
BIN
test-long-title
BIN
test-long-title
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
test-session
BIN
test-session
Binary file not shown.
BIN
test-upload
BIN
test-upload
Binary file not shown.
BIN
test-websocket
BIN
test-websocket
Binary file not shown.
Binary file not shown.
BIN
test_upload.gif
BIN
test_upload.gif
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Reference in New Issue
Block a user