@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.
- package/README.md +62 -30
- package/package.json +10 -1
- package/src/app/api/agents/[id]/clone/route.ts +40 -0
- package/src/app/api/agents/route.ts +39 -14
- package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
- package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
- package/src/app/api/chatrooms/[id]/route.ts +34 -2
- package/src/app/api/chatrooms/route.ts +26 -3
- package/src/app/api/connectors/[id]/health/route.ts +64 -0
- package/src/app/api/connectors/route.ts +17 -2
- package/src/app/api/knowledge/route.ts +6 -1
- package/src/app/api/openclaw/doctor/route.ts +17 -0
- package/src/app/api/schedules/[id]/run/route.ts +3 -0
- package/src/app/api/sessions/[id]/chat/route.ts +5 -1
- package/src/app/api/sessions/route.ts +11 -2
- package/src/app/api/tasks/[id]/route.ts +18 -13
- package/src/app/api/tasks/route.ts +44 -1
- package/src/app/api/usage/route.ts +16 -7
- package/src/app/api/wallets/[id]/approve/route.ts +62 -0
- package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
- package/src/app/api/wallets/[id]/route.ts +118 -0
- package/src/app/api/wallets/[id]/send/route.ts +118 -0
- package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
- package/src/app/api/wallets/route.ts +74 -0
- package/src/app/globals.css +8 -0
- package/src/cli/index.js +20 -0
- package/src/cli/index.ts +223 -39
- package/src/cli/spec.js +14 -0
- package/src/components/agents/agent-avatar.tsx +15 -1
- package/src/components/agents/agent-card.tsx +38 -6
- package/src/components/agents/agent-chat-list.tsx +79 -3
- package/src/components/agents/agent-sheet.tsx +191 -26
- package/src/components/auth/setup-wizard.tsx +268 -353
- package/src/components/chat/chat-area.tsx +24 -9
- package/src/components/chat/chat-header.tsx +48 -19
- package/src/components/chat/chat-tool-toggles.tsx +1 -1
- package/src/components/chat/delegation-banner.test.ts +27 -0
- package/src/components/chat/delegation-banner.tsx +109 -23
- package/src/components/chat/message-bubble.tsx +17 -16
- package/src/components/chat/message-list.tsx +6 -5
- package/src/components/chat/streaming-bubble.tsx +3 -2
- package/src/components/chat/thinking-indicator.tsx +3 -2
- package/src/components/chat/transfer-agent-picker.tsx +1 -1
- package/src/components/chatrooms/agent-hover-card.tsx +1 -1
- package/src/components/chatrooms/chatroom-input.tsx +1 -1
- package/src/components/chatrooms/chatroom-message.tsx +165 -23
- package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
- package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
- package/src/components/chatrooms/chatroom-view.tsx +62 -17
- package/src/components/connectors/connector-health.tsx +120 -0
- package/src/components/connectors/connector-list.tsx +1 -1
- package/src/components/connectors/connector-sheet.tsx +9 -0
- package/src/components/home/home-view.tsx +25 -3
- package/src/components/input/chat-input.tsx +8 -1
- package/src/components/knowledge/knowledge-list.tsx +1 -1
- package/src/components/knowledge/knowledge-sheet.tsx +1 -1
- package/src/components/layout/app-layout.tsx +35 -4
- package/src/components/memory/memory-agent-list.tsx +1 -1
- package/src/components/memory/memory-browser.tsx +1 -0
- package/src/components/memory/memory-card.tsx +3 -2
- package/src/components/memory/memory-detail.tsx +3 -3
- package/src/components/memory/memory-sheet.tsx +2 -2
- package/src/components/projects/project-detail.tsx +4 -4
- package/src/components/schedules/schedule-list.tsx +55 -9
- package/src/components/schedules/schedule-sheet.tsx +134 -23
- package/src/components/secrets/secret-sheet.tsx +1 -1
- package/src/components/secrets/secrets-list.tsx +1 -1
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/shared/agent-picker-list.tsx +1 -1
- package/src/components/shared/agent-switch-dialog.tsx +1 -1
- package/src/components/shared/command-palette.tsx +237 -0
- package/src/components/shared/connector-platform-icon.tsx +1 -0
- package/src/components/shared/settings/section-user-preferences.tsx +4 -4
- package/src/components/skills/skill-list.tsx +1 -1
- package/src/components/skills/skill-sheet.tsx +1 -1
- package/src/components/tasks/task-board.tsx +3 -3
- package/src/components/tasks/task-card.tsx +22 -2
- package/src/components/tasks/task-sheet.tsx +112 -17
- package/src/components/usage/metrics-dashboard.tsx +13 -25
- package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
- package/src/components/wallets/wallet-panel.tsx +616 -0
- package/src/components/wallets/wallet-section.tsx +100 -0
- package/src/hooks/use-swipe.ts +49 -0
- package/src/lib/providers/anthropic.ts +16 -2
- package/src/lib/providers/claude-cli.ts +7 -1
- package/src/lib/providers/index.ts +7 -0
- package/src/lib/providers/ollama.ts +16 -2
- package/src/lib/providers/openai.ts +7 -2
- package/src/lib/providers/openclaw.ts +6 -1
- package/src/lib/providers/provider-defaults.ts +7 -0
- package/src/lib/schedule-templates.ts +115 -0
- package/src/lib/server/agent-registry.ts +2 -2
- package/src/lib/server/alert-dispatch.ts +64 -0
- package/src/lib/server/chat-execution.ts +76 -4
- package/src/lib/server/chatroom-health.ts +60 -0
- package/src/lib/server/chatroom-helpers.test.ts +94 -0
- package/src/lib/server/chatroom-helpers.ts +86 -12
- package/src/lib/server/chatroom-routing.ts +65 -0
- package/src/lib/server/connectors/discord.ts +3 -0
- package/src/lib/server/connectors/email.ts +267 -0
- package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
- package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
- package/src/lib/server/connectors/manager.ts +239 -5
- package/src/lib/server/connectors/openclaw.ts +3 -0
- package/src/lib/server/connectors/slack.ts +6 -0
- package/src/lib/server/connectors/telegram.ts +18 -0
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
- package/src/lib/server/connectors/whatsapp-text.ts +26 -0
- package/src/lib/server/connectors/whatsapp.ts +17 -5
- package/src/lib/server/cost.ts +70 -0
- package/src/lib/server/create-notification.ts +2 -0
- package/src/lib/server/daemon-state.ts +124 -0
- package/src/lib/server/dag-validation.ts +115 -0
- package/src/lib/server/memory-db.ts +12 -7
- package/src/lib/server/openclaw-doctor.ts +48 -0
- package/src/lib/server/orchestrator-lg.ts +12 -2
- package/src/lib/server/orchestrator.ts +6 -1
- package/src/lib/server/queue-followups.test.ts +224 -0
- package/src/lib/server/queue.ts +238 -24
- package/src/lib/server/scheduler.ts +3 -0
- package/src/lib/server/session-run-manager.ts +22 -1
- package/src/lib/server/session-tools/chatroom.ts +11 -2
- package/src/lib/server/session-tools/context-mgmt.ts +2 -2
- package/src/lib/server/session-tools/index.ts +8 -2
- package/src/lib/server/session-tools/memory.ts +23 -4
- package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
- package/src/lib/server/session-tools/shell.ts +1 -1
- package/src/lib/server/session-tools/wallet.ts +124 -0
- package/src/lib/server/session-tools/web.ts +2 -2
- package/src/lib/server/solana.ts +122 -0
- package/src/lib/server/storage.ts +158 -6
- package/src/lib/server/stream-agent-chat.ts +126 -63
- package/src/lib/server/task-mention.test.ts +41 -0
- package/src/lib/server/task-mention.ts +3 -2
- package/src/lib/setup-defaults.ts +277 -0
- package/src/lib/tool-definitions.ts +1 -0
- package/src/lib/validation/schemas.ts +69 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/stores/use-app-store.ts +15 -3
- package/src/stores/use-chatroom-store.ts +52 -2
- package/src/types/index.ts +98 -2
- 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
|
-
//
|
|
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:
|
|
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 =
|
|
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
|
+
}
|
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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) {
|