@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.
Files changed (80) hide show
  1. package/README.md +57 -27
  2. package/package.json +6 -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 +17 -1
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +19 -1
  8. package/src/app/api/chatrooms/route.ts +12 -2
  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/sessions/[id]/chat/route.ts +5 -1
  14. package/src/app/api/sessions/route.ts +11 -2
  15. package/src/app/api/tasks/[id]/route.ts +18 -13
  16. package/src/app/api/tasks/route.ts +20 -1
  17. package/src/app/api/usage/route.ts +16 -7
  18. package/src/cli/index.js +5 -0
  19. package/src/cli/index.ts +223 -39
  20. package/src/components/agents/agent-card.tsx +37 -6
  21. package/src/components/agents/agent-chat-list.tsx +78 -2
  22. package/src/components/agents/agent-sheet.tsx +79 -0
  23. package/src/components/auth/setup-wizard.tsx +268 -353
  24. package/src/components/chat/chat-area.tsx +22 -7
  25. package/src/components/chat/message-bubble.tsx +14 -14
  26. package/src/components/chat/message-list.tsx +1 -1
  27. package/src/components/chatrooms/chatroom-message.tsx +164 -22
  28. package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
  29. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  30. package/src/components/connectors/connector-health.tsx +120 -0
  31. package/src/components/connectors/connector-sheet.tsx +9 -0
  32. package/src/components/home/home-view.tsx +23 -2
  33. package/src/components/input/chat-input.tsx +8 -1
  34. package/src/components/layout/app-layout.tsx +17 -1
  35. package/src/components/schedules/schedule-list.tsx +55 -9
  36. package/src/components/schedules/schedule-sheet.tsx +134 -23
  37. package/src/components/shared/command-palette.tsx +237 -0
  38. package/src/components/shared/connector-platform-icon.tsx +1 -0
  39. package/src/components/tasks/task-card.tsx +22 -2
  40. package/src/components/tasks/task-sheet.tsx +91 -16
  41. package/src/components/usage/metrics-dashboard.tsx +13 -25
  42. package/src/hooks/use-swipe.ts +49 -0
  43. package/src/lib/providers/anthropic.ts +16 -2
  44. package/src/lib/providers/claude-cli.ts +7 -1
  45. package/src/lib/providers/index.ts +7 -0
  46. package/src/lib/providers/ollama.ts +16 -2
  47. package/src/lib/providers/openai.ts +7 -2
  48. package/src/lib/providers/openclaw.ts +6 -1
  49. package/src/lib/providers/provider-defaults.ts +7 -0
  50. package/src/lib/schedule-templates.ts +115 -0
  51. package/src/lib/server/alert-dispatch.ts +64 -0
  52. package/src/lib/server/chat-execution.ts +41 -1
  53. package/src/lib/server/chatroom-helpers.ts +22 -1
  54. package/src/lib/server/chatroom-routing.ts +65 -0
  55. package/src/lib/server/connectors/discord.ts +3 -0
  56. package/src/lib/server/connectors/email.ts +267 -0
  57. package/src/lib/server/connectors/manager.ts +159 -3
  58. package/src/lib/server/connectors/openclaw.ts +3 -0
  59. package/src/lib/server/connectors/slack.ts +6 -0
  60. package/src/lib/server/connectors/telegram.ts +18 -0
  61. package/src/lib/server/connectors/types.ts +2 -0
  62. package/src/lib/server/connectors/whatsapp.ts +9 -0
  63. package/src/lib/server/cost.ts +70 -0
  64. package/src/lib/server/create-notification.ts +2 -0
  65. package/src/lib/server/daemon-state.ts +124 -0
  66. package/src/lib/server/dag-validation.ts +115 -0
  67. package/src/lib/server/memory-db.ts +12 -7
  68. package/src/lib/server/openclaw-doctor.ts +48 -0
  69. package/src/lib/server/queue.ts +12 -0
  70. package/src/lib/server/session-run-manager.ts +22 -1
  71. package/src/lib/server/session-tools/index.ts +2 -0
  72. package/src/lib/server/session-tools/memory.ts +22 -3
  73. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  74. package/src/lib/server/storage.ts +120 -6
  75. package/src/lib/setup-defaults.ts +277 -0
  76. package/src/lib/validation/schemas.ts +69 -0
  77. package/src/stores/use-app-store.ts +7 -3
  78. package/src/stores/use-chatroom-store.ts +52 -2
  79. package/src/types/index.ts +38 -1
  80. 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