feat(history): complete messaging core before multi-account gateways
This commit is contained in:
+41
-25
@@ -10,32 +10,48 @@ function parseJson(text) {
|
||||
export async function deepseek(system, user, json = false) {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
|
||||
try {
|
||||
const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
||||
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
|
||||
temperature: json ? 0.1 : 0.3,
|
||||
system,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
||||
}),
|
||||
})
|
||||
const body = await response.json()
|
||||
if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
|
||||
const text = contentText(body)
|
||||
return json ? parseJson(text) : text
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
|
||||
const attempts = json ? 2 : 1
|
||||
let prompt = user
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
||||
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
|
||||
temperature: json ? 0 : 0.3,
|
||||
system,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
const body = await response.json()
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
|
||||
}
|
||||
const text = contentText(body)
|
||||
if (!json) return text
|
||||
try {
|
||||
return parseJson(text)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。`
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
|
||||
}
|
||||
|
||||
export function conversationContext(messages) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import crypto from 'node:crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { simpleParser } from 'mailparser'
|
||||
|
||||
function encryptionKey() {
|
||||
const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET
|
||||
if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 })
|
||||
return crypto.createHash('sha256').update(secret).digest()
|
||||
}
|
||||
|
||||
export function encryptEmailPassword(password) {
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv)
|
||||
const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()])
|
||||
return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
|
||||
}
|
||||
|
||||
export function decryptEmailPassword(value) {
|
||||
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':')
|
||||
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) {
|
||||
throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 })
|
||||
}
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'))
|
||||
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedValue, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
}
|
||||
|
||||
function imapClient(channel, password) {
|
||||
return new ImapFlow({
|
||||
host: channel.imapHost,
|
||||
port: Number(channel.imapPort || 993),
|
||||
secure: channel.imapSecure !== false,
|
||||
auth: { user: channel.account, pass: password },
|
||||
logger: false,
|
||||
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
|
||||
})
|
||||
}
|
||||
|
||||
function smtpTransport(channel, password) {
|
||||
return nodemailer.createTransport({
|
||||
host: channel.smtpHost,
|
||||
port: Number(channel.smtpPort || 465),
|
||||
secure: channel.smtpSecure !== false,
|
||||
auth: { user: channel.account, pass: password },
|
||||
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyEmailConnection(channel, password) {
|
||||
const startedAt = Date.now()
|
||||
const imap = imapClient(channel, password)
|
||||
try {
|
||||
await imap.connect()
|
||||
await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true })
|
||||
} finally {
|
||||
if (imap.usable) await imap.logout().catch(() => undefined)
|
||||
}
|
||||
const smtp = smtpTransport(channel, password)
|
||||
await smtp.verify()
|
||||
smtp.close()
|
||||
return { latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
|
||||
function address(value) {
|
||||
const item = value?.value?.[0]
|
||||
return {
|
||||
name: String(item?.name || item?.address || '').trim(),
|
||||
email: String(item?.address || '').trim().toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEmailMessages(channel, { full = false } = {}) {
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const client = imapClient(channel, password)
|
||||
const messages = []
|
||||
try {
|
||||
await client.connect()
|
||||
await client.mailboxOpen(channel.imapFolder || 'INBOX')
|
||||
let uids
|
||||
if (!full && Number(channel.lastUid || 0) > 0) {
|
||||
uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true })
|
||||
} else {
|
||||
const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30)))
|
||||
uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true })
|
||||
}
|
||||
uids = (uids || []).slice(-500)
|
||||
if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) }
|
||||
|
||||
for await (const row of client.fetch(uids, {
|
||||
uid: true,
|
||||
envelope: true,
|
||||
flags: true,
|
||||
internalDate: true,
|
||||
source: true,
|
||||
}, { uid: true })) {
|
||||
const parsed = await simpleParser(row.source)
|
||||
const sender = address(parsed.from)
|
||||
const recipients = parsed.to?.value || []
|
||||
const attachments = (parsed.attachments || []).map((attachment, index) => ({
|
||||
id: `${row.uid}-${index + 1}`,
|
||||
name: attachment.filename || `attachment-${index + 1}`,
|
||||
mimeType: attachment.contentType || 'application/octet-stream',
|
||||
size: attachment.size || attachment.content?.length || 0,
|
||||
contentId: attachment.contentId,
|
||||
base64: attachment.content?.length <= 20 * 1024 * 1024
|
||||
? attachment.content.toString('base64')
|
||||
: undefined,
|
||||
}))
|
||||
messages.push({
|
||||
uid: Number(row.uid),
|
||||
messageId: String(parsed.messageId || `imap-${row.uid}`),
|
||||
inReplyTo: parsed.inReplyTo || undefined,
|
||||
references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [],
|
||||
subject: String(parsed.subject || '(无主题)').trim(),
|
||||
from: sender,
|
||||
to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })),
|
||||
text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
|
||||
html: typeof parsed.html === 'string' ? parsed.html : undefined,
|
||||
date: (parsed.date || row.internalDate || new Date()).toISOString(),
|
||||
seen: row.flags?.has('\\Seen') || false,
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
return {
|
||||
messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)),
|
||||
lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)),
|
||||
}
|
||||
} finally {
|
||||
if (client.usable) await client.logout().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailMessage(channel, {
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments,
|
||||
}) {
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const transport = smtpTransport(channel, password)
|
||||
try {
|
||||
return await transport.sendMail({
|
||||
from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments,
|
||||
})
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function markEmailUidsSeen(channel, uids) {
|
||||
const values = [...new Set(uids.map(Number).filter(Number.isFinite))]
|
||||
if (!values.length) return
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const client = imapClient(channel, password)
|
||||
try {
|
||||
await client.connect()
|
||||
await client.mailboxOpen(channel.imapFolder || 'INBOX')
|
||||
await client.messageFlagsAdd(values, ['\\Seen'], { uid: true })
|
||||
} finally {
|
||||
if (client.usable) await client.logout().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
+1070
-70
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user