@swarmclawai/swarmclaw 0.6.6 → 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 +57 -27
- package/package.json +6 -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 +17 -1
- package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
- package/src/app/api/chatrooms/[id]/route.ts +19 -1
- package/src/app/api/chatrooms/route.ts +12 -2
- 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/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 +20 -1
- package/src/app/api/usage/route.ts +16 -7
- package/src/cli/index.js +5 -0
- package/src/cli/index.ts +223 -39
- package/src/components/agents/agent-card.tsx +37 -6
- package/src/components/agents/agent-chat-list.tsx +78 -2
- package/src/components/agents/agent-sheet.tsx +79 -0
- package/src/components/auth/setup-wizard.tsx +268 -353
- package/src/components/chat/chat-area.tsx +22 -7
- package/src/components/chat/message-bubble.tsx +14 -14
- package/src/components/chat/message-list.tsx +1 -1
- package/src/components/chatrooms/chatroom-message.tsx +164 -22
- package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
- package/src/components/chatrooms/chatroom-view.tsx +62 -17
- package/src/components/connectors/connector-health.tsx +120 -0
- package/src/components/connectors/connector-sheet.tsx +9 -0
- package/src/components/home/home-view.tsx +23 -2
- package/src/components/input/chat-input.tsx +8 -1
- package/src/components/layout/app-layout.tsx +17 -1
- package/src/components/schedules/schedule-list.tsx +55 -9
- package/src/components/schedules/schedule-sheet.tsx +134 -23
- package/src/components/shared/command-palette.tsx +237 -0
- package/src/components/shared/connector-platform-icon.tsx +1 -0
- package/src/components/tasks/task-card.tsx +22 -2
- package/src/components/tasks/task-sheet.tsx +91 -16
- package/src/components/usage/metrics-dashboard.tsx +13 -25
- 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/alert-dispatch.ts +64 -0
- package/src/lib/server/chat-execution.ts +41 -1
- package/src/lib/server/chatroom-helpers.ts +22 -1
- 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/manager.ts +159 -3
- 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.ts +9 -0
- 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/queue.ts +12 -0
- package/src/lib/server/session-run-manager.ts +22 -1
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +22 -3
- package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
- package/src/lib/server/storage.ts +120 -6
- package/src/lib/setup-defaults.ts +277 -0
- package/src/lib/validation/schemas.ts +69 -0
- package/src/stores/use-app-store.ts +7 -3
- package/src/stores/use-chatroom-store.ts +52 -2
- package/src/types/index.ts +38 -1
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { loadSettings } from './storage'
|
|
2
|
+
import type { AppNotification } from '@/types'
|
|
3
|
+
|
|
4
|
+
/** In-memory rate limiter: dedupKey → last dispatch timestamp */
|
|
5
|
+
const recentDispatches = new Map<string, number>()
|
|
6
|
+
const DEDUP_WINDOW_MS = 60_000
|
|
7
|
+
|
|
8
|
+
export async function dispatchAlert(notification: AppNotification): Promise<void> {
|
|
9
|
+
const settings = loadSettings()
|
|
10
|
+
const url = typeof settings.alertWebhookUrl === 'string' ? settings.alertWebhookUrl.trim() : ''
|
|
11
|
+
if (!url) return
|
|
12
|
+
|
|
13
|
+
const allowedEvents: string[] = Array.isArray(settings.alertWebhookEvents)
|
|
14
|
+
? settings.alertWebhookEvents
|
|
15
|
+
: ['error']
|
|
16
|
+
if (!allowedEvents.includes(notification.type)) return
|
|
17
|
+
|
|
18
|
+
// Rate limit by dedupKey (or notification id as fallback)
|
|
19
|
+
const dedupKey = notification.dedupKey || notification.id
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
const lastSent = recentDispatches.get(dedupKey)
|
|
22
|
+
if (lastSent && now - lastSent < DEDUP_WINDOW_MS) return
|
|
23
|
+
recentDispatches.set(dedupKey, now)
|
|
24
|
+
|
|
25
|
+
// Prune stale entries periodically
|
|
26
|
+
if (recentDispatches.size > 200) {
|
|
27
|
+
for (const [key, ts] of recentDispatches) {
|
|
28
|
+
if (now - ts > DEDUP_WINDOW_MS) recentDispatches.delete(key)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const webhookType = settings.alertWebhookType || 'custom'
|
|
33
|
+
let body: string
|
|
34
|
+
|
|
35
|
+
if (webhookType === 'discord') {
|
|
36
|
+
body = JSON.stringify({
|
|
37
|
+
content: `⚠️ **${notification.title}**${notification.message ? `\n${notification.message}` : ''}`,
|
|
38
|
+
})
|
|
39
|
+
} else if (webhookType === 'slack') {
|
|
40
|
+
body = JSON.stringify({
|
|
41
|
+
text: `⚠️ *${notification.title}*${notification.message ? `\n${notification.message}` : ''}`,
|
|
42
|
+
})
|
|
43
|
+
} else {
|
|
44
|
+
body = JSON.stringify({
|
|
45
|
+
type: notification.type,
|
|
46
|
+
title: notification.title,
|
|
47
|
+
message: notification.message || null,
|
|
48
|
+
entityType: notification.entityType || null,
|
|
49
|
+
entityId: notification.entityId || null,
|
|
50
|
+
timestamp: notification.createdAt,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await fetch(url, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body,
|
|
59
|
+
signal: AbortSignal.timeout(5000),
|
|
60
|
+
})
|
|
61
|
+
} catch (err: unknown) {
|
|
62
|
+
console.warn('[alert-dispatch] Webhook delivery failed:', err instanceof Error ? err.message : String(err))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
active,
|
|
14
14
|
} from './storage'
|
|
15
15
|
import { getProvider } from '@/lib/providers'
|
|
16
|
-
import { estimateCost } from './cost'
|
|
16
|
+
import { estimateCost, checkBudget } from './cost'
|
|
17
17
|
import { log } from './logger'
|
|
18
18
|
import { logExecution } from './execution-log'
|
|
19
19
|
import { streamAgentChat } from './stream-agent-chat'
|
|
@@ -582,6 +582,45 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
582
582
|
onEvent?.({ t: 'err', text: `Capability policy blocked tools for this run: ${blockedSummary}` })
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
+
// --- Agent monthly budget enforcement ---
|
|
586
|
+
if (session.agentId) {
|
|
587
|
+
const agentsMap = loadAgents()
|
|
588
|
+
const agent = agentsMap[session.agentId]
|
|
589
|
+
if (agent?.monthlyBudget && agent.monthlyBudget > 0) {
|
|
590
|
+
const budgetResult = checkBudget(agent)
|
|
591
|
+
if (!budgetResult.ok) {
|
|
592
|
+
const action = agent.budgetAction || 'warn'
|
|
593
|
+
if (action === 'block') {
|
|
594
|
+
const budgetError = budgetResult.message || `Agent budget exceeded: $${budgetResult.spend.toFixed(4)} / $${budgetResult.budget.toFixed(2)}`
|
|
595
|
+
onEvent?.({ t: 'err', text: budgetError })
|
|
596
|
+
|
|
597
|
+
let persisted = false
|
|
598
|
+
if (!internal) {
|
|
599
|
+
session.messages.push({
|
|
600
|
+
role: 'assistant',
|
|
601
|
+
text: budgetError,
|
|
602
|
+
time: Date.now(),
|
|
603
|
+
})
|
|
604
|
+
session.lastActiveAt = Date.now()
|
|
605
|
+
saveSessions(sessions)
|
|
606
|
+
persisted = true
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
runId,
|
|
611
|
+
sessionId,
|
|
612
|
+
text: budgetError,
|
|
613
|
+
persisted,
|
|
614
|
+
toolEvents: [],
|
|
615
|
+
error: budgetError,
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// budgetAction === 'warn': emit a warning but continue
|
|
619
|
+
onEvent?.({ t: 'status', text: JSON.stringify({ budgetWarning: budgetResult.message }) })
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
585
624
|
const dailySpendLimitUsd = parseUsdLimit(appSettings.safetyMaxDailySpendUsd)
|
|
586
625
|
if (dailySpendLimitUsd !== null) {
|
|
587
626
|
const todaySpendUsd = getTodaySpendUsd()
|
|
@@ -731,6 +770,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
731
770
|
active,
|
|
732
771
|
loadHistory: isAutoRunNoHistory ? () => getSessionMessages(sessionId).slice(-6) : (sid: string) => applyContextClearBoundary(getSessionMessages(sid)),
|
|
733
772
|
onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
|
|
773
|
+
signal: abortController.signal,
|
|
734
774
|
})
|
|
735
775
|
} catch (err: any) {
|
|
736
776
|
errorMessage = err?.message || String(err)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
|
|
2
2
|
import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
|
|
3
3
|
import { genId } from '@/lib/id'
|
|
4
|
-
import type { Chatroom, Agent, Session, Message } from '@/types'
|
|
4
|
+
import type { Chatroom, ChatroomMember, Agent, Session, Message } from '@/types'
|
|
5
5
|
|
|
6
6
|
/** Resolve API key from an agent's credentialId */
|
|
7
7
|
export function resolveApiKey(credentialId: string | null | undefined): string | null {
|
|
@@ -12,6 +12,27 @@ export function resolveApiKey(credentialId: string | null | undefined): string |
|
|
|
12
12
|
try { return decryptKey(cred.encryptedKey) } catch { return null }
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Derive chatroom members from the `members` array if present, otherwise fallback to `agentIds` with default 'member' role. */
|
|
16
|
+
export function getMembers(chatroom: Chatroom): ChatroomMember[] {
|
|
17
|
+
if (chatroom.members?.length) return chatroom.members
|
|
18
|
+
return chatroom.agentIds.map((agentId) => ({ agentId, role: 'member' as const }))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Return the role of an agent in a chatroom, defaulting to 'member'. */
|
|
22
|
+
export function getMemberRole(chatroom: Chatroom, agentId: string): string {
|
|
23
|
+
const members = getMembers(chatroom)
|
|
24
|
+
const member = members.find((m) => m.agentId === agentId)
|
|
25
|
+
return member?.role || 'member'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Check if an agent is currently muted in the chatroom. */
|
|
29
|
+
export function isMuted(chatroom: Chatroom, agentId: string): boolean {
|
|
30
|
+
const members = getMembers(chatroom)
|
|
31
|
+
const member = members.find((m) => m.agentId === agentId)
|
|
32
|
+
if (!member?.mutedUntil) return false
|
|
33
|
+
return new Date(member.mutedUntil).getTime() > Date.now()
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
const COMPACTION_PREFIX = '[Conversation summary]'
|
|
16
37
|
|
|
17
38
|
function normalizeMentionToken(raw: string): string {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ChatroomRoutingRule, Agent } from '@/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evaluate routing rules against inbound message text.
|
|
5
|
+
*
|
|
6
|
+
* Rules are evaluated in priority order (lower number = higher priority).
|
|
7
|
+
* First match wins — returns the matched agentIds.
|
|
8
|
+
*
|
|
9
|
+
* - 'keyword' rules: case-insensitive substring match against `keywords[]`,
|
|
10
|
+
* or regex match against `pattern`.
|
|
11
|
+
* - 'capability' rules: match `pattern` against each agent's `capabilities[]`.
|
|
12
|
+
*/
|
|
13
|
+
export function evaluateRoutingRules(
|
|
14
|
+
text: string,
|
|
15
|
+
rules: ChatroomRoutingRule[],
|
|
16
|
+
agents: Agent[],
|
|
17
|
+
): string[] {
|
|
18
|
+
if (!rules.length) return []
|
|
19
|
+
|
|
20
|
+
const sorted = [...rules].sort((a, b) => a.priority - b.priority)
|
|
21
|
+
const lowerText = text.toLowerCase()
|
|
22
|
+
|
|
23
|
+
for (const rule of sorted) {
|
|
24
|
+
if (rule.type === 'keyword') {
|
|
25
|
+
let matched = false
|
|
26
|
+
|
|
27
|
+
// Check keywords (case-insensitive substring)
|
|
28
|
+
if (rule.keywords?.length) {
|
|
29
|
+
matched = rule.keywords.some((kw) => lowerText.includes(kw.toLowerCase()))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check pattern (regex)
|
|
33
|
+
if (!matched && rule.pattern) {
|
|
34
|
+
try {
|
|
35
|
+
const re = new RegExp(rule.pattern, 'i')
|
|
36
|
+
matched = re.test(text)
|
|
37
|
+
} catch {
|
|
38
|
+
// Invalid regex — skip
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (matched) return [rule.agentId]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (rule.type === 'capability') {
|
|
46
|
+
if (!rule.pattern) continue
|
|
47
|
+
const patternLower = rule.pattern.toLowerCase()
|
|
48
|
+
|
|
49
|
+
// Check if the specific agent has a matching capability
|
|
50
|
+
const agent = agents.find((a) => a.id === rule.agentId)
|
|
51
|
+
if (agent?.capabilities?.some((cap) => cap.toLowerCase().includes(patternLower))) {
|
|
52
|
+
// Only match if the message text is relevant to the capability
|
|
53
|
+
// Use the pattern as a keyword match against the message text too
|
|
54
|
+
try {
|
|
55
|
+
const re = new RegExp(rule.pattern, 'i')
|
|
56
|
+
if (re.test(text)) return [rule.agentId]
|
|
57
|
+
} catch {
|
|
58
|
+
if (lowerText.includes(patternLower)) return [rule.agentId]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
@@ -105,6 +105,9 @@ const discord: PlatformConnector = {
|
|
|
105
105
|
|
|
106
106
|
return {
|
|
107
107
|
connector,
|
|
108
|
+
isAlive() {
|
|
109
|
+
return client.isReady()
|
|
110
|
+
},
|
|
108
111
|
async sendMessage(channelId, text, options) {
|
|
109
112
|
const channel = await client.channels.fetch(channelId)
|
|
110
113
|
if (!channel || !('send' in channel) || typeof (channel as any).send !== 'function') {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ImapFlow } from 'imapflow'
|
|
2
|
+
import { createTransport, type Transporter } from 'nodemailer'
|
|
3
|
+
import { simpleParser } from 'mailparser'
|
|
4
|
+
import type { Connector } from '@/types'
|
|
5
|
+
import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
|
|
6
|
+
import { isNoMessage } from './manager'
|
|
7
|
+
|
|
8
|
+
interface EmailConfig {
|
|
9
|
+
imapHost: string
|
|
10
|
+
imapPort?: number
|
|
11
|
+
smtpHost: string
|
|
12
|
+
smtpPort?: number
|
|
13
|
+
user: string
|
|
14
|
+
password: string
|
|
15
|
+
folder?: string
|
|
16
|
+
pollIntervalSec?: number
|
|
17
|
+
subjectPrefix?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getConfig(connector: Connector): EmailConfig {
|
|
21
|
+
const c = connector.config as Record<string, unknown>
|
|
22
|
+
return {
|
|
23
|
+
imapHost: String(c.imapHost ?? ''),
|
|
24
|
+
imapPort: Number(c.imapPort ?? 993),
|
|
25
|
+
smtpHost: String(c.smtpHost ?? ''),
|
|
26
|
+
smtpPort: Number(c.smtpPort ?? 587),
|
|
27
|
+
user: String(c.user ?? ''),
|
|
28
|
+
password: String(c.password ?? ''),
|
|
29
|
+
folder: String(c.folder ?? 'INBOX'),
|
|
30
|
+
pollIntervalSec: Number(c.pollIntervalSec ?? 60),
|
|
31
|
+
subjectPrefix: c.subjectPrefix ? String(c.subjectPrefix) : undefined,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const email: PlatformConnector = {
|
|
36
|
+
async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
|
|
37
|
+
const config = getConfig(connector)
|
|
38
|
+
|
|
39
|
+
if (!config.imapHost || !config.smtpHost || !config.user || !config.password) {
|
|
40
|
+
throw new Error('Email connector requires imapHost, smtpHost, user, and password')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const folder = config.folder || 'INBOX'
|
|
44
|
+
const pollMs = (config.pollIntervalSec || 60) * 1000
|
|
45
|
+
|
|
46
|
+
// IMAP client for inbound
|
|
47
|
+
const imap = new ImapFlow({
|
|
48
|
+
host: config.imapHost,
|
|
49
|
+
port: config.imapPort || 993,
|
|
50
|
+
secure: (config.imapPort || 993) === 993,
|
|
51
|
+
auth: {
|
|
52
|
+
user: config.user,
|
|
53
|
+
pass: config.password,
|
|
54
|
+
},
|
|
55
|
+
logger: false,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// SMTP transport for outbound
|
|
59
|
+
const smtp: Transporter = createTransport({
|
|
60
|
+
host: config.smtpHost,
|
|
61
|
+
port: config.smtpPort || 587,
|
|
62
|
+
secure: (config.smtpPort || 587) === 465,
|
|
63
|
+
auth: {
|
|
64
|
+
user: config.user,
|
|
65
|
+
pass: config.password,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Track last seen UID to only process new messages
|
|
70
|
+
let highwaterUid = 0
|
|
71
|
+
let connected = false
|
|
72
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
73
|
+
|
|
74
|
+
// Map to track original sender per channelId (email address) for replies
|
|
75
|
+
const senderMap = new Map<string, { address: string; subject: string; messageId?: string }>()
|
|
76
|
+
|
|
77
|
+
async function connectImap(): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
await imap.connect()
|
|
80
|
+
connected = true
|
|
81
|
+
console.log(`[email] IMAP connected to ${config.imapHost}`)
|
|
82
|
+
|
|
83
|
+
// Get the current highest UID as highwater mark (don't process old messages)
|
|
84
|
+
const lock = await imap.getMailboxLock(folder)
|
|
85
|
+
try {
|
|
86
|
+
const status = await imap.status(folder, { uidNext: true })
|
|
87
|
+
// uidNext is the next UID that will be assigned; current highest is uidNext - 1
|
|
88
|
+
highwaterUid = typeof status.uidNext === 'number' ? status.uidNext - 1 : 0
|
|
89
|
+
console.log(`[email] Initial highwater UID: ${highwaterUid} in ${folder}`)
|
|
90
|
+
} finally {
|
|
91
|
+
lock.release()
|
|
92
|
+
}
|
|
93
|
+
} catch (err: unknown) {
|
|
94
|
+
connected = false
|
|
95
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
96
|
+
console.error(`[email] IMAP connection failed: ${msg}`)
|
|
97
|
+
throw err
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function pollForNewMessages(): Promise<void> {
|
|
102
|
+
if (!connected) return
|
|
103
|
+
|
|
104
|
+
let lock
|
|
105
|
+
try {
|
|
106
|
+
lock = await imap.getMailboxLock(folder)
|
|
107
|
+
} catch (err: unknown) {
|
|
108
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
109
|
+
console.error(`[email] Failed to acquire mailbox lock: ${msg}`)
|
|
110
|
+
connected = false
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Fetch messages with UID > highwaterUid
|
|
116
|
+
const range = `${highwaterUid + 1}:*`
|
|
117
|
+
const messages = []
|
|
118
|
+
|
|
119
|
+
for await (const msg of imap.fetch(range, { envelope: true, source: true, uid: true }, { uid: true })) {
|
|
120
|
+
if (msg.uid <= highwaterUid) continue
|
|
121
|
+
messages.push(msg)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const msg of messages) {
|
|
125
|
+
try {
|
|
126
|
+
await processMessage(msg)
|
|
127
|
+
} catch (err: unknown) {
|
|
128
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
129
|
+
console.error(`[email] Error processing message UID ${msg.uid}: ${errMsg}`)
|
|
130
|
+
}
|
|
131
|
+
if (msg.uid > highwaterUid) {
|
|
132
|
+
highwaterUid = msg.uid
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch (err: unknown) {
|
|
136
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
137
|
+
// A fetch on an empty range can throw; that's normal
|
|
138
|
+
if (!errMsg.includes('Nothing to fetch')) {
|
|
139
|
+
console.error(`[email] Poll error: ${errMsg}`)
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
lock.release()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function processMessage(msg: { uid: number; envelope?: { from?: Array<{ name?: string; address?: string }>; subject?: string; messageId?: string }; source?: Buffer }): Promise<void> {
|
|
147
|
+
const envelope = msg.envelope
|
|
148
|
+
if (!envelope) return
|
|
149
|
+
|
|
150
|
+
const fromAddr = envelope.from?.[0]?.address || 'unknown'
|
|
151
|
+
const fromName = envelope.from?.[0]?.name || fromAddr
|
|
152
|
+
const subject = envelope.subject || '(no subject)'
|
|
153
|
+
|
|
154
|
+
// Filter by subject prefix if configured
|
|
155
|
+
if (config.subjectPrefix && !subject.startsWith(config.subjectPrefix)) {
|
|
156
|
+
console.log(`[email] Skipping message from ${fromAddr} — subject "${subject}" doesn't match prefix "${config.subjectPrefix}"`)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Parse the email body for text content
|
|
161
|
+
let bodyText = ''
|
|
162
|
+
if (msg.source) {
|
|
163
|
+
const parsed = await simpleParser(msg.source)
|
|
164
|
+
bodyText = parsed.text || ''
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!bodyText.trim()) {
|
|
168
|
+
console.log(`[email] Skipping empty message from ${fromAddr}`)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(`[email] New message from ${fromName} <${fromAddr}>: ${subject}`)
|
|
173
|
+
|
|
174
|
+
// Use the sender's email as channelId
|
|
175
|
+
const channelId = fromAddr
|
|
176
|
+
|
|
177
|
+
// Store sender info for replies
|
|
178
|
+
senderMap.set(channelId, {
|
|
179
|
+
address: fromAddr,
|
|
180
|
+
subject,
|
|
181
|
+
messageId: envelope.messageId,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const inbound: InboundMessage = {
|
|
185
|
+
platform: 'email',
|
|
186
|
+
channelId,
|
|
187
|
+
channelName: `Email: ${fromName}`,
|
|
188
|
+
senderId: fromAddr,
|
|
189
|
+
senderName: fromName,
|
|
190
|
+
text: bodyText.trim(),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const response = await onMessage(inbound)
|
|
195
|
+
if (isNoMessage(response)) return
|
|
196
|
+
|
|
197
|
+
// Reply via SMTP
|
|
198
|
+
await sendReply(channelId, response)
|
|
199
|
+
} catch (err: unknown) {
|
|
200
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
201
|
+
console.error(`[email] Error handling message from ${fromAddr}: ${errMsg}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function sendReply(channelId: string, text: string): Promise<void> {
|
|
206
|
+
const sender = senderMap.get(channelId)
|
|
207
|
+
const to = sender?.address || channelId
|
|
208
|
+
const subject = sender?.subject ? `Re: ${sender.subject.replace(/^Re:\s*/i, '')}` : 'Re: SwarmClaw'
|
|
209
|
+
|
|
210
|
+
const mailOptions: Record<string, unknown> = {
|
|
211
|
+
from: config.user,
|
|
212
|
+
to,
|
|
213
|
+
subject,
|
|
214
|
+
text,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Thread the reply using In-Reply-To header
|
|
218
|
+
if (sender?.messageId) {
|
|
219
|
+
mailOptions['inReplyTo'] = sender.messageId
|
|
220
|
+
mailOptions['references'] = sender.messageId
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await smtp.sendMail(mailOptions)
|
|
224
|
+
console.log(`[email] Reply sent to ${to}`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Connect and start polling
|
|
228
|
+
await connectImap()
|
|
229
|
+
|
|
230
|
+
pollTimer = setInterval(() => {
|
|
231
|
+
pollForNewMessages().catch((err: unknown) => {
|
|
232
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
233
|
+
console.error(`[email] Poll interval error: ${msg}`)
|
|
234
|
+
})
|
|
235
|
+
}, pollMs)
|
|
236
|
+
|
|
237
|
+
console.log(`[email] Connector started — polling every ${config.pollIntervalSec || 60}s`)
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
connector,
|
|
241
|
+
|
|
242
|
+
isAlive() {
|
|
243
|
+
return connected && imap.usable
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async sendMessage(channelId, text) {
|
|
247
|
+
await sendReply(channelId, text)
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async stop() {
|
|
251
|
+
if (pollTimer) {
|
|
252
|
+
clearInterval(pollTimer)
|
|
253
|
+
pollTimer = null
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await imap.logout()
|
|
257
|
+
} catch {
|
|
258
|
+
// Connection may already be closed
|
|
259
|
+
}
|
|
260
|
+
connected = false
|
|
261
|
+
console.log(`[email] Connector stopped`)
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export default email
|