feat(history): complete messaging core before multi-account gateways

This commit is contained in:
wmq
2026-07-28 22:36:58 +08:00
parent cd06d049fe
commit 7f09132fad
54 changed files with 4781 additions and 601 deletions
+41 -25
View File
@@ -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) {
+176
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff