278 lines
9.2 KiB
TypeScript
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}`);
|
|
}
|