feat(history): establish Telegram SaaS platform foundation
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
function contentText(payload) {
|
||||
return payload?.content?.map((part) => part.text || '').join('') || ''
|
||||
}
|
||||
|
||||
function parseJson(text) {
|
||||
const cleaned = text.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||
return JSON.parse(cleaned)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export function conversationContext(messages) {
|
||||
return messages.slice(-80).map((m) => {
|
||||
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
|
||||
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
|
||||
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
|
||||
return `${m.createdAt || ''} ${role}: ${content}${translation}`
|
||||
}).join('\n')
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export function telegramAttachment(content = {}) {
|
||||
const type = content['@type']
|
||||
if (type === 'messagePhoto') {
|
||||
const sizes = Array.isArray(content.photo?.sizes) ? content.photo.sizes : []
|
||||
const file = sizes.at(-1)?.photo
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: `telegram-photo-${file.id}.jpg`,
|
||||
attachmentMimeType: 'image/jpeg',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageDocument') {
|
||||
const document = content.document || {}
|
||||
const file = document.document
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: document.file_name || `document-${file.id}`,
|
||||
attachmentMimeType: document.mime_type || 'application/octet-stream',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageVoiceNote') {
|
||||
const file = content.voice_note?.voice
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: `voice-${file.id}.ogg`,
|
||||
attachmentMimeType: content.voice_note?.mime_type || 'audio/ogg',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
duration: Number(content.voice_note?.duration || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageAudio') {
|
||||
const audio = content.audio || {}
|
||||
const file = audio.audio
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: audio.file_name || audio.title || `audio-${file.id}`,
|
||||
attachmentMimeType: audio.mime_type || 'audio/mpeg',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
duration: Number(audio.duration || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageSticker') {
|
||||
const sticker = content.sticker || {}
|
||||
const file = sticker.sticker
|
||||
const format = sticker.format?.['@type']
|
||||
const extension = format === 'stickerFormatTgs' ? 'tgs' : format === 'stickerFormatWebm' ? 'webm' : 'webp'
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: `sticker-${file.id}.${extension}`,
|
||||
attachmentMimeType: extension === 'webp' ? 'image/webp' : extension === 'webm' ? 'video/webm' : 'application/x-tgsticker',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
stickerEmoji: sticker.emoji || '',
|
||||
} : {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export const dateKey = (value = Date.now()) => {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export const percent = (numerator, denominator) =>
|
||||
denominator ? Math.round((numerator / denominator) * 1000) / 10 : 0
|
||||
|
||||
export const countBy = (list, key) => {
|
||||
const counts = new Map()
|
||||
for (const item of list) {
|
||||
const value = typeof key === 'function' ? key(item) : item[key]
|
||||
if (!value) continue
|
||||
counts.set(String(value), (counts.get(String(value)) || 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import pg from 'pg'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const { Pool } = pg
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
export async function migrate() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id uuid PRIMARY KEY, name text NOT NULL, industry text NOT NULL DEFAULT '',
|
||||
size text NOT NULL DEFAULT '', country text NOT NULL DEFAULT 'CN',
|
||||
plan text NOT NULL DEFAULT 'pro', created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, account text NOT NULL UNIQUE, password_hash text NOT NULL,
|
||||
phone text NOT NULL DEFAULT '', role text NOT NULL DEFAULT 'admin',
|
||||
department text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'active', last_login_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, id text NOT NULL, data jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, kind, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS resources_kind_idx ON resources(tenant_id, kind, updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS telegram_bindings (
|
||||
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
gateway_account_id text, phone text, display_name text,
|
||||
status text NOT NULL DEFAULT 'unconnected', auth_state text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id uuid PRIMARY KEY, tenant_id uuid, user_id uuid, action text NOT NULL,
|
||||
target text NOT NULL DEFAULT '', detail jsonb NOT NULL DEFAULT '{}',
|
||||
ip text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS login_records (
|
||||
id uuid PRIMARY KEY, tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES users(id) ON DELETE SET NULL, account text NOT NULL DEFAULT '',
|
||||
device text NOT NULL DEFAULT '', ip text NOT NULL DEFAULT '',
|
||||
status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS login_records_tenant_idx ON login_records(tenant_id, created_at DESC);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version integer NOT NULL DEFAULT 0;
|
||||
`)
|
||||
}
|
||||
|
||||
export const uid = () => crypto.randomUUID()
|
||||
|
||||
export async function listResources(tenantId, kind) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 ORDER BY updated_at DESC',
|
||||
[tenantId, kind],
|
||||
)
|
||||
return rows.map((row) => row.data)
|
||||
}
|
||||
|
||||
export async function getResource(tenantId, kind, id) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3',
|
||||
[tenantId, kind, id],
|
||||
)
|
||||
return rows[0]?.data ?? null
|
||||
}
|
||||
|
||||
export async function putResource(tenantId, kind, value) {
|
||||
const data = { ...value, id: value.id || uid(), updatedAt: new Date().toISOString() }
|
||||
await pool.query(
|
||||
`INSERT INTO resources(tenant_id,kind,id,data) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(tenant_id,kind,id) DO UPDATE SET data=$4,updated_at=now()`,
|
||||
[tenantId, kind, data.id, data],
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function patchResource(tenantId, kind, id, patch) {
|
||||
const current = await getResource(tenantId, kind, id)
|
||||
if (!current) return null
|
||||
return putResource(tenantId, kind, { ...current, ...patch, id })
|
||||
}
|
||||
|
||||
export async function deleteResource(tenantId, kind, id) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3',
|
||||
[tenantId, kind, id],
|
||||
)
|
||||
return result.rowCount > 0
|
||||
}
|
||||
|
||||
export async function audit(req, action, target, detail = {}) {
|
||||
await pool.query(
|
||||
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)',
|
||||
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const base = () => (process.env.TELEGRAM_GATEWAY_URL || 'http://host.docker.internal:3000/api').replace(/\/$/, '')
|
||||
|
||||
let gatewayToken = process.env.TELEGRAM_GATEWAY_TOKEN || ''
|
||||
let refreshPromise
|
||||
|
||||
function tokenExpiresSoon(token) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'))
|
||||
return !payload.exp || payload.exp * 1000 < Date.now() + 5 * 60 * 1000
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGatewayToken() {
|
||||
if (refreshPromise) return refreshPromise
|
||||
refreshPromise = (async () => {
|
||||
const email = process.env.TELEGRAM_GATEWAY_SERVICE_EMAIL
|
||||
const password = process.env.TELEGRAM_GATEWAY_SERVICE_PASSWORD
|
||||
const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID
|
||||
if (!email || !password || !tenantId) {
|
||||
throw Object.assign(new Error('Telegram 网关服务账号凭据尚未配置'), { status: 503 })
|
||||
}
|
||||
const response = await fetch(`${base()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, tenantId }),
|
||||
})
|
||||
const raw = await response.text()
|
||||
let result
|
||||
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
|
||||
const token = result?.data?.token || result?.token
|
||||
if (!response.ok || !token) {
|
||||
throw Object.assign(new Error(result?.message || 'Telegram 网关服务账号续签失败'), { status: response.status || 503 })
|
||||
}
|
||||
gatewayToken = token
|
||||
return token
|
||||
})().finally(() => { refreshPromise = undefined })
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
export async function gatewayRequest(path, options = {}) {
|
||||
const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID
|
||||
if (!tenantId) throw Object.assign(new Error('Telegram 网关租户尚未配置'), { status: 503 })
|
||||
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
|
||||
const send = () => fetch(`${base()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||
authorization: `Bearer ${gatewayToken}`,
|
||||
'x-tenant-id': tenantId,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
let response = await send()
|
||||
if (response.status === 401) {
|
||||
await refreshGatewayToken()
|
||||
response = await send()
|
||||
}
|
||||
const raw = await response.text()
|
||||
let result
|
||||
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(result?.message || `Telegram 网关请求失败 (${response.status})`), { status: response.status })
|
||||
}
|
||||
return result?.data ?? result
|
||||
}
|
||||
|
||||
export function postGateway(path, body) {
|
||||
return gatewayRequest(path, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function putGateway(path, body) {
|
||||
return gatewayRequest(path, { method: 'PUT', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function uploadGateway(path, formData) {
|
||||
return gatewayRequest(path, { method: 'POST', body: formData })
|
||||
}
|
||||
+1754
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user