Files
pos-system/microservices/apps/tpos-mvp-next/src/server/integrations/external.ts
2026-06-03 13:17:46 +07:00

278 lines
9.2 KiB
TypeScript

import { createHmac, createHash, randomUUID } from "node:crypto";
type JsonRecord = Record<string, unknown>;
function requireEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for this external integration`);
}
return value;
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function postJson(url: string, body: unknown, headers: Record<string, string> = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers
},
body: JSON.stringify(body)
});
const text = await response.text();
let payload: unknown = text;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(`External request failed (${response.status}): ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
}
return payload;
}
export async function callOpenAiResponses(input: string, tools: unknown[] = []) {
const apiKey = requireEnv("OPENAI_API_KEY");
const model = process.env.OPENAI_MODEL || "gpt-5.1";
return postJson(
"https://api.openai.com/v1/responses",
{
model,
input,
tools: tools.length ? tools : undefined
},
{ Authorization: `Bearer ${apiKey}` }
);
}
export async function callOpenRouterChat(messages: unknown[], tools: unknown[] = []) {
const apiKey = requireEnv("OPENROUTER_API_KEY");
const model = process.env.OPENROUTER_MODEL || "openai/gpt-5.1";
return postJson(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages,
tools: tools.length ? tools : undefined
},
{
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": "https://goodgo.vn",
"X-Title": "GoodGo TPOS Next"
}
);
}
export async function callClaudeMessages(messages: unknown[], tools: unknown[] = []) {
const apiKey = requireEnv("ANTHROPIC_API_KEY");
const model = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5-20250929";
return postJson(
"https://api.anthropic.com/v1/messages",
{
model,
max_tokens: 4096,
messages,
tools: tools.length ? tools : undefined
},
{
"x-api-key": apiKey,
"anthropic-version": "2023-06-01"
}
);
}
export async function callConfiguredAi(provider: string, message: string, tools: unknown[] = []) {
if (provider === "openrouter") {
return callOpenRouterChat([{ role: "user", content: message }], tools);
}
if (provider === "claude" || provider === "anthropic") {
return callClaudeMessages([{ role: "user", content: message }], tools);
}
return callOpenAiResponses(message, tools);
}
function sha256Hex(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function hmac(key: Buffer | string, value: string) {
return createHmac("sha256", key).update(value).digest();
}
function hmacHex(key: Buffer | string, value: string) {
return createHmac("sha256", key).update(value).digest("hex");
}
function awsDate(date = new Date()) {
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
}
function signingKey(secret: string, date: string, region: string, service: string) {
const kDate = hmac(`AWS4${secret}`, date);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, service);
return hmac(kService, "aws4_request");
}
export function buildS3ObjectKey(fileName: string) {
const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 160) || "file";
return `tpos/${new Date().toISOString().slice(0, 10)}/${randomUUID()}-${safeName}`;
}
export async function uploadS3Object(key: string, file: File, accessLevel = "public") {
const endpoint = requireEnv("S3_ENDPOINT").replace(/\/+$/, "");
const region = requireEnv("S3_REGION");
const bucket = requireEnv("S3_BUCKET");
const accessKey = requireEnv("S3_ACCESS_KEY_ID");
const secretKey = requireEnv("S3_SECRET_ACCESS_KEY");
const body = Buffer.from(await file.arrayBuffer());
const now = awsDate();
const date = now.slice(0, 8);
const path = `/${bucket}/${key}`;
const url = `${endpoint}${path}`;
const host = new URL(endpoint).host;
const payloadHash = sha256Hex(body.toString("binary"));
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
const canonical = [
"PUT",
path,
accessLevel === "public" ? "x-amz-acl=public-read" : "",
`host:${host}`,
`x-amz-content-sha256:${payloadHash}`,
`x-amz-date:${now}`,
"",
signedHeaders,
payloadHash
].join("\n");
const scope = `${date}/${region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", now, scope, sha256Hex(canonical)].join("\n");
const signature = hmacHex(signingKey(secretKey, date, region, "s3"), stringToSign);
const response = await fetch(url + (accessLevel === "public" ? "?x-amz-acl=public-read" : ""), {
method: "PUT",
headers: {
"Content-Type": file.type || "application/octet-stream",
"x-amz-content-sha256": payloadHash,
"x-amz-date": now,
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
},
body
});
if (!response.ok) {
throw new Error(`S3 upload failed (${response.status}): ${await response.text()}`);
}
const publicBase = process.env.S3_PUBLIC_BASE_URL?.replace(/\/+$/, "");
return publicBase ? `${publicBase}/${key}` : `${endpoint}/${bucket}/${key}`;
}
export function s3DeleteConfigStatus() {
const required = ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"] as const;
const present = required.filter((name) => Boolean(process.env[name]));
return {
configured: present.length === required.length,
missing: required.filter((name) => !process.env[name]),
hasAny: present.length > 0
};
}
export async function deleteS3Object(key: string) {
const endpoint = requireEnv("S3_ENDPOINT").replace(/\/+$/, "");
const region = requireEnv("S3_REGION");
const bucket = requireEnv("S3_BUCKET");
const accessKey = requireEnv("S3_ACCESS_KEY_ID");
const secretKey = requireEnv("S3_SECRET_ACCESS_KEY");
const now = awsDate();
const date = now.slice(0, 8);
const path = `/${bucket}/${key}`;
const host = new URL(endpoint).host;
const payloadHash = sha256Hex("");
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
const canonical = [
"DELETE",
path,
"",
`host:${host}`,
`x-amz-content-sha256:${payloadHash}`,
`x-amz-date:${now}`,
"",
signedHeaders,
payloadHash
].join("\n");
const scope = `${date}/${region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", now, scope, sha256Hex(canonical)].join("\n");
const signature = hmacHex(signingKey(secretKey, date, region, "s3"), stringToSign);
const response = await fetch(`${endpoint}${path}`, {
method: "DELETE",
headers: {
"x-amz-content-sha256": payloadHash,
"x-amz-date": now,
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
}
});
if (!response.ok) {
throw new Error(`S3 delete failed (${response.status}): ${await response.text()}`);
}
}
export function providerCredentialStatus() {
return {
openai: Boolean(process.env.OPENAI_API_KEY),
anthropic: Boolean(process.env.ANTHROPIC_API_KEY),
openrouter: Boolean(process.env.OPENROUTER_API_KEY),
s3: Boolean(process.env.S3_ENDPOINT && process.env.S3_REGION && process.env.S3_BUCKET && process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY),
facebook: Boolean(process.env.FACEBOOK_PAGE_TOKEN && process.env.FACEBOOK_PAGE_ID),
zalo: Boolean(process.env.ZALO_OA_ACCESS_TOKEN),
whatsapp: Boolean(process.env.WHATSAPP_ACCESS_TOKEN && process.env.WHATSAPP_PHONE_NUMBER_ID),
x: false
};
}
export async function publishSocial(provider: string, input: JsonRecord) {
const content = stringValue(input.content);
if (!content) throw new Error("content is required");
if (provider === "facebook") {
const pageToken = requireEnv("FACEBOOK_PAGE_TOKEN");
const pageId = requireEnv("FACEBOOK_PAGE_ID");
return postJson(`https://graph.facebook.com/v20.0/${pageId}/feed?access_token=${encodeURIComponent(pageToken)}`, {
message: content
});
}
if (provider === "zalo") {
const token = requireEnv("ZALO_OA_ACCESS_TOKEN");
return postJson(
"https://openapi.zalo.me/v3.0/oa/message/cs",
{
recipient: { user_id: stringValue(input.recipientId) },
message: { text: content }
},
{ access_token: token }
);
}
if (provider === "whatsapp") {
const token = requireEnv("WHATSAPP_ACCESS_TOKEN");
const phoneId = requireEnv("WHATSAPP_PHONE_NUMBER_ID");
return postJson(
`https://graph.facebook.com/v20.0/${phoneId}/messages`,
{
messaging_product: "whatsapp",
to: stringValue(input.to),
text: { body: content }
},
{ Authorization: `Bearer ${token}` }
);
}
if (provider === "x") {
throw new Error("X publishing requires OAuth 1.0a signing; configure a dedicated X service adapter before enabling this provider.");
}
throw new Error(`Unsupported social provider: ${provider}`);
}