nailed it
This commit is contained in:
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 ./
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,25 @@ 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 != "" {
|
||||
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")
|
||||
} 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 +93,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 +117,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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user