feat: add client type tracking and broadcast fallback for disconnected WS clients
When the main Android app's WebSocket connection is down, messages are
now delivered via Android broadcast intent (am broadcast) so the app can
save them to DB and show notifications. WebSocket sessions now carry a
client_type parameter ("main" for the Android app) to distinguish
between different client types for future Google Assistant replacement
clients. Heartbeat always targets the last "main" session.
- Add LastMainChannel to state for heartbeat targeting
- Add clientTypes map to WebSocketChannel (retained after disconnect)
- Add broadcast fallback in WebSocket Send for "main" clients
- Add pkg/broadcast package using am broadcast IPC
- Add AgentMessageReceiver + NotificationHelper on Android side
- Request POST_NOTIFICATIONS runtime permission on Android 13+
- Share state.Manager between AgentLoop and HeartbeatService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
961c54b375
commit
4246ac5321
13 changed files with 352 additions and 44 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|
@ -38,6 +39,14 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receiver.AgentMessageReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="io.picoclaw.android.AGENT_MESSAGE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
package io.picoclaw.android
|
package io.picoclaw.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
|
@ -13,8 +18,14 @@ import io.picoclaw.android.feature.chat.screen.SettingsScreen
|
||||||
import io.picoclaw.android.navigation.NavRoutes
|
import io.picoclaw.android.navigation.NavRoutes
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { /* User choice recorded; no further action needed. */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
PicoClawTheme {
|
PicoClawTheme {
|
||||||
|
|
@ -34,4 +45,14 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package io.picoclaw.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import io.picoclaw.android.di.appModule
|
import io.picoclaw.android.di.appModule
|
||||||
|
import io.picoclaw.android.receiver.NotificationHelper
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
|
@ -12,5 +13,6 @@ class PicoClawApp : Application() {
|
||||||
androidContext(this@PicoClawApp)
|
androidContext(this@PicoClawApp)
|
||||||
modules(appModule)
|
modules(appModule)
|
||||||
}
|
}
|
||||||
|
NotificationHelper.createNotificationChannel(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package io.picoclaw.android.receiver
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import io.picoclaw.android.core.data.local.dao.MessageDao
|
||||||
|
import io.picoclaw.android.core.data.mapper.MessageMapper
|
||||||
|
import io.picoclaw.android.core.data.remote.dto.WsOutgoing
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
class AgentMessageReceiver : BroadcastReceiver(), KoinComponent {
|
||||||
|
|
||||||
|
private val messageDao: MessageDao by inject()
|
||||||
|
private val scope: CoroutineScope by inject()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val messageJson = intent.getStringExtra("message") ?: return
|
||||||
|
|
||||||
|
Log.d(TAG, "Received broadcast message: ${messageJson.take(100)}")
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val msg = json.decodeFromString<WsOutgoing>(messageJson)
|
||||||
|
|
||||||
|
// Skip ephemeral status messages
|
||||||
|
if (msg.type == "status" || msg.type == "status_end") {
|
||||||
|
pendingResult.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to DB then finish
|
||||||
|
val entity = MessageMapper.toEntity(msg)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
messageDao.insert(entity)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to insert message to DB", e)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification (synchronous, safe to call here)
|
||||||
|
NotificationHelper.showMessageNotification(context, msg.content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to process broadcast message", e)
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AgentMessageReceiver"
|
||||||
|
const val ACTION = "io.picoclaw.android.AGENT_MESSAGE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package io.picoclaw.android.receiver
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "picoclaw_messages"
|
||||||
|
private const val CHANNEL_NAME = "Agent Messages"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
fun createNotificationChannel(context: Context) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Messages from PicoClaw agent"
|
||||||
|
}
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMessageNotification(context: Context, content: String) {
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("PicoClaw")
|
||||||
|
.setContentText(content.take(200))
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(content.take(1000)))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ class WebSocketClient(
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
_connectionState.value = ConnectionState.CONNECTING
|
_connectionState.value = ConnectionState.CONNECTING
|
||||||
val url = "$wsUrl?client_id=$clientId"
|
val url = "$wsUrl?client_id=$clientId&client_type=main"
|
||||||
client.webSocket(url) {
|
client.webSocket(url) {
|
||||||
session = this
|
session = this
|
||||||
_connectionState.value = ConnectionState.CONNECTED
|
_connectionState.value = ConnectionState.CONNECTED
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,7 @@ func gatewayCmd() {
|
||||||
cfg.DataPath(),
|
cfg.DataPath(),
|
||||||
cfg.Heartbeat.Interval,
|
cfg.Heartbeat.Interval,
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
|
agentLoop.StateManager(),
|
||||||
)
|
)
|
||||||
heartbeatService.SetBus(msgBus)
|
heartbeatService.SetBus(msgBus)
|
||||||
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
|
||||||
|
|
@ -58,16 +58,17 @@ type activeProcess struct {
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
type processOptions struct {
|
type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID for tool execution
|
ChatID string // Target chat ID for tool execution
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
Media []string // Base64 data URLs for images
|
Media []string // Base64 data URLs for images
|
||||||
DefaultResponse string // Response when LLM returns empty
|
DefaultResponse string // Response when LLM returns empty
|
||||||
EnableSummary bool // Whether to trigger summarization
|
EnableSummary bool // Whether to trigger summarization
|
||||||
SendResponse bool // Whether to send response via bus
|
SendResponse bool // Whether to send response via bus
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
InputMode string // "voice" or "text"
|
InputMode string // "voice" or "text"
|
||||||
|
Metadata map[string]string // Channel metadata (e.g. client_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createToolRegistry creates a tool registry with common tools.
|
// createToolRegistry creates a tool registry with common tools.
|
||||||
|
|
@ -296,6 +297,12 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
al.channelManager = cm
|
al.channelManager = cm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateManager returns the state manager used by this agent loop.
|
||||||
|
// This allows sharing the same instance with other services (e.g. heartbeat).
|
||||||
|
func (al *AgentLoop) StateManager() *state.Manager {
|
||||||
|
return al.state
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLastChannel records the last active channel for this workspace.
|
// RecordLastChannel records the last active channel for this workspace.
|
||||||
// This uses the atomic state save mechanism to prevent data loss on crash.
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
||||||
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
||||||
|
|
@ -394,6 +401,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
InputMode: inputMode,
|
InputMode: inputMode,
|
||||||
|
Metadata: msg.Metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -457,8 +465,18 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
// Don't record internal channels (cli, system, subagent)
|
// Don't record internal channels (cli, system, subagent)
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
clientType := ""
|
||||||
logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
|
if opts.Metadata != nil {
|
||||||
|
clientType = opts.Metadata["client_type"]
|
||||||
|
}
|
||||||
|
if clientType != "" {
|
||||||
|
if err := al.state.SetLastChannelWithType(channelKey, clientType); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
pkg/broadcast/broadcast.go
Normal file
51
pkg/broadcast/broadcast.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package broadcast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Action is the intent action the Android app listens for.
|
||||||
|
Action = "io.picoclaw.android.AGENT_MESSAGE"
|
||||||
|
// Package is the Android app package name.
|
||||||
|
Package = "io.picoclaw.android"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message represents a message to send via Android broadcast.
|
||||||
|
type Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to the Android app via am broadcast.
|
||||||
|
// This works because the Go server runs inside Termux on the same device.
|
||||||
|
func Send(msg Message) error {
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal broadcast message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("am", "broadcast",
|
||||||
|
"-a", Action,
|
||||||
|
"-p", Package,
|
||||||
|
"--es", "message", string(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("broadcast", "am broadcast failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"output": string(output),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("am broadcast failed: %w (%s)", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("broadcast", "Broadcast sent", map[string]interface{}{
|
||||||
|
"content_len": len(msg.Content),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/broadcast"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -32,14 +33,15 @@ type wsOutgoing struct {
|
||||||
// connections from clients (e.g. a Google Assistant replacement APK).
|
// connections from clients (e.g. a Google Assistant replacement APK).
|
||||||
type WebSocketChannel struct {
|
type WebSocketChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
config config.WebSocketConfig
|
config config.WebSocketConfig
|
||||||
server *http.Server
|
server *http.Server
|
||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
clients map[*websocket.Conn]string // conn → clientID
|
clients map[*websocket.Conn]string // conn → clientID
|
||||||
chatConns map[string]*websocket.Conn // chatID → conn
|
chatConns map[string]*websocket.Conn // chatID → conn
|
||||||
mu sync.RWMutex
|
clientTypes map[string]string // chatID → clientType (retained after disconnect)
|
||||||
ctx context.Context
|
mu sync.RWMutex
|
||||||
cancel context.CancelFunc
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus) (*WebSocketChannel, error) {
|
func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus) (*WebSocketChannel, error) {
|
||||||
|
|
@ -51,8 +53,9 @@ func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus) (*W
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
},
|
},
|
||||||
clients: make(map[*websocket.Conn]string),
|
clients: make(map[*websocket.Conn]string),
|
||||||
chatConns: make(map[string]*websocket.Conn),
|
chatConns: make(map[string]*websocket.Conn),
|
||||||
|
clientTypes: make(map[string]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +109,7 @@ func (c *WebSocketChannel) Stop(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.clients = make(map[*websocket.Conn]string)
|
c.clients = make(map[*websocket.Conn]string)
|
||||||
c.chatConns = make(map[string]*websocket.Conn)
|
c.chatConns = make(map[string]*websocket.Conn)
|
||||||
|
c.clientTypes = make(map[string]string)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if c.server != nil {
|
if c.server != nil {
|
||||||
|
|
@ -128,10 +132,12 @@ func (c *WebSocketChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
|
||||||
|
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
conn, ok := c.chatConns[msg.ChatID]
|
conn, ok := c.chatConns[msg.ChatID]
|
||||||
|
clientType := c.clientTypes[msg.ChatID] // Read under lock to avoid data race
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("no connection for chat %s", msg.ChatID)
|
// Connection not found — try broadcast fallback for "main" clients.
|
||||||
|
return c.maybeBroadcast(msg, clientType, fmt.Errorf("no connection for chat %s", msg.ChatID))
|
||||||
}
|
}
|
||||||
|
|
||||||
out := wsOutgoing{Content: msg.Content, Type: msg.Type}
|
out := wsOutgoing{Content: msg.Content, Type: msg.Type}
|
||||||
|
|
@ -145,7 +151,7 @@ func (c *WebSocketChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
|
||||||
|
|
||||||
// Verify connection still exists (may have been removed during cleanup).
|
// Verify connection still exists (may have been removed during cleanup).
|
||||||
if _, exists := c.clients[conn]; !exists {
|
if _, exists := c.clients[conn]; !exists {
|
||||||
return fmt.Errorf("connection for chat %s no longer active", msg.ChatID)
|
return c.maybeBroadcast(msg, c.clientTypes[msg.ChatID], fmt.Errorf("connection for chat %s no longer active", msg.ChatID))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
|
|
@ -153,12 +159,41 @@ func (c *WebSocketChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return err
|
return c.maybeBroadcast(msg, c.clientTypes[msg.ChatID], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeBroadcast sends a message via Android broadcast if the disconnected
|
||||||
|
// client is of type "main". Status/status_end messages are ephemeral and
|
||||||
|
// skipped. Returns the original error if broadcast is not applicable.
|
||||||
|
// clientType must be read under lock by the caller to avoid data races.
|
||||||
|
func (c *WebSocketChannel) maybeBroadcast(msg bus.OutboundMessage, clientType string, originalErr error) error {
|
||||||
|
// Status messages are ephemeral — don't broadcast.
|
||||||
|
if msg.Type == "status" || msg.Type == "status_end" {
|
||||||
|
return originalErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only broadcast for "main" type clients.
|
||||||
|
if clientType != "main" {
|
||||||
|
return originalErr
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("websocket", "Using broadcast fallback for disconnected main client", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"content_len": len(msg.Content),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := broadcast.Send(broadcast.Message{
|
||||||
|
Content: msg.Content,
|
||||||
|
Type: msg.Type,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("ws send failed and broadcast fallback also failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := c.upgrader.Upgrade(w, r, nil)
|
conn, err := c.upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -173,8 +208,14 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
clientID = uuid.New().String()
|
clientID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientType := r.URL.Query().Get("client_type")
|
||||||
|
if clientType == "" {
|
||||||
|
clientType = "main" // Default for backward compatibility
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("websocket", "New WebSocket connection", map[string]interface{}{
|
logger.InfoCF("websocket", "New WebSocket connection", map[string]interface{}{
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
|
"client_type": clientType,
|
||||||
"remote_addr": r.RemoteAddr,
|
"remote_addr": r.RemoteAddr,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -187,12 +228,22 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
c.clients[conn] = clientID
|
c.clients[conn] = clientID
|
||||||
c.chatConns[chatID] = conn
|
c.chatConns[chatID] = conn
|
||||||
|
c.clientTypes[chatID] = clientType
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
go c.readPump(conn, clientID, chatID)
|
go c.readPump(conn, clientID, chatID, clientType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID string) {
|
// GetClientType returns the client type for a given chatID.
|
||||||
|
// Returns empty string if unknown. The value is retained after disconnect
|
||||||
|
// so that the broadcast fallback can check the type.
|
||||||
|
func (c *WebSocketChannel) GetClientType(chatID string) string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.clientTypes[chatID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID, clientType string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.clients, conn)
|
delete(c.clients, conn)
|
||||||
|
|
@ -258,7 +309,8 @@ func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID strin
|
||||||
inputMode = "text"
|
inputMode = "text"
|
||||||
}
|
}
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"input_mode": inputMode,
|
"input_mode": inputMode,
|
||||||
|
"client_type": clientType,
|
||||||
}
|
}
|
||||||
c.HandleMessage(senderID, chatID, content, media, metadata)
|
c.HandleMessage(senderID, chatID, content, media, metadata)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,9 @@ type HeartbeatService struct {
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHeartbeatService creates a new heartbeat service
|
// NewHeartbeatService creates a new heartbeat service.
|
||||||
func NewHeartbeatService(workspace string, dataDir string, intervalMinutes int, enabled bool) *HeartbeatService {
|
// If stateManager is nil, a new state.Manager is created internally (for tests).
|
||||||
|
func NewHeartbeatService(workspace string, dataDir string, intervalMinutes int, enabled bool, stateManager *state.Manager) *HeartbeatService {
|
||||||
// Apply minimum interval
|
// Apply minimum interval
|
||||||
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
||||||
intervalMinutes = minIntervalMinutes
|
intervalMinutes = minIntervalMinutes
|
||||||
|
|
@ -55,12 +56,16 @@ func NewHeartbeatService(workspace string, dataDir string, intervalMinutes int,
|
||||||
intervalMinutes = defaultIntervalMinutes
|
intervalMinutes = defaultIntervalMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stateManager == nil {
|
||||||
|
stateManager = state.NewManager(dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
return &HeartbeatService{
|
return &HeartbeatService{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
interval: time.Duration(intervalMinutes) * time.Minute,
|
interval: time.Duration(intervalMinutes) * time.Minute,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
state: state.NewManager(dataDir),
|
state: stateManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,8 +177,13 @@ func (hs *HeartbeatService) executeHeartbeat() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last channel info for context
|
// Get last channel info for context.
|
||||||
lastChannel := hs.state.GetLastChannel()
|
// Prefer the last "main" client session so heartbeat always targets the
|
||||||
|
// primary Android app, even if a non-main client was active more recently.
|
||||||
|
lastChannel := hs.state.GetLastMainChannel()
|
||||||
|
if lastChannel == "" {
|
||||||
|
lastChannel = hs.state.GetLastChannel() // Backward-compat fallback
|
||||||
|
}
|
||||||
channel, chatID := hs.parseLastChannel(lastChannel)
|
channel, chatID := hs.parseLastChannel(lastChannel)
|
||||||
|
|
||||||
// Debug log for channel resolution
|
// Debug log for channel resolution
|
||||||
|
|
@ -295,8 +305,11 @@ func (hs *HeartbeatService) sendResponse(response string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last channel from state
|
// Get last channel from state (prefer "main" client session)
|
||||||
lastChannel := hs.state.GetLastChannel()
|
lastChannel := hs.state.GetLastMainChannel()
|
||||||
|
if lastChannel == "" {
|
||||||
|
lastChannel = hs.state.GetLastChannel() // Backward-compat fallback
|
||||||
|
}
|
||||||
if lastChannel == "" {
|
if lastChannel == "" {
|
||||||
hs.logInfo("No last channel recorded, heartbeat result not sent")
|
hs.logInfo("No last channel recorded, heartbeat result not sent")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true, nil)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
asyncCalled := false
|
asyncCalled := false
|
||||||
|
|
@ -54,7 +54,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true, nil)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
@ -92,7 +92,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true, nil)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
@ -130,7 +130,7 @@ func TestHeartbeatService_StartStop(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 1, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 1, true, nil)
|
||||||
|
|
||||||
err = hs.Start()
|
err = hs.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -149,7 +149,7 @@ func TestHeartbeatService_Disabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 1, false)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 1, false, nil)
|
||||||
|
|
||||||
if hs.enabled != false {
|
if hs.enabled != false {
|
||||||
t.Error("Expected service to be disabled")
|
t.Error("Expected service to be disabled")
|
||||||
|
|
@ -166,7 +166,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true, nil)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
@ -188,7 +188,7 @@ func TestLogPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true, nil)
|
||||||
|
|
||||||
// Write a log entry
|
// Write a log entry
|
||||||
hs.log("INFO", "Test log entry")
|
hs.log("INFO", "Test log entry")
|
||||||
|
|
@ -208,7 +208,7 @@ func TestHeartbeatFilePath(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir, 30, true, nil)
|
||||||
|
|
||||||
// Trigger default template creation
|
// Trigger default template creation
|
||||||
hs.buildPrompt()
|
hs.buildPrompt()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ type State struct {
|
||||||
// LastChatID is the last chat ID used for communication
|
// LastChatID is the last chat ID used for communication
|
||||||
LastChatID string `json:"last_chat_id,omitempty"`
|
LastChatID string `json:"last_chat_id,omitempty"`
|
||||||
|
|
||||||
|
// LastMainChannel is the last channel used by a "main" client type.
|
||||||
|
// Used by heartbeat to always target the main (Android app) session.
|
||||||
|
LastMainChannel string `json:"last_main_channel,omitempty"`
|
||||||
|
|
||||||
// Timestamp is the last time this state was updated
|
// Timestamp is the last time this state was updated
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +118,34 @@ func (sm *Manager) GetLastChatID() string {
|
||||||
return sm.state.LastChatID
|
return sm.state.LastChatID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLastChannelWithType atomically updates the last channel and, if the
|
||||||
|
// client type is "main", also updates LastMainChannel. Both fields are
|
||||||
|
// persisted in a single atomic write.
|
||||||
|
func (sm *Manager) SetLastChannelWithType(channel, clientType string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
sm.state.LastChannel = channel
|
||||||
|
sm.state.Timestamp = time.Now()
|
||||||
|
|
||||||
|
if clientType == "main" {
|
||||||
|
sm.state.LastMainChannel = channel
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sm.saveAtomic(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save state atomically: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastMainChannel returns the last channel used by a "main" client type.
|
||||||
|
func (sm *Manager) GetLastMainChannel() string {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
return sm.state.LastMainChannel
|
||||||
|
}
|
||||||
|
|
||||||
// GetTimestamp returns the timestamp of the last state update.
|
// GetTimestamp returns the timestamp of the last state update.
|
||||||
func (sm *Manager) GetTimestamp() time.Time {
|
func (sm *Manager) GetTimestamp() time.Time {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue