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
+8
View File
@@ -146,6 +146,14 @@ export const config = {
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
/**
* Strop na soubor odeslany z `ctx.http.postForm`.
*
* Soubor prochazi krokem stromu jako Base64, takze se cely drzi v pameti
* a zapisuje se do zaznamu behu. Nizsi cislo nez u cizich sluzeb je zamer:
* OpenAI zvladne stovky megabajtu, nas beh kroku ne.
*/
scriptMaxUploadBytes: positiveNumber(process.env.SCRIPT_MAX_UPLOAD_BYTES, 10_000_000),
/**
* Strop na text chybove odpovedi cizi sluzby.
*
+21
View File
@@ -36,6 +36,12 @@ export interface PermissionDefinition {
export const permissionCatalog: PermissionDefinition[] = [
// ------------------------------------------------------------------ tickety
{ key: 'ticket.view', label: 'Vidět tickety', group: 'Tickety' },
{
key: 'ticket.create',
label: 'Založit ticket ručně',
group: 'Tickety',
hint: 'Bez tohoto práva vznikají tickety jen z automatizací a z příchozích událostí.',
},
{ key: 'ticket.comment', label: 'Komentovat ticket', group: 'Tickety' },
{ key: 'ticket.assign.self', label: 'Vzít ticket na sebe', group: 'Tickety' },
{
@@ -53,6 +59,20 @@ export const permissionCatalog: PermissionDefinition[] = [
// ------------------------------------------------------------- nastaveni firmy
{ key: 'ticketType.manage', label: 'Spravovat typy ticketů', group: 'Nastavení firmy' },
// ----------------------------------------------------------------- helpdesk
{
key: 'helpdesk.view',
label: 'Vidět helpdesk',
group: 'Helpdesk',
hint: 'Sekce s požadavky, které vaše firma poslala svému dodavateli.',
},
{
key: 'helpdesk.create',
label: 'Poslat požadavek na helpdesk',
group: 'Helpdesk',
hint: 'Bez tohoto práva člověk požadavky jen vidí.',
},
{ key: 'action.manage', label: 'Spravovat definice akcí', group: 'Nastavení firmy' },
{ key: 'automation.edit', label: 'Upravovat automatizace', group: 'Nastavení firmy' },
{ key: 'connector.manage', label: 'Spravovat konektory', group: 'Nastavení firmy' },
@@ -153,6 +173,7 @@ export function systemRoles(): Role[] {
description: 'Řeší tickety. Nemůže přehazovat práci ani měnit nastavení.',
permissions: [
'ticket.view',
'ticket.create',
'ticket.comment',
'ticket.assign.self',
'ticket.status.change',
+839 -37
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -67,7 +67,34 @@ function asText(value: unknown): string {
* mit prednost - jinak by se hledal krok `st_faktura` a v nem pole `invoiceId`,
* coz je sice totez, ale jen nahodou.
*/
export function renderTemplate(template: string, source: unknown): string {
export interface RenderOptions {
/**
* Uprava **dosazene hodnoty**, ne cele sablony.
*
* Kvuli HTML telu e-mailu: sablonu pise nas clovek a ostre zavorky v ni jsou
* zamer, kdezto hodnota casto prichazi od zakaznika. Bez tohohle by text
* ticketu s `<b>` prepsal rozvrzeni zpravy a `<script>` by se dostal
* prijemci do schranky. Escapovat az vysledek nejde - to uz nejde poznat,
* co bylo sablona a co dosazena hodnota.
*/
escape?: (value: string) => string;
}
/** Nahrada znaku, ktere by v HTML zmenily vyznam. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function renderTemplate(
template: string,
source: unknown,
options: RenderOptions = {},
): string {
return template.replace(REFERENCE, (_match, reference: string) => {
const direct =
source !== null && typeof source === 'object'
@@ -80,6 +107,7 @@ export function renderTemplate(template: string, source: unknown): string {
console.warn(`[template] odkaz na neznamy parametr "${reference}", dosazuji prazdno`);
return '';
}
return asText(value);
const text = asText(value);
return options.escape ? options.escape(text) : text;
});
}
+11
View File
@@ -30,6 +30,7 @@ export type ModuleKey =
| 'connectors'
| 'scripts'
| 'tickets'
| 'helpdesk'
| 'people'
| 'incidents'
| 'settings';
@@ -52,6 +53,16 @@ export interface ModuleDefinition {
export const moduleCatalog: ModuleDefinition[] = [
{ key: 'overview', label: 'Přehled', permissions: [], required: true },
{ key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] },
/*
* Helpdesk je pohled **zadavatele**, ne resitele: firma tu vidi pozadavky,
* ktere sama poslala svemu dodavateli. Tickety, ktere resi ona, zustavaji
* v zalozce Tickety.
*
* `required` je tu ze stejneho duvodu jako u akci a widgetu: firmy zalozene
* driv maji v ulozisti seznam modulu bez tohohle klice. Kdo zalozku uvidi,
* stejne rozhoduje pravo `helpdesk.view`, ktere prideluje admin firmy.
*/
{ key: 'helpdesk', label: 'Helpdesk', permissions: ['helpdesk.view'], required: true },
{ key: 'people', label: 'Lidé', permissions: ['ticket.view'], required: true },
{ key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] },
/*
+34 -3
View File
@@ -30,6 +30,17 @@ export interface Tenant extends TenantEntity {
* a tim i to, v jakem rozsahu je externi ID unikatni.
*/
intakeToken: string;
/**
* Firma, ktera teto firme resi pozadavky z helpdesku.
*
* `null` = helpdesk nema komu poslat, takze se pozadavek nezalozi a rekne se
* to nahlas. Nastavuje to spravce platformy, ne firma sama: kdo koho obsluhuje
* je obchodni vztah, ne volba klienta.
*
* Pozadavek pak vznikne jako ticket **teto dodavatelske firmy** a zadavatel
* zustane v `helpdeskSourceId`.
*/
helpdeskProviderId: string | null;
}
/**
@@ -47,11 +58,31 @@ export function generateIntakeToken(): string {
export function seedTenants(): Tenant[] {
const timestamp = nowIso();
const base = { tenantId: null, note: '', enabled: true, createdAt: timestamp, updatedAt: timestamp };
const base = {
tenantId: null,
note: '',
enabled: true,
helpdeskProviderId: null,
createdAt: timestamp,
updatedAt: timestamp,
};
return [
{ ...base, id: 'tnt_automia', name: 'Automia', intakeToken: generateIntakeToken() },
{ ...base, id: 'tnt_nordis', name: 'Nordis a.s.', intakeToken: generateIntakeToken() },
{ ...base, id: 'tnt_logitrans', name: 'LogiTrans', intakeToken: generateIntakeToken() },
// Obe klientske firmy maji helpdesk u Automie - je to ta, ktera jim to spravuje.
{
...base,
id: 'tnt_nordis',
name: 'Nordis a.s.',
intakeToken: generateIntakeToken(),
helpdeskProviderId: 'tnt_automia',
},
{
...base,
id: 'tnt_logitrans',
name: 'LogiTrans',
intakeToken: generateIntakeToken(),
helpdeskProviderId: 'tnt_automia',
},
];
}
+76 -7
View File
@@ -130,6 +130,18 @@ export interface Ticket {
id: string;
/** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */
tenantId: string;
/**
* Firma, ze ktere pozadavek prisel pres helpdesk. `null` u beznych ticketu.
*
* Vlastnikem ticketu (`tenantId`) je ta firma, ktera pozadavek **resi** -
* u helpdesku se dosadi sama podle toho, koho ma zadavatel nastaveneho jako
* dodavatele. Zadavatel neni vlastnik: kdyby byl, videl by ho resitel jen
* jako cizi ticket a nemel by ho ve sve fronte.
*
* Podle tohohle pole vidi zadavatel svoje pozadavky, a **jen je**. Do bezneho
* seznamu ticketu se nepromita, ten zustava vlastnikovi.
*/
helpdeskSourceId: string | null;
/**
* ID u odesilatele, napr. cislo objednavky.
*
@@ -233,8 +245,10 @@ interface StoredTicket
| 'resolvedAt'
| 'resolvedById'
| 'reopenCount'
| 'helpdeskSourceId'
> {
assigneeId: string | null;
helpdeskSourceId?: string | null;
assigneeGroupId?: string | null;
typeId?: string | null;
fields?: Record<string, string | number | boolean | null>;
@@ -862,6 +876,7 @@ function toTicket(stored: StoredTicket): Ticket {
resolvedAt: stored.resolvedAt ?? null,
resolvedById: stored.resolvedById ?? null,
reopenCount: stored.reopenCount ?? 0,
helpdeskSourceId: stored.helpdeskSourceId ?? null,
};
if (!assigneeId) return { ...base, assignee: null };
@@ -883,6 +898,14 @@ export interface TicketFilter {
* driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data.
*/
tenantIds: string[];
/**
* Firmy, ze kterych pozadavek prisel pres helpdesk.
*
* Vyplnene **nahrazuje** filtr podle vlastnika: zadavatel vlastnikem neni,
* takze by mu jinak nezbylo nic. Prazdne pole tady znamena "nefiltrovat
* podle zdroje", ne "nic" - vlastnicky filtr plati dal.
*/
helpdeskSourceIds?: string[];
/** ID resitele, nebo 'unassigned' pro nepridelene. */
assignee?: string;
status?: TicketStatus;
@@ -896,8 +919,14 @@ export interface TicketFilter {
}
export function listTickets(filter: TicketFilter): Ticket[] {
const sources = filter.helpdeskSourceIds;
const selected = tickets.filter((ticket) => {
if (!filter.tenantIds.includes(ticket.tenantId)) return false;
if (sources && sources.length > 0) {
// Pohled zadavatele: vidi svoje pozadavky bez ohledu na to, kdo je resi.
if (!ticket.helpdeskSourceId || !sources.includes(ticket.helpdeskSourceId)) return false;
} else if (!filter.tenantIds.includes(ticket.tenantId)) {
return false;
}
if (filter.status && ticket.status !== filter.status) return false;
if (filter.channel && ticket.channel !== filter.channel) return false;
if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId)
@@ -930,11 +959,28 @@ export function listTickets(filter: TicketFilter): Ticket[] {
.map(toTicket);
}
/** Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici. */
export function getTicket(id: string, tenantIds: string[]): TicketDetail | undefined {
/**
* Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici.
*
* `helpdeskSourceIds` je druha cesta dovnitr: firma, ktera pozadavek poslala,
* ho smi cist, i kdyz ho nevlastni. Bez toho by zadavatel videl v seznamu
* pozadavek, ktery si nemuze otevrit.
*/
export function getTicket(
id: string,
tenantIds: string[],
helpdeskSourceIds: string[] = [],
): TicketDetail | undefined {
const stored = tickets.find((t) => t.id === id);
if (!stored) return undefined;
if (!tenantIds.includes(stored.tenantId)) {
const owns = tenantIds.includes(stored.tenantId);
const asked =
stored.helpdeskSourceId !== null &&
stored.helpdeskSourceId !== undefined &&
helpdeskSourceIds.includes(stored.helpdeskSourceId);
if (!owns && !asked) {
console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`);
return undefined;
}
@@ -1361,7 +1407,14 @@ export interface CreateTicketInput {
body?: string;
sourceRef?: string | null;
channel: TicketChannel;
customer: TicketCustomer;
/**
* Zakaznik je **nepovinny**. U pozadavku z helpdesku dava smysl vedet, kdo
* ho poslal, u ticketu zalozeneho rucne casto nikdo takovy neni - je to
* ukol, ne pozadavek od nekoho zvenku.
*/
customer?: TicketCustomer;
/** Firma, ktera pozadavek poslala pres helpdesk. Vlastnikem je ta, ktera resi. */
helpdeskSourceId?: string | null;
priority: TicketPriority;
/** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */
status?: string;
@@ -1401,7 +1454,8 @@ export function createTicket(input: CreateTicketInput): Ticket {
body: input.body ?? '',
sourceRef: input.sourceRef ?? null,
channel: input.channel,
customer: input.customer,
customer: input.customer ?? { id: null, company: '', contact: '', reply: '' },
helpdeskSourceId: input.helpdeskSourceId ?? null,
// Vychozi stav je jen doporuceni. Kdo posle vlastni, ma vlastni.
status: input.status ?? defaultStatuses[0],
priority: input.priority,
@@ -1682,13 +1736,28 @@ export function ticketAssignee(id: string, tenantIds: string[]): string | null |
return ticket.assigneeId;
}
/**
* Komentar k ticketu.
*
* `helpdeskSourceIds` pusti ke slovu i zadavatele z helpdesku. Je to jedina
* zmena, kterou nad cizim ticketem smi - doplnit, co zapomnel napsat, je presne
* to, kvuli cemu se pozadavek otevira. Prehazovat resitele nebo menit stav uz
* ne, na to se ho nikdo neptal.
*/
export function addComment(
id: string,
author: string,
text: string,
tenantIds: string[],
helpdeskSourceIds: string[] = [],
): Ticket | undefined {
const ticket = findWritable(id, tenantIds);
const found = tickets.find((item) => item.id === id);
const asked =
found?.helpdeskSourceId !== null &&
found?.helpdeskSourceId !== undefined &&
helpdeskSourceIds.includes(found.helpdeskSourceId);
const ticket = asked ? found : findWritable(id, tenantIds);
if (!ticket) {
console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`);
return undefined;
+310
View File
@@ -0,0 +1,310 @@
/**
* Odesilani e-mailu pres SMTP.
*
* Proc to neni skript: skript umi jen `ctx.http`. SMTP neni HTTP, takze by
* musel dostat sit, a tim by padlo pravidlo, ze skript nema jak zavolat ven
* jinudy nez pres nas klient. E-mail je proto **vnitrni krok** stejne jako
* zalozeni ticketu, jen misto do naseho uloziste sahá na posmovni server.
*
* Pristupove udaje ale zustavaji tam, kde maji - v konektoru firmy. Kazda
* firma odesila ze sve schranky a nikdo z nich nesmi videt hodnoty ostatnich.
*
* Chyby se **nevyhazuji**. Vraci se vysledek s `ok`, hlaskou a priznakem,
* jestli ma smysl zkusit znovu: plna schranka za hodinu neni, spatne heslo
* za hodinu porad spatne bude.
*/
import nodemailer from 'nodemailer';
import { config } from '../config.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import { isPrivateHost } from '../scripts/http.js';
import { createRedactor, truncate } from '../scripts/util.js';
export interface SmtpSettings {
host: string;
port: number;
/** true = sifrovane od navazani spojeni (port 465). */
secure: boolean;
/** true = vyzadovat STARTTLS, tedy nespokojit se s nesifrovanym spojenim. */
requireTls: boolean;
user: string;
password: string;
from: string;
fromName: string | null;
replyTo: string | null;
}
export interface MailMessage {
to: string;
cc?: string | null;
bcc?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
}
export interface MailResult {
ok: boolean;
message: string;
/** Cela odpoved serveru, uz zredigovana. Tam je napsane, co mu vadilo. */
detail: string | null;
/** Ciselny kod SMTP, kdyz se k odpovedi serveru vubec doslo. */
status: number | null;
/** true = docasna prekazka, opakovani ma smysl. */
retryable: boolean;
/** Vyplnene jen u uspesneho odeslani. */
messageId?: string;
accepted?: number;
rejected?: number;
}
/** Kam se to posilalo. Do hlasky i na kartu konektoru, bez udaju. */
export function smtpTargetUrl(settings: SmtpSettings): string {
return `smtp://${settings.host}:${settings.port}`;
}
/**
* Nastaveni z konektoru.
*
* Vraci `null` a duvod, kdyz udaje nedavaji smysl. Padat na tom nemuzeme:
* spatne vyplneny konektor je bezny stav, ne chyba aplikace.
*/
export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings } | { error: string } {
const value = (key: string): string => (target.serviceConfig[key] ?? '').trim();
const host = value('host');
const user = value('user');
const password = value('password');
const from = value('from') || user;
if (host === '') return { error: 'Konektor nemá vyplněný SMTP server.' };
// Stejne pravidlo jako u HTTP: adresu vyplnuje firma, takze tohle je jedina
// zabrana proti navazani spojeni dovnitr nasi site.
if (!config.allowPrivateTargets && isPrivateHost(host)) {
return {
error:
`Server ${host} míří do vnitřní sítě. ` +
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
};
}
if (user === '') return { error: 'Konektor nemá vyplněného uživatele.' };
if (password === '') return { error: 'Konektor nemá vyplněné heslo.' };
if (from === '') return { error: 'Konektor nemá vyplněnou adresu odesílatele.' };
const port = Number(value('port'));
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { error: `Port "${value('port')}" není platné číslo portu.` };
}
/*
* Prazdne sifrovani se ridi portem, protoze to je zvyklost, kterou zna
* kazdy: 465 je sifrovane od zacatku, 587 zacina necisté a povysi se
* STARTTLS. Vyslovna hodnota to prebije - jsou servery, ktere to maji jinak.
*/
const security = value('security').toLowerCase();
let secure = port === 465;
let requireTls = !secure;
if (security !== '') {
if (security === 'ssl' || security === 'tls') {
secure = true;
requireTls = false;
} else if (security === 'starttls') {
secure = false;
requireTls = true;
} else if (security === 'zadne' || security === 'žádné' || security === 'none') {
secure = false;
requireTls = false;
} else {
return { error: `Šifrování "${security}" neznáme. Použijte ssl, starttls nebo zadne.` };
}
}
return {
settings: {
host,
port,
secure,
requireTls,
user,
password,
from,
fromName: value('fromName') || null,
replyTo: value('replyTo') || null,
},
};
}
function transportFor(settings: SmtpSettings) {
return nodemailer.createTransport({
host: settings.host,
port: settings.port,
secure: settings.secure,
requireTLS: settings.requireTls,
auth: { user: settings.user, pass: settings.password },
// Bez stropu by krok visel na nedostupnem serveru, dokud ho nekdo nezabije.
connectionTimeout: 15_000,
greetingTimeout: 15_000,
socketTimeout: 30_000,
});
}
/** Kody od nodemaileru, u kterych ma smysl zkusit znovu. */
const retryableCodes = new Set(['ECONNECTION', 'ETIMEDOUT', 'ESOCKET', 'EDNS', 'ECONNRESET']);
/**
* Prevede vyjimku od nodemaileru na vysledek.
*
* Duvod, proc se to nevraci jen jako `err.message`: hlaska nodemaileru je
* casto jen "Invalid login", kdezto server k tomu napsal celou vetu o tom,
* ze ucet chce heslo pro aplikaci. Prave ta veta cloveku pomuze.
*/
function failure(err: unknown, settings: SmtpSettings, what: string): MailResult {
// Redaguje se heslo, ne uzivatel. Uzivatel je adresa schranky a prave ta
// v hlasce pomaha - skrtnout ji by z vety udelalo hadanku.
const redact = createRedactor([settings.password]);
const error = err as { code?: string; responseCode?: number; response?: string; message?: string };
const code = error?.code ?? '';
const status = typeof error?.responseCode === 'number' ? error.responseCode : null;
const said = error?.response ? ` Server odpověděl: ${redact(error.response)}` : '';
const where = `${what} na ${smtpTargetUrl(settings)}`;
// 4xx je docasne odmitnuti (fronta, limit), 5xx koncove.
const softStatus = status !== null && status >= 400 && status < 500;
const retryable = retryableCodes.has(code) || softStatus;
let message: string;
if (code === 'EAUTH' || status === 535) {
message =
`Přihlášení odmítnuto: ${where}. Server údaje dostal a neuznal je, jde tedy ` +
'o uživatele nebo heslo. U schránek s dvoufázovým ověřením musí být heslo ' +
`pro aplikaci, ne heslo k účtu.${said}`;
} else if (code === 'EENVELOPE') {
message =
`Adresy neprošly: ${where}. Server nepřijal odesílatele nebo některého ` +
`příjemce. Odesílatel musí být adresa, kterou má účet povolenou.${said}`;
} else if (retryableCodes.has(code)) {
message =
`Nepodařilo se spojit se serverem: ${where} (${code}). Zkontrolujte adresu, ` +
`port a šifrování.${said}`;
} else {
message = `${what} selhalo na ${smtpTargetUrl(settings)}.${said || ` ${error?.message ?? ''}`}`;
}
const detail = redact(
truncate(
[error?.message, error?.response, code && `code=${code}`].filter(Boolean).join('\n'),
config.errorDetailBytes,
),
);
return { ok: false, message: message.trim(), detail: detail || null, status, retryable };
}
/**
* Overi, ze se na server da prihlasit.
*
* `verify` nic neposila, jen naváže spojeni a prihlasi se. Presne to, co
* overeni konektoru potrebuje: **cteci** akce, ktera bez platnych udaju
* neprojde a nikomu nic nedorucí.
*/
export async function verifySmtp(target: ResolvedTarget): Promise<MailResult> {
const resolved = smtpSettings(target);
if ('error' in resolved) {
return { ok: false, message: resolved.error, detail: null, status: null, retryable: false };
}
const transport = transportFor(resolved.settings);
try {
await transport.verify();
return {
ok: true,
message: `Přihlášení na ${smtpTargetUrl(resolved.settings)} funguje, údaje jsou platné.`,
detail: null,
status: null,
retryable: false,
};
} catch (err) {
return failure(err, resolved.settings, 'Přihlášení');
} finally {
transport.close();
}
}
/**
* Textova verze z HTML.
*
* Neni to plnohodnotny prevod a byt nemusi: je to nahradnik pro klienty, ktere
* HTML nezobrazi. Bez nej by takovy prijemce dostal prazdnou zpravu a filtry
* nespamu berou chybejici textovou cast jako priznak spamu.
*/
export function textFromHtml(html: string): string {
return html
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|tr|h[1-6]|li)>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** Odesle jednu zpravu. Nikdy nevyhodi vyjimku. */
export async function sendMail(target: ResolvedTarget, message: MailMessage): Promise<MailResult> {
const resolved = smtpSettings(target);
if ('error' in resolved) {
return { ok: false, message: resolved.error, detail: null, status: null, retryable: false };
}
const settings = resolved.settings;
const transport = transportFor(settings);
try {
const info = await transport.sendMail({
from: settings.fromName
? { name: settings.fromName, address: settings.from }
: settings.from,
to: message.to,
...(message.cc ? { cc: message.cc } : {}),
...(message.bcc ? { bcc: message.bcc } : {}),
// Krok smi adresu pro odpovedi prebit: odpoved casto ma zamirit do
// ticketu, ne do schranky, ze ktere se odeslalo.
...(message.replyTo ?? settings.replyTo
? { replyTo: message.replyTo ?? settings.replyTo ?? undefined }
: {}),
subject: message.subject,
html: message.html,
text: message.text && message.text.trim() !== '' ? message.text : textFromHtml(message.html),
});
const accepted = Array.isArray(info.accepted) ? info.accepted.length : 0;
const rejected = Array.isArray(info.rejected) ? info.rejected.length : 0;
return {
ok: rejected === 0,
message:
rejected === 0
? `Odesláno ${accepted} příjemcům.`
: `Odesláno ${accepted} příjemcům, ${rejected} server nepřijal.`,
detail: typeof info.response === 'string' ? info.response : null,
status: null,
retryable: false,
messageId: typeof info.messageId === 'string' ? info.messageId : '',
accepted,
rejected,
};
} catch (err) {
return failure(err, settings, 'Odeslání');
} finally {
transport.close();
}
}
+54 -5
View File
@@ -41,7 +41,8 @@ import {
} from '../data/services.js';
import { config } from '../config.js';
import { egressIp } from '../data/egressIp.js';
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
import { createRedactor, describe, truncate } from '../scripts/util.js';
@@ -313,11 +314,57 @@ connectorsRouter.post('/:id/test', async (req, res) => {
return res.json({ ok: false, checked: 'nic', message, baseUrl: target.baseUrl });
}
/*
* SMTP se neoveruje ctecim volanim, ale prihlasenim. `verify` nic neposila,
* takze test nikomu nic nedorucí - a pritom bez platneho hesla neprojde,
* coz je presne to, co ma overeni rict.
*/
if (service.transport === 'smtp') {
const settings = smtpSettings(target);
const url = 'settings' in settings ? smtpTargetUrl(settings.settings) : '';
const outcome = await verifySmtp(target);
const request = url === '' ? null : { method: 'SMTP', path: 'LOGIN', url };
const checked = 'přístupové údaje';
await setConnectorStatus(
connector.id,
outcome.ok ? 'ok' : 'error',
outcome.ok ? null : outcome.message,
{
at: new Date().toISOString(),
ok: outcome.ok,
checked,
status: outcome.status,
message: outcome.message,
detail: outcome.detail,
request,
responseHeaders: null,
// Posmovni servery seznamy povolenych IP nemaji, radu by to nedalo.
egressIp: null,
},
[tenantId],
);
if (!outcome.ok) console.warn(`[connectors] test ${connector.id} selhal: ${outcome.message}`);
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
return res.json({
ok: outcome.ok,
checked,
...(outcome.status !== null ? { status: outcome.status } : {}),
request,
message: outcome.message,
baseUrl: url,
...(outcome.detail ? { detail: outcome.detail } : {}),
});
}
const path = service.verifyPath ?? '/health';
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
// Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema.
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
const redact = createRedactor(Object.values(target.headers));
// Query se odrizne stejne jako v `ScriptRequestInfo` - muze v ni byt tajemstvi.
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path.split('?')[0]}`;
const redact = createRedactor(targetSecrets(target));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
@@ -336,7 +383,7 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const message = service.verifyPath
? 'Napojení funguje, přístupové údaje jsou platné.'
: 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.';
const request = { method: 'GET', path, url: verifyUrl };
const request = { method: 'GET', path: path.split('?')[0], url: verifyUrl };
// Do historie patri i uspech. Bez nej se neda poznat, jestli konektor
// nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje.
@@ -378,7 +425,9 @@ connectorsRouter.post('/:id/test', async (req, res) => {
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
const request =
isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl };
isScriptError && err.request
? err.request
: { method: 'GET', path: path.split('?')[0], url: verifyUrl };
// Hlavicky rikaji, kdo odpoved vydal. U 403 bez tela je to vsechno,
// co zbyde: `Server: Kestrel` je aplikace, `Via: 1.1 Caddy` proxy.
const responseHeaders = isScriptError ? (err.responseHeaders ?? null) : null;
+137
View File
@@ -55,6 +55,7 @@ import {
addComment,
assignTicket,
claimTicket,
createTicket,
ticketAssignee,
getAgentStats,
getTicket,
@@ -67,6 +68,7 @@ import {
import { requireAuth } from '../middleware/auth.js';
import { validateRules } from '../scripts/mapping.js';
import { connectorsRouter } from './connectors.js';
import { helpdeskRouter } from './helpdesk.js';
import { settingsRouter } from './settings.js';
import { ticketActionsRouter } from './ticketActions.js';
import { widgetDataRouter } from './widgetData.js';
@@ -546,6 +548,109 @@ dashboardRouter.get('/tickets', (req, res) => {
});
});
/**
* Rucne zalozeny ticket.
*
* Dosud ticket vznikal jen z automatizace nebo z prichozi udalosti. Jenze
* pozadavek casto prijde telefonem nebo pri kafi a nekdo ho musi zapsat -
* bez toho konci na papirku a v systemu neni.
*
* **Zakaznik je nepovinny.** Ticket zalozeny rucne je casto ukol, ne pozadavek
* od nekoho zvenku, a nutit k nemu firmu a kontakt by znamenalo vymyslet si je.
*/
const createTicketSchema = z.object({
subject: z.string().trim().min(1, 'Předmět nesmí být prázdný.'),
body: z.string().default(''),
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
typeId: z.string().trim().min(1).nullable().optional(),
assigneeId: z.string().trim().min(1).nullable().optional(),
assigneeGroupId: z.string().trim().min(1).nullable().optional(),
tags: z.array(z.string().trim().min(1)).max(20).default([]),
/** Nepovinny. Prazdna pole se neukladaji jako prazdne retezce nasilim. */
customer: z
.object({
company: z.string().trim().default(''),
contact: z.string().trim().default(''),
reply: z.string().trim().default(''),
})
.optional(),
});
dashboardRouter.post('/tickets', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'ticket.create')) {
return res.status(403).json({
error: 'forbidden',
message: 'Nemáte právo zakládat tickety.',
});
}
const parsed = createTicketSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Ticket se nepodařilo založit.',
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
// Zaklada se vzdy do firmy, ve ktere clovek prave je. Vybirat ji ve formulari
// by znamenalo, ze se ticket omylem zalozi jinam.
const tenantId = scope.tenantId;
if (!tenantId) {
return res.status(400).json({
error: 'no_tenant',
message: 'Vyberte firmu, do které ticket patří.',
});
}
const input = parsed.data;
const customer = input.customer;
const hasCustomer =
customer !== undefined &&
(customer.company !== '' || customer.contact !== '' || customer.reply !== '');
const ticket = createTicket({
tenantId,
subject: input.subject,
body: input.body,
// Rucne zalozeny ticket prisel z portalu, ne z WhatsAppu ani z e-mailu.
channel: 'portal',
priority: input.priority,
typeId: input.typeId ?? null,
assigneeId: input.assigneeId ?? null,
assigneeGroupId: input.assigneeGroupId ?? null,
tags: input.tags,
...(hasCustomer
? { customer: { id: null, company: customer.company, contact: customer.contact, reply: customer.reply } }
: {}),
trace: [
{
kind: 'note',
label: 'Založeno ručně',
status: 'info',
response: `Ticket založil ${req.user!.email} v portálu.`,
},
],
});
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'ticket.create',
target: ticket.id,
detail: { subject: ticket.subject },
});
return res.status(201).json(ticket);
});
/**
* Prevzeti ticketu.
*
@@ -615,6 +720,28 @@ dashboardRouter.post('/tickets/:id/claim', (req, res) => {
});
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
/**
* Stavy, ktere firma opravdu pouziva.
*
* Stav je **volny retezec**, ne ciselnik: ticket muze prijit z cizi aplikace
* s jejim vlastnim stavem. Pevny seznam v rozhrani by na nej nikdy nesedel.
* Tohle je proto jen naseptavac - vraci to, co uz v datech je, a nova hodnota
* projde stejne dobre.
*
* Musi byt registrovane pred `/tickets/:id`, jinak by se `statuses` chytilo
* jako ID ticketu.
*/
dashboardRouter.get('/tickets/statuses', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
const items = [
...new Set(listTickets({ tenantIds: scope.tenantIds }).map((ticket) => ticket.status)),
].sort((a, b) => a.localeCompare(b, 'cs'));
return res.json({ items });
});
dashboardRouter.get('/tickets/workload', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
@@ -749,6 +876,10 @@ dashboardRouter.use('/widget-data', widgetDataRouter);
// Akce na ticketu. Musi byt pred obecnym `/tickets/:id`.
dashboardRouter.use('/tickets', ticketActionsRouter);
// Pohled zadavatele na jeho vlastni pozadavky. Vlastni router, protoze se
// scopuje podle `helpdeskSourceId`, ne podle vlastnika ticketu.
dashboardRouter.use('/helpdesk', helpdeskRouter);
// ------------------------------------------------------------------- sluzby
/**
@@ -779,6 +910,12 @@ dashboardRouter.get('/services', (req, res) => {
id: script.id,
name: script.name,
})),
// Typy ticketu jsou vlastnost firmy. Bez teto nabidky by uzivatel musel
// ID typu nekde vyhledat a prepsat, coz je presne to, co nikdo nedela.
ticketTypes: listTicketTypes(tenantIds).map((type) => ({
id: type.id,
name: type.name,
})),
},
),
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
+226
View File
@@ -0,0 +1,226 @@
/**
* Helpdesk: pohled **zadavatele**, ne resitele.
*
* Firma tu vidi pozadavky, ktere sama poslala svemu dodavateli, a smi k nim
* pripsat komentar. Nic vic - stav, resitele ani prioritu urcuje ten, kdo to
* resi.
*
* Rozdeleni, na kterem to cele stoji:
*
* | Pole | Kdo to je |
* | ------------------------ | ------------------------------------------ |
* | `Ticket.tenantId` | firma, ktera pozadavek **resi**, tedy vlastnik |
* | `Ticket.helpdeskSourceId`| firma, ktera pozadavek **poslala** |
*
* Vlastnikem je zamerne dodavatel: kdyby jim byl zadavatel, mel by resitel
* pozadavek jen jako cizi ticket a nemel by ho ve sve fronte. Diky tomuhle
* rozdeleni funguje strana resitele beze zmeny - je to obycejny ticket v jeho
* seznamu.
*
* Kdo z firmy helpdesk vidi, urcuje pravo `helpdesk.view`, ktere prideluje
* admin te firmy. Komu pozadavek pripadne, urcuje `helpdeskProviderId` na
* firme, ktery nastavujeme my - kdo koho obsluhuje je obchodni vztah, ne volba
* klienta.
*/
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { findPersonByEmail } from '../data/people.js';
import { findTenant } from '../data/tenants.js';
import { addComment, createTicket, getTicket, listTickets } from '../data/ticketStore.js';
export const helpdeskRouter = Router();
/**
* Firma, za kterou clovek prave jedna, plus kontrola prava.
*
* Helpdesk je vzdy za **jednu** firmu. Pohled pres vsechny firmy tu nedava
* smysl: pozadavek se posila konkretnimu dodavateli a ten se odviji od toho,
* kdo se pta.
*/
function asking(req: Request, res: Response, permission: string): string | null {
if (!hasPermission(req.user!, permission)) {
res.status(403).json({
error: 'forbidden',
message:
permission === 'helpdesk.create'
? 'Nemáte právo posílat požadavky na helpdesk.'
: 'Nemáte přístup k helpdesku.',
});
return null;
}
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const access = accessFor(req.user!, requested);
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId || !access.tenants.some((tenant) => tenant.id === tenantId)) {
res.status(403).json({
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy, za kterou by šlo požadavek poslat.',
});
return null;
}
return tenantId;
}
/** Komu firma posila pozadavky. Vraci null a duvod uz odpovi. */
function providerFor(tenantId: string, res: Response): string | null {
const tenant = findTenant(tenantId);
const provider = tenant?.helpdeskProviderId ?? null;
if (!provider) {
res.status(400).json({
error: 'no_provider',
message:
'Vaše firma nemá nastaveného dodavatele helpdesku, takže není komu ' +
'požadavek poslat. Ozvěte se nám a doplníme to.',
});
return null;
}
if (!findTenant(provider)) {
res.status(400).json({
error: 'unknown_provider',
message: 'Nastavený dodavatel helpdesku už neexistuje.',
});
return null;
}
return provider;
}
/**
* Pozadavky teto firmy.
*
* Filtruje se podle `helpdeskSourceIds`, ne podle vlastnika - zadavatel
* vlastnikem neni. Bezny seznam ticketu tim zustava nedotceny.
*/
helpdeskRouter.get('/', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.view');
if (!tenantId) return;
const tenant = findTenant(tenantId);
const provider = tenant?.helpdeskProviderId ?? null;
return res.json({
items: listTickets({ tenantIds: [], helpdeskSourceIds: [tenantId] }),
tenantId,
/** Komu se posila. Klient podle toho rekne, na koho se pozadavek obraci. */
provider: provider ? { id: provider, name: findTenant(provider)?.name ?? provider } : null,
canCreate: hasPermission(req.user!, 'helpdesk.create'),
});
});
const createSchema = z.object({
subject: z.string().trim().min(1, 'Napište, čeho se požadavek týká.'),
body: z.string().default(''),
priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
});
/**
* Novy pozadavek.
*
* Vlastnika **nevybira uzivatel**, dosadi se dodavatel jeho firmy. Kdyby si ho
* vybiral sam, poslal by pozadavek nekomu, kdo s nim nema smlouvu.
*/
helpdeskRouter.post('/', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.create');
if (!tenantId) return;
const provider = providerFor(tenantId, res);
if (!provider) return;
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Požadavek se nepodařilo poslat.',
});
}
const tenant = findTenant(tenantId);
const person = findPersonByEmail(req.user!.email);
const ticket = createTicket({
// Vlastnikem je dodavatel, aby to mel ve sve fronte jako kazdy jiny ticket.
tenantId: provider,
helpdeskSourceId: tenantId,
subject: parsed.data.subject,
body: parsed.data.body,
channel: 'portal',
priority: parsed.data.priority,
// Zakaznik se vyplni sam: u helpdesku je znamy a je to prave ten, kdo se pta.
customer: {
id: null,
company: tenant?.name ?? '',
contact: person?.name ?? req.user!.email,
reply: req.user!.email,
},
trace: [
{
kind: 'note',
label: 'Požadavek z helpdesku',
status: 'info',
response: `Poslal ${req.user!.email} za firmu ${tenant?.name ?? tenantId}.`,
},
],
});
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
tenantId,
action: 'helpdesk.create',
target: ticket.id,
detail: { subject: ticket.subject, provider },
});
return res.status(201).json(ticket);
});
/** Detail vlastniho pozadavku. Cizi se tvari jako neexistujici. */
helpdeskRouter.get('/:id', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.view');
if (!tenantId) return;
// Prazdne `tenantIds`: pres helpdesk se ctou jen pozadavky, ktere firma
// poslala. Vlastni tickety ma v zalozce Tickety.
const ticket = getTicket(req.params.id, [], [tenantId]);
if (!ticket) {
return res.status(404).json({ error: 'not_found', message: 'Požadavek neexistuje.' });
}
return res.json(ticket);
});
const commentSchema = z.object({ text: z.string().trim().min(1, 'Komentář nesmí být prázdný.') });
/**
* Komentar zadavatele.
*
* Jedina zmena, kterou nad pozadavkem smi. Doplnit, co zapomnel napsat, je
* presne to, kvuli cemu se pozadavek otevira; stav a resitele urcuje ten,
* kdo to resi.
*/
helpdeskRouter.post('/:id/comment', (req, res) => {
const tenantId = asking(req, res, 'helpdesk.view');
if (!tenantId) return;
const parsed = commentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Komentář se nepodařilo uložit.',
});
}
const person = findPersonByEmail(req.user!.email);
const author = person?.name ?? req.user!.email;
const updated = addComment(req.params.id, author, parsed.data.text, [], [tenantId]);
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Požadavek neexistuje.' });
}
return res.json(updated);
});
+4
View File
@@ -103,6 +103,8 @@ settingsRouter.use(
name: z.string().trim().min(2).max(80).optional(),
note: z.string().trim().max(500).optional(),
enabled: z.boolean().optional(),
/** Kdo teto firme resi helpdesk. null = nikdo, pozadavek nepujde poslat. */
helpdeskProviderId: z.string().trim().min(1).nullable().optional(),
}),
writePermission: 'tenant.manage',
platformOnly: true,
@@ -115,6 +117,8 @@ settingsRouter.use(
// Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval
// bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse.
intakeToken: generateIntakeToken(),
// Dodavatele helpdesku doplni spravce az pri nastaveni vztahu.
helpdeskProviderId: null,
}),
validate: (tenant, all) =>
all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase())
+76
View File
@@ -9,7 +9,10 @@
* a ve stromu se nastavuje stejne.
*/
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
import { createIncident } from '../data/incidentStore.js';
import { sendMail } from '../mail/smtp.js';
import { resolveTarget } from '../scripts/connections.js';
import { findTenantScript, noteRun } from '../data/tenantScripts.js';
import { runSandbox } from './sandbox.js';
import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js';
@@ -32,6 +35,14 @@ export interface StepContext {
tenantId: string;
/** Ticket, ke kteremu beh patri. Nekdy vznikne az behem nej. */
ticketId: string | null;
/**
* Napojeni, pod kterym se ma krok vykonat. `null` znamena vychozi konektor
* firmy pro tuhle sluzbu, stejne jako u skriptu.
*
* Vetsina vnitrnich kroku ho nepotrebuje - zalozeni ticketu sahá do naseho
* uloziste. E-mail ano: odesila se ze schranky firmy.
*/
connectorId?: string | null;
/**
* Data, kterymi beh zacal - u webhooku cele prijate telo.
*
@@ -54,6 +65,14 @@ export interface StepOutcome {
* misto osmdesati radku.
*/
quiet?: boolean;
/**
* true = docasna prekazka, opakovani ma smysl.
*
* Vnitrni kroky obvykle selhavaji na spatnem nastaveni, ktere se opakovanim
* nespravi. E-mail je vyjimka: nedostupny posmovni server za minutu bezet
* muze, kdezto spatne heslo bude spatne porad.
*/
retryable?: boolean;
/** Vyplnene, kdyz krok zalozil nebo nasel ticket. Dalsi kroky ho pak maji. */
ticketId?: string;
}
@@ -116,6 +135,63 @@ function scalarFields(value: Record<string, unknown>): Record<string, string | n
* Registr kroku. Klic je `serviceId/operationId` z katalogu.
*/
const handlers: Record<string, Handler> = {
/**
* Odesle e-mail ze schranky firmy.
*
* Proc je to vnitrni krok a ne skript: SMTP neni HTTP a skript umi jen
* `ctx.http`. Pristupove udaje ale zustavaji v konektoru, takze kazda firma
* odesila ze sve schranky - stejne jako u sluzeb, ktere jdou pres HTTP.
*
* Sablony jsou uz dosazene, vcetne escapovani hodnot v HTML tele. Deje se to
* v `fillTemplates` v executoru, protoze jen tam je jeste videt, co byla
* sablona a co dosazena hodnota.
*/
'email/send': async (inputs, context) => {
const to = inputs.to?.trim();
const subject = inputs.subject?.trim();
const html = inputs.html ?? '';
if (!to) return missing('příjemce');
if (!subject) return missing('předmět');
if (html.trim() === '') return missing('tělo zprávy');
const connector = context.connectorId
? await getConnector(context.connectorId, [context.tenantId])
: await defaultConnectorFor(context.tenantId, 'email');
const target = resolveTarget('email', connector ?? null);
if (!target.ready) {
return {
ok: false,
summary: 'napojení není hotové',
detail: target.missing.join(', '),
outputs: {},
};
}
const result = await sendMail(target, {
to,
cc: inputs.cc?.trim() || null,
bcc: inputs.bcc?.trim() || null,
subject,
html,
text: inputs.text ?? null,
replyTo: inputs.replyTo?.trim() || null,
});
return {
ok: result.ok,
summary: result.ok ? `e-mail odeslán na ${to}` : result.message,
detail: result.detail,
retryable: result.retryable,
outputs: {
messageId: result.messageId ?? '',
accepted: result.accepted ?? 0,
rejected: result.rejected ?? 0,
},
};
},
/**
* Zalozi ticket, nebo doplni existujici podle externiho ID.
*
+24 -6
View File
@@ -24,7 +24,8 @@
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
import type { FlowStep } from '../data/automationStore.js';
import { isUnary, type ConditionOperator } from '../data/conditions.js';
import { renderTemplate } from '../data/templates.js';
import { escapeHtml, renderTemplate } from '../data/templates.js';
import { actionInputsFor, type OperationField } from '../data/services.js';
import { getPath } from '../scripts/mapping.js';
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
import { scriptIdFor } from '../scripts/lookup.js';
@@ -308,7 +309,11 @@ async function runAction(
const startedAt = Date.now();
const label = `${step.serviceId}/${step.operationId}`;
const inputsForStep = fillTemplates(step.inputs ?? {}, context);
const inputsForStep = fillTemplates(
step.inputs ?? {},
context,
actionInputsFor(step.serviceId, step.operationId),
);
/*
* Nejdriv nase vlastni kroky. Zalozit ticket nebo prehodit ho na cloveka
@@ -321,6 +326,9 @@ async function runAction(
const outcome = await builtin(inputsForStep, {
tenantId: options.tenantId,
ticketId: options.ticketId,
// Vetsina vnitrnich kroku napojeni nepotrebuje. Odeslani e-mailu ano:
// posila se ze schranky firmy, tedy pod jejim konektorem.
connectorId: step.connectorId ?? null,
trigger: options.trigger,
});
@@ -347,9 +355,13 @@ async function runAction(
detail: outcome.detail ?? null,
// Krok muze rict, ze se nic nestalo a do logu ticketu nepatri.
quiet: outcome.quiet === true,
// Vnitrni krok selhava na spatnem nastaveni, ne na vypadku. Opakovani
// by jen pettkrat zopakovalo tutéz chybu.
retryable: false,
/*
* Vnitrni krok obvykle selhava na spatnem nastaveni, ne na vypadku,
* takze opakovani by jen pettkrat zopakovalo tutéz chybu. Vyjimka je
* odeslani e-mailu: nedostupny posmovni server za minutu bezet muze.
* Rozhoduje o tom krok sam, protoze jen on vi, co se stalo.
*/
retryable: outcome.retryable === true,
durationMs: Date.now() - startedAt,
};
} catch (err) {
@@ -440,15 +452,21 @@ async function runAction(
function fillTemplates(
inputs: Record<string, string>,
context: RunContext,
fields: OperationField[] = [],
): Record<string, string> {
/*
* Kontext se predava **syrovy**, ne predem prevedeny na retezce. Odkaz
* muze byt cesta do struktury (`{{data.order.items[0].name}}`) a do plocheho
* seznamu retezcu se zanorit neda.
*/
const html = new Set(fields.filter((field) => field.kind === 'html').map((field) => field.id));
const filled: Record<string, string> = {};
for (const [key, template] of Object.entries(inputs)) {
filled[key] = renderTemplate(template, context);
// U HTML pole se escapuje **dosazena hodnota**, ne sablona. Znacky, ktere
// napsal autor sablony, jsou zamer; ostre zavorky v hodnote od zakaznika
// ne. Viz `RenderOptions` v templates.ts.
filled[key] = renderTemplate(template, context, html.has(key) ? { escape: escapeHtml } : {});
}
return filled;
}
+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>>;
}
/**