@swarmclawai/swarmclaw 0.6.4 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +62 -30
  2. package/package.json +10 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +34 -2
  8. package/src/app/api/chatrooms/route.ts +26 -3
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  14. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  15. package/src/app/api/sessions/route.ts +11 -2
  16. package/src/app/api/tasks/[id]/route.ts +18 -13
  17. package/src/app/api/tasks/route.ts +44 -1
  18. package/src/app/api/usage/route.ts +16 -7
  19. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  20. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  21. package/src/app/api/wallets/[id]/route.ts +118 -0
  22. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  23. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  24. package/src/app/api/wallets/route.ts +74 -0
  25. package/src/app/globals.css +8 -0
  26. package/src/cli/index.js +20 -0
  27. package/src/cli/index.ts +223 -39
  28. package/src/cli/spec.js +14 -0
  29. package/src/components/agents/agent-avatar.tsx +15 -1
  30. package/src/components/agents/agent-card.tsx +38 -6
  31. package/src/components/agents/agent-chat-list.tsx +79 -3
  32. package/src/components/agents/agent-sheet.tsx +191 -26
  33. package/src/components/auth/setup-wizard.tsx +268 -353
  34. package/src/components/chat/chat-area.tsx +24 -9
  35. package/src/components/chat/chat-header.tsx +48 -19
  36. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  37. package/src/components/chat/delegation-banner.test.ts +27 -0
  38. package/src/components/chat/delegation-banner.tsx +109 -23
  39. package/src/components/chat/message-bubble.tsx +17 -16
  40. package/src/components/chat/message-list.tsx +6 -5
  41. package/src/components/chat/streaming-bubble.tsx +3 -2
  42. package/src/components/chat/thinking-indicator.tsx +3 -2
  43. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  44. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  45. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  46. package/src/components/chatrooms/chatroom-message.tsx +165 -23
  47. package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
  48. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  49. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  50. package/src/components/connectors/connector-health.tsx +120 -0
  51. package/src/components/connectors/connector-list.tsx +1 -1
  52. package/src/components/connectors/connector-sheet.tsx +9 -0
  53. package/src/components/home/home-view.tsx +25 -3
  54. package/src/components/input/chat-input.tsx +8 -1
  55. package/src/components/knowledge/knowledge-list.tsx +1 -1
  56. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  57. package/src/components/layout/app-layout.tsx +35 -4
  58. package/src/components/memory/memory-agent-list.tsx +1 -1
  59. package/src/components/memory/memory-browser.tsx +1 -0
  60. package/src/components/memory/memory-card.tsx +3 -2
  61. package/src/components/memory/memory-detail.tsx +3 -3
  62. package/src/components/memory/memory-sheet.tsx +2 -2
  63. package/src/components/projects/project-detail.tsx +4 -4
  64. package/src/components/schedules/schedule-list.tsx +55 -9
  65. package/src/components/schedules/schedule-sheet.tsx +134 -23
  66. package/src/components/secrets/secret-sheet.tsx +1 -1
  67. package/src/components/secrets/secrets-list.tsx +1 -1
  68. package/src/components/sessions/session-card.tsx +1 -1
  69. package/src/components/shared/agent-picker-list.tsx +1 -1
  70. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  71. package/src/components/shared/command-palette.tsx +237 -0
  72. package/src/components/shared/connector-platform-icon.tsx +1 -0
  73. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  74. package/src/components/skills/skill-list.tsx +1 -1
  75. package/src/components/skills/skill-sheet.tsx +1 -1
  76. package/src/components/tasks/task-board.tsx +3 -3
  77. package/src/components/tasks/task-card.tsx +22 -2
  78. package/src/components/tasks/task-sheet.tsx +112 -17
  79. package/src/components/usage/metrics-dashboard.tsx +13 -25
  80. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  81. package/src/components/wallets/wallet-panel.tsx +616 -0
  82. package/src/components/wallets/wallet-section.tsx +100 -0
  83. package/src/hooks/use-swipe.ts +49 -0
  84. package/src/lib/providers/anthropic.ts +16 -2
  85. package/src/lib/providers/claude-cli.ts +7 -1
  86. package/src/lib/providers/index.ts +7 -0
  87. package/src/lib/providers/ollama.ts +16 -2
  88. package/src/lib/providers/openai.ts +7 -2
  89. package/src/lib/providers/openclaw.ts +6 -1
  90. package/src/lib/providers/provider-defaults.ts +7 -0
  91. package/src/lib/schedule-templates.ts +115 -0
  92. package/src/lib/server/agent-registry.ts +2 -2
  93. package/src/lib/server/alert-dispatch.ts +64 -0
  94. package/src/lib/server/chat-execution.ts +76 -4
  95. package/src/lib/server/chatroom-health.ts +60 -0
  96. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  97. package/src/lib/server/chatroom-helpers.ts +86 -12
  98. package/src/lib/server/chatroom-routing.ts +65 -0
  99. package/src/lib/server/connectors/discord.ts +3 -0
  100. package/src/lib/server/connectors/email.ts +267 -0
  101. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  102. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  103. package/src/lib/server/connectors/manager.ts +239 -5
  104. package/src/lib/server/connectors/openclaw.ts +3 -0
  105. package/src/lib/server/connectors/slack.ts +6 -0
  106. package/src/lib/server/connectors/telegram.ts +18 -0
  107. package/src/lib/server/connectors/types.ts +2 -0
  108. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  109. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  110. package/src/lib/server/connectors/whatsapp.ts +17 -5
  111. package/src/lib/server/cost.ts +70 -0
  112. package/src/lib/server/create-notification.ts +2 -0
  113. package/src/lib/server/daemon-state.ts +124 -0
  114. package/src/lib/server/dag-validation.ts +115 -0
  115. package/src/lib/server/memory-db.ts +12 -7
  116. package/src/lib/server/openclaw-doctor.ts +48 -0
  117. package/src/lib/server/orchestrator-lg.ts +12 -2
  118. package/src/lib/server/orchestrator.ts +6 -1
  119. package/src/lib/server/queue-followups.test.ts +224 -0
  120. package/src/lib/server/queue.ts +238 -24
  121. package/src/lib/server/scheduler.ts +3 -0
  122. package/src/lib/server/session-run-manager.ts +22 -1
  123. package/src/lib/server/session-tools/chatroom.ts +11 -2
  124. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  125. package/src/lib/server/session-tools/index.ts +8 -2
  126. package/src/lib/server/session-tools/memory.ts +23 -4
  127. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  128. package/src/lib/server/session-tools/shell.ts +1 -1
  129. package/src/lib/server/session-tools/wallet.ts +124 -0
  130. package/src/lib/server/session-tools/web.ts +2 -2
  131. package/src/lib/server/solana.ts +122 -0
  132. package/src/lib/server/storage.ts +158 -6
  133. package/src/lib/server/stream-agent-chat.ts +126 -63
  134. package/src/lib/server/task-mention.test.ts +41 -0
  135. package/src/lib/server/task-mention.ts +3 -2
  136. package/src/lib/setup-defaults.ts +277 -0
  137. package/src/lib/tool-definitions.ts +1 -0
  138. package/src/lib/validation/schemas.ts +69 -0
  139. package/src/lib/view-routes.ts +1 -0
  140. package/src/stores/use-app-store.ts +15 -3
  141. package/src/stores/use-chatroom-store.ts +52 -2
  142. package/src/types/index.ts +98 -2
  143. package/tsconfig.json +2 -1
@@ -3,7 +3,9 @@ import {
3
3
  loadConnectors, saveConnectors, loadSessions, saveSessions,
4
4
  loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
5
5
  loadChatrooms, saveChatrooms,
6
+ upsertConnectorHealthEvent,
6
7
  } from '../storage'
8
+ import type { ConnectorHealthEventType } from '@/types'
7
9
  import { WORKSPACE_DIR } from '../data-dir'
8
10
  import { UPLOAD_DIR } from '../storage'
9
11
  import fs from 'fs'
@@ -16,12 +18,17 @@ import { requestHeartbeatNow } from '../heartbeat-wake'
16
18
  import { buildCurrentDateTimePromptContext } from '../prompt-runtime-context'
17
19
  import {
18
20
  parseMentions,
21
+ compactChatroomMessages,
19
22
  buildChatroomSystemPrompt,
20
23
  buildSyntheticSession,
21
24
  buildAgentSystemPromptForChatroom,
22
25
  buildHistoryForAgent,
23
26
  resolveApiKey as resolveApiKeyHelper,
24
27
  } from '../chatroom-helpers'
28
+ import { filterHealthyChatroomAgents } from '../chatroom-health'
29
+ import { evaluateRoutingRules } from '../chatroom-routing'
30
+ import { markProviderFailure, markProviderSuccess } from '../provider-health'
31
+ import { getProvider } from '@/lib/providers'
25
32
  import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
26
33
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
27
34
  import {
@@ -35,6 +42,7 @@ import {
35
42
  parsePairingPolicy,
36
43
  type PairingPolicy,
37
44
  } from './pairing'
45
+ import { enrichInboundMessageWithAudioTranscript } from './inbound-audio-transcription'
38
46
 
39
47
  function resolveUploadPathFromUrl(rawUrl: string): string | null {
40
48
  if (!rawUrl) return null
@@ -271,6 +279,35 @@ const followupKey = '__swarmclaw_connector_followups__' as const
271
279
  const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
272
280
  g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
273
281
 
282
+ /** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
283
+ export interface ConnectorReconnectState {
284
+ attempts: number
285
+ lastAttemptAt: number
286
+ nextRetryAt: number
287
+ backoffMs: number
288
+ error: string
289
+ }
290
+
291
+ const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
292
+ const reconnectState: Map<string, ConnectorReconnectState> =
293
+ g[reconnectStateKey] ?? (g[reconnectStateKey] = new Map<string, ConnectorReconnectState>())
294
+
295
+ const RECONNECT_INITIAL_BACKOFF_MS = 1_000
296
+ const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
297
+ const RECONNECT_MAX_ATTEMPTS = 10
298
+
299
+ /** Record a health event for a connector (persisted to connector_health collection) */
300
+ function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType, message?: string): void {
301
+ const id = genId()
302
+ upsertConnectorHealthEvent(id, {
303
+ id,
304
+ connectorId,
305
+ event,
306
+ message: message || undefined,
307
+ timestamp: new Date().toISOString(),
308
+ })
309
+ }
310
+
274
311
  type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
275
312
  const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
276
313
  const routeMessageHandlerRef: { current: RouteMessageHandler } =
@@ -309,6 +346,7 @@ export async function getPlatform(platform: string) {
309
346
  case 'teams': return (await import('./teams')).default
310
347
  case 'googlechat': return (await import('./googlechat')).default
311
348
  case 'matrix': return (await import('./matrix')).default
349
+ case 'email': return (await import('./email')).default
312
350
  default: throw new Error(`Unknown platform: ${platform}`)
313
351
  }
314
352
  }
@@ -657,10 +695,27 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
657
695
  if (!chatroom) return '[Error] Chatroom not found.'
658
696
 
659
697
  const agents = loadAgents()
698
+ const preferredCredentialId = (() => {
699
+ if (connector.agentId && agents[connector.agentId]?.credentialId) {
700
+ return agents[connector.agentId].credentialId as string
701
+ }
702
+ for (const agentId of chatroom.agentIds) {
703
+ const credentialId = agents[agentId]?.credentialId
704
+ if (credentialId) return credentialId as string
705
+ }
706
+ return null
707
+ })()
708
+ msg = await enrichInboundMessageWithAudioTranscript({
709
+ msg,
710
+ preferredCredentialId,
711
+ })
712
+
660
713
  const source: MessageSource = {
661
714
  platform: connector.platform,
662
715
  connectorId: connector.id,
663
716
  connectorName: connector.name,
717
+ channelId: msg.channelId,
718
+ senderId: msg.senderId,
664
719
  senderName: msg.senderName,
665
720
  }
666
721
  const inboundText = formatInboundUserText(msg)
@@ -669,10 +724,17 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
669
724
 
670
725
  // Parse mentions from the message text
671
726
  let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
672
- // Auto-address: if enabled and no explicit mentions, address all agents
727
+ // Routing rules: if no explicit mentions, evaluate keyword/capability rules
728
+ if (mentions.length === 0 && chatroom.routingRules?.length) {
729
+ const agentList = chatroom.agentIds.map((id) => agents[id]).filter(Boolean)
730
+ mentions = evaluateRoutingRules(msg.text || '', chatroom.routingRules, agentList)
731
+ }
732
+ // Auto-address: if enabled and still no mentions, address all agents
673
733
  if (chatroom.autoAddress && mentions.length === 0) {
674
734
  mentions = [...chatroom.agentIds]
675
735
  }
736
+ const mentionHealth = filterHealthyChatroomAgents(mentions, agents)
737
+ mentions = mentionHealth.healthyAgentIds
676
738
 
677
739
  // Create and persist the user message in the chatroom
678
740
  const userMessage: ChatroomMessage = {
@@ -689,12 +751,23 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
689
751
  source,
690
752
  }
691
753
  chatroom.messages.push(userMessage)
754
+ compactChatroomMessages(chatroom)
692
755
  chatroom.updatedAt = Date.now()
693
756
  chatrooms[chatroomId] = chatroom
694
757
  saveChatrooms(chatrooms)
695
758
  notify('chatrooms')
696
759
  notify(`chatroom:${chatroomId}`)
697
760
 
761
+ if (mentions.length === 0) {
762
+ if (mentionHealth.skipped.length > 0) {
763
+ const skippedSummary = mentionHealth.skipped
764
+ .map((row) => `${agents[row.agentId]?.name || row.agentId}: ${row.reason}`)
765
+ .join(', ')
766
+ return `[Error] No healthy agents were available for this request. Skipped: ${skippedSummary}`
767
+ }
768
+ return '[Error] No agents were selected for this request.'
769
+ }
770
+
698
771
  // Process mentioned agents sequentially and collect responses
699
772
  const responses: string[] = []
700
773
  for (const agentId of mentions) {
@@ -704,6 +777,23 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
704
777
  const apiKey = resolveApiKeyHelper(agent.credentialId)
705
778
  const freshChatrooms = loadChatrooms()
706
779
  const freshChatroom = freshChatrooms[chatroomId] as Chatroom
780
+ if (compactChatroomMessages(freshChatroom)) {
781
+ freshChatrooms[chatroomId] = freshChatroom
782
+ saveChatrooms(freshChatrooms)
783
+ notify(`chatroom:${chatroomId}`)
784
+ }
785
+
786
+ const providerInfo = getProvider(agent.provider)
787
+ if (providerInfo?.requiresApiKey && !apiKey) {
788
+ markProviderFailure(agent.provider, 'missing_api_credentials')
789
+ responses.push(`[${agent.name}] [Error] Missing API credentials.`)
790
+ continue
791
+ }
792
+ if (providerInfo?.requiresEndpoint && !agent.apiEndpoint) {
793
+ markProviderFailure(agent.provider, 'missing_api_endpoint')
794
+ responses.push(`[${agent.name}] [Error] Missing endpoint configuration.`)
795
+ continue
796
+ }
707
797
 
708
798
  const syntheticSession = buildSyntheticSession(agent, chatroomId)
709
799
  const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
@@ -730,6 +820,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
730
820
  platform: connector.platform,
731
821
  connectorId: connector.id,
732
822
  connectorName: connector.name,
823
+ channelId: msg.channelId,
733
824
  }
734
825
  const agentMessage: ChatroomMessage = {
735
826
  id: genId(),
@@ -737,7 +828,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
737
828
  senderName: agent.name,
738
829
  role: 'assistant',
739
830
  text: responseText,
740
- mentions: parseMentions(responseText, agents, freshChatroom.agentIds),
831
+ mentions: filterHealthyChatroomAgents(
832
+ parseMentions(responseText, agents, freshChatroom.agentIds),
833
+ agents,
834
+ ).healthyAgentIds,
741
835
  reactions: [],
742
836
  time: Date.now(),
743
837
  source: agentSource,
@@ -750,10 +844,14 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
750
844
  saveChatrooms(latestChatrooms)
751
845
  notify(`chatroom:${chatroomId}`)
752
846
 
847
+ markProviderSuccess(agent.provider)
753
848
  responses.push(`[${agent.name}] ${responseText}`)
849
+ } else {
850
+ markProviderSuccess(agent.provider)
754
851
  }
755
852
  } catch (err: unknown) {
756
853
  const errMsg = err instanceof Error ? err.message : String(err)
854
+ markProviderFailure(agent.provider, errMsg)
757
855
  console.error(`[connector] Chatroom agent ${agent.name} error:`, errMsg)
758
856
  }
759
857
  }
@@ -798,6 +896,10 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
798
896
  if (!effectiveAgentId) return '[Error] Connector has no agent configured.'
799
897
  const agent = agents[effectiveAgentId]
800
898
  if (!agent) return '[Error] Connector agent not found.'
899
+ msg = await enrichInboundMessageWithAudioTranscript({
900
+ msg,
901
+ preferredCredentialId: agent.credentialId || null,
902
+ })
801
903
 
802
904
  // Enqueue system event + heartbeat wake for the agent
803
905
  const preview = (msg.text || '').slice(0, 80)
@@ -931,9 +1033,14 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
931
1033
  return commandResult
932
1034
  }
933
1035
 
934
- // Build system prompt: [userPrompt] \n\n [soul] \n\n [systemPrompt]
1036
+ // Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
935
1037
  const settings = loadSettings()
936
1038
  const promptParts: string[] = []
1039
+ // Identity block — agent needs to know who it is
1040
+ const identityLines = [`## My Identity`, `My name is ${agent.name}.`]
1041
+ if (agent.description) identityLines.push(agent.description)
1042
+ identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
1043
+ promptParts.push(identityLines.join(' '))
937
1044
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
938
1045
  promptParts.push(buildCurrentDateTimePromptContext())
939
1046
  if (agent.soul) promptParts.push(agent.soul)
@@ -960,6 +1067,11 @@ Do not end every reply with a question.
960
1067
  Only ask a question when a specific missing detail blocks progress.
961
1068
  When a task is complete, state the result plainly and stop.
962
1069
 
1070
+ ## Async Update Routing
1071
+ When you start work that may finish later (task, schedule, delegated run), tell the user where updates will be sent.
1072
+ Default to this same ${msg.platform} chat unless the user requested another destination.
1073
+ If channel preference is ambiguous and there are multiple reasonable destinations, ask one short routing question.
1074
+
963
1075
  ## Knowing When Not to Reply
964
1076
  Real conversations have natural pauses — not every message needs a response. Reply with exactly "NO_MESSAGE" (nothing else) to stay silent when replying would feel unnatural or forced.
965
1077
  Stay silent for simple acknowledgments ("okay", "alright", "cool", "got it", "sounds good"), conversation closers ("thanks", "bye", "night", "ttyl"), reactions (emoji, "haha", "lol"), and forwarded content with no question attached.
@@ -987,6 +1099,8 @@ If media sending fails, report the exact error and retry with a corrected path/t
987
1099
  platform: connector.platform,
988
1100
  connectorId: connector.id,
989
1101
  connectorName: connector.name,
1102
+ channelId: msg.channelId,
1103
+ senderId: msg.senderId,
990
1104
  senderName: msg.senderName,
991
1105
  }
992
1106
  session.messages.push({
@@ -1002,6 +1116,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1002
1116
  const s1 = loadSessions()
1003
1117
  s1[session.id] = session
1004
1118
  saveSessions(s1)
1119
+ notify(`messages:${session.id}`)
1005
1120
 
1006
1121
  // Stream the response
1007
1122
  let fullText = ''
@@ -1109,6 +1224,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1109
1224
  platform: connector.platform,
1110
1225
  connectorId: connector.id,
1111
1226
  connectorName: connector.name,
1227
+ channelId: msg.channelId,
1112
1228
  }
1113
1229
  if (fullText.trim()) {
1114
1230
  session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
@@ -1235,7 +1351,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
1235
1351
  botToken = connector.config.password
1236
1352
  }
1237
1353
 
1238
- if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal') {
1354
+ if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
1239
1355
  throw new Error('No bot token configured')
1240
1356
  }
1241
1357
 
@@ -1262,14 +1378,17 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
1262
1378
  notify('connectors')
1263
1379
 
1264
1380
  console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
1381
+ recordHealthEvent(connectorId, 'started', `${connector.platform} connector "${connector.name}" started`)
1265
1382
  } catch (err: unknown) {
1383
+ const errMsg = err instanceof Error ? err.message : String(err)
1266
1384
  connector.status = 'error'
1267
1385
  connector.isEnabled = false
1268
- connector.lastError = err instanceof Error ? err.message : String(err)
1386
+ connector.lastError = errMsg
1269
1387
  connector.updatedAt = Date.now()
1270
1388
  connectors[connectorId] = connector
1271
1389
  saveConnectors(connectors)
1272
1390
  notify('connectors')
1391
+ recordHealthEvent(connectorId, 'error', errMsg)
1273
1392
  throw err
1274
1393
  }
1275
1394
  }
@@ -1301,6 +1420,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
1301
1420
  }
1302
1421
 
1303
1422
  console.log(`[connector] Stopped connector: ${connectorId}`)
1423
+ recordHealthEvent(connectorId, 'stopped', `Connector stopped`)
1304
1424
  }
1305
1425
 
1306
1426
  /** Get the runtime status of a connector */
@@ -1581,3 +1701,117 @@ export function scheduleConnectorFollowUp(params: {
1581
1701
 
1582
1702
  return { followUpId, sendAt }
1583
1703
  }
1704
+
1705
+ /**
1706
+ * Check health of all running connectors via `isAlive()`.
1707
+ * Dead connectors that are still enabled get automatic reconnection with exponential backoff.
1708
+ * After RECONNECT_MAX_ATTEMPTS, the connector is marked as error and retries stop.
1709
+ */
1710
+ export async function checkConnectorHealth(): Promise<void> {
1711
+ const connectors = loadConnectors()
1712
+ let connectorsDirty = false
1713
+
1714
+ for (const [id, instance] of running.entries()) {
1715
+ // If the instance has no isAlive method, skip (e.g. OpenClaw, BlueBubbles)
1716
+ if (typeof instance.isAlive !== 'function') continue
1717
+
1718
+ if (instance.isAlive()) {
1719
+ // Connector is healthy — clear any reconnect state
1720
+ if (reconnectState.has(id)) {
1721
+ console.log(`[connector-health] Connector "${instance.connector.name}" recovered`)
1722
+ reconnectState.delete(id)
1723
+ }
1724
+ continue
1725
+ }
1726
+
1727
+ // Connector is dead but still in the running Map
1728
+ console.warn(`[connector-health] Connector "${instance.connector.name}" (${id}) isAlive=false — removing from running`)
1729
+ recordHealthEvent(id, 'disconnected', `Connector "${instance.connector.name}" detected as dead (isAlive=false)`)
1730
+
1731
+ // Clean up the dead instance
1732
+ try { await instance.stop() } catch { /* ignore */ }
1733
+ running.delete(id)
1734
+
1735
+ const connector = connectors[id] as Connector | undefined
1736
+ if (!connector) continue
1737
+
1738
+ // If the connector is not enabled, don't attempt reconnect
1739
+ if (!connector.isEnabled) {
1740
+ reconnectState.delete(id)
1741
+ continue
1742
+ }
1743
+
1744
+ // Attempt reconnect with backoff
1745
+ const state = reconnectState.get(id) ?? {
1746
+ attempts: 0,
1747
+ lastAttemptAt: 0,
1748
+ nextRetryAt: 0,
1749
+ backoffMs: RECONNECT_INITIAL_BACKOFF_MS,
1750
+ error: '',
1751
+ }
1752
+
1753
+ // Check if we've exceeded max attempts
1754
+ if (state.attempts >= RECONNECT_MAX_ATTEMPTS) {
1755
+ console.warn(`[connector-health] Connector "${connector.name}" exceeded ${RECONNECT_MAX_ATTEMPTS} reconnect attempts — marking as error`)
1756
+ connector.status = 'error'
1757
+ connector.lastError = `Auto-reconnect gave up after ${RECONNECT_MAX_ATTEMPTS} attempts: ${state.error}`
1758
+ connector.updatedAt = Date.now()
1759
+ connectors[id] = connector
1760
+ connectorsDirty = true
1761
+ reconnectState.delete(id)
1762
+ notify('connectors')
1763
+ continue
1764
+ }
1765
+
1766
+ const now = Date.now()
1767
+
1768
+ // Check if enough time has passed for the next retry
1769
+ if (now < state.nextRetryAt) {
1770
+ // Not yet time to retry — keep state and skip
1771
+ continue
1772
+ }
1773
+
1774
+ state.attempts += 1
1775
+ state.lastAttemptAt = now
1776
+ reconnectState.set(id, state)
1777
+
1778
+ try {
1779
+ console.log(`[connector-health] Reconnecting "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS})`)
1780
+ await startConnector(id)
1781
+ // Success — clear reconnect state
1782
+ reconnectState.delete(id)
1783
+ console.log(`[connector-health] Connector "${connector.name}" reconnected successfully`)
1784
+ recordHealthEvent(id, 'reconnected', `Connector "${connector.name}" reconnected after ${state.attempts} attempt(s)`)
1785
+ } catch (err: unknown) {
1786
+ const errorMsg = err instanceof Error ? err.message : String(err)
1787
+ state.error = errorMsg
1788
+ state.backoffMs = Math.min(RECONNECT_MAX_BACKOFF_MS, RECONNECT_INITIAL_BACKOFF_MS * (2 ** state.attempts))
1789
+ state.nextRetryAt = now + state.backoffMs
1790
+ reconnectState.set(id, state)
1791
+ console.warn(`[connector-health] Reconnect failed for "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS}): ${errorMsg}. Next retry at ${new Date(state.nextRetryAt).toISOString()}`)
1792
+ }
1793
+ }
1794
+
1795
+ if (connectorsDirty) {
1796
+ saveConnectors(connectors)
1797
+ }
1798
+
1799
+ // Purge reconnect state for connectors that no longer exist
1800
+ for (const id of reconnectState.keys()) {
1801
+ if (!connectors[id]) reconnectState.delete(id)
1802
+ }
1803
+ }
1804
+
1805
+ /** Get the reconnect state for a specific connector (null if not in reconnect cycle) */
1806
+ export function getReconnectState(connectorId: string): ConnectorReconnectState | null {
1807
+ return reconnectState.get(connectorId) ?? null
1808
+ }
1809
+
1810
+ /** Get all reconnect states (for dashboard/API) */
1811
+ export function getAllReconnectStates(): Record<string, ConnectorReconnectState> {
1812
+ const result: Record<string, ConnectorReconnectState> = {}
1813
+ for (const [id, state] of reconnectState.entries()) {
1814
+ result[id] = { ...state }
1815
+ }
1816
+ return result
1817
+ }
@@ -1185,6 +1185,9 @@ const openclaw: PlatformConnector = {
1185
1185
  throw err
1186
1186
  }
1187
1187
  },
1188
+ isAlive() {
1189
+ return !stopped && connected && !!ws && ws.readyState === WebSocket.OPEN
1190
+ },
1188
1191
  async stop() {
1189
1192
  stopped = true
1190
1193
  cleanupSocket()
@@ -193,8 +193,13 @@ const slack: PlatformConnector = {
193
193
  await app.start()
194
194
  console.log(`[slack] Bot connected (socket mode)`)
195
195
 
196
+ let appStopped = false
197
+
196
198
  return {
197
199
  connector,
200
+ isAlive() {
201
+ return !appStopped && !!app.client
202
+ },
198
203
  async sendMessage(channelId, text, options) {
199
204
  const webClient = app.client
200
205
 
@@ -248,6 +253,7 @@ const slack: PlatformConnector = {
248
253
  return { messageId: lastTs }
249
254
  },
250
255
  async stop() {
256
+ appStopped = true
251
257
  await app.stop()
252
258
  console.log(`[slack] Bot disconnected`)
253
259
  },
@@ -40,8 +40,18 @@ const telegram: PlatformConnector = {
40
40
  bot.on('message', async (ctx) => {
41
41
  if (!ctx.message || !ctx.from || !ctx.chat) return
42
42
  const chatId = String(ctx.chat.id)
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
44
  const raw = ctx.message as any
44
45
  const text = raw.text || raw.caption || ''
46
+
47
+ // Filter out Telegram service/system messages (read receipts, reactions, etc.)
48
+ // that appear as short bracketed strings like [rr], [e], [read] etc.
49
+ const hasMedia = raw.photo || raw.video || raw.audio || raw.voice || raw.document || raw.animation
50
+ if (!hasMedia && /^\[.{1,5}\]$/.test(text.trim())) {
51
+ console.log(`[telegram] Ignoring system event from ${ctx.from.first_name}: ${text}`)
52
+ return
53
+ }
54
+
45
55
  console.log(`[telegram] Message from ${ctx.from.first_name} (chat=${chatId}): ${String(text).slice(0, 80)}`)
46
56
 
47
57
  // Filter by allowed chats if configured
@@ -157,6 +167,9 @@ const telegram: PlatformConnector = {
157
167
  }
158
168
  })
159
169
 
170
+ // Track whether the bot is actively polling
171
+ let botRunning = true
172
+
160
173
  // Start polling — not awaited (runs in background)
161
174
  bot.start({
162
175
  allowed_updates: ['message', 'edited_message'],
@@ -164,11 +177,15 @@ const telegram: PlatformConnector = {
164
177
  console.log(`[telegram] Bot started as @${botInfo.username} — polling for updates`)
165
178
  },
166
179
  }).catch((err) => {
180
+ botRunning = false
167
181
  console.error(`[telegram] Polling stopped with error:`, err.message || err)
168
182
  })
169
183
 
170
184
  return {
171
185
  connector,
186
+ isAlive() {
187
+ return botRunning
188
+ },
172
189
  async sendMessage(channelId, text, options) {
173
190
  const chatId = channelId
174
191
  const caption = options?.caption || text || undefined
@@ -221,6 +238,7 @@ const telegram: PlatformConnector = {
221
238
  return { messageId: lastId }
222
239
  },
223
240
  async stop() {
241
+ botRunning = false
224
242
  await bot.stop()
225
243
  console.log(`[telegram] Bot stopped`)
226
244
  },
@@ -62,6 +62,8 @@ export interface ConnectorInstance {
62
62
  deleteMessage?: (channelId: string, messageId: string) => Promise<void>
63
63
  /** Rich messaging: pin a message */
64
64
  pinMessage?: (channelId: string, messageId: string) => Promise<void>
65
+ /** Health check: returns true if the underlying connection is alive */
66
+ isAlive?: () => boolean
65
67
  }
66
68
 
67
69
  /** Platform-specific connector implementation */
@@ -0,0 +1,29 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { formatTextForWhatsApp } from './whatsapp-text'
4
+
5
+ describe('formatTextForWhatsApp', () => {
6
+ it('converts markdown links to readable whatsapp text', () => {
7
+ const input = 'See [Google](https://google.com) and [https://x.com](https://x.com)'
8
+ const output = formatTextForWhatsApp(input)
9
+ assert.equal(output, 'See Google: https://google.com and https://x.com')
10
+ })
11
+
12
+ it('converts common markdown emphasis syntax', () => {
13
+ const input = '**Bold** __Italic__ ~~Strike~~'
14
+ const output = formatTextForWhatsApp(input)
15
+ assert.equal(output, 'Bold Italic Strike')
16
+ })
17
+
18
+ it('removes headings and preserves body text', () => {
19
+ const input = '# Title\n\n## Subtitle\nBody line'
20
+ const output = formatTextForWhatsApp(input)
21
+ assert.equal(output, 'Title\n\nSubtitle\nBody line')
22
+ })
23
+
24
+ it('converts code fences to plain text content', () => {
25
+ const input = '```ts\nconst x = 1\n```\n\nDone.'
26
+ const output = formatTextForWhatsApp(input)
27
+ assert.equal(output, 'const x = 1\n\nDone.')
28
+ })
29
+ })
@@ -0,0 +1,26 @@
1
+ import removeMarkdown from 'remove-markdown'
2
+
3
+ export function stripMarkdownForPlainChat(raw: string): string {
4
+ const source = String(raw || '').replace(/\r\n?/g, '\n')
5
+ if (!source) return ''
6
+
7
+ let text = removeMarkdown(source, {
8
+ gfm: true,
9
+ useImgAltText: true,
10
+ replaceLinksWithURL: true,
11
+ separateLinksAndTexts: ': ',
12
+ })
13
+
14
+ // Collapse duplicate "url: url" patterns when link label already equals URL.
15
+ text = text.replace(/(https?:\/\/[^\s]+): \1/g, '$1')
16
+ text = text.replace(/\n{3,}/g, '\n\n')
17
+ return text.trim()
18
+ }
19
+
20
+ /**
21
+ * Convert markdown-heavy model output into WhatsApp-friendly plain text.
22
+ * Uses a markdown parser package instead of ad-hoc regex-only stripping.
23
+ */
24
+ export function formatTextForWhatsApp(raw: string): string {
25
+ return stripMarkdownForPlainChat(raw)
26
+ }
@@ -12,6 +12,7 @@ import type { Connector } from '@/types'
12
12
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
13
13
  import { saveInboundMediaBuffer, mimeFromPath, isImageMime, isAudioMime } from './media'
14
14
  import { isNoMessage } from './manager'
15
+ import { formatTextForWhatsApp } from './whatsapp-text'
15
16
 
16
17
  import { DATA_DIR } from '../data-dir'
17
18
 
@@ -65,17 +66,28 @@ const whatsapp: PlatformConnector = {
65
66
  qrDataUrl: null,
66
67
  authenticated: false,
67
68
  hasCredentials: hasStoredCreds(authDir),
69
+ isAlive() {
70
+ if (stopped || !sock) return false
71
+ // Check the underlying WebSocket connection state
72
+ const ws = sock.ws
73
+ if (!ws) return false
74
+ // If authenticated, the connection is alive
75
+ // If we have a socket but not yet authenticated (QR phase), still considered alive
76
+ return !stopped
77
+ },
68
78
  async sendMessage(channelId, text, options) {
69
79
  if (!sock) throw new Error('WhatsApp connector is not connected')
80
+ const normalizedText = formatTextForWhatsApp(text || '')
81
+ const normalizedCaption = formatTextForWhatsApp(options?.caption || normalizedText)
70
82
  // Local file path takes priority
71
83
  if (options?.mediaPath) {
72
84
  if (!fs.existsSync(options.mediaPath)) throw new Error(`File not found: ${options.mediaPath}`)
73
85
  const buf = fs.readFileSync(options.mediaPath)
74
86
  const mime = options.mimeType || mimeFromPath(options.mediaPath)
75
- const caption = options.caption || text || undefined
87
+ const caption = normalizedCaption || undefined
76
88
  const fName = options.fileName || path.basename(options.mediaPath)
77
89
  let sent
78
- if (isImageMime(mime)) {
90
+ if (isImageMime(mime) || mime.startsWith('video/')) {
79
91
  try {
80
92
  sent = await sock.sendMessage(channelId, { image: buf, caption, mimetype: mime })
81
93
  } catch (err: unknown) {
@@ -94,7 +106,7 @@ const whatsapp: PlatformConnector = {
94
106
  if (options?.imageUrl) {
95
107
  const sent = await sock.sendMessage(channelId, {
96
108
  image: { url: options.imageUrl },
97
- caption: options.caption || text || undefined,
109
+ caption: normalizedCaption || undefined,
98
110
  })
99
111
  if (sent?.key?.id) sentMessageIds.add(sent.key.id)
100
112
  return { messageId: sent?.key?.id || undefined }
@@ -104,13 +116,13 @@ const whatsapp: PlatformConnector = {
104
116
  document: { url: options.fileUrl },
105
117
  fileName: options.fileName || 'attachment',
106
118
  mimetype: options.mimeType || 'application/octet-stream',
107
- caption: options.caption || text || undefined,
119
+ caption: normalizedCaption || undefined,
108
120
  })
109
121
  if (sent?.key?.id) sentMessageIds.add(sent.key.id)
110
122
  return { messageId: sent?.key?.id || undefined }
111
123
  }
112
124
 
113
- const payload = text || options?.caption || ''
125
+ const payload = normalizedText || normalizedCaption || ''
114
126
  const chunks = payload.length <= 4096 ? [payload] : (payload.match(/[\s\S]{1,4000}/g) || [payload])
115
127
  let lastMessageId: string | undefined
116
128
  for (const chunk of chunks) {