Realne sluzby, OpenAI, odesilani e-mailu a helpdesk

Katalog srovnany s tim, co opravdu bezi na services.csbot.cz/apps:
trinact sluzeb dostalo pristupove udaje a levne cteci overeni, opravena
appId, ktera nikam nevedla (ppl, microsoft365, transcription), a GA4,
Search Console, Google Ads i Sklik ted stoji na aplikaci analytics,
kazda s vlastnimi udaji. Nove sluzby SAP Business One, Google Workspace
a Meta Ads. K tomu 23 skriptu, ktere s nimi opravdu neco delaji.

OpenAI jako prvni sluzba, ktera nebezi u nas: Service.baseUrl s absolutni
adresou, prepis pres <SLUZBA>_BASE_URL nebo adresu u konektoru, predpona
hlavicky u pole udaju (uzivatel vlepi holy klic, Bearer dopise runtime).
Dotaz na model, nahrani souboru, otazka nad souborem, prepis zvuku.
Skript umi odeslat soubor pres ctx.http.postForm (multipart, obsah Base64).

Sluzba E-mail pres SMTP. Neni to skript, ale vnitrni krok - SMTP neni HTTP.
Konektor nese schranku firmy, krok ma HTML telo, ve kterem se dosazene
hodnoty escapuji (znacky autora sablony jsou zamer, ostre zavorky od
zakaznika ne). Overeni konektoru se prihlasi na server a nic neodesle.

Helpdesk: Ticket.helpdeskSourceId drzi firmu, ktera pozadavek poslala,
vlastnikem zustava ta, ktera ho resi - jinak by ho resitel nemel ve sve
fronte. Komu pozadavek pripadne, urcuje Tenant.helpdeskProviderId.
Zadavatel vidi jen svoje pozadavky a smi k nim pripsat komentar.

Opravy v portalu:
- hlasky o ulozisti a odchozi IP vidi jen spravce platformy
- typ ticketu se v automatizaci vybira ze seznamu firmy, nebo dosadi z dat
- stav ticketu je otevreny naseptavac, ne ciselnik
- ticket jde zalozit rucne, zakaznik u nej neni povinny
- kanal se prejmenoval a parametry u webhooku jsou oznacene jako nepovinne
- srovnane markdown tabulky v cele dokumentaci

Co z teto davky jeste neni: prepinac firmy je porad jen stav uvnitr stranky
Prehled, takze se prepnuti neprojevi v Lidech ani jinde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-26 07:40:16 +02:00
co-authored by Claude Opus 5
parent b25a149574
commit a771834e57
78 changed files with 7021 additions and 932 deletions
+53 -4
View File
@@ -23,6 +23,14 @@ export interface ResolvedTarget {
baseUrl: string;
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
headers: Record<string, string>;
/**
* Vsechno, co se musi zredigovat, nez to skonci v logu nebo v chybe.
*
* Je toho vic nez hodnot hlavicek: u `Authorization: Bearer <klic>` je
* tajemstvim i samotny klic bez slova Bearer, a prave v tom tvaru ho cizi
* sluzby rady vraceji v chybove hlasce.
*/
secretValues: string[];
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
serviceConfig: Record<string, string>;
/** false = chybi povinne udaje, volat nema smysl. */
@@ -31,11 +39,34 @@ export interface ResolvedTarget {
missing: string[];
}
/**
* Presmerovani jedne sluzby promennou `<SLUZBA>_BASE_URL`.
*
* Nazev vznikne z ID sluzby velkymi pismeny, pomlcka je podtrzitko:
* `openai` je `OPENAI_BASE_URL`, `sap-bo` je `SAP_BO_BASE_URL`.
*
* K cemu to je: ukazat sluzbu na napodobeninu pri vyvoji, nebo cizi API
* prehodit na branu (Azure OpenAI). Konektor si adresu smi prepsat taky,
* ale to je vlastnost jedne firmy - tohle plati pro celou instanci.
*/
function baseUrlOverride(serviceId: string): string | null {
const variable = `${serviceId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BASE_URL`;
const value = (process.env[variable] ?? '').trim().replace(/\/+$/, '');
return value === '' ? null : value;
}
/**
* Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
* bere se ze `SERVICES_BASE_URL` (AGENTS.md).
*
* Sluzba, ktera **nebezi u nas** (OpenAI), ma misto `appId` vlastni absolutni
* `baseUrl`. Skladat ji ze `SERVICES_BASE_URL` by nedavalo smysl - to je zaklad
* nasich aplikaci, ne cizich.
*/
export function serviceBaseUrl(service: Service): string {
const override = baseUrlOverride(service.id);
if (override !== null) return override;
if (service.baseUrl) return service.baseUrl.replace(/\/+$/, '');
if (service.appId === null) return '';
return `${config.servicesBaseUrl}/${service.appId}`;
}
@@ -56,6 +87,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
connectorId: connector?.id ?? null,
baseUrl: '',
headers: {},
secretValues: [],
serviceConfig: {},
ready: false,
missing: [`Služba ${serviceId} v katalogu neexistuje.`],
@@ -63,6 +95,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
}
const headers: Record<string, string> = {};
const secretValues: string[] = [];
const serviceConfig: Record<string, string> = {};
const missing: string[] = [];
@@ -74,8 +107,18 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
continue;
}
if (field.target === 'header') headers[field.name] = value;
else serviceConfig[field.name] = value;
if (field.target === 'header') {
const header = `${field.prefix ?? ''}${value}`;
headers[field.name] = header;
// Obojí: cizi sluzba vraci v chybe jednou celou hlavicku, jindy jen klic.
if (field.secret) {
secretValues.push(value);
if (header !== value) secretValues.push(header);
}
} else {
serviceConfig[field.name] = value;
if (field.secret) secretValues.push(value);
}
}
if (!service.general && connector === null) {
@@ -90,13 +133,19 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
connectorId: connector?.id ?? null,
baseUrl: connector?.baseUrl ?? serviceBaseUrl(service),
headers,
secretValues,
serviceConfig,
ready: missing.length === 0,
missing,
};
}
/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
/**
* Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu.
*
* Vsechny hlavicky, protoze i ta neoznacena jako tajna nese identifikator uctu,
* a k tomu holé tajne hodnoty bez predpony `Bearer `.
*/
export function targetSecrets(target: ResolvedTarget): string[] {
return Object.values(target.headers);
return [...Object.values(target.headers), ...target.secretValues];
}
+57 -3
View File
@@ -12,6 +12,7 @@ import { config } from '../config.js';
import type { ResolvedTarget } from './connections.js';
import {
ScriptError,
type ScriptFormValue,
type ScriptHttp,
type ScriptHttpOptions,
type ScriptHttpResponse,
@@ -43,6 +44,17 @@ function joinUrl(baseUrl: string, path: string): string {
return `${base}${suffix}`;
}
/**
* Miri tohle jmeno do vnitrni site?
*
* Exportovane, protoze totez potrebuje odesilani e-mailu: adresu SMTP serveru
* vyplnuje firma a bez teto kontroly by si mohla nechat navazat spojeni na
* cokoliv uvnitr. Pravidlo ma byt jedno, ne dve, ktera se casem rozejdou.
*/
export function isPrivateHost(hostname: string): boolean {
return privateHostPattern.test(hostname);
}
/**
* Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
* je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
@@ -51,7 +63,7 @@ function assertAllowedUrl(url: URL): void {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
}
if (!config.allowPrivateTargets && privateHostPattern.test(url.hostname)) {
if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) {
throw new ScriptError(
'config',
`Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
@@ -282,6 +294,44 @@ Příčina: ${err.cause.message}` : '';
});
}
/**
* Slozi `multipart/form-data` z toho, co skript predal.
*
* Prazdne polozky se vynechavaji: `null` prevedeny na text by cizi sluzba
* dostala jako retezec "null" a hadala by, co s nim.
*
* Hranici (boundary) i `Content-Type` dopisuje az fetch. Kdybychom hlavicku
* nastavili sami, chybela by v ni hranice a sluzba by telo neprecetla.
*/
function buildForm(fields: Record<string, ScriptFormValue | null | undefined>): FormData {
const form = new FormData();
for (const [name, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === '') continue;
if (typeof value === 'object') {
const bytes = Buffer.from(value.base64, 'base64');
if (bytes.byteLength === 0) {
throw new ScriptError('validation', `Soubor ${value.filename} je prázdný nebo to není Base64.`);
}
if (bytes.byteLength > config.scriptMaxUploadBytes) {
throw new ScriptError(
'validation',
`Soubor ${value.filename} má ${bytes.byteLength} B, což je nad povolený strop ` +
`${config.scriptMaxUploadBytes} B.`,
);
}
const blob = new Blob([bytes], { type: value.contentType ?? 'application/octet-stream' });
form.append(name, blob, value.filename);
continue;
}
form.append(name, String(value));
}
return form;
}
export interface CreateHttpOptions {
target: ResolvedTarget;
signal: AbortSignal;
@@ -300,6 +350,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
path: string,
body: unknown,
httpOptions?: ScriptHttpOptions,
form?: FormData,
): Promise<ScriptHttpResponse<T>> {
const url = buildUrl(target, path, httpOptions);
// Server ano, query ne. Bez serveru neni z hlasky poznat, kam to vlastne
@@ -309,7 +360,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
path: url.pathname,
url: `${url.origin}${url.pathname}`,
};
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
const hasBody = form === undefined && body !== undefined && method !== 'GET' && method !== 'DELETE';
const startedAt = Date.now();
onCall();
@@ -326,7 +377,8 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...httpOptions?.headers,
},
body: hasBody ? JSON.stringify(body) : undefined,
// U formulare se telo predava jako FormData, aby fetch dopsal hranici.
body: form ?? (hasBody ? JSON.stringify(body) : undefined),
});
} catch (err) {
throw transportError(err, request);
@@ -387,5 +439,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
patch: (path, body, httpOptions) => request('PATCH', path, body, httpOptions),
put: (path, body, httpOptions) => request('PUT', path, body, httpOptions),
del: (path, httpOptions) => request('DELETE', path, undefined, httpOptions),
postForm: (path, fields, httpOptions) =>
request('POST', path, undefined, httpOptions, buildForm(fields)),
};
}
+29
View File
@@ -229,6 +229,23 @@ export interface ScriptHttpOptions {
allowStatus?: number[];
}
/**
* Soubor v multipart pozadavku.
*
* Obsah je Base64, protoze parametr skriptu je vzdy hodnota, ktera jde zapsat
* do JSONu - binarni data by se do stromu ani do logu behu nevesla jinak.
* Prevod zpatky na bajty dela runtime, ne skript.
*/
export interface ScriptFormFile {
filename: string;
/** Obsah souboru zakodovany v Base64, bez prefixu `data:`. */
base64: string;
/** Typ obsahu. Kdyz chybi, posle se application/octet-stream. */
contentType?: string;
}
export type ScriptFormValue = string | number | boolean | ScriptFormFile;
/**
* HTTP klient predany skriptu. Adresu a autorizaci doplnuje runtime podle
* napojeni, takze **skript se k pristupovym udajum nedostane**.
@@ -251,6 +268,18 @@ export interface ScriptHttp {
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
del<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
/**
* Odeslani formulare, vcetne souboru (`multipart/form-data`).
*
* Je to zvlast metoda, ne priznak u `post`: telo se sklada jinak, hranici
* dopisuje az runtime a musi se hlidat velikost. Prazdne a nevyplnene polozky
* se vynechavaji, aby se cizi sluzbe neposilalo `null` jako text.
*/
postForm<T = unknown>(
path: string,
fields: Record<string, ScriptFormValue | null | undefined>,
options?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>>;
}
/**