@leviyuan/lodestar 0.1.0 โ 2.0.14
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 +76 -67
- package/cli.ts +176 -0
- package/config.ts +135 -0
- package/daemon.ts +1080 -144
- package/email-worker.ts +534 -0
- package/env-bootstrap.ts +7 -0
- package/feishu-mcp.ts +482 -0
- package/package.json +36 -37
- package/runtime-api.ts +569 -0
- package/scripts/runtime-thread.sh +91 -0
- package/status-dashboard.ts +733 -0
- package/src/cardkit.ts +0 -215
- package/src/cards.ts +0 -304
- package/src/claude-process.ts +0 -301
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -365
- package/src/instructions.ts +0 -22
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -41
- package/src/session.ts +0 -447
package/email-worker.ts
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email worker โ IMAP listener + SMTP sender + task queue.
|
|
3
|
+
*
|
|
4
|
+
* Watches a mailbox via IMAP, filters by whitelist, parses
|
|
5
|
+
* project + request from subject/body, creates a DeepSeek thread+turn
|
|
6
|
+
* via Runtime API, and replies with the result via SMTP.
|
|
7
|
+
*
|
|
8
|
+
* Feishu notifications are sent at three points:
|
|
9
|
+
* ๐ฉ received ๐ started โ
completed (or โฐ timeout / โ error)
|
|
10
|
+
*
|
|
11
|
+
* DeepSeek TUI migration: spawn claude -p โ Runtime API createThread + createTurn.
|
|
12
|
+
*
|
|
13
|
+
* Imported and started by daemon.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { homedir } from 'os'
|
|
19
|
+
import { RuntimeApiClient } from './runtime-api'
|
|
20
|
+
|
|
21
|
+
// โโ Types โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
22
|
+
|
|
23
|
+
export interface EmailConfig {
|
|
24
|
+
imap: { host: string; port?: number; user: string; pass: string }
|
|
25
|
+
smtp: { host: string; port?: number; user: string; pass: string }
|
|
26
|
+
projects: Record<string, ProjectConfig>
|
|
27
|
+
defaults?: {
|
|
28
|
+
timeout_minutes?: number
|
|
29
|
+
permission_mode?: string
|
|
30
|
+
max_budget_usd?: number
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProjectConfig {
|
|
35
|
+
whitelist: string[]
|
|
36
|
+
allowed_intents?: string
|
|
37
|
+
system_prompt?: string
|
|
38
|
+
timeout_minutes?: number
|
|
39
|
+
permission_mode?: string
|
|
40
|
+
max_budget_usd?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EmailTask {
|
|
44
|
+
from: string
|
|
45
|
+
subject: string
|
|
46
|
+
project: string
|
|
47
|
+
request: string
|
|
48
|
+
messageId: string
|
|
49
|
+
inReplyTo?: string
|
|
50
|
+
receivedAt: Date
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
54
|
+
|
|
55
|
+
const STATE_DIR = join(homedir(), '.deepseek', 'lodestar')
|
|
56
|
+
const PROJECTS_ROOT = process.env.FEISHU_PROJECTS_ROOT ?? homedir()
|
|
57
|
+
const CONFIG_FILE = join(STATE_DIR, 'email-config.json')
|
|
58
|
+
const API_URL = process.env.DEEPSEEK_API_URL ?? 'http://localhost:7878'
|
|
59
|
+
const API_TOKEN = process.env.DEEPSEEK_API_TOKEN ?? ''
|
|
60
|
+
|
|
61
|
+
let config: EmailConfig | null = null
|
|
62
|
+
let imapClient: any = null
|
|
63
|
+
let smtpTransport: any = null
|
|
64
|
+
let logFn: (msg: string) => void = console.log
|
|
65
|
+
let sendFeishuFn: ((chatId: string, text: string) => Promise<void>) | null = null
|
|
66
|
+
let chatNameCacheRef: Map<string, string> | null = null
|
|
67
|
+
let api: RuntimeApiClient
|
|
68
|
+
|
|
69
|
+
// Task queue: project โ pending tasks
|
|
70
|
+
const taskQueues = new Map<string, EmailTask[]>()
|
|
71
|
+
const activeJobs = new Map<string, boolean>()
|
|
72
|
+
|
|
73
|
+
// Dedup: track processed message IDs to avoid re-processing after IMAP reconnect
|
|
74
|
+
const processedMessageIds = new Set<string>()
|
|
75
|
+
|
|
76
|
+
// โโ Public API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Initialize the email worker. Called from daemon.ts boot().
|
|
80
|
+
*
|
|
81
|
+
* @param log daemon's log function
|
|
82
|
+
* @param sendText daemon's sendTextMessage function
|
|
83
|
+
* @param chatNames daemon's chatNameCache (to find chat_id for a project)
|
|
84
|
+
*/
|
|
85
|
+
export async function startEmailWorker(
|
|
86
|
+
log: (msg: string) => void,
|
|
87
|
+
sendText: (chatId: string, text: string) => Promise<void>,
|
|
88
|
+
chatNames: Map<string, string>,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
logFn = log
|
|
91
|
+
sendFeishuFn = sendText
|
|
92
|
+
chatNameCacheRef = chatNames
|
|
93
|
+
|
|
94
|
+
api = new RuntimeApiClient({ baseUrl: API_URL, authToken: API_TOKEN })
|
|
95
|
+
|
|
96
|
+
config = loadConfig()
|
|
97
|
+
if (!config) {
|
|
98
|
+
logFn('email-worker: no email-config.json found, email channel disabled')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logFn(`email-worker: projects: ${Object.keys(config.projects).join(', ')}`)
|
|
103
|
+
for (const [name, proj] of Object.entries(config.projects)) {
|
|
104
|
+
logFn(`email-worker: ${name} whitelist: [${proj.whitelist.join(', ')}]`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await setupSMTP()
|
|
109
|
+
await setupIMAP()
|
|
110
|
+
logFn('email-worker: ready')
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logFn(`email-worker: startup failed: ${err}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function stopEmailWorker(): void {
|
|
117
|
+
try { imapClient?.close() } catch {}
|
|
118
|
+
try { imapClient?.logout() } catch {}
|
|
119
|
+
try { smtpTransport?.close() } catch {}
|
|
120
|
+
logFn('email-worker: stopped')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// โโ Config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
124
|
+
|
|
125
|
+
function loadConfig(): EmailConfig | null {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// โโ IMAP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
134
|
+
|
|
135
|
+
let ImapFlowClass: any = null
|
|
136
|
+
|
|
137
|
+
async function createImapClient(): Promise<any> {
|
|
138
|
+
if (!ImapFlowClass) {
|
|
139
|
+
const mod = await import('imapflow')
|
|
140
|
+
ImapFlowClass = mod.ImapFlow
|
|
141
|
+
}
|
|
142
|
+
const imap = config!.imap
|
|
143
|
+
const client = new ImapFlowClass({
|
|
144
|
+
host: imap.host,
|
|
145
|
+
port: imap.port ?? 993,
|
|
146
|
+
secure: true,
|
|
147
|
+
auth: { user: imap.user, pass: imap.pass },
|
|
148
|
+
logger: false,
|
|
149
|
+
})
|
|
150
|
+
client.on('error', (err: any) => {
|
|
151
|
+
logFn(`email-worker: IMAP error: ${err}`)
|
|
152
|
+
})
|
|
153
|
+
return client
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function setupIMAP(): Promise<void> {
|
|
157
|
+
imapClient = await createImapClient()
|
|
158
|
+
await imapClient.connect()
|
|
159
|
+
logFn('email-worker: IMAP connected')
|
|
160
|
+
|
|
161
|
+
listenForMail().catch(err => logFn(`email-worker: listen loop crashed: ${err}`))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function reconnectIMAP(): Promise<void> {
|
|
165
|
+
try { imapClient?.logout() } catch {}
|
|
166
|
+
imapClient = await createImapClient()
|
|
167
|
+
await imapClient.connect()
|
|
168
|
+
logFn('email-worker: IMAP reconnected')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function listenForMail(): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
const lock = await imapClient.getMailboxLock('INBOX')
|
|
174
|
+
try {
|
|
175
|
+
await processUnseen()
|
|
176
|
+
} finally {
|
|
177
|
+
lock.release()
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logFn(`email-worker: initial processUnseen failed: ${err}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setInterval(async () => {
|
|
184
|
+
try {
|
|
185
|
+
const lock = await imapClient.getMailboxLock('INBOX')
|
|
186
|
+
try {
|
|
187
|
+
await processUnseen()
|
|
188
|
+
} finally {
|
|
189
|
+
lock.release()
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
logFn(`email-worker: poll error: ${err}`)
|
|
193
|
+
try {
|
|
194
|
+
await reconnectIMAP()
|
|
195
|
+
} catch (reconErr) {
|
|
196
|
+
logFn(`email-worker: reconnect failed: ${reconErr}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}, 30_000)
|
|
200
|
+
|
|
201
|
+
logFn('email-worker: polling every 30s for new mail')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function processUnseen(): Promise<void> {
|
|
205
|
+
const messages = imapClient.fetch({ seen: false }, {
|
|
206
|
+
envelope: true,
|
|
207
|
+
source: true,
|
|
208
|
+
uid: true,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
for await (const msg of messages) {
|
|
212
|
+
try {
|
|
213
|
+
await handleEmail(msg)
|
|
214
|
+
await imapClient.messageFlagsAdd({ uid: msg.uid }, ['\\Seen'], { uid: true })
|
|
215
|
+
} catch (err) {
|
|
216
|
+
logFn(`email-worker: handle email error: ${err}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// โโ Email parsing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
222
|
+
|
|
223
|
+
async function handleEmail(msg: any): Promise<void> {
|
|
224
|
+
const envelope = msg.envelope
|
|
225
|
+
const from = envelope?.from?.[0]?.address?.toLowerCase() ?? ''
|
|
226
|
+
const subject = envelope?.subject ?? ''
|
|
227
|
+
const messageId = envelope?.messageId ?? ''
|
|
228
|
+
const inReplyTo = envelope?.inReplyTo ?? undefined
|
|
229
|
+
|
|
230
|
+
logFn(`email-worker: received from=${from} subject="${subject}" msgId=${messageId}`)
|
|
231
|
+
|
|
232
|
+
if (processedMessageIds.has(messageId)) {
|
|
233
|
+
logFn(`email-worker: skipping duplicate messageId=${messageId}`)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
processedMessageIds.add(messageId)
|
|
237
|
+
if (processedMessageIds.size > 500) {
|
|
238
|
+
const arr = [...processedMessageIds]
|
|
239
|
+
for (let i = 0; i < 250; i++) processedMessageIds.delete(arr[i])
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const projectMatch = subject.match(/^\[([^\]]+)\]\s*(.*)$/)
|
|
243
|
+
if (!projectMatch) {
|
|
244
|
+
logFn(`email-worker: no [project] in subject, ignoring`)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const project = projectMatch[1].trim()
|
|
249
|
+
const requestTitle = projectMatch[2].trim()
|
|
250
|
+
|
|
251
|
+
if (!config!.projects[project]) {
|
|
252
|
+
logFn(`email-worker: project "${project}" not in config, ignoring`)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const projConfig = config!.projects[project]
|
|
257
|
+
if (!projConfig.whitelist.some(w => w.toLowerCase() === from)) {
|
|
258
|
+
logFn(`email-worker: ${from} not in whitelist for project "${project}", ignoring`)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const workDir = join(PROJECTS_ROOT, project)
|
|
263
|
+
if (!existsSync(workDir)) {
|
|
264
|
+
logFn(`email-worker: directory ~/${project} does not exist`)
|
|
265
|
+
await sendReply(from, subject, messageId,
|
|
266
|
+
`้กน็ฎ็ฎๅฝ ~/${project} ไธๅญๅจ`)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const body = await extractPlainText(msg.source?.toString() ?? '')
|
|
271
|
+
|
|
272
|
+
const task: EmailTask = {
|
|
273
|
+
from,
|
|
274
|
+
subject,
|
|
275
|
+
project,
|
|
276
|
+
request: body || requestTitle,
|
|
277
|
+
messageId,
|
|
278
|
+
inReplyTo,
|
|
279
|
+
receivedAt: new Date(),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
enqueue(task)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function extractPlainText(raw: string): Promise<string> {
|
|
286
|
+
try {
|
|
287
|
+
const { simpleParser } = await import('mailparser')
|
|
288
|
+
const parsed = await simpleParser(raw)
|
|
289
|
+
let body = parsed.text ?? ''
|
|
290
|
+
if (!body && parsed.html) {
|
|
291
|
+
body = parsed.html.replace(/<[^>]+>/g, '').replace(/ /g, ' ')
|
|
292
|
+
}
|
|
293
|
+
body = body.trim()
|
|
294
|
+
if (body.length > 10000) body = body.slice(0, 10000)
|
|
295
|
+
return body
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logFn(`email-worker: mailparser failed: ${err}`)
|
|
298
|
+
return '(้ฎไปถๆญฃๆ่งฃๆๅคฑ่ดฅ๏ผ่ฏทไฝฟ็จ็บฏๆๆฌๆ ผๅผๅ้)'
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// โโ Task queue & execution โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
303
|
+
|
|
304
|
+
function mirrorQueueState(): void {
|
|
305
|
+
try {
|
|
306
|
+
const projects: Record<string, any> = {}
|
|
307
|
+
for (const [name, q] of taskQueues) {
|
|
308
|
+
const running = activeJobs.get(name) && q.length >= 0
|
|
309
|
+
const current = running ? (taskQueues.get(name)?.[0]?.subject ?? null) : null
|
|
310
|
+
projects[name] = { queued: q.length, running: current, total: 0 }
|
|
311
|
+
}
|
|
312
|
+
const path = join(STATE_DIR, 'email-queue.json')
|
|
313
|
+
writeFileSync(path, JSON.stringify({ projects }), 'utf-8')
|
|
314
|
+
} catch {}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function enqueue(task: EmailTask): void {
|
|
318
|
+
const q = taskQueues.get(task.project) ?? []
|
|
319
|
+
q.push(task)
|
|
320
|
+
taskQueues.set(task.project, q)
|
|
321
|
+
mirrorQueueState()
|
|
322
|
+
if (!activeJobs.get(task.project)) {
|
|
323
|
+
activeJobs.set(task.project, true)
|
|
324
|
+
processQueue(task.project)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function processQueue(project: string): Promise<void> {
|
|
329
|
+
const q = taskQueues.get(project)
|
|
330
|
+
|
|
331
|
+
while (q?.length) {
|
|
332
|
+
const task = q.shift()!
|
|
333
|
+
mirrorQueueState()
|
|
334
|
+
await executeTask(task)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
activeJobs.set(project, false)
|
|
338
|
+
mirrorQueueState()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function executeTask(task: EmailTask): Promise<void> {
|
|
342
|
+
const { project, from, subject, request, messageId } = task
|
|
343
|
+
|
|
344
|
+
const projConfig = config!.projects[project]
|
|
345
|
+
const defaults = config!.defaults ?? {}
|
|
346
|
+
const timeoutMin = projConfig.timeout_minutes ?? defaults.timeout_minutes ?? 30
|
|
347
|
+
|
|
348
|
+
const systemPrompt = buildSystemPrompt(project, projConfig, from)
|
|
349
|
+
|
|
350
|
+
// Notify Feishu
|
|
351
|
+
await notifyFeishu(project,
|
|
352
|
+
`๐ฉ๐ ๆถๅฐ้ฎไปถไปปๅก๏ผๅผๅงๆง่ก\nๅไปถไบบ: ${from}\nไธป้ข: ${subject}\nๆญฃๆ: ${request}`)
|
|
353
|
+
logFn(`email-worker: executing task for "${project}": ${subject}`)
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const result = await runDeepseek(project, systemPrompt, request, timeoutMin)
|
|
357
|
+
|
|
358
|
+
await sendReply(from, subject, messageId, result)
|
|
359
|
+
|
|
360
|
+
const preview = result.length > 200 ? result.slice(0, 200) + '...' : result
|
|
361
|
+
await notifyFeishu(project, `โ
้ฎไปถไปปๅกๅฎๆ\nไธป้ข: ${subject}\n็ปๆ้ข่ง: ${preview}`)
|
|
362
|
+
logFn(`email-worker: task completed for "${project}": ${subject}`)
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
365
|
+
await sendReply(from, subject, messageId,
|
|
366
|
+
`ไปปๅกๆง่กๅคฑ่ดฅ: ${errMsg}\n\n่ฏท่็ณป็ฎก็ๅๆฃๆฅๆฅๅฟใ`)
|
|
367
|
+
await notifyFeishu(project, `โ ้ฎไปถไปปๅกๅคฑ่ดฅ\nไธป้ข: ${subject}\n้่ฏฏ: ${errMsg}`)
|
|
368
|
+
logFn(`email-worker: task failed for "${project}": ${errMsg}`)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function buildSystemPrompt(project: string, projConfig: ProjectConfig, sender: string): string {
|
|
373
|
+
const parts: string[] = []
|
|
374
|
+
|
|
375
|
+
if (projConfig.system_prompt) {
|
|
376
|
+
parts.push(projConfig.system_prompt)
|
|
377
|
+
} else {
|
|
378
|
+
parts.push(`ไฝ ๆฏ ${project} ้กน็ฎ็ๅผๅๅฉๆใ`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (projConfig.allowed_intents) {
|
|
382
|
+
parts.push(`ไฝ ๅช่ขซๅ
่ฎธ่ฟ่กไปฅไธ็ฑปๅ็ๅทฅไฝ๏ผ${projConfig.allowed_intents}ใ`)
|
|
383
|
+
parts.push('ๅฆๆ้ๆฑ่ถ
ๅบ่ๅด๏ผ็ดๆฅๆ็ปๅนถ่ฏดๆๅๅ ใ')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
parts.push(`ๅฝๅไปปๅกๆฅ่ช้ฎไปถ็จๆท ${sender}ใ`)
|
|
387
|
+
parts.push('่ฏทๅฎๆไปปๅกๅ็ปๅบๆธ
ๆฐ็็ปๆๆ่ฆใ')
|
|
388
|
+
parts.push('ๅฎๆๅ๏ผๅจๆๅไธๆฌกๅๅคไธญ่ฏท็จ markdown ๆ ผๅผๆธ
ๆฐๆป็ปไฝ ็ๅทฅไฝใ')
|
|
389
|
+
|
|
390
|
+
return parts.join('\n')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// โโ DeepSeek execution (replaces spawn claude -p) โโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
394
|
+
|
|
395
|
+
async function runDeepseek(
|
|
396
|
+
project: string,
|
|
397
|
+
systemPrompt: string,
|
|
398
|
+
request: string,
|
|
399
|
+
timeoutMin: number,
|
|
400
|
+
): Promise<string> {
|
|
401
|
+
const workDir = join(PROJECTS_ROOT, project)
|
|
402
|
+
const threadTitle = `email-${project}-${Date.now()}`
|
|
403
|
+
|
|
404
|
+
// Create a one-shot thread for this email task
|
|
405
|
+
const thread = await api.createThread({
|
|
406
|
+
title: threadTitle,
|
|
407
|
+
workspace: workDir,
|
|
408
|
+
mode: 'yolo',
|
|
409
|
+
auto_approve: true,
|
|
410
|
+
system_prompt: systemPrompt,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
logFn(`email-worker: created thread ${thread.id} for ${project}`)
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
// Inject the turn
|
|
417
|
+
const turnRes = await api.createTurn(thread.id, {
|
|
418
|
+
prompt: request,
|
|
419
|
+
auto_approve: true,
|
|
420
|
+
mode: 'yolo',
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
const turnId = turnRes.turn.id
|
|
424
|
+
logFn(`email-worker: turn ${turnId} started for ${project}`)
|
|
425
|
+
|
|
426
|
+
// Poll for completion via thread detail (turns embedded in thread response)
|
|
427
|
+
const deadline = Date.now() + timeoutMin * 60 * 1000
|
|
428
|
+
|
|
429
|
+
while (Date.now() < deadline) {
|
|
430
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const detail = await api.getThread(thread.id)
|
|
434
|
+
const turn = detail.turns.find(t => t.id === turnId)
|
|
435
|
+
if (!turn) continue
|
|
436
|
+
|
|
437
|
+
if (turn.status === 'completed') {
|
|
438
|
+
const agentMessages = detail.items
|
|
439
|
+
.filter(item => item.kind === 'agent_message')
|
|
440
|
+
.map(item => item.detail || item.summary)
|
|
441
|
+
return agentMessages.join('\n').trim() || '(ไปปๅกๅฎๆ๏ผๆ ๆๆฌ่พๅบ)'
|
|
442
|
+
}
|
|
443
|
+
if (turn.status === 'error') {
|
|
444
|
+
throw new Error('turn ended with error status')
|
|
445
|
+
}
|
|
446
|
+
} catch (err: any) {
|
|
447
|
+
if (err.message?.includes('not found')) continue
|
|
448
|
+
throw err
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Timeout โ try to interrupt
|
|
453
|
+
try { await api.interruptTurn(thread.id, turnId) } catch {}
|
|
454
|
+
throw new Error(`่ถ
ๆถ (${timeoutMin} ๅ้)`)
|
|
455
|
+
} finally {
|
|
456
|
+
// Cleanup: archive the thread
|
|
457
|
+
try { await api.archiveThread(thread.id) } catch {}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// โโ SMTP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
462
|
+
|
|
463
|
+
async function setupSMTP(): Promise<void> {
|
|
464
|
+
const nodemailer = await import('nodemailer')
|
|
465
|
+
const smtp = config!.smtp
|
|
466
|
+
smtpTransport = nodemailer.default.createTransport({
|
|
467
|
+
host: smtp.host,
|
|
468
|
+
port: smtp.port ?? 465,
|
|
469
|
+
secure: true,
|
|
470
|
+
auth: { user: smtp.user, pass: smtp.pass },
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
await smtpTransport.verify()
|
|
474
|
+
logFn('email-worker: SMTP connected')
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function sendReply(
|
|
478
|
+
to: string,
|
|
479
|
+
originalSubject: string,
|
|
480
|
+
inReplyTo: string,
|
|
481
|
+
body: string,
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
if (!smtpTransport || !config) return
|
|
484
|
+
|
|
485
|
+
const subject = originalSubject.startsWith('Re:')
|
|
486
|
+
? originalSubject
|
|
487
|
+
: `Re: ${originalSubject}`
|
|
488
|
+
|
|
489
|
+
const attachments: { filename: string; path: string }[] = []
|
|
490
|
+
const cleanBody = body.replace(/\[ATTACH:([^\]]+)\]/g, (_match, filePath: string) => {
|
|
491
|
+
const p = filePath.trim()
|
|
492
|
+
if (existsSync(p)) {
|
|
493
|
+
attachments.push({ filename: p.split('/').pop() ?? 'attachment', path: p })
|
|
494
|
+
return `(้ไปถ: ${p.split('/').pop()})`
|
|
495
|
+
}
|
|
496
|
+
return `(้ไปถๆชๆพๅฐ: ${p})`
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
await smtpTransport.sendMail({
|
|
501
|
+
from: config.smtp.user,
|
|
502
|
+
to,
|
|
503
|
+
subject,
|
|
504
|
+
text: cleanBody,
|
|
505
|
+
inReplyTo,
|
|
506
|
+
references: inReplyTo,
|
|
507
|
+
...(attachments.length ? { attachments } : {}),
|
|
508
|
+
})
|
|
509
|
+
logFn(`email-worker: replied to ${to}: ${subject} (${attachments.length} attachments)`)
|
|
510
|
+
} catch (err) {
|
|
511
|
+
logFn(`email-worker: SMTP send failed: ${err}`)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// โโ Feishu notifications โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
516
|
+
|
|
517
|
+
async function notifyFeishu(project: string, text: string): Promise<void> {
|
|
518
|
+
if (!sendFeishuFn || !chatNameCacheRef) return
|
|
519
|
+
|
|
520
|
+
let chatId: string | undefined
|
|
521
|
+
for (const [id, name] of chatNameCacheRef) {
|
|
522
|
+
if (name === project) {
|
|
523
|
+
chatId = id
|
|
524
|
+
break
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (chatId) {
|
|
529
|
+
await sendFeishuFn(chatId, text).catch(err =>
|
|
530
|
+
logFn(`email-worker: feishu notify failed: ${err}`))
|
|
531
|
+
} else {
|
|
532
|
+
logFn(`email-worker: no feishu group found for project "${project}", skipping notification`)
|
|
533
|
+
}
|
|
534
|
+
}
|