This commit is contained in:
cottongin
2025-11-01 21:00:16 -04:00
parent bd9513b86c
commit dd398c9a8c
31 changed files with 5211 additions and 4 deletions

View File

@@ -215,6 +215,11 @@ func (b *Birc) doConnect() {
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
func sanitizeNick(nick string) string {
sanitize := func(r rune) rune {
// Allow invisible characters used for preventing highlights
// U+200B: zero-width space, U+2060: word joiner
if r == '\u200B' || r == '\u2060' || r == '\x0F' {
return r
}
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
return '-'
}
@@ -229,12 +234,36 @@ func (b *Birc) doSend() {
for msg := range b.Local {
<-throttle.C
username := msg.Username
// Insert invisible characters into the actual username to prevent highlights
// The username may already be formatted like "[protocol] <nick> " so we need to find
// the actual nick part and modify that
if len(msg.Username) > 0 {
// Try to find the actual username within angle brackets <username>
if strings.Contains(username, "<") && strings.Contains(username, ">") {
startIdx := strings.Index(username, "<") + 1
endIdx := strings.Index(username, ">")
if startIdx < endIdx && endIdx <= len(username) {
actualNick := username[startIdx:endIdx]
if len(actualNick) > 1 {
// Insert invisible characters after first character of actual nick
modifiedNick := string(actualNick[0]) + "\u200B\u2060\x0F" + actualNick[1:]
username = username[:startIdx] + modifiedNick + username[endIdx:]
b.Log.Infof("Modified username: %q -> %q", msg.Username, username)
}
}
} else if len(username) > 1 {
// Fallback: no angle brackets, just modify the username directly
username = string(username[0]) + "\u200B\u2060\x0F" + username[1:]
b.Log.Infof("Modified username (no brackets): %q -> %q", msg.Username, username)
}
}
// Optional support for the proposed RELAYMSG extension, described at
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
// nolint:nestif
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
b.GetBool("UseRelayMsg") {
username = sanitizeNick(username)
b.Log.Infof("After sanitizeNick: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
text := msg.Text
// Work around girc chomping leading commas on single word messages?
@@ -245,23 +274,24 @@ func (b *Birc) doSend() {
if msg.Event == config.EventUserAction {
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
} else {
b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
b.Log.Infof("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
}
} else {
if b.GetBool("Colornicks") {
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username)
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, username)
}
b.Log.Infof("Final username before send: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
switch msg.Event {
case config.EventUserAction:
b.i.Cmd.Action(msg.Channel, username+msg.Text)
case config.EventNoticeIRC:
b.Log.Debugf("Sending notice to channel %s", msg.Channel)
b.Log.Infof("Sending notice to channel %s", msg.Channel)
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
default:
b.Log.Debugf("Sending to channel %s", msg.Channel)
b.Log.Infof("Sending to channel %s", msg.Channel)
b.i.Cmd.Message(msg.Channel, username+msg.Text)
}
}

139
bridge/jackbox/errors.go Normal file
View File

@@ -0,0 +1,139 @@
package jackbox
import (
"errors"
"fmt"
)
// Sentinel errors for common failure scenarios
var (
// ErrNotAuthenticated indicates the client is not authenticated
ErrNotAuthenticated = errors.New("not authenticated")
// ErrAuthFailed indicates authentication failed
ErrAuthFailed = errors.New("authentication failed")
// ErrConnectionLost indicates the WebSocket connection was lost
ErrConnectionLost = errors.New("connection lost")
// ErrTokenExpired indicates the authentication token has expired
ErrTokenExpired = errors.New("token expired")
// ErrInvalidResponse indicates an unexpected response from the API
ErrInvalidResponse = errors.New("invalid response from API")
// ErrSessionNotFound indicates the specified session does not exist
ErrSessionNotFound = errors.New("session not found")
// ErrNotSubscribed indicates not subscribed to any session
ErrNotSubscribed = errors.New("not subscribed to any session")
)
// APIError represents an API-related error with context
type APIError struct {
Op string // Operation that failed (e.g., "vote", "get_session", "authenticate")
StatusCode int // HTTP status code
Message string // Error message from API
Err error // Underlying error
}
func (e *APIError) Error() string {
if e.StatusCode > 0 {
return fmt.Sprintf("API error during %s (HTTP %d): %s", e.Op, e.StatusCode, e.Message)
}
if e.Err != nil {
return fmt.Sprintf("API error during %s: %s (%v)", e.Op, e.Message, e.Err)
}
return fmt.Sprintf("API error during %s: %s", e.Op, e.Message)
}
func (e *APIError) Unwrap() error {
return e.Err
}
// WebSocketError represents a WebSocket-related error with context
type WebSocketError struct {
Op string // Operation that failed (e.g., "connect", "subscribe", "send")
Err error // Underlying error
}
func (e *WebSocketError) Error() string {
return fmt.Sprintf("WebSocket error during %s: %v", e.Op, e.Err)
}
func (e *WebSocketError) Unwrap() error {
return e.Err
}
// SessionError represents a session-related error with context
type SessionError struct {
Op string // Operation that failed (e.g., "subscribe", "get_active")
SessionID int // Session ID
Err error // Underlying error
}
func (e *SessionError) Error() string {
return fmt.Sprintf("session error during %s for session %d: %v", e.Op, e.SessionID, e.Err)
}
func (e *SessionError) Unwrap() error {
return e.Err
}
// IsRetryable returns true if the error is transient and the operation should be retried
func IsRetryable(err error) bool {
if err == nil {
return false
}
// Check for known retryable errors
if errors.Is(err, ErrConnectionLost) ||
errors.Is(err, ErrTokenExpired) {
return true
}
// Check for API errors with retryable status codes
var apiErr *APIError
if errors.As(err, &apiErr) {
// 5xx errors are typically retryable
if apiErr.StatusCode >= 500 && apiErr.StatusCode < 600 {
return true
}
// 429 Too Many Requests is retryable
if apiErr.StatusCode == 429 {
return true
}
}
// WebSocket errors are generally retryable
var wsErr *WebSocketError
if errors.As(err, &wsErr) {
return true
}
return false
}
// IsFatal returns true if the error is fatal and reconnection should not be attempted
func IsFatal(err error) bool {
if err == nil {
return false
}
// Check for known fatal errors
if errors.Is(err, ErrAuthFailed) ||
errors.Is(err, ErrSessionNotFound) {
return true
}
// Check for API errors with fatal status codes
var apiErr *APIError
if errors.As(err, &apiErr) {
// 4xx errors (except 429) are typically fatal
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 429 {
return true
}
}
return false
}

396
bridge/kosmi/auth.go Normal file
View File

@@ -0,0 +1,396 @@
package bkosmi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/sirupsen/logrus"
)
const (
// Token expiry buffer - refresh if token expires within this window
tokenExpiryBuffer = 5 * time.Minute
)
// AuthManager handles authentication with Kosmi
type AuthManager struct {
email string
password string
token string
tokenExpiry time.Time
refreshToken string
userID string
mu sync.RWMutex
log *logrus.Entry
httpClient *http.Client
}
// NewAuthManager creates a new authentication manager
func NewAuthManager(email, password string, log *logrus.Entry) *AuthManager {
return &AuthManager{
email: email,
password: password,
log: log,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Login performs email/password authentication
//
// NOTE: The actual login API format needs to be verified through monitoring.
// This implementation is based on common GraphQL patterns and may need adjustment.
func (a *AuthManager) Login() error {
a.mu.Lock()
defer a.mu.Unlock()
a.log.Info("Logging in to Kosmi...")
// Prepare login mutation
// Based on common GraphQL patterns, likely something like:
// mutation { login(email: "...", password: "...") { token user { id displayName } } }
mutation := map[string]interface{}{
"query": `mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
refreshToken
expiresIn
user {
id
displayName
username
}
}
}`,
"variables": map[string]interface{}{
"email": a.email,
"password": a.password,
},
}
jsonBody, err := json.Marshal(mutation)
if err != nil {
return &AuthError{
Op: "login",
Reason: "failed to marshal request",
Err: err,
}
}
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
if err != nil {
return &AuthError{
Op: "login",
Reason: "failed to create request",
Err: err,
}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://app.kosmi.io/")
req.Header.Set("User-Agent", userAgent)
resp, err := a.httpClient.Do(req)
if err != nil {
return &AuthError{
Op: "login",
Reason: "request failed",
Err: err,
}
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
return &AuthError{
Op: "login",
Reason: "invalid credentials",
Err: ErrAuthFailed,
}
}
if resp.StatusCode != 200 {
return &AuthError{
Op: "login",
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
Err: ErrAuthFailed,
}
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return &AuthError{
Op: "login",
Reason: "failed to parse response",
Err: err,
}
}
// Extract token and user info
if data, ok := result["data"].(map[string]interface{}); ok {
if login, ok := data["login"].(map[string]interface{}); ok {
if token, ok := login["token"].(string); ok {
a.token = token
// Extract refresh token if present
if refreshToken, ok := login["refreshToken"].(string); ok {
a.refreshToken = refreshToken
}
// Calculate token expiry
if expiresIn, ok := login["expiresIn"].(float64); ok {
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
} else {
// Default to 24 hours if not specified
a.tokenExpiry = time.Now().Add(24 * time.Hour)
}
// Extract user ID
if user, ok := login["user"].(map[string]interface{}); ok {
if userID, ok := user["id"].(string); ok {
a.userID = userID
}
if displayName, ok := user["displayName"].(string); ok {
a.log.Infof("Logged in as: %s", displayName)
}
}
a.log.Info("✅ Login successful")
return nil
}
}
}
// Check for GraphQL errors
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
if errObj, ok := errors[0].(map[string]interface{}); ok {
if message, ok := errObj["message"].(string); ok {
return &AuthError{
Op: "login",
Reason: message,
Err: ErrAuthFailed,
}
}
}
}
return &AuthError{
Op: "login",
Reason: "no token in response",
Err: ErrAuthFailed,
}
}
// GetToken returns a valid token, refreshing if necessary
func (a *AuthManager) GetToken() (string, error) {
a.mu.RLock()
// Check if token is still valid
if a.token != "" && time.Now().Before(a.tokenExpiry.Add(-tokenExpiryBuffer)) {
token := a.token
a.mu.RUnlock()
return token, nil
}
a.mu.RUnlock()
// Token is expired or about to expire, refresh it
if a.refreshToken != "" {
if err := a.RefreshToken(); err != nil {
a.log.Warnf("Token refresh failed, attempting re-login: %v", err)
if err := a.Login(); err != nil {
return "", err
}
}
} else {
// No refresh token, need to login again
if err := a.Login(); err != nil {
return "", err
}
}
a.mu.RLock()
defer a.mu.RUnlock()
return a.token, nil
}
// RefreshToken renews the access token using the refresh token
func (a *AuthManager) RefreshToken() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.refreshToken == "" {
return &AuthError{
Op: "token_refresh",
Reason: "no refresh token available",
Err: ErrTokenExpired,
}
}
a.log.Debug("Refreshing authentication token...")
// Prepare refresh mutation
mutation := map[string]interface{}{
"query": `mutation RefreshToken($refreshToken: String!) {
refreshToken(refreshToken: $refreshToken) {
token
refreshToken
expiresIn
}
}`,
"variables": map[string]interface{}{
"refreshToken": a.refreshToken,
},
}
jsonBody, err := json.Marshal(mutation)
if err != nil {
return &AuthError{
Op: "token_refresh",
Reason: "failed to marshal request",
Err: err,
}
}
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
if err != nil {
return &AuthError{
Op: "token_refresh",
Reason: "failed to create request",
Err: err,
}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://app.kosmi.io/")
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
resp, err := a.httpClient.Do(req)
if err != nil {
return &AuthError{
Op: "token_refresh",
Reason: "request failed",
Err: err,
}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return &AuthError{
Op: "token_refresh",
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
Err: ErrTokenExpired,
}
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return &AuthError{
Op: "token_refresh",
Reason: "failed to parse response",
Err: err,
}
}
// Extract new token
if data, ok := result["data"].(map[string]interface{}); ok {
if refresh, ok := data["refreshToken"].(map[string]interface{}); ok {
if token, ok := refresh["token"].(string); ok {
a.token = token
// Update refresh token if provided
if newRefreshToken, ok := refresh["refreshToken"].(string); ok {
a.refreshToken = newRefreshToken
}
// Update expiry
if expiresIn, ok := refresh["expiresIn"].(float64); ok {
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
} else {
a.tokenExpiry = time.Now().Add(24 * time.Hour)
}
a.log.Debug("✅ Token refreshed successfully")
return nil
}
}
}
return &AuthError{
Op: "token_refresh",
Reason: "no token in response",
Err: ErrTokenExpired,
}
}
// Logout invalidates the current session
func (a *AuthManager) Logout() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.token == "" {
return nil // Already logged out
}
a.log.Info("Logging out from Kosmi...")
// Prepare logout mutation
mutation := map[string]interface{}{
"query": `mutation Logout {
logout {
ok
}
}`,
}
jsonBody, err := json.Marshal(mutation)
if err != nil {
a.log.Warnf("Failed to marshal logout request: %v", err)
// Continue with local cleanup
} else {
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
if err == nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://app.kosmi.io/")
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
resp, err := a.httpClient.Do(req)
if err != nil {
a.log.Warnf("Logout request failed: %v", err)
} else {
resp.Body.Close()
if resp.StatusCode != 200 {
a.log.Warnf("Logout returned HTTP %d", resp.StatusCode)
}
}
}
}
// Clear local state regardless of server response
a.token = ""
a.refreshToken = ""
a.tokenExpiry = time.Time{}
a.userID = ""
a.log.Info("✅ Logged out")
return nil
}
// IsAuthenticated checks if we have a valid token
func (a *AuthManager) IsAuthenticated() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.token != "" && time.Now().Before(a.tokenExpiry)
}
// GetUserID returns the authenticated user's ID
func (a *AuthManager) GetUserID() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.userID
}

View File

@@ -0,0 +1,462 @@
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()
// Create context with timeout
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
// Set a reasonable timeout for the entire login process
ctx, cancel = context.WithTimeout(ctx, 90*time.Second)
defer cancel()
var token string
// Run the browser 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),
// Click Login button (find by text content using JS with error handling)
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)
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';
});
if (btn) {
btn.click();
return true;
}
return false;
})()
`, &found).Do(ctx); err != nil {
return err
}
if !found {
return fmt.Errorf("Login button not found (found buttons: %v)", buttonTexts)
}
return nil
}),
// Wait for login modal
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)
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 (found buttons: %v)", buttonTexts)
}
return nil
}),
// Wait for email form
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.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.Sleep(500*time.Millisecond),
// Wait a moment for form validation
chromedp.Sleep(1*time.Second),
// Click the login submit button (be very specific)
chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debug("Attempting to click submit button...")
var result string
if err := chromedp.Evaluate(`
(() => {
const buttons = Array.from(document.querySelectorAll('button'));
// 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';
})()
`, &result).Do(ctx); err != nil {
return err
}
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")
return nil
}),
// Wait for login to complete (page will reload/redirect)
chromedp.Sleep(5*time.Second),
// Check if login succeeded by looking for error messages
chromedp.ActionFunc(func(ctx context.Context) error {
var errorText string
chromedp.Evaluate(`
(() => {
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
return errorEl ? errorEl.textContent : '';
})()
`, &errorText).Do(ctx)
if errorText != "" {
return fmt.Errorf("login failed with error: %s", errorText)
}
b.log.Debug("No error messages found, checking token...")
return nil
}),
// Extract token from localStorage
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
// Verify the token is not anonymous by checking if user info exists
chromedp.ActionFunc(func(ctx context.Context) error {
var userInfo string
chromedp.Evaluate(`
(() => {
try {
const token = localStorage.getItem('token');
if (!token) return 'NO_TOKEN';
// Decode JWT payload (middle part)
const parts = token.split('.');
if (parts.length !== 3) return 'INVALID_TOKEN';
const payload = JSON.parse(atob(parts[1]));
return JSON.stringify({
sub: payload.sub,
typ: payload.typ,
isAnon: payload.sub ? false : true
});
} catch (e) {
return 'ERROR: ' + e.message;
}
})()
`, &userInfo).Do(ctx)
b.log.Debugf("Token info from browser: %s", userInfo)
return nil
}),
)
if err != nil {
return fmt.Errorf("browser automation failed: %w", err)
}
if token == "" {
return fmt.Errorf("no token found in localStorage after login")
}
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
}
// 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
}

155
bridge/kosmi/errors.go Normal file
View File

@@ -0,0 +1,155 @@
package bkosmi
import (
"errors"
"fmt"
)
// Sentinel errors for common failure scenarios
var (
// ErrNotConnected indicates the WebSocket connection is not established
ErrNotConnected = errors.New("not connected to Kosmi")
// ErrAuthFailed indicates authentication with Kosmi failed
ErrAuthFailed = errors.New("authentication failed")
// ErrConnectionLost indicates the WebSocket connection was lost
ErrConnectionLost = errors.New("connection lost")
// ErrTokenExpired indicates the authentication token has expired
ErrTokenExpired = errors.New("token expired")
// ErrInvalidRoomID indicates the room ID format is invalid
ErrInvalidRoomID = errors.New("invalid room ID")
// ErrRoomNotFound indicates the specified room does not exist
ErrRoomNotFound = errors.New("room not found")
// ErrMessageSendFailed indicates a message could not be sent
ErrMessageSendFailed = errors.New("failed to send message")
// ErrSubscriptionFailed indicates subscription to room messages failed
ErrSubscriptionFailed = errors.New("failed to subscribe to messages")
// ErrJoinRoomFailed indicates joining the room failed
ErrJoinRoomFailed = errors.New("failed to join room")
// ErrConnectionTimeout indicates a connection timeout occurred
ErrConnectionTimeout = errors.New("connection timeout")
// ErrInvalidResponse indicates an unexpected response from the server
ErrInvalidResponse = errors.New("invalid response from server")
)
// ConnectionError represents a connection-related error with context
type ConnectionError struct {
Op string // Operation that failed (e.g., "dial", "handshake", "subscribe")
URL string // WebSocket URL
Err error // Underlying error
}
func (e *ConnectionError) Error() string {
return fmt.Sprintf("connection error during %s to %s: %v", e.Op, e.URL, e.Err)
}
func (e *ConnectionError) Unwrap() error {
return e.Err
}
// AuthError represents an authentication-related error with context
type AuthError struct {
Op string // Operation that failed (e.g., "login", "token_refresh", "anonymous_login")
Reason string // Human-readable reason
Err error // Underlying error
}
func (e *AuthError) Error() string {
if e.Err != nil {
return fmt.Sprintf("auth error during %s: %s (%v)", e.Op, e.Reason, e.Err)
}
return fmt.Sprintf("auth error during %s: %s", e.Op, e.Reason)
}
func (e *AuthError) Unwrap() error {
return e.Err
}
// MessageError represents a message-related error with context
type MessageError struct {
Op string // Operation that failed (e.g., "send", "receive", "parse")
RoomID string // Room ID
Message string // Message content (truncated if long)
Err error // Underlying error
}
func (e *MessageError) Error() string {
return fmt.Sprintf("message error during %s in room %s: %v", e.Op, e.RoomID, e.Err)
}
func (e *MessageError) Unwrap() error {
return e.Err
}
// RoomError represents a room-related error with context
type RoomError struct {
Op string // Operation that failed (e.g., "join", "leave", "subscribe")
RoomID string // Room ID
Err error // Underlying error
}
func (e *RoomError) Error() string {
return fmt.Sprintf("room error during %s for room %s: %v", e.Op, e.RoomID, e.Err)
}
func (e *RoomError) Unwrap() error {
return e.Err
}
// IsRetryable returns true if the error is transient and the operation should be retried
func IsRetryable(err error) bool {
if err == nil {
return false
}
// Check for known retryable errors
if errors.Is(err, ErrConnectionLost) ||
errors.Is(err, ErrConnectionTimeout) ||
errors.Is(err, ErrTokenExpired) {
return true
}
// Check for connection errors (usually retryable)
var connErr *ConnectionError
if errors.As(err, &connErr) {
return true
}
return false
}
// IsFatal returns true if the error is fatal and reconnection should not be attempted
func IsFatal(err error) bool {
if err == nil {
return false
}
// Check for known fatal errors
if errors.Is(err, ErrAuthFailed) ||
errors.Is(err, ErrInvalidRoomID) ||
errors.Is(err, ErrRoomNotFound) {
return true
}
// Check for auth errors with specific reasons
var authErr *AuthError
if errors.As(err, &authErr) {
// Invalid credentials are fatal
if authErr.Reason == "invalid credentials" ||
authErr.Reason == "account not found" {
return true
}
}
return false
}