Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript

Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.

Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.

Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.

Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.

Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
This commit is contained in:
JiriUhlir
2026-09-09 15:11:02 +02:00
parent 42b3da8303
commit 22dda2d139
312 changed files with 21085 additions and 15319 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* Sablona noveho skriptu. Soubory od podtrzitka se nenacitaji, takze tenhle
* nikde nevznikne jako operace - je tu jen ke zkopirovani.
*
* Postup:
* 1. zkopirovat na `<sluzba>.<operace>.js`, napriklad `idoklad.get-contact.js`,
* 2. srovnat `manifest.id` s nazvem souboru, musi byt stejne,
* 3. vyplnit vstupy a vystupy,
* 4. napsat `run`.
*
* Nic se nerestartuje. Server si zmenu vsimne podle casu souboru a nacte ji
* pri dalsim dotazu. Totez plati pri uprave v portalu.
*
* Co ma skript k dispozici je jen `ctx`. Zadny import, zadny pristup k sitim
* mimo `ctx.http` a zadne pristupove udaje - ty dosazuje runtime podle napojeni.
*/
export const manifest = {
/** Musi odpovidat nazvu souboru bez .js. Sluzba musi existovat v katalogu. */
id: 'sluzba.operace',
name: 'Nazev, ktery uvidi uzivatel v builderu',
description: 'Jedna veta o tom, co se stane. Cte to clovek, ktery staví strom.',
/**
* Co skript potrebuje. Presne tohle se v builderu vykresli jako pole kroku
* a server to pred spustenim overi.
*
* type: 'string' | 'number' | 'boolean' | 'date'
* required: true = bez hodnoty se skript vubec nespusti
* options: vyber z hodnot, jina neprojde
* multiline: pole na vic radku
* default: dosadi se, kdyz hodnota chybi a pole neni povinne
* pattern: dalsi kontrola regularnim vyrazem (jen u string)
*/
inputs: [
{
id: 'prikladVstupu',
label: 'Příklad vstupu',
type: 'string',
required: true,
hint: 'Napoveda pod polem.',
},
],
/**
* Co skript vraci. Tohle je to, s cim pak umi pracovat strom - podminka se
* na to muze zeptat a sablona to muze dosadit jako `{{prikladVystupu}}`.
*
* Povinny vystup, ktery skript nevrati, je chyba skriptu. Zamerne: strom by
* jinak veril parametru, ktery nikdy nedosel.
*/
outputs: [{ id: 'prikladVystupu', label: 'Příklad výstupu', type: 'string', required: true }],
};
/**
* @param {Record<string, string | number | boolean | null>} inputs
* Uz overene a prevedene na typy z manifestu.
* @param {{
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function,
* postForm: Function },
* util: { unwrap: Function, pick: Function, first: Function, list: Function,
* text: Function, num: Function, bool: Function, date: Function, day: Function,
* round: Function, need: Function, get: Function, addresses: Function,
* quote: Function, base64: Function, applyRules: Function, fillJson: Function },
* log: Function, config: Record<string, string>, idempotencyKey: string,
* fail: Function, retry: Function,
* }} ctx
*/
export async function run(inputs, ctx) {
// Soubor se posila pres `ctx.http.postForm`, obsah jako Base64:
// await ctx.http.postForm('/files', {
// purpose: 'user_data',
// file: { filename: 'faktura.pdf', base64: inputs.obsah, contentType: 'application/pdf' },
// });
// Cesta je relativni k adrese napojeni, cela adresa se nikam nepise.
const { body } = await ctx.http.get('/nejaky-endpoint', {
query: { hledat: inputs.prikladVstupu },
});
const data = ctx.util.unwrap(body);
// ctx.fail = koncova chyba, neopakuje se.
// ctx.retry = docasna chyba, runtime to zkusi znovu.
if (!data) ctx.fail('Služba nic nevrátila.');
return {
prikladVystupu: ctx.util.need(ctx.util.text(ctx.util.pick(data, 'nazev')), 'název'),
};
}
+118
View File
@@ -0,0 +1,118 @@
/**
* CSOB (PSD2): nacteni pohybu na uctu.
*
* Sluzba: https://services.csbot.cz/apps/csob
* Endpointy: GET /accounts, GET /accounts/{id}/transactions
*
* Vzor **dvou volani, kde prvni jen zjistuje ID**. Cislo uctu, ktere clovek
* zna, neni to, cim se ucet v PSD2 adresuje - banka pouziva vlastni
* identifikator. Prekladat si ho rucne do kroku stromu by znamenalo, ze po
* kazde vymene souhlasu prestane strom fungovat.
*/
export const manifest = {
id: 'csob.list-transactions',
name: 'Načíst pohyby',
description:
'Stáhne zaúčtované pohyby na účtu za období. Nic nemění, jen čte. ' +
'Účet se vybírá číslem účtu, převod na identifikátor banky řeší krok sám.',
inputs: [
{
id: 'accountNumber',
label: 'Číslo účtu',
type: 'string',
required: false,
hint:
'Ve tvaru, jakým ho zná klient, například 123456789/0300, nebo IBAN. ' +
'Bez vyplnění se vezme první účet, na který souhlas dosáhne.',
},
{
id: 'dateFrom',
label: 'Od data',
type: 'date',
required: false,
hint: 'Bez vyplnění rozhoduje banka, obvykle vrací poslední pohyby.',
},
{ id: 'dateTo', label: 'Do data', type: 'date', required: false },
{
id: 'limit',
label: 'Nejvýše kolik pohybů',
type: 'number',
required: false,
default: 100,
hint: 'Strop na jednu stránku. Vyšší číslo znamená větší odpověď.',
},
],
outputs: [
{ id: 'accountId', label: 'Identifikátor účtu', type: 'string', required: true },
{ id: 'count', label: 'Počet pohybů', type: 'number', required: true },
{ id: 'transactions', label: 'Pohyby', type: 'list', required: true },
{ id: 'newest', label: 'Datum nejnovějšího pohybu', type: 'string', required: false },
],
timeoutMs: 30000,
};
/** Jen cislice. Cislo uctu se pise s pomlckami, lomitkem i s mezerami. */
function digits(value) {
return String(value ?? '').replace(/[^0-9]/g, '');
}
export async function run(inputs, ctx) {
const { pick, text, need } = ctx.util;
const { body: accountsBody } = await ctx.http.get('/accounts', { query: { size: 50 } });
// COBS obaluje kolekci pod vlastni nazev, `list` zkusi i obvykle obalky.
const accounts = ctx.util.list(accountsBody, 'accounts');
if (accounts === null) ctx.fail('Odpověď banky nemá seznam účtů.', accountsBody);
if (accounts.length === 0) {
ctx.fail('Souhlas nedosáhne na žádný účet. Zkontrolujte platnost souhlasu v ČSOB.');
}
let account = accounts[0];
if (inputs.accountNumber) {
const wanted = digits(inputs.accountNumber);
const match = accounts.find((item) => {
const iban = digits(pick(item, 'iban'));
const number = digits(
pick(pick(item, 'accountNumber'), 'number') ?? pick(item, 'accountNumber'),
);
return (iban !== '' && iban.includes(wanted)) || (number !== '' && wanted.endsWith(number));
});
if (!match) {
ctx.fail(
`Účet ${inputs.accountNumber} mezi ${accounts.length} dostupnými není. ` +
'Souhlas může platit na jiný účet.',
);
}
account = match;
} else {
ctx.log(`Číslo účtu nezadáno, bere se první z ${accounts.length} dostupných.`);
}
const accountId = need(text(pick(account, 'accountId', 'id')), 'identifikátor účtu');
const { body } = await ctx.http.get(`/accounts/${accountId}/transactions`, {
query: {
dateFrom: inputs.dateFrom ? inputs.dateFrom.slice(0, 10) : undefined,
dateTo: inputs.dateTo ? inputs.dateTo.slice(0, 10) : undefined,
size: inputs.limit,
},
});
const transactions = ctx.util.list(body, 'transactions');
if (transactions === null) ctx.fail('Odpověď banky nemá seznam pohybů.', body);
return {
accountId,
count: transactions.length,
transactions,
newest:
transactions.length > 0
? text(pick(transactions[0], 'bookingDate', 'valueDate', 'creationDate'))
: null,
};
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Google Analytics 4: report.
*
* Sluzba: https://services.csbot.cz/apps/analytics
* Endpoint: POST /ga/data/properties/{property_id}/runReport
*
* GA4 vraci report jako **hlavicky zvlast a hodnoty zvlast**, tedy tvar, se
* kterym se ve strome nedá pracovat. Skript ho proto srovna na seznam objektu,
* kde ma kazdy radek pojmenovana pole - podminka za krokem se pak muze zeptat
* na `activeUsers` misto na `metricValues.0.value`.
*/
export const manifest = {
id: 'ga4.run-report',
name: 'Načíst report',
description:
'Stáhne metriky z Google Analytics 4 za období. Vrací řádky s pojmenovanými ' +
'poli, takže se s nimi dá rovnou počítat.',
inputs: [
{
id: 'propertyId',
label: 'ID property',
type: 'string',
required: true,
pattern: '^[0-9]+$',
hint: 'Jen číslice. GA4: Administrace, Nastavení property, ID property.',
},
{
id: 'metrics',
label: 'Metriky',
type: 'string',
required: false,
default: 'activeUsers,sessions',
hint: 'Oddělené čárkou, například activeUsers,sessions,conversions.',
},
{
id: 'dimensions',
label: 'Dimenze',
type: 'string',
required: false,
hint: 'Oddělené čárkou, například date,country. Bez nich vyjde jeden souhrnný řádek.',
},
{
id: 'startDate',
label: 'Od',
type: 'string',
required: false,
default: '7daysAgo',
hint: 'Datum jako 2026-08-01, nebo relativně: 7daysAgo, yesterday, today.',
},
{
id: 'endDate',
label: 'Do',
type: 'string',
required: false,
default: 'today',
},
{
id: 'limit',
label: 'Nejvýše kolik řádků',
type: 'number',
required: false,
default: 100,
},
],
outputs: [
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
{ id: 'totalRows', label: 'Řádků celkem v GA', type: 'number', required: false },
{ id: 'firstRow', label: 'První řádek', type: 'object', required: false },
],
timeoutMs: 45000,
};
/** Pole, nebo prazdno. Cizi odpoved nemusi mit tvar, ktery cekame. */
function list(value) {
return Array.isArray(value) ? value : [];
}
/** Seznam z jednoho radku. Prazdne polozky se zahazuji, at neposilame "". */
function names(value) {
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '');
}
export async function run(inputs, ctx) {
const { pick, num } = ctx.util;
const metrics = names(inputs.metrics);
if (metrics.length === 0) ctx.fail('Vyplňte aspoň jednu metriku.');
const dimensions = names(inputs.dimensions);
const { body } = await ctx.http.post(`/ga/data/properties/${inputs.propertyId}/runReport`, {
dateRanges: [{ startDate: inputs.startDate, endDate: inputs.endDate }],
metrics: metrics.map((name) => ({ name })),
...(dimensions.length > 0 ? { dimensions: dimensions.map((name) => ({ name })) } : {}),
limit: inputs.limit,
});
const dimensionHeaders = list(pick(body, 'dimensionHeaders')).map((header) =>
String(pick(header, 'name')),
);
const metricHeaders = list(pick(body, 'metricHeaders')).map((header) =>
String(pick(header, 'name')),
);
const rows = list(pick(body, 'rows')).map((row) => {
const flat = {};
const dimensionValues = list(pick(row, 'dimensionValues'));
const metricValues = list(pick(row, 'metricValues'));
dimensionHeaders.forEach((name, index) => {
flat[name] = pick(dimensionValues[index], 'value') ?? null;
});
// Metriky se prevadeji na cisla. GA je vraci jako text a soucet retezcu
// by v dalsim kroku dal "12" misto 3.
metricHeaders.forEach((name, index) => {
flat[name] = num(pick(metricValues[index], 'value'), 0);
});
return flat;
});
return {
count: rows.length,
rows,
totalRows: num(pick(body, 'rowCount')),
firstRow: rows[0] ?? null,
};
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Google Ads: vykon kampani.
*
* Sluzba: https://services.csbot.cz/apps/analytics
* Endpoint: POST /googleads/customers/{customer_id}/search
*
* Google Ads se pta jazykem GAQL. Skript proto nabizi dve cesty: bud se vyplni
* obdobi a vezme se pripraveny dotaz na kampane, nebo se napise vlastni GAQL.
* Bez te druhe moznosti by kazdy jiny report znamenal novy skript.
*
* Castky chodi v mikrojednotkach (milion = jedna koruna). Prepocitavaji se
* tady, aby se v kazdem dalsim kroku nedelilo znovu a nekde spatne.
*/
export const manifest = {
id: 'google-ads.campaign-report',
name: 'Načíst výkon kampaní',
description:
'Stáhne náklady, prokliky a konverze podle kampaní za období. ' +
'Místo období lze zadat vlastní GAQL dotaz.',
inputs: [
{
id: 'customerId',
label: 'ID účtu',
type: 'string',
required: true,
pattern: '^[0-9-]{10,14}$',
hint: 'Desetimístné číslo účtu. Pomlčky se odstraní.',
},
{
id: 'period',
label: 'Období',
type: 'string',
required: false,
default: 'LAST_7_DAYS',
options: [
{ value: 'TODAY', label: 'Dnes' },
{ value: 'YESTERDAY', label: 'Včera' },
{ value: 'LAST_7_DAYS', label: 'Posledních 7 dní' },
{ value: 'LAST_30_DAYS', label: 'Posledních 30 dní' },
{ value: 'THIS_MONTH', label: 'Tento měsíc' },
{ value: 'LAST_MONTH', label: 'Minulý měsíc' },
],
hint: 'Použije se jen tehdy, když není vyplněný vlastní dotaz.',
},
{
id: 'query',
label: 'Vlastní GAQL dotaz',
type: 'string',
required: false,
multiline: true,
hint: 'Když je vyplněný, období se ignoruje. Například SELECT ad_group.name FROM ad_group.',
},
],
outputs: [
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
{ id: 'cost', label: 'Náklady celkem', type: 'number', required: true },
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
],
timeoutMs: 60000,
};
const campaignQuery = (period) =>
'SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, ' +
'metrics.clicks, metrics.cost_micros, metrics.conversions ' +
`FROM campaign WHERE segments.date DURING ${period}`;
export async function run(inputs, ctx) {
const { pick, num, round } = ctx.util;
const customerId = inputs.customerId.replace(/-/g, '');
const query = inputs.query ?? campaignQuery(inputs.period);
const { body } = await ctx.http.post(`/googleads/customers/${customerId}/search`, { query });
const raw = pick(body, 'results', 'rows');
const items = Array.isArray(raw) ? raw : [];
// Vlastni dotaz vraci vlastni tvar. Prevlekat ho do poli kampane by znamenalo
// seznam samych null - radky se proto vrati tak, jak prisly.
if (inputs.query) {
ctx.log(`Vlastní dotaz vrátil ${items.length} řádků, souhrny se nepočítají.`);
return { count: items.length, rows: items, cost: 0, clicks: 0 };
}
let cost = 0;
let clicks = 0;
const rows = items.map((row) => {
const campaign = pick(row, 'campaign') ?? {};
const metrics = pick(row, 'metrics') ?? {};
// Mikrojednotky na koruny. Pri deleni az v dalsim kroku by se na to
// nekde zapomnelo a report by hlasil milionove castky.
const rowCost = round(num(pick(metrics, 'costMicros', 'cost_micros'), 0) / 1_000_000, 2);
const rowClicks = num(pick(metrics, 'clicks'), 0);
cost += rowCost;
clicks += rowClicks;
return {
campaignId: pick(campaign, 'id') ?? null,
campaignName: pick(campaign, 'name') ?? null,
status: pick(campaign, 'status') ?? null,
impressions: num(pick(metrics, 'impressions'), 0),
clicks: rowClicks,
cost: rowCost,
conversions: num(pick(metrics, 'conversions'), 0),
};
});
return { count: rows.length, rows, cost: round(cost, 2), clicks };
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Google Workspace: pripsani radku do tabulky.
*
* Sluzba: https://services.csbot.cz/apps/google-service
* Endpoint: POST /google/sheets/spreadsheets/{spreadsheetId}/values/append
*
* Tabulka se zadava odkazem, ne ID. Odkaz je to, co ma clovek v prohlizeci;
* ID z nej vytahne skript. Kdyby ho mel opisovat rucne, byl by to prvni zdroj
* chyb, ktery se pozna az podle prazdne tabulky.
*/
export const manifest = {
id: 'google.append-sheet-row',
name: 'Přidat řádek do tabulky',
description:
'Připíše řádek na konec listu v Google Tabulkách. Hodí se na evidenci ' +
'poptávek, objednávek nebo běhů automatizace.',
inputs: [
{
id: 'spreadsheet',
label: 'Tabulka',
type: 'string',
required: true,
hint: 'Odkaz na tabulku z prohlížeče, nebo samotné ID.',
},
{
id: 'sheetName',
label: 'Název listu',
type: 'string',
required: false,
default: 'List1',
hint: 'Přesně tak, jak je napsaný na záložce dole.',
},
{
id: 'values',
label: 'Hodnoty řádku',
type: 'list',
required: true,
hint: 'Seznam hodnot zleva doprava, například ["2026-08-26", "Nová poptávka", 1200].',
},
],
outputs: [
{ id: 'updatedRange', label: 'Kam se zapsalo', type: 'string', required: true },
{ id: 'updatedCells', label: 'Počet zapsaných buněk', type: 'number', required: false },
{ id: 'spreadsheetId', label: 'ID tabulky', type: 'string', required: true },
],
timeoutMs: 30000,
};
/**
* ID tabulky z odkazu.
*
* Odkaz ma tvar .../spreadsheets/d/<id>/edit. Kdyz uzivatel vlozi rovnou ID,
* projde beze zmeny - obe cesty vedou ke stejnemu vysledku.
*/
function spreadsheetId(value) {
const match = /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/.exec(value);
return match ? match[1] : value.trim();
}
export async function run(inputs, ctx) {
const { pick, text, num, need } = ctx.util;
if (inputs.values.length === 0) ctx.fail('Řádek nemá žádné hodnoty.');
const id = spreadsheetId(inputs.spreadsheet);
// Rozsah je jen nazev listu: append si sam najde prvni volny radek.
const range = inputs.sheetName;
const { body } = await ctx.http.post(
`/google/sheets/spreadsheets/${encodeURIComponent(id)}/values/append`,
{
range,
majorDimension: 'ROWS',
// Vsechno na text: Sheets si cisla i data prevede samo podle formatu
// bunky, kdezto smisene typy v jednom poli odmita.
values: [inputs.values.map((value) => (value === null ? '' : String(value)))],
},
{ query: { range } },
);
const updates = pick(body, 'updates') ?? body;
return {
updatedRange: need(text(pick(updates, 'updatedRange')), 'zapsaný rozsah'),
updatedCells: num(pick(updates, 'updatedCells')),
spreadsheetId: id,
};
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Google Workspace: odeslani e-mailu pres Gmail.
*
* Sluzba: https://services.csbot.cz/apps/google-service
* Endpoint: POST /google/gmail/messages/send
*
* Gmail neprijima predmet a telo zvlast. Chce **cely e-mail podle RFC 2822**
* zakodovany do base64url, takze zpravu sklada skript. Predmet s diakritikou
* se navic musi zabalit do MIME slova, jinak z nej cestou zbydou otazniky.
*
* Kodovani dela `ctx.util.base64`, ne Buffer ani `btoa`: skript nesmi
* zaviset na globalech prostredi, aby bezel stejne i az pobezi v sandboxu.
*/
export const manifest = {
id: 'google.send-email',
name: 'Odeslat e-mail',
description:
'Pošle e-mail přes Gmail účtu, pod kterým je napojení. Příjemců může být víc, ' +
'oddělují se čárkou.',
inputs: [
{
id: 'to',
label: 'Příjemci',
type: 'string',
required: true,
hint: 'E-maily oddělené čárkou.',
},
{ id: 'cc', label: 'Kopie', type: 'string', required: false },
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
{ id: 'body', label: 'Text zprávy', type: 'string', required: true, multiline: true },
{
id: 'html',
label: 'Text je HTML',
type: 'boolean',
required: false,
default: false,
hint: 'Vypnuté odešle zprávu jako čistý text.',
},
{
id: 'from',
label: 'Odesílatel',
type: 'string',
required: false,
hint: 'Jen když má účet povolený alias. Bez vyplnění pošle Gmail zprávu za vlastníka schránky.',
},
],
outputs: [
{ id: 'messageId', label: 'ID zprávy', type: 'string', required: true },
{ id: 'threadId', label: 'ID konverzace', type: 'string', required: false },
{ id: 'recipients', label: 'Počet příjemců', type: 'number', required: true },
],
timeoutMs: 30000,
};
/** Base64url bez vypln, jak ho chce Gmail. `base64` je `ctx.util.base64`. */
function base64Url(base64, value) {
return base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Hlavicka s diakritikou musi byt MIME slovo, jinak z ni zbydou otazniky. */
function header(base64, value) {
for (const character of value) {
if (character.codePointAt(0) > 127) return `=?UTF-8?B?${base64(value)}?=`;
}
return value;
}
export async function run(inputs, ctx) {
const { pick, text, need, addresses, base64 } = ctx.util;
// Clovek pise carky, Gmail chce jeden retezec.
const to = addresses(inputs.to);
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
const cc = addresses(inputs.cc);
const lines = [
`To: ${to.join(', ')}`,
...(cc.length > 0 ? [`Cc: ${cc.join(', ')}`] : []),
...(inputs.from ? [`From: ${inputs.from}`] : []),
`Subject: ${header(base64, inputs.subject)}`,
'MIME-Version: 1.0',
`Content-Type: text/${inputs.html ? 'html' : 'plain'}; charset="UTF-8"`,
'Content-Transfer-Encoding: 8bit',
'',
inputs.body,
];
const { body } = await ctx.http.post('/google/gmail/messages/send', {
raw: base64Url(base64, lines.join('\r\n')),
});
ctx.log(`Odesláno ${to.length + cc.length} příjemcům.`);
return {
messageId: need(text(pick(body, 'id')), 'ID zprávy'),
threadId: text(pick(body, 'threadId')),
recipients: to.length + cc.length,
};
}
+105
View File
@@ -0,0 +1,105 @@
/**
* iDoklad: zalozeni odberatele.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: POST /contacts
*
* **Nic nedohledava.** Kdyz uz kontakt existuje, vznikne druhy se stejnym ICO -
* a to je spravne chovani teto operace, ne chyba. Hledani je vlastni krok
* (`Najit kontakt`), takze si strom sam rekne, kdy hledat a kdy zakladat:
*
* 1. Najit kontakt (podle ICO)
* 2. podminka: nenalezeno?
* ano -> Najit kontakt (podle e-mailu)
* 3. podminka: porad nenalezeno?
* ano -> Zalozit kontakt
*
* Kdyz nekomu staci "chci mit jistotu, ze tam je", pouzije jednim krokem
* `Najit nebo zalozit kontakt`. Obojí ma smysl a ani jedno nenahrazuje druhe.
*/
export const manifest = {
id: 'idoklad.create-contact',
name: 'Založit kontakt',
description:
'Založí v iDokladu nového odběratele a vrátí jeho ID. Nic nedohledává - ' +
'na to je krok Najít kontakt.',
timeoutMs: 25000,
inputs: [
{
id: 'companyName',
label: 'Název odběratele',
type: 'string',
required: true,
hint: 'U firmy název, u fyzické osoby jméno.',
},
{
id: 'identificationNumber',
label: 'IČO',
type: 'string',
required: false,
pattern: '^[0-9]{6,12}$',
hint: 'Jen číslice. U fyzické osoby zůstane prázdné.',
},
{ id: 'vatNumber', label: 'DIČ', type: 'string', required: false },
{ id: 'email', label: 'E-mail', type: 'string', required: false },
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
{ id: 'street', label: 'Ulice', type: 'string', required: false },
{ id: 'city', label: 'Město', type: 'string', required: false },
{ id: 'postalCode', label: 'PSČ', type: 'string', required: false },
{
id: 'countryCode',
label: 'Země',
type: 'string',
required: false,
default: 'CZ',
hint: 'Dvoupísmenný kód. Nevyplněno = CZ.',
},
],
outputs: [
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
{ id: 'companyName', label: 'Název odběratele', type: 'string', required: true },
{ id: 'contact', label: 'Celý kontakt', type: 'object', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, need } = ctx.util;
const companyName = text(inputs.companyName);
if (!companyName) {
ctx.fail('Bez názvu odběratele iDoklad kontakt nezaloží.');
}
/*
* Posilaji se jen vyplnena pole. Prazdny retezec neni totez co "neuvedeno" -
* u DIC by z nej iDoklad udelal platce DPH s prazdnym cislem.
*/
const body = {
CompanyName: companyName,
CountryId: null,
...(text(inputs.identificationNumber)
? { IdentificationNumber: text(inputs.identificationNumber) }
: {}),
...(text(inputs.vatNumber) ? { VatIdentificationNumber: text(inputs.vatNumber) } : {}),
...(text(inputs.email) ? { Email: text(inputs.email) } : {}),
...(text(inputs.phone) ? { Mobile: text(inputs.phone) } : {}),
...(text(inputs.street) ? { Street: text(inputs.street) } : {}),
...(text(inputs.city) ? { City: text(inputs.city) } : {}),
...(text(inputs.postalCode) ? { PostalCode: text(inputs.postalCode) } : {}),
...(text(inputs.countryCode) ? { CountryCode: text(inputs.countryCode) } : {}),
};
const response = await ctx.http.post('/contacts', body);
const created = unwrap(response.body);
ctx.log(`Kontakt ${companyName} založen.`);
return {
contactId: need(num(pick(created, 'id')), 'ID založeného kontaktu'),
companyName: text(pick(created, 'companyName', 'name')) || companyName,
contact: created && typeof created === 'object' ? created : {},
};
}
@@ -0,0 +1,100 @@
/**
* iDoklad: vystaveni faktury z hotoveho objektu.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /issued-invoices/default, POST /issued-invoices
*
* Druha polovina prikladu z transformace: prijde objednavka z e-shopu,
* `transform.map-fields` z ni udela telo dokladu a tenhle krok ho odesle.
*
* Rozdil proti `idoklad.create-issued-invoice`: ta akce ma pole na kliknuti
* a umi jednu polozku. Tahle bere cely objekt, takze zvladne vic polozek
* a cokoliv dalsiho, co iDoklad prijima. Cenou je, ze tvar tela musi znat ten,
* kdo stavi transformaci.
*
* Vzor z `/default` se poradi i tady: nese povinna pole, ktera nikdo vyplnovat
* nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`). Objekt z transformace
* ho jen prepise tam, kde neco rika.
*/
export const manifest = {
id: 'idoklad.create-invoice-from-object',
name: 'Vystavit fakturu z objektu',
description:
'Odešle do iDokladu připravené tělo dokladu. Chybějící povinná pole se ' +
'doplní z předvyplněného vzoru iDokladu.',
timeoutMs: 25000,
inputs: [
{
id: 'payload',
label: 'Tělo dokladu',
type: 'object',
required: true,
hint: 'Objekt z transformace dat. Klíče podle iDokladu, například partnerId, items.',
},
{
id: 'requireItems',
label: 'Vyžadovat aspoň jednu položku',
type: 'boolean',
required: false,
default: true,
hint: 'Doklad bez položek iDoklad přijme, ale bude na nulu.',
},
],
outputs: [
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
{ id: 'itemCount', label: 'Počet položek', type: 'number', required: true },
{ id: 'invoice', label: 'Celý doklad', type: 'object', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, need } = ctx.util;
const payload = inputs.payload;
/*
* Polozky se hledaji pod obema jmeny. iDoklad je v tele ceka jako
* `IssuedInvoiceItems`, ale prevod je casto pojmenuje `items` podle toho,
* jak se jmenuji ve zdrojove objednavce. Trvat na jednom z nich by znamenalo,
* ze prevod projde kontrolou a doklad prijde do iDokladu bez radku.
*/
const rawItems = pick(payload, 'IssuedInvoiceItems', 'items');
const items = Array.isArray(rawItems) ? rawItems : [];
if ((inputs.requireItems ?? true) && items.length === 0) {
ctx.fail('Tělo dokladu neobsahuje žádné položky. Zkontrolujte pravidla transformace.');
}
if (num(pick(payload, 'partnerId')) === null) {
ctx.fail('Tělo dokladu neobsahuje partnerId, iDoklad by doklad nepřijal.');
}
// Vzor nese povinna pole, ktera z objednavky nikdy neprijdou.
const defaults = unwrap((await ctx.http.get('/issued-invoices/default')).body);
if (!defaults || typeof defaults !== 'object') {
ctx.retry('iDoklad nevrátil předvyplněný vzor faktury.');
}
/*
* Vzor nese povinna pole, prevod prepise ta, kterym rozumime. Polozky se
* doplni pod jmenem, ktere ceka iDoklad, at uz prisly pojmenovane jakkoliv.
*/
const body = { ...defaults, ...payload, IssuedInvoiceItems: items };
delete body.items;
const response = await ctx.http.post('/issued-invoices', body);
const invoice = unwrap(response.body);
ctx.log(`Doklad vystaven z objektu, položek: ${items.length}.`);
return {
invoiceId: need(num(pick(invoice, 'id')), 'ID vystavené faktury'),
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
itemCount: items.length,
// Cely doklad dal, aby na nej sel navazat dalsi krok bez druheho volani.
invoice: invoice && typeof invoice === 'object' ? invoice : {},
};
}
+151
View File
@@ -0,0 +1,151 @@
/**
* iDoklad: vystaveni vydane faktury s jednou polozkou.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /issued-invoices/default, POST /issued-invoices
*
* Vzor **dvou volani za sebou**. iDoklad u faktury vyzaduje pole, ktera nikdo
* rucne vyplnovat nechce (`documentSerialNumber`, `isEet`, `isIncomeTax`),
* takze se nejdriv vezme predvyplneny vzor z `/issued-invoices/default`
* a prepisou se v nem jen ty veci, ktere prisly ze stromu.
*
* Kdyby se telo skladalo od nuly, rozbila by ho kazda zmena povinnych poli
* na strane iDokladu. Takhle se prepisuje jen to, cemu rozumime.
*/
export const manifest = {
id: 'idoklad.create-issued-invoice',
name: 'Vystavit vydanou fakturu',
description:
'Vystaví v iDokladu vydanou fakturu s jednou položkou. Chybějící údaje ' +
'se doplní z předvyplněného vzoru iDokladu.',
timeoutMs: 25000,
inputs: [
{
id: 'partnerId',
label: 'ID odběratele v iDokladu',
type: 'number',
required: true,
hint: 'Umí ho dohledat akce Najít kontakt.',
},
{
id: 'description',
label: 'Popis dokladu',
type: 'string',
required: true,
hint: 'Text v hlavičce faktury, například Objednávka {{orderNumber}}.',
},
{ id: 'itemName', label: 'Název položky', type: 'string', required: true },
{
id: 'unitPrice',
label: 'Cena za jednotku',
type: 'number',
required: true,
hint: 'V měně dokladu. Desetinná čárka i tečka projdou.',
},
{ id: 'amount', label: 'Počet jednotek', type: 'number', required: false, default: 1 },
{ id: 'unit', label: 'Jednotka', type: 'string', required: false, default: 'ks' },
{
id: 'dateOfIssue',
label: 'Datum vystavení',
type: 'date',
required: false,
hint: 'Nevyplněno = dnes.',
},
{
id: 'maturityDays',
label: 'Splatnost ve dnech',
type: 'number',
required: false,
default: 14,
},
{ id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
{ id: 'note', label: 'Poznámka', type: 'string', required: false, multiline: true },
{
id: 'vatRateType',
label: 'Kód sazby DPH',
type: 'number',
required: false,
default: 0,
hint: 'Číselný kód VatRateType z iDokladu. Když nevíte, nechte 0.',
},
{
id: 'priceType',
label: 'Kód typu ceny',
type: 'number',
required: false,
default: 0,
hint: 'Číselný kód PriceType z iDokladu (cena s DPH nebo bez). Když nevíte, nechte 0.',
},
],
outputs: [
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
],
};
function addDays(value, days) {
const date = new Date(value);
date.setUTCDate(date.getUTCDate() + days);
return date;
}
export async function run(inputs, ctx) {
// `day` dava datum ve tvaru, ktery iDoklad ceka (2026-08-20).
const { unwrap, pick, text, num, date, need, day } = ctx.util;
if (inputs.unitPrice < 0) ctx.fail('Cena za jednotku nemůže být záporná.');
const amount = inputs.amount ?? 1;
if (amount <= 0) ctx.fail('Počet jednotek musí být větší než nula.');
// 1. Predvyplneny vzor. Nese povinna pole, ktera nechceme vyplnovat rucne.
const defaults = unwrap((await ctx.http.get('/issued-invoices/default')).body);
if (!defaults || typeof defaults !== 'object') {
ctx.retry('iDoklad nevrátil předvyplněný vzor faktury.');
}
const issuedAt = inputs.dateOfIssue ? new Date(inputs.dateOfIssue) : new Date();
const maturityAt = addDays(issuedAt, inputs.maturityDays ?? 14);
// 2. Prepisou se jen ta pole, kterym rozumime. Zbytek zustava ze vzoru.
const payload = {
...defaults,
partnerId: inputs.partnerId,
description: inputs.description,
dateOfIssue: day(issuedAt),
dateOfTaxing: day(issuedAt),
dateOfMaturity: day(maturityAt),
items: [
{
name: inputs.itemName,
amount,
unit: inputs.unit ?? 'ks',
unitPrice: inputs.unitPrice,
discountPercentage: 0,
isTaxMovement: false,
priceType: inputs.priceType ?? 0,
vatRateType: inputs.vatRateType ?? 0,
},
],
};
if (inputs.variableSymbol) payload.variableSymbol = inputs.variableSymbol;
if (inputs.note) payload.note = inputs.note;
const { body } = await ctx.http.post('/issued-invoices', payload);
const invoice = unwrap(body);
ctx.log(`Faktura vystavena pro odběratele ${inputs.partnerId}.`);
return {
invoiceId: need(num(pick(invoice, 'id')), 'ID vystavené faktury'),
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
dateOfMaturity:
date(pick(invoice, 'dateOfMaturity')) ?? need(date(maturityAt), 'datum splatnosti'),
};
}
+97
View File
@@ -0,0 +1,97 @@
/**
* iDoklad: dohledani kontaktu podle ICO nebo e-mailu.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: GET /contacts?filter=(IdentificationNumber~eq~12345678)
*
* Vzor **vlastni kontroly vstupu**. Manifest umi rict "tohle pole je povinne",
* ale ne "aspon jedno z dvojice". Takova pravidla patri do kodu, protoze jen
* tam jde napsat citelny duvod.
*/
export const manifest = {
id: 'idoklad.find-contact',
name: 'Najít kontakt',
description:
'Dohledá odběratele v iDokladu podle IČO nebo e-mailu. Nic nezakládá. ' +
'Výsledek se použije jako ID odběratele při vystavení faktury.',
inputs: [
{
id: 'identificationNumber',
label: 'IČO',
type: 'string',
required: false,
pattern: '^[0-9]{6,12}$',
hint: 'Jen číslice. Přesnější než e-mail, hledá se podle něj první.',
},
{
id: 'email',
label: 'E-mail',
type: 'string',
required: false,
hint: 'Použije se, když IČO není k dispozici.',
},
],
outputs: [
{ id: 'found', label: 'Kontakt nalezen', type: 'boolean', required: true },
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: false },
{ id: 'companyName', label: 'Název firmy', type: 'string', required: false },
{ id: 'identificationNumber', label: 'IČO', type: 'string', required: false },
{ id: 'email', label: 'E-mail', type: 'string', required: false },
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
],
};
const notFound = {
found: false,
contactId: null,
companyName: null,
identificationNumber: null,
email: null,
matchedBy: 'nenalezeno',
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num } = ctx.util;
if (!inputs.identificationNumber && !inputs.email) {
ctx.fail('Vyplňte IČO nebo e-mail, jinak není podle čeho hledat.');
}
/** Jedno hledani podle jednoho pole. Vraci kontakt, nebo null. */
async function search(field, value) {
const { body } = await ctx.http.get('/contacts', {
query: { filter: `(${field}~eq~${value})`, filtertype: 'and', pageSize: 1 },
});
const items = unwrap(body);
return Array.isArray(items) && items.length > 0 ? items[0] : null;
}
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny.
const attempts = [];
if (inputs.identificationNumber) {
attempts.push(['IdentificationNumber', inputs.identificationNumber, 'IČO']);
}
if (inputs.email) attempts.push(['Email', inputs.email, 'e-mail']);
for (const [field, value, label] of attempts) {
const contact = await search(field, value);
if (!contact) {
ctx.log(`Podle ${label} ${value} se nic nenašlo.`);
continue;
}
return {
found: true,
contactId: num(pick(contact, 'id')),
companyName: text(pick(contact, 'companyName', 'name')),
identificationNumber: text(pick(contact, 'identificationNumber')),
email: text(pick(contact, 'email')),
matchedBy: label,
};
}
return notFound;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* iDoklad: dohledani vydane faktury podle cisla dokladu.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: GET /issued-invoices?filter=(DocumentNumber~eq~2024001)
*
* Tohle je vzor **predvalidace**: skript nic nemeni, jen odpovi, jestli doklad
* existuje. Vystup `found` je pak to, na co se ve strome vetvi podminka.
* Stejny princip jako akce "Dohledat firmu" u CRM, viz documentation/06-tickety.md.
*
* Proto taky nenalezena faktura NENI chyba. Kdyby skript spadl, nesla by
* postavit vetev "doklad neznam, zaloz ho".
*/
export const manifest = {
id: 'idoklad.find-issued-invoice',
name: 'Najít vydanou fakturu',
description:
'Zjistí, jestli v iDokladu existuje vydaná faktura s daným číslem dokladu. ' +
'Nic nezakládá ani nemění. Podle výsledku se strom větví.',
inputs: [
{
id: 'documentNumber',
label: 'Číslo dokladu',
type: 'string',
required: true,
hint: 'Číslo, jak je na faktuře, například 2024001.',
},
],
outputs: [
{ id: 'found', label: 'Faktura nalezena', type: 'boolean', required: true },
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: false },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: false },
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: false },
{ id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: false },
{
id: 'ambiguous',
label: 'Odpovídá víc faktur',
type: 'boolean',
required: true,
},
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, num, bool, date } = ctx.util;
const { body } = await ctx.http.get('/issued-invoices', {
query: {
// Tvar filtru je dany iDokladem: (Pole~operator~hodnota)
filter: `(DocumentNumber~eq~${inputs.documentNumber})`,
filtertype: 'and',
// Dva staci: jeden na vysledek, druhy na zjisteni, ze neni jednoznacny.
pageSize: 2,
},
});
const items = unwrap(body);
const list = Array.isArray(items) ? items : [];
if (list.length === 0) {
ctx.log(`Faktura ${inputs.documentNumber} v iDokladu není.`);
return {
found: false,
invoiceId: null,
totalWithVat: null,
dateOfMaturity: null,
isPaid: null,
ambiguous: false,
};
}
if (list.length > 1) {
// Neni to chyba, ale nekdo to ma vedet - cislo dokladu ma byt jednoznacne.
ctx.log(`Číslu dokladu ${inputs.documentNumber} odpovídá víc faktur, beru první.`);
}
const invoice = list[0];
return {
found: true,
invoiceId: num(pick(invoice, 'id')),
totalWithVat: num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
dateOfMaturity: date(pick(invoice, 'dateOfMaturity')),
isPaid: bool(pick(invoice, 'isPaid')),
ambiguous: list.length > 1,
};
}
+76
View File
@@ -0,0 +1,76 @@
/**
* iDoklad: nacteni vydane faktury podle ID.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: GET /issued-invoices/{id}
*
* Nejjednodussi tvar skriptu: jedno volani a prevod odpovedi na vystupy.
*
* iDoklad vraci pole s velkym pocatecnim pismenem a nekdy obaluje odpoved
* do `Data`. Proto `unwrap` a `pick` - nespoléhá se na presny tvar odpovedi,
* protoze ten se u cizich sluzeb meni bez ohlaseni.
*/
export const manifest = {
id: 'idoklad.get-issued-invoice',
name: 'Získat vydanou fakturu',
description:
'Načte vydanou fakturu z iDokladu podle jejího ID. Používá se před rozhodnutím, ' +
'co s ní dál, například jestli je už uhrazená.',
inputs: [
{
id: 'invoiceId',
label: 'ID faktury v iDokladu',
type: 'number',
required: true,
hint: 'Interní ID, ne číslo dokladu. Číslo dokladu umí dohledat akce Najít vydanou fakturu.',
},
],
outputs: [
{ id: 'invoiceId', label: 'ID faktury', type: 'number', required: true },
{ id: 'documentNumber', label: 'Číslo dokladu', type: 'string', required: true },
{ id: 'variableSymbol', label: 'Variabilní symbol', type: 'string', required: false },
{ id: 'partnerId', label: 'ID odběratele', type: 'number', required: false },
{ id: 'partnerName', label: 'Odběratel', type: 'string', required: false },
{ id: 'totalWithVat', label: 'Celkem s DPH', type: 'number', required: true },
{ id: 'currencyId', label: 'ID měny', type: 'number', required: false },
{ id: 'dateOfIssue', label: 'Datum vystavení', type: 'date', required: true },
{ id: 'dateOfMaturity', label: 'Datum splatnosti', type: 'date', required: true },
{ id: 'isPaid', label: 'Je uhrazená', type: 'boolean', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, bool, date, need } = ctx.util;
const { body } = await ctx.http.get(`/issued-invoices/${inputs.invoiceId}`);
const invoice = unwrap(body);
if (!invoice || typeof invoice !== 'object') {
ctx.fail(`Faktura ${inputs.invoiceId} v iDokladu neexistuje.`);
}
// Odberatel muze byt jak plocha hodnota, tak vnoreny objekt partnera.
const partner = pick(invoice, 'partner', 'customer');
const partnerName =
text(pick(invoice, 'partnerName', 'customerName')) ??
text(pick(partner, 'companyName', 'name'));
return {
invoiceId: need(num(pick(invoice, 'id')), 'ID faktury'),
documentNumber: need(text(pick(invoice, 'documentNumber', 'number')), 'číslo dokladu'),
variableSymbol: text(pick(invoice, 'variableSymbol')),
partnerId: num(pick(invoice, 'partnerId')) ?? num(pick(partner, 'id')),
partnerName,
totalWithVat: need(
num(pick(invoice, 'totalWithVat', 'totalWithVatHc', 'total')),
'celkovou částku',
),
currencyId: num(pick(invoice, 'currencyId')),
dateOfIssue: need(date(pick(invoice, 'dateOfIssue')), 'datum vystavení'),
dateOfMaturity: need(date(pick(invoice, 'dateOfMaturity')), 'datum splatnosti'),
isPaid: bool(pick(invoice, 'isPaid')),
};
}
+96
View File
@@ -0,0 +1,96 @@
/**
* iDoklad: zapsani uhrady k vydane fakture.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /issued-payments/default/{invoiceId}, POST /issued-payments
*
* Vzor akce, ktera **neco meni**. U te zalezi na idempotenci: kdyz runtime krok
* zopakuje po timeoutu, nesmi vzniknout druha uhrada. Klic `ctx.idempotencyKey`
* je pro tentyz krok stejny pres vsechny pokusy a runtime ho posila v hlavicce
* `Idempotency-Key` automaticky, takze skript nemusi delat nic navic.
*
* Castka se necha prazdna pro plnou uhradu - vzor z iDokladu uz nese zbytek
* k zaplaceni, takze se nemusi pocitat tady.
*/
export const manifest = {
id: 'idoklad.register-payment',
name: 'Zapsat úhradu faktury',
description:
'Zapíše k vydané faktuře úhradu. Bez zadané částky se použije zbytek ' +
'k zaplacení podle iDokladu.',
inputs: [
{ id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
{
id: 'amount',
label: 'Uhrazená částka',
type: 'number',
required: false,
hint: 'Nevyplněno = celý zbytek k zaplacení.',
},
{
id: 'dateOfPayment',
label: 'Datum úhrady',
type: 'date',
required: false,
hint: 'Nevyplněno = dnes.',
},
{
id: 'sendConfirmation',
label: 'Poslat potvrzení odběrateli',
type: 'boolean',
required: false,
default: false,
},
],
outputs: [
{ id: 'paymentId', label: 'ID úhrady', type: 'number', required: true },
{ id: 'amount', label: 'Zapsaná částka', type: 'number', required: true },
{ id: 'dateOfPayment', label: 'Datum úhrady', type: 'date', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, num, date, need, day } = ctx.util;
if (inputs.amount !== null && inputs.amount <= 0) {
ctx.fail('Uhrazená částka musí být větší než nula.');
}
// Vzor nese zbytek k zaplaceni i vychozi zpusob platby.
const defaults = unwrap(
(await ctx.http.get(`/issued-payments/default/${inputs.invoiceId}`)).body,
);
if (!defaults || typeof defaults !== 'object') {
ctx.fail(`K faktuře ${inputs.invoiceId} nejde zapsat úhradu, iDoklad ji nezná.`);
}
const suggested = num(pick(defaults, 'paymentAmount'));
const amount = inputs.amount ?? suggested;
if (amount === null) {
ctx.fail('iDoklad nevrátil zbytek k zaplacení, zadejte částku ručně.');
}
const paidAt = inputs.dateOfPayment ? new Date(inputs.dateOfPayment) : new Date();
const payload = {
...defaults,
invoiceId: inputs.invoiceId,
paymentAmount: amount,
dateOfPayment: day(paidAt),
sendPaymentConfirmation: inputs.sendConfirmation ?? false,
};
const { body } = await ctx.http.post('/issued-payments', payload);
const payment = unwrap(body);
ctx.log(`K faktuře ${inputs.invoiceId} zapsána úhrada ${amount}.`);
return {
paymentId: need(num(pick(payment, 'id')), 'ID úhrady'),
amount: need(num(pick(payment, 'paymentAmount')) ?? amount, 'zapsanou částku'),
dateOfPayment: need(date(pick(payment, 'dateOfPayment')) ?? date(paidAt), 'datum úhrady'),
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* iDoklad: odeslani vydane faktury e-mailem.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpoint: POST /mail/issued-invoices/send
*
* Vzor akce, u ktere **odpoved sluzby nic nevraci**. Vystup se proto sklada
* z toho, co skript posilal, ne z toho, co prislo zpatky. Bez toho by strom
* za timhle krokem nemel na cem stavet podminku.
*
* Zaroven je to vzor toho, jak nahradit jeden vstup dvema chovanimi: kdyz je
* vyplneny e-mail, posle se na nej. Kdyz neni, posle se na adresu odberatele
* vedenou v iDokladu.
*/
export const manifest = {
id: 'idoklad.send-invoice-email',
name: 'Odeslat fakturu e-mailem',
description:
'Odešle vydanou fakturu e-mailem. Bez zadané adresy jde na e-mail ' +
'odběratele vedený v iDokladu.',
timeoutMs: 30000,
inputs: [
{ id: 'invoiceId', label: 'ID faktury v iDokladu', type: 'number', required: true },
{
id: 'email',
label: 'E-mail příjemce',
type: 'string',
required: false,
pattern: '^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$',
hint: 'Nevyplněno = adresa odběratele z iDokladu.',
},
{ id: 'subject', label: 'Předmět', type: 'string', required: false },
{ id: 'body', label: 'Text e-mailu', type: 'string', required: false, multiline: true },
{
id: 'sendAttachment',
label: 'Přiložit PDF faktury',
type: 'boolean',
required: false,
default: true,
},
{
id: 'sendToSelf',
label: 'Poslat kopii sobě',
type: 'boolean',
required: false,
default: false,
},
],
outputs: [
{ id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
{ id: 'recipient', label: 'Komu se odeslalo', type: 'string', required: true },
],
};
export async function run(inputs, ctx) {
const toGivenAddress = Boolean(inputs.email);
const payload = {
documentId: inputs.invoiceId,
// Vsechna tri pole jsou u iDokladu povinna, i kdyz jsou nepravdiva.
sendToPartner: !toGivenAddress,
sendToAccountant: false,
sendToSelf: inputs.sendToSelf ?? false,
sendAttachment: inputs.sendAttachment ?? true,
...(toGivenAddress ? { otherRecipients: [inputs.email] } : {}),
...(inputs.subject ? { emailSubject: inputs.subject } : {}),
...(inputs.body ? { emailBody: inputs.body } : {}),
};
// Nektere instance vraci 204 bez tela, jine 200 s potvrzenim. Obojí je uspech.
const { status } = await ctx.http.post('/mail/issued-invoices/send', payload);
const recipient = toGivenAddress ? String(inputs.email) : 'odběratel z iDokladu';
ctx.log(`Faktura ${inputs.invoiceId} odeslána (${recipient}), HTTP ${status}.`);
return { sent: true, recipient };
}
+146
View File
@@ -0,0 +1,146 @@
/**
* iDoklad: najde odberatele, a kdyz neni, zalozi ho.
*
* Sluzba: https://services.csbot.cz/apps/idoklad
* Endpointy: GET /contacts?filter=..., POST /contacts
*
* Proc jeden krok a ne dvojice "najdi" plus "zaloz" ve vetvich podminky:
* vetev podminky **nepridava nic do sekvence za podminkou**, protoze nemusela
* probehnout. ID odberatele by tedy za podminkou nebylo k dispozici, i kdyz
* ho ve skutecnosti daji obe vetve. Sloucit to do jednoho kroku je poctivejsi
* nez to obchazet - a navic je to presne ta operace, kterou clovek chce:
* "chci mit jistotu, ze odberatel v iDokladu je".
*
* Vystup `created` rekne, co se stalo, takze se na to da navazat podminkou
* (poslat uvitaci mail jen novemu zakaznikovi).
*/
export const manifest = {
id: 'idoklad.upsert-contact',
name: 'Najít nebo založit kontakt',
description:
'Dohledá odběratele podle IČO nebo e-mailu. Když neexistuje, založí ho ' +
'a vrátí nové ID. Používá se před vystavením faktury.',
timeoutMs: 25000,
inputs: [
{
id: 'identificationNumber',
label: 'IČO',
type: 'string',
required: false,
pattern: '^[0-9]{6,12}$',
hint: 'Jen číslice. Přesnější než e-mail, hledá se podle něj první.',
},
{
id: 'email',
label: 'E-mail',
type: 'string',
required: false,
hint: 'Použije se, když IČO není k dispozici.',
},
{
id: 'companyName',
label: 'Název odběratele',
type: 'string',
required: true,
hint: 'U firmy název, u fyzické osoby jméno. Bez něj kontakt založit nejde.',
},
{ id: 'vatNumber', label: 'DIČ', type: 'string', required: false },
{ id: 'street', label: 'Ulice', type: 'string', required: false },
{ id: 'city', label: 'Město', type: 'string', required: false },
{ id: 'postalCode', label: 'PSČ', type: 'string', required: false },
{
id: 'countryCode',
label: 'Země',
type: 'string',
required: false,
default: 'CZ',
hint: 'Dvoupísmenný kód. Nevyplněno = CZ.',
},
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
],
outputs: [
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
{ id: 'created', label: 'Kontakt vznikl teď', type: 'boolean', required: true },
{ id: 'companyName', label: 'Název odběratele', type: 'string', required: false },
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, text, num, need } = ctx.util;
const ico = text(inputs.identificationNumber);
const email = text(inputs.email);
if (!ico && !email) {
ctx.fail('Vyplňte IČO nebo e-mail, jinak není podle čeho hledat ani zakládat.');
}
/** Jedno hledani podle jednoho pole. Vraci kontakt, nebo null. */
async function search(field, value) {
const { body } = await ctx.http.get('/contacts', {
query: { filter: `(${field}~eq~${value})`, filtertype: 'and', pageSize: 1 },
});
const items = unwrap(body);
return Array.isArray(items) && items.length > 0 ? items[0] : null;
}
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny.
const attempts = [];
if (ico) attempts.push(['IdentificationNumber', ico, 'IČO']);
if (email) attempts.push(['Email', email, 'e-mail']);
for (const [field, value, label] of attempts) {
const contact = await search(field, value);
if (!contact) {
ctx.log(`Podle ${label} ${value} se nic nenašlo.`);
continue;
}
ctx.log(`Kontakt nalezen podle ${label}, zakládat se nebude.`);
return {
contactId: need(num(pick(contact, 'id')), 'ID nalezeného kontaktu'),
created: false,
companyName: text(pick(contact, 'companyName', 'name')),
matchedBy: label,
};
}
/*
* Zaklada se az tady, kdyz uz je jiste, ze kontakt neexistuje. Poradi je
* dulezite: zalozit napred a hledat potom by pri kazde objednavce vyrobilo
* dalsiho odberatele se stejnym ICO.
*/
const companyName = text(inputs.companyName);
if (!companyName) {
ctx.fail('Kontakt v iDokladu neexistuje a bez názvu odběratele ho nejde založit.');
}
const body = {
CompanyName: companyName,
...(ico ? { IdentificationNumber: ico } : {}),
...(text(inputs.vatNumber) ? { VatIdentificationNumber: text(inputs.vatNumber) } : {}),
...(email ? { Email: email } : {}),
...(text(inputs.phone) ? { Mobile: text(inputs.phone) } : {}),
...(text(inputs.street) ? { Street: text(inputs.street) } : {}),
...(text(inputs.city) ? { City: text(inputs.city) } : {}),
...(text(inputs.postalCode) ? { PostalCode: text(inputs.postalCode) } : {}),
CountryId: null,
...(text(inputs.countryCode) ? { CountryCode: text(inputs.countryCode) } : {}),
};
const response = await ctx.http.post('/contacts', body);
const created = unwrap(response.body);
ctx.log(`Kontakt ${companyName} založen.`);
return {
contactId: need(num(pick(created, 'id')), 'ID založeného kontaktu'),
created: true,
companyName: text(pick(created, 'companyName', 'name')) || companyName,
matchedBy: 'založeno',
};
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Meta Ads: vykon reklam na Facebooku a Instagramu.
*
* Sluzba: https://services.csbot.cz/apps/meta
* Endpoint: GET /ads/insights/{object_id}
*
* Meta pocita **velke reporty asynchronne**. Tenhle krok je synchronni verze
* pro bezne obdobi a uroven; kdyz Meta odpovi, ze uloha bezi, rekne se to
* nahlas misto toho, aby krok predstiral prazdny vysledek.
*
* Utrata chodi v mene uctu jako text. Prevadi se na cislo tady, aby se s ni
* v dalsim kroku dalo pocitat.
*/
export const manifest = {
id: 'meta-ads.insights',
name: 'Načíst výkon reklam',
description:
'Stáhne útratu, zobrazení, prokliky a konverze za období. Objektem může být ' +
'reklamní účet, kampaň, sestava i jednotlivá reklama.',
inputs: [
{
id: 'objectId',
label: 'ID objektu',
type: 'string',
required: true,
hint: 'Reklamní účet jako act_123456789, nebo ID kampaně, sestavy či reklamy.',
},
{
id: 'level',
label: 'Úroveň',
type: 'string',
required: false,
default: 'campaign',
options: [
{ value: 'account', label: 'Účet' },
{ value: 'campaign', label: 'Kampaň' },
{ value: 'adset', label: 'Sestava' },
{ value: 'ad', label: 'Reklama' },
],
},
{
id: 'period',
label: 'Období',
type: 'string',
required: false,
default: 'last_7d',
options: [
{ value: 'today', label: 'Dnes' },
{ value: 'yesterday', label: 'Včera' },
{ value: 'last_7d', label: 'Posledních 7 dní' },
{ value: 'last_30d', label: 'Posledních 30 dní' },
{ value: 'this_month', label: 'Tento měsíc' },
{ value: 'last_month', label: 'Minulý měsíc' },
],
hint: 'Použije se, když není vyplněné vlastní datum.',
},
{
id: 'dateFrom',
label: 'Od',
type: 'date',
required: false,
hint: 'Vyplněné datum od i do přebije volbu období.',
},
{ id: 'dateTo', label: 'Do', type: 'date', required: false },
{
id: 'fields',
label: 'Pole',
type: 'string',
required: false,
default: 'campaign_name,impressions,clicks,spend,ctr,cpc,actions',
hint: 'Oddělená čárkou, podle dokumentace Meta Marketing API.',
},
],
outputs: [
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
{ id: 'spend', label: 'Útrata celkem', type: 'number', required: true },
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
],
timeoutMs: 60000,
};
export async function run(inputs, ctx) {
const { pick, num, round } = ctx.util;
const query = { level: inputs.level, fields: inputs.fields };
if (inputs.dateFrom && inputs.dateTo) {
// Meta chce rozsah jako JSON v query parametru, ne jako dva parametry.
query.time_range = JSON.stringify({
since: inputs.dateFrom.slice(0, 10),
until: inputs.dateTo.slice(0, 10),
});
} else {
if (inputs.dateFrom || inputs.dateTo) {
ctx.log('Vyplněné je jen jedno datum, použije se zvolené období.');
}
query.date_preset = inputs.period;
}
const { body } = await ctx.http.get(`/ads/insights/${inputs.objectId}`, { query });
const raw = pick(body, 'data', 'rows');
const items = Array.isArray(raw) ? raw : [];
if (items.length === 0 && pick(body, 'report_run_id') !== undefined) {
ctx.fail(
'Meta report počítá na pozadí, synchronní volání ho nestihlo. ' +
'Zkraťte období nebo zvolte vyšší úroveň.',
body,
);
}
let spend = 0;
let clicks = 0;
let impressions = 0;
const rows = items.map((row) => {
// Meta vraci cisla jako text, vcetne utraty.
const rowSpend = num(pick(row, 'spend'), 0);
const rowClicks = num(pick(row, 'clicks'), 0);
const rowImpressions = num(pick(row, 'impressions'), 0);
spend += rowSpend;
clicks += rowClicks;
impressions += rowImpressions;
return {
...row,
spend: rowSpend,
clicks: rowClicks,
impressions: rowImpressions,
};
});
if (ctx.util.bool(pick(body, 'truncated'))) {
ctx.log('Meta vrátila jen část stránek, výsledek není úplný.');
}
return { count: rows.length, rows, spend: round(spend, 2), clicks, impressions };
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Microsoft 365: zalozeni schuzky v kalendari.
*
* Sluzba: https://services.csbot.cz/apps/microsoft-365-service
* Endpoint: POST /users/{user_id}/calendar/events
*
* Casova zona se posila vzdy vyslovne. Graph bez ni bere cas jako UTC, takze
* schuzka domluvena na desatou by v letnim case vysla na dvanactou - a nikdo
* by nevedel proc.
*/
export const manifest = {
id: 'microsoft365.create-event',
name: 'Vytvořit schůzku',
description: 'Založí schůzku v kalendáři zvoleného uživatele a pozve účastníky.',
inputs: [
{
id: 'mailbox',
label: 'Kalendář uživatele',
type: 'string',
required: true,
hint: 'E-mail nebo ID uživatele, jehož kalendář se použije.',
},
{ id: 'subject', label: 'Název schůzky', type: 'string', required: true },
{
id: 'start',
label: 'Začátek',
type: 'date',
required: true,
hint: 'Datum a čas začátku.',
},
{
id: 'end',
label: 'Konec',
type: 'date',
required: true,
hint: 'Musí být po začátku.',
},
{
id: 'timeZone',
label: 'Časová zóna',
type: 'string',
required: false,
default: 'Central Europe Standard Time',
hint: 'Název zóny podle Windows, jak ho očekává Graph.',
},
{
id: 'attendees',
label: 'Účastníci',
type: 'string',
required: false,
hint: 'E-maily oddělené čárkou.',
},
{ id: 'location', label: 'Místo', type: 'string', required: false },
{
id: 'body',
label: 'Popis',
type: 'string',
required: false,
multiline: true,
},
{
id: 'onlineMeeting',
label: 'Přidat schůzku v Teams',
type: 'boolean',
required: false,
default: false,
},
],
outputs: [
{ id: 'eventId', label: 'ID schůzky', type: 'string', required: true },
{ id: 'webLink', label: 'Odkaz na schůzku', type: 'string', required: false },
{ id: 'joinUrl', label: 'Odkaz do Teams', type: 'string', required: false },
],
timeoutMs: 30000,
};
export async function run(inputs, ctx) {
const { pick, text, need } = ctx.util;
if (Date.parse(inputs.end) <= Date.parse(inputs.start)) {
ctx.fail('Konec schůzky musí být po jejím začátku.');
}
// Graph chce cas bez zonoveho posunu a zonu zvlast. ISO z validace parametru
// konci na Z, takze se posun odrizne a zona se posle v `timeZone`.
const local = (value) => value.replace(/(\.\d+)?Z$/, '');
const payload = {
subject: inputs.subject,
start: { dateTime: local(inputs.start), timeZone: inputs.timeZone },
end: { dateTime: local(inputs.end), timeZone: inputs.timeZone },
is_online_meeting: inputs.onlineMeeting,
};
if (inputs.body) payload.body = inputs.body;
if (inputs.location) payload.location = inputs.location;
if (inputs.onlineMeeting) payload.online_meeting_provider = 'teamsForBusiness';
const attendees = String(inputs.attendees ?? '')
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item !== '')
.map((address) => ({ address }));
if (attendees.length > 0) payload.attendees = attendees;
const { body } = await ctx.http.post(
`/users/${encodeURIComponent(inputs.mailbox)}/calendar/events`,
payload,
);
return {
eventId: need(text(pick(body, 'id')), 'ID schůzky'),
webLink: text(pick(body, 'webLink')),
joinUrl: text(pick(body, 'onlineMeetingUrl') ?? pick(pick(body, 'onlineMeeting'), 'joinUrl')),
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Microsoft 365: odeslani e-mailu pres Graph.
*
* Sluzba: https://services.csbot.cz/apps/microsoft-365-service
* Endpoint: POST /users/{user_id}/mail/send
*
* Napojeni je **aplikacni**, ne uzivatelske: prihlasuje se registrovana
* aplikace, ne clovek. Proto je schranka vzdy soucasti kroku - aplikace sama
* o sobe zadnou nema a bez uvedeneho uzivatele by Graph nevedel, odkud poslat.
*/
export const manifest = {
id: 'microsoft365.send-mail',
name: 'Odeslat e-mail',
description:
'Odešle e-mail ze zvolené schránky Microsoft 365. Příjemců může být víc, ' +
'oddělují se čárkou.',
inputs: [
{
id: 'mailbox',
label: 'Schránka odesílatele',
type: 'string',
required: true,
hint: 'E-mail nebo ID uživatele, například servis@firma.cz.',
},
{
id: 'to',
label: 'Příjemci',
type: 'string',
required: true,
hint: 'E-maily oddělené čárkou.',
},
{ id: 'cc', label: 'Kopie', type: 'string', required: false },
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
{
id: 'body',
label: 'Text zprávy',
type: 'string',
required: true,
multiline: true,
},
{
id: 'html',
label: 'Text je HTML',
type: 'boolean',
required: false,
default: true,
hint: 'Vypnuté odešle zprávu jako čistý text.',
},
{
id: 'replyTo',
label: 'Adresa pro odpověď',
type: 'string',
required: false,
hint: 'Když se má odpovídat jinam než do schránky odesílatele.',
},
],
outputs: [
{ id: 'sent', label: 'Odesláno', type: 'boolean', required: true },
{ id: 'recipients', label: 'Počet příjemců', type: 'number', required: true },
{ id: 'subject', label: 'Předmět', type: 'string', required: true },
],
timeoutMs: 30000,
};
export async function run(inputs, ctx) {
// Graph chce pole objektu, clovek pise carky.
const addresses = (value) => ctx.util.addresses(value).map((address) => ({ address }));
const to = addresses(inputs.to);
if (to.length === 0) ctx.fail('Nezůstal žádný příjemce, zkontrolujte pole Příjemci.');
const payload = {
subject: inputs.subject,
body: inputs.body,
body_content_type: inputs.html ? 'HTML' : 'Text',
to,
};
const cc = addresses(inputs.cc);
if (cc.length > 0) payload.cc = cc;
if (inputs.replyTo) payload.reply_to = addresses(inputs.replyTo);
// Cesta obsahuje e-mail, ktery muze mit znaky mimo ASCII.
await ctx.http.post(`/users/${encodeURIComponent(inputs.mailbox)}/mail/send`, payload);
ctx.log(`Odesláno ${to.length + cc.length} příjemcům ze schránky ${inputs.mailbox}.`);
// Graph na uspesne odeslani nevraci telo, vystup se sklada ze vstupu.
return { sent: true, recipients: to.length + cc.length, subject: inputs.subject };
}
+147
View File
@@ -0,0 +1,147 @@
/**
* OpenAI: otazka nad nahranym souborem.
*
* Sluzba: https://api.openai.com/v1
* Endpoint: POST /responses
*
* Proc jiny endpoint nez `openai.chat`: soubor jako vstup umi az Responses API.
* Chat Completions by prijalo jen text, takze by se obsah musel do dotazu
* vlepit rucne - a u PDF nebo tabulky to nejde.
*
* Soubor se nejdriv nahraje krokem `openai.upload-file`, ktery vrati `fileId`.
* Rozdeleni na dva kroky je zamer: jednoho souboru se casto pta vic dotazu
* a nahravat ho pokazde znovu by stalo cas i penize.
*/
export const manifest = {
id: 'openai.ask-about-file',
name: 'Zeptat se na soubor',
description:
'Položí otázku nad souborem nahraným do OpenAI. Zvládne dokument i obrázek, ' +
'takže se hodí na vytěžení faktury, smlouvy nebo fotky.',
inputs: [
{
id: 'fileId',
label: 'ID souboru',
type: 'string',
required: true,
hint: 'Vrací ho krok Nahrát soubor jako fileId.',
},
{
id: 'kind',
label: 'Druh souboru',
type: 'string',
required: false,
default: 'document',
options: [
{ value: 'document', label: 'Dokument (PDF, text, tabulka)' },
{ value: 'image', label: 'Obrázek' },
],
hint:
'Obrázek se modelu předává jinak než dokument. Musí být nahraný ' +
's účelem Obrázek k analýze.',
},
{
id: 'prompt',
label: 'Otázka',
type: 'string',
required: true,
multiline: true,
hint: 'Například: Vypiš číslo faktury, datum splatnosti a celkovou částku.',
},
{
id: 'model',
label: 'Model',
type: 'string',
required: false,
default: 'gpt-4o-mini',
hint: 'Musí to být model, který umí číst soubory a obrázky.',
},
{
id: 'instructions',
label: 'Instrukce pro model',
type: 'string',
required: false,
multiline: true,
},
{
id: 'maxTokens',
label: 'Strop na délku odpovědi',
type: 'number',
required: false,
hint: 'V tokenech. Bez vyplnění rozhoduje model.',
},
],
outputs: [
{ id: 'text', label: 'Odpověď', type: 'string', required: true },
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
{ id: 'inputTokens', label: 'Tokeny na vstupu', type: 'number', required: false },
{ id: 'outputTokens', label: 'Tokeny na výstupu', type: 'number', required: false },
{ id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false },
],
// Cteni dokumentu trva dele nez bezny dotaz.
timeoutMs: 90000,
};
/**
* Odpoved Responses API je strom, ne jedno pole.
*
* Prochazi se cely: model muze pred odpovedi vratit i jine polozky (napriklad
* zaznam o uvaze) a brat naslepo prvni prvek by u nich vratilo prazdno.
*/
function answerText(output) {
if (!Array.isArray(output)) return null;
const parts = [];
for (const item of output) {
const content = item && item.content;
if (!Array.isArray(content)) continue;
for (const part of content) {
if (part && part.type === 'output_text' && typeof part.text === 'string') {
parts.push(part.text);
}
}
}
return parts.length > 0 ? parts.join('\n') : null;
}
export async function run(inputs, ctx) {
const { pick, text, num, need } = ctx.util;
const attachment =
inputs.kind === 'image'
? { type: 'input_image', file_id: inputs.fileId }
: { type: 'input_file', file_id: inputs.fileId };
const body = {
model: inputs.model,
input: [
{
role: 'user',
content: [attachment, { type: 'input_text', text: inputs.prompt }],
},
],
};
if (inputs.instructions) body.instructions = inputs.instructions;
if (inputs.maxTokens !== null) body.max_output_tokens = inputs.maxTokens;
const { body: answer } = await ctx.http.post('/responses', body);
if (text(pick(answer, 'status')) === 'incomplete') {
ctx.log('Odpověď je neúplná, model narazil na strop délky.');
}
const usage = pick(answer, 'usage') ?? {};
return {
text: need(answerText(pick(answer, 'output')), 'odpověď modelu'),
model: need(text(pick(answer, 'model')), 'název modelu'),
inputTokens: num(pick(usage, 'input_tokens')),
outputTokens: num(pick(usage, 'output_tokens')),
totalTokens: num(pick(usage, 'total_tokens')),
};
}
+112
View File
@@ -0,0 +1,112 @@
/**
* OpenAI: dotaz na jazykovy model.
*
* Sluzba: https://api.openai.com/v1 (nebezi u nas, adresa je v katalogu sluzeb)
* Endpoint: POST /chat/completions
*
* Model je **volny text s vychozi hodnotou**, ne vyber z pevneho seznamu.
* Seznam by zestarl pri kazdem vydani noveho modelu a krok stromu by pak
* odmital hodnotu, kterou ucet umi. Co ucet umi, vrati `openai.list-models`.
*/
export const manifest = {
id: 'openai.chat',
name: 'Zeptat se modelu',
description:
'Pošle otázku vybranému modelu OpenAI a vrátí odpověď včetně spotřeby tokenů. ' +
'Hodí se na shrnutí, klasifikaci i sepsání odpovědi zákazníkovi.',
inputs: [
{
id: 'model',
label: 'Model',
type: 'string',
required: false,
default: 'gpt-4o-mini',
hint: 'Například gpt-4o-mini, gpt-4o nebo gpt-4.1. Seznam vrátí operace Načíst seznam modelů.',
},
{
id: 'instructions',
label: 'Instrukce pro model',
type: 'string',
required: false,
multiline: true,
hint: 'Role a pravidla, například: Jsi zákaznická podpora, odpovídej česky a stručně.',
},
{
id: 'prompt',
label: 'Otázka',
type: 'string',
required: true,
multiline: true,
hint: 'Text dotazu. Může obsahovat data z předchozích kroků.',
},
{
id: 'temperature',
label: 'Teplota',
type: 'number',
required: false,
hint:
'Od 0 do 2. Nižší je předvídatelnější. Nevyplněné se neposílá vůbec, ' +
'protože novější modely teplotu nepřijímají.',
},
{
id: 'maxTokens',
label: 'Strop na délku odpovědi',
type: 'number',
required: false,
hint: 'V tokenech. Bez vyplnění rozhoduje model.',
},
{
id: 'jsonOutput',
label: 'Vyžádat odpověď v JSONu',
type: 'boolean',
required: false,
default: false,
hint: 'Model pak vrátí platný JSON. V instrukcích popište, jaká pole má mít.',
},
],
outputs: [
{ id: 'text', label: 'Odpověď', type: 'string', required: true },
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
{ id: 'finishReason', label: 'Důvod ukončení', type: 'string', required: false },
{ id: 'promptTokens', label: 'Tokeny na vstupu', type: 'number', required: false },
{ id: 'completionTokens', label: 'Tokeny na výstupu', type: 'number', required: false },
{ id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false },
],
// Delsi odpoved trva. Vychozich 15 s je na generovani textu malo.
timeoutMs: 60000,
};
export async function run(inputs, ctx) {
const { pick, text, num, need } = ctx.util;
const messages = [];
if (inputs.instructions) messages.push({ role: 'system', content: inputs.instructions });
messages.push({ role: 'user', content: inputs.prompt });
const body = { model: inputs.model, messages };
// Nevyplnene se neposila. Novejsi modely `temperature` odmitaji uplne,
// takze poslat vychozi hodnotu by krok rozbilo tam, kde o ni nikdo nestal.
if (inputs.temperature !== null) body.temperature = inputs.temperature;
if (inputs.maxTokens !== null) body.max_completion_tokens = inputs.maxTokens;
if (inputs.jsonOutput) body.response_format = { type: 'json_object' };
const { body: answer } = await ctx.http.post('/chat/completions', body);
const choice = ctx.util.first(pick(answer, 'choices'));
if (!choice) ctx.fail('OpenAI nevrátilo žádnou odpověď.', answer);
const usage = pick(answer, 'usage') ?? {};
return {
text: need(text(pick(pick(choice, 'message'), 'content')), 'odpověď modelu'),
model: need(text(pick(answer, 'model')), 'název modelu'),
finishReason: text(pick(choice, 'finish_reason')),
promptTokens: num(pick(usage, 'prompt_tokens')),
completionTokens: num(pick(usage, 'completion_tokens')),
totalTokens: num(pick(usage, 'total_tokens')),
};
}
+50
View File
@@ -0,0 +1,50 @@
/**
* OpenAI: seznam modelu, na ktere ucet dosahne.
*
* Sluzba: https://api.openai.com/v1
* Endpoint: GET /models
*
* Proc to tu je: v kroku `openai.chat` je model volny text, aby seznam
* v manifestu nezestarl. Tohle je zpusob, jak zjistit, co do nej napsat,
* aniz by clovek odchazel z portalu.
*/
export const manifest = {
id: 'openai.list-models',
name: 'Načíst seznam modelů',
description:
'Vrátí modely, na které API klíč dosáhne. Nic nemění a nic nestojí. ' +
'Použije se ke zjištění, co napsat do pole Model.',
inputs: [
{
id: 'contains',
label: 'Obsahuje text',
type: 'string',
required: false,
hint: 'Zúží seznam, například gpt nebo whisper. Bez vyplnění se vrátí vše.',
},
],
outputs: [
{ id: 'count', label: 'Počet modelů', type: 'number', required: true },
{ id: 'models', label: 'Seznam modelů', type: 'list', required: true },
{ id: 'first', label: 'První model', type: 'string', required: false },
],
};
export async function run(inputs, ctx) {
const { pick, text } = ctx.util;
const { body } = await ctx.http.get('/models');
const items = pick(body, 'data');
if (!Array.isArray(items)) ctx.fail('OpenAI nevrátilo seznam modelů.', body);
const needle = (inputs.contains ?? '').toLowerCase();
const models = items
.map((item) => text(pick(item, 'id')))
.filter((id) => id !== null && (needle === '' || id.toLowerCase().includes(needle)))
.sort((a, b) => a.localeCompare(b));
return { count: models.length, models, first: models[0] ?? null };
}
+111
View File
@@ -0,0 +1,111 @@
/**
* OpenAI: prepis nahravky na text.
*
* Sluzba: https://api.openai.com/v1
* Endpoint: POST /audio/transcriptions (multipart/form-data)
*
* Rozdil proti sluzbe Prepis hovoru: tam bezi dva enginy naraz a jejich
* vysledky se slucuji, coz je presnejsi a drazsi. Tohle je jeden pruchod
* pod vlastnim klicem, kdyz staci obycejny prepis.
*/
export const manifest = {
id: 'openai.transcribe-audio',
name: 'Přepsat zvuk',
description: 'Přepíše nahrávku na text jedním přepisovacím modelem OpenAI.',
inputs: [
{
id: 'fileName',
label: 'Název nahrávky',
type: 'string',
required: true,
hint: 'Včetně přípony, například hovor.mp3. Podle ní se pozná formát.',
},
{
id: 'contentBase64',
label: 'Nahrávka (Base64)',
type: 'string',
required: true,
multiline: true,
hint: 'Obsah zakódovaný v Base64. Formáty mp3, mp4, m4a, wav, webm, ogg a flac.',
},
{
id: 'model',
label: 'Model',
type: 'string',
required: false,
default: 'gpt-4o-mini-transcribe',
hint: 'Například gpt-4o-mini-transcribe, gpt-4o-transcribe nebo whisper-1.',
},
{
id: 'language',
label: 'Jazyk nahrávky',
type: 'string',
required: false,
pattern: '^[a-zA-Z]{2}$',
hint: 'Dvoupísmenný kód, například cs. Vyplněný jazyk přepis zpřesní i zrychlí.',
},
{
id: 'prompt',
label: 'Nápověda k přepisu',
type: 'string',
required: false,
multiline: true,
hint: 'Vlastní jména, zkratky a názvy produktů, které se v nahrávce objeví.',
},
],
outputs: [
{ id: 'text', label: 'Přepis', type: 'string', required: true },
{ id: 'model', label: 'Použitý model', type: 'string', required: true },
{ id: 'length', label: 'Délka přepisu ve znacích', type: 'number', required: true },
],
// Prepis je nejpomalejsi operace v katalogu, delsi zaznam trva minuty.
timeoutMs: 120000,
};
/** Typ obsahu podle pripony. Zaroven seznam toho, co OpenAI prijme. */
const byExtension = {
mp3: 'audio/mpeg',
mpga: 'audio/mpeg',
mpeg: 'audio/mpeg',
mp4: 'audio/mp4',
m4a: 'audio/mp4',
wav: 'audio/wav',
webm: 'audio/webm',
ogg: 'audio/ogg',
oga: 'audio/ogg',
flac: 'audio/flac',
};
export async function run(inputs, ctx) {
const { pick, text, need } = ctx.util;
const parts = inputs.fileName.split('.');
const extension = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
const contentType = byExtension[extension];
if (!contentType) {
ctx.fail(
`Přípona "${extension}" není podporovaný formát zvuku. ` +
'Použijte mp3, mp4, m4a, wav, webm, ogg nebo flac.',
);
}
const { body } = await ctx.http.postForm('/audio/transcriptions', {
model: inputs.model,
language: inputs.language,
prompt: inputs.prompt,
response_format: 'json',
file: {
filename: inputs.fileName,
base64: inputs.contentBase64.trim(),
contentType,
},
});
const transcript = need(text(pick(body, 'text')), 'přepis');
return { text: transcript, model: inputs.model, length: transcript.length };
}
+115
View File
@@ -0,0 +1,115 @@
/**
* OpenAI: nahrani souboru.
*
* Sluzba: https://api.openai.com/v1
* Endpoint: POST /files (multipart/form-data)
*
* Vzor **odeslani souboru**. Obsah prichazi jako Base64, protoze parametr kroku
* je vzdy hodnota zapsatelna do JSONu - binarni data by se do stromu ani do
* zaznamu behu jinak nevesla. Prevod na bajty a slozeni multipart tela dela
* `ctx.http.postForm`, skript o hranicich ani o hlavickach nevi.
*
* Vraci ID souboru. Otazku nad nim polozi `openai.ask-about-file`.
*/
export const manifest = {
id: 'openai.upload-file',
name: 'Nahrát soubor',
description:
'Odešle soubor do OpenAI a vrátí jeho ID. To se pak použije jako vstup ' +
'operace Zeptat se na soubor.',
inputs: [
{
id: 'fileName',
label: 'Název souboru',
type: 'string',
required: true,
hint: 'Včetně přípony, například smlouva.pdf. Podle ní se pozná typ obsahu.',
},
{
id: 'contentBase64',
label: 'Obsah souboru (Base64)',
type: 'string',
required: true,
multiline: true,
hint: 'Obsah zakódovaný v Base64. Bez předpony data:.',
},
{
id: 'contentType',
label: 'Typ obsahu',
type: 'string',
required: false,
hint: 'Například application/pdf. Bez vyplnění se odvodí z přípony.',
},
{
id: 'purpose',
label: 'K čemu soubor je',
type: 'string',
required: false,
default: 'user_data',
options: [
{ value: 'user_data', label: 'Podklad k dotazu' },
{ value: 'assistants', label: 'Asistenti a vyhledávání v souborech' },
{ value: 'vision', label: 'Obrázek k analýze' },
{ value: 'batch', label: 'Dávkové zpracování' },
],
hint: 'OpenAI podle toho rozhoduje, kde se soubor smí použít.',
},
],
outputs: [
{ id: 'fileId', label: 'ID souboru', type: 'string', required: true },
{ id: 'fileName', label: 'Název souboru', type: 'string', required: true },
{ id: 'bytes', label: 'Velikost v bajtech', type: 'number', required: false },
{ id: 'purpose', label: 'K čemu soubor je', type: 'string', required: false },
],
timeoutMs: 60000,
};
/** Typ obsahu podle pripony. Jen to, co se v automatizaci opravdu posila. */
const byExtension = {
pdf: 'application/pdf',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
json: 'application/json',
html: 'text/html',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
};
export async function run(inputs, ctx) {
const { pick, text, num, need } = ctx.util;
const extension = inputs.fileName.split('.').pop()?.toLowerCase() ?? '';
const contentType = inputs.contentType ?? byExtension[extension] ?? 'application/octet-stream';
// Prazdny obsah se pozna tady, ne az podle nicnerikajici chyby od OpenAI.
if (inputs.contentBase64.trim() === '') ctx.fail('Obsah souboru je prázdný.');
const { body } = await ctx.http.postForm('/files', {
purpose: inputs.purpose,
file: {
filename: inputs.fileName,
base64: inputs.contentBase64.trim(),
contentType,
},
});
ctx.log(`Nahráno ${inputs.fileName} jako ${contentType}.`);
return {
fileId: need(text(pick(body, 'id')), 'ID souboru'),
fileName: need(text(pick(body, 'filename')) ?? inputs.fileName, 'název souboru'),
bytes: num(pick(body, 'bytes')),
purpose: text(pick(body, 'purpose')),
};
}
+152
View File
@@ -0,0 +1,152 @@
/**
* PPL CPL: zalozeni zasilky.
*
* Sluzba: https://services.csbot.cz/apps/pplcplapi
* Endpoint: POST /shipments/create-and-wait
*
* CPL zpracovava zasilky **davkove**: zalozi se batch, pak se ceka na jeho
* zpracovani a teprve pak je znamé cislo zasilky. Sluzba na to ma synchronni
* obalku, ktera cely tok provede za nas - proto se vola ona a ne
* `/shipments/batch`. Krok stromu, ktery by musel sam pollovat, by potreboval
* runtime s cekanim, a ten zatim neni.
*
* Idempotence: hlavicku `Idempotency-Key` doplnuje runtime a je stabilni pres
* vsechny pokusy tehoz kroku, takze druhy pokus po timeoutu neposle druhy balik.
*/
export const manifest = {
id: 'ppl.create-shipment',
name: 'Vytvořit zásilku',
description:
'Založí zásilku v PPL a počká na její zpracování. Vrací číslo zásilky ' +
'a volitelně i etiketu k tisku.',
inputs: [
{
id: 'referenceId',
label: 'Reference',
type: 'string',
required: true,
hint: 'Vaše číslo, podle kterého zásilku poznáte. Typicky číslo objednávky.',
},
{
id: 'productType',
label: 'Produkt',
type: 'string',
required: false,
default: 'BUSS',
hint: 'Kód produktu PPL, například BUSS (Business) nebo PRIV (Private). Číselník vrací /codelists/product.',
},
{ id: 'recipientName', label: 'Příjemce', type: 'string', required: true },
{ id: 'recipientStreet', label: 'Ulice a číslo', type: 'string', required: true },
{ id: 'recipientCity', label: 'Město', type: 'string', required: true },
{
id: 'recipientZip',
label: 'PSČ',
type: 'string',
required: true,
hint: 'Bez mezer, například 60200.',
},
{
id: 'recipientCountry',
label: 'Země',
type: 'string',
required: false,
default: 'CZ',
hint: 'Dvoupísmenný kód země.',
},
{ id: 'recipientPhone', label: 'Telefon příjemce', type: 'string', required: false },
{ id: 'recipientEmail', label: 'E-mail příjemce', type: 'string', required: false },
{
id: 'note',
label: 'Poznámka',
type: 'string',
required: false,
hint: 'Text pro kurýra. Diakritiku odstraní služba sama, CPL ji nepřijímá.',
},
{
id: 'withLabel',
label: 'Vrátit i etiketu',
type: 'boolean',
required: false,
default: false,
hint: 'Etiketa je binární PDF, takže se vrací jen odkaz na ni, ne obsah.',
},
],
outputs: [
{ id: 'shipmentNumber', label: 'Číslo zásilky', type: 'string', required: false },
{ id: 'batchId', label: 'ID dávky', type: 'string', required: false },
{ id: 'completed', label: 'Zpracováno', type: 'boolean', required: true },
{ id: 'result', label: 'Celá odpověď PPL', type: 'object', required: true },
],
// Sluzba uvnitr ceka na zpracovani davky. Kratsi limit by utnul i uspech.
timeoutMs: 90000,
};
/**
* Cislo zasilky z odpovedi.
*
* CPL ho vraci na ruznych mistech podle toho, kolik zasilek davka mela.
* Hledani do hloubky je levnejsi nez tri varianty `pick` za sebou, ktere by
* stejne nepokryly dalsi tvar.
*/
function findShipmentNumber(value, depth = 0) {
if (value === null || typeof value !== 'object' || depth > 5) return null;
if (!Array.isArray(value)) {
for (const [key, item] of Object.entries(value)) {
if (/^shipmentnumber$/i.test(key) && typeof item === 'string' && item !== '') return item;
}
}
for (const item of Array.isArray(value) ? value : Object.values(value)) {
const found = findShipmentNumber(item, depth + 1);
if (found !== null) return found;
}
return null;
}
export async function run(inputs, ctx) {
const { pick, text, bool } = ctx.util;
const recipient = {
name: inputs.recipientName,
street: inputs.recipientStreet,
city: inputs.recipientCity,
zipCode: inputs.recipientZip.replace(/\s+/g, ''),
country: inputs.recipientCountry,
};
if (inputs.recipientPhone) recipient.phone = inputs.recipientPhone;
if (inputs.recipientEmail) recipient.email = inputs.recipientEmail;
const shipment = {
referenceId: inputs.referenceId,
productType: inputs.productType,
recipient,
};
if (inputs.note) shipment.note = inputs.note;
const { body } = await ctx.http.post(
'/shipments/create-and-wait',
{ shipments: [shipment] },
{ query: { include_labels: inputs.withLabel } },
);
const completed = bool(pick(body, 'completed'));
const shipmentNumber = findShipmentNumber(body);
if (!completed) {
// Neni to chyba: davka bezi dal a stav se da dotahnout pres batchId.
// Vyhodit chybu by znamenalo, ze uz zalozena zasilka vypadne ze stromu.
ctx.log('PPL dávku ještě nedokončil, stav zjistíte podle ID dávky.');
}
return {
shipmentNumber,
batchId: text(pick(body, 'batchId', 'batch_id')),
completed,
result: body,
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* PPL CPL: stav zasilky.
*
* Sluzba: https://services.csbot.cz/apps/pplcplapi
* Endpoint: GET /shipments?shipmentNumbers=...
*
* Vzor **cteciho kroku, u ktereho nenalezeno neni chyba**. Zasilka, kterou PPL
* jeste nenacetl, je bezny stav prvnich minut po zalozeni, ne selhani.
*/
export const manifest = {
id: 'ppl.track',
name: 'Zjistit stav zásilky',
description: 'Vrátí aktuální stav zásilky a historii jejích událostí. Nic nemění.',
inputs: [
{
id: 'shipmentNumber',
label: 'Číslo zásilky',
type: 'string',
required: false,
hint: 'Vrací ho krok Vytvořit zásilku. Vyplňte tohle, nebo referenci.',
},
{
id: 'reference',
label: 'Reference',
type: 'string',
required: false,
hint: 'Vaše číslo zadané při zakládání, typicky číslo objednávky.',
},
],
outputs: [
{ id: 'found', label: 'Zásilka nalezena', type: 'boolean', required: true },
{ id: 'shipmentNumber', label: 'Číslo zásilky', type: 'string', required: false },
{ id: 'state', label: 'Stav', type: 'string', required: false },
{ id: 'stateDate', label: 'Datum stavu', type: 'string', required: false },
{ id: 'delivered', label: 'Doručeno', type: 'boolean', required: true },
{ id: 'shipment', label: 'Celý záznam zásilky', type: 'object', required: false },
],
};
const notFound = {
found: false,
shipmentNumber: null,
state: null,
stateDate: null,
delivered: false,
shipment: null,
};
export async function run(inputs, ctx) {
const { pick, first, text } = ctx.util;
if (!inputs.shipmentNumber && !inputs.reference) {
ctx.fail('Vyplňte číslo zásilky nebo referenci, jinak není co hledat.');
}
const { body } = await ctx.http.get('/shipments', {
query: {
shipmentNumbers: inputs.shipmentNumber ?? undefined,
customerReferences: inputs.reference ?? undefined,
limit: 5,
},
});
const items = Array.isArray(body) ? body : pick(body, 'shipments', 'data', 'items');
const shipment = first(Array.isArray(items) ? items : []);
if (!shipment) {
ctx.log('PPL o zásilce zatím nic neví. Krátce po založení je to běžné.');
return notFound;
}
// Posledni udalost je aktualni stav. PPL vraci historii vzestupne.
const events = pick(shipment, 'shipmentEvents', 'events', 'states');
const last = Array.isArray(events) && events.length > 0 ? events[events.length - 1] : null;
const state = text(
pick(last, 'code', 'name', 'statusCode') ?? pick(shipment, 'statusCode', 'state'),
);
const stateDate = text(pick(last, 'date', 'eventDate') ?? pick(shipment, 'statusDate'));
return {
found: true,
shipmentNumber: text(pick(shipment, 'shipmentNumber')),
state,
stateDate,
// Kod doruceno je u PPL DELIVERED. Porovnava se bez ohledu na velikost pismen.
delivered: (state ?? '').toUpperCase().includes('DELIVER'),
shipment,
};
}
+91
View File
@@ -0,0 +1,91 @@
/**
* RAYNET CRM: zalozeni poptavky (lead).
*
* Sluzba: https://services.csbot.cz/apps/raynet
* Endpoint: POST /lead
*
* Navazuje na `raynet.find-company`: kdyz firma existuje, poptavka se na ni
* naveze, jinak vznikne bez vazby a doplni ji obchodnik. Zakladat firmu tady
* zamerne neumime - to je rozhodnuti, ktere ma udelat clovek.
*/
export const manifest = {
id: 'raynet.create-lead',
name: 'Založit poptávku',
description:
'Vytvoří v RAYNETu novou poptávku. Volitelně ji naváže na už dohledanou firmu ' +
'a kontaktní osobu.',
inputs: [
{
id: 'topic',
label: 'Předmět poptávky',
type: 'string',
required: true,
hint: 'To, co obchodník uvidí v seznamu. Například: Poptávka z webu, {{subject}}.',
},
{
id: 'description',
label: 'Popis',
type: 'string',
required: false,
multiline: true,
hint: 'Celý text poptávky, typicky obsah e-mailu nebo formuláře.',
},
{
id: 'companyId',
label: 'ID firmy',
type: 'number',
required: false,
hint: 'Vrací ho krok Dohledat firmu.',
},
{
id: 'personId',
label: 'ID kontaktní osoby',
type: 'number',
required: false,
hint: 'Vrací ho krok Založit nebo aktualizovat kontakt.',
},
{
id: 'ownerId',
label: 'ID vlastníka',
type: 'number',
required: false,
hint: 'Uživatel RAYNETu, kterému poptávka připadne. Bez něj rozhodne RAYNET.',
},
{
id: 'priority',
label: 'Priorita',
type: 'string',
required: false,
options: [
{ value: 'LOW', label: 'Nízká' },
{ value: 'NORMAL', label: 'Normální' },
{ value: 'HIGH', label: 'Vysoká' },
],
},
],
outputs: [
{ id: 'leadId', label: 'ID poptávky', type: 'number', required: true },
{ id: 'topic', label: 'Předmět poptávky', type: 'string', required: true },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, num, need } = ctx.util;
const payload = { topic: inputs.topic };
if (inputs.description) payload.description = inputs.description;
if (inputs.companyId !== null) payload.company = { id: inputs.companyId };
if (inputs.personId !== null) payload.person = { id: inputs.personId };
if (inputs.ownerId !== null) payload.owner = { id: inputs.ownerId };
if (inputs.priority) payload.priority = inputs.priority;
const { body } = await ctx.http.post('/lead', payload);
const id = need(num(pick(unwrap(body), 'id')), 'ID poptávky');
ctx.log(`Založena poptávka ${id}.`);
return { leadId: id, topic: inputs.topic };
}
+106
View File
@@ -0,0 +1,106 @@
/**
* RAYNET CRM: dohledani firmy.
*
* Sluzba: https://services.csbot.cz/apps/raynet
* Endpoint: GET /company?fulltext=...
*
* Vzor **predvalidace**: nenalezeno tady neni chyba. Prave podle toho se strom
* vetvi na "znameho zakaznika" a "noveho zajemce", takze chyba by rozhodnuti
* zahodila misto aby ho umoznila.
*/
export const manifest = {
id: 'raynet.find-company',
name: 'Dohledat firmu',
description:
'Zjistí, jestli je odesílatel v CRM. Hledá podle IČO, e-mailu nebo názvu. ' +
'Nic nezakládá, výsledek slouží k větvení stromu.',
inputs: [
{
id: 'regNumber',
label: 'IČO',
type: 'string',
required: false,
pattern: '^[0-9]{6,12}$',
hint: 'Jen číslice. Nejpřesnější, hledá se podle něj první.',
},
{
id: 'email',
label: 'E-mail',
type: 'string',
required: false,
hint: 'Například {{from}} u e-mailu.',
},
{
id: 'name',
label: 'Název firmy',
type: 'string',
required: false,
hint: 'Použije se jako poslední, název se píše pokaždé jinak.',
},
],
outputs: [
{ id: 'customerKnown', label: 'Firma nalezena', type: 'boolean', required: true },
{ id: 'companyId', label: 'ID firmy', type: 'number', required: false },
{ id: 'companyName', label: 'Název firmy', type: 'string', required: false },
{ id: 'regNumber', label: 'IČO', type: 'string', required: false },
{ id: 'ownerName', label: 'Vlastník záznamu', type: 'string', required: false },
{ id: 'matchedBy', label: 'Podle čeho se našla', type: 'string', required: true },
],
};
const notFound = {
customerKnown: false,
companyId: null,
companyName: null,
regNumber: null,
ownerName: null,
matchedBy: 'nenalezeno',
};
export async function run(inputs, ctx) {
const { unwrap, pick, first, text, num } = ctx.util;
if (!inputs.regNumber && !inputs.email && !inputs.name) {
ctx.fail('Vyplňte IČO, e-mail nebo název firmy, jinak není podle čeho hledat.');
}
/** Jedno hledani. RAYNET ma na vsechno jeden fulltext, filtr podle pole nema. */
async function search(value) {
const { body } = await ctx.http.get('/company', { query: { fulltext: value, limit: 5 } });
const items = unwrap(body);
return Array.isArray(items) ? items : [];
}
// Poradi je zamer: ICO je jednoznacne, e-mail muze mit vic firem stejny
// a nazev se v kazdem systemu pise jinak.
const attempts = [];
if (inputs.regNumber) attempts.push([inputs.regNumber, 'IČO']);
if (inputs.email) attempts.push([inputs.email, 'e-mail']);
if (inputs.name) attempts.push([inputs.name, 'název']);
for (const [value, label] of attempts) {
const items = await search(value);
const company = first(items);
if (!company) {
ctx.log(`Podle ${label} se nic nenašlo.`);
continue;
}
if (items.length > 1) {
ctx.log(`Podle ${label} vyšlo ${items.length} záznamů, bere se první.`);
}
return {
customerKnown: true,
companyId: num(pick(company, 'id')),
companyName: text(pick(company, 'name', 'companyName')),
regNumber: text(pick(company, 'regNumber', 'identificationNumber')),
ownerName: text(pick(pick(company, 'owner'), 'fullName', 'name')),
matchedBy: label,
};
}
return notFound;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* RAYNET CRM: zalozeni nebo doplneni kontaktni osoby.
*
* Sluzba: https://services.csbot.cz/apps/raynet
* Endpointy: GET /person?fulltext=..., POST /person, PUT /person/{id}
*
* Vzor **upsert**: nejdriv se hleda, pak se rozhoduje. Zakladat naslepo by
* po tydnu znamenalo tri kontakty s tymz e-mailem a obchodnik by nevedel,
* do ktereho psat.
*/
export const manifest = {
id: 'raynet.upsert-contact',
name: 'Založit nebo aktualizovat kontakt',
description:
'Podle e-mailu kontakt najde a doplní chybějící údaje, jinak založí nový. ' +
'Vrací ID kontaktu a to, jestli teprve vznikl.',
inputs: [
{
id: 'email',
label: 'E-mail',
type: 'string',
required: true,
hint: 'Podle něj se pozná, jestli kontakt už existuje.',
},
{ id: 'firstName', label: 'Jméno', type: 'string', required: false },
{
id: 'lastName',
label: 'Příjmení',
type: 'string',
required: false,
hint: 'Bez příjmení se u nového kontaktu dosadí část e-mailu před zavináčem.',
},
{ id: 'phone', label: 'Telefon', type: 'string', required: false },
{
id: 'companyId',
label: 'ID firmy',
type: 'number',
required: false,
hint: 'Vrací ho krok Dohledat firmu. Bez něj kontakt zůstane bez vazby.',
},
],
outputs: [
{ id: 'contactId', label: 'ID kontaktu', type: 'number', required: true },
{ id: 'created', label: 'Kontakt teprve vznikl', type: 'boolean', required: true },
{ id: 'fullName', label: 'Jméno kontaktu', type: 'string', required: false },
],
};
export async function run(inputs, ctx) {
const { unwrap, pick, first, text, num, need } = ctx.util;
const { body: found } = await ctx.http.get('/person', {
query: { fulltext: inputs.email, limit: 5 },
});
const existing = first(unwrap(found));
const contactInfo = {};
if (inputs.email) contactInfo.email = inputs.email;
if (inputs.phone) contactInfo.tel1 = inputs.phone;
if (existing) {
const id = need(num(pick(existing, 'id')), 'ID kontaktu');
// Prepisuji se jen vyplnena pole. Prazdna hodnota v kroku stromu znamena
// "nevim", ne "smaz" - jinak by jeden beh bez telefonu telefon vymazal.
const patch = {};
if (inputs.firstName) patch.firstName = inputs.firstName;
if (inputs.lastName) patch.lastName = inputs.lastName;
if (Object.keys(contactInfo).length > 0) patch.contactInfo = contactInfo;
if (inputs.companyId !== null) patch.company = { id: inputs.companyId };
if (Object.keys(patch).length > 0) {
await ctx.http.put(`/person/${id}`, patch);
ctx.log(`Kontakt ${id} doplněn.`);
} else {
ctx.log(`Kontakt ${id} už má vše, nic se nemění.`);
}
return {
contactId: id,
created: false,
fullName: text(pick(existing, 'fullName', 'lastName')),
};
}
// Prijmeni je v RAYNETu povinne. Kdyz nedoslo, vezme se cast e-mailu -
// kontakt bez jmena se v seznamu neda najit a nikdo ho uz neopravi.
const lastName = inputs.lastName ?? inputs.email.split('@')[0];
const { body: created } = await ctx.http.post('/person', {
...(inputs.firstName ? { firstName: inputs.firstName } : {}),
lastName,
contactInfo,
...(inputs.companyId !== null ? { company: { id: inputs.companyId } } : {}),
});
const id = need(num(pick(unwrap(created), 'id')), 'ID nového kontaktu');
ctx.log(`Založen kontakt ${id}.`);
return {
contactId: id,
created: true,
fullName: [inputs.firstName, lastName].filter(Boolean).join(' '),
};
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SAP Business One: dohledani obchodniho partnera.
*
* Sluzba: https://services.csbot.cz/apps/sap-bo
* Endpoint: GET /api/business-partners?$filter=...
*
* Vzor **predvalidace nad OData**. Service Layer filtruje jazykem OData, kde
* se retezce uvozuji apostrofem - a apostrof v nazvu firmy by dotaz rozbil,
* takze se zdvojuje. Nenalezeno neni chyba: podle toho se strom vetvi na
* zalozeni noveho partnera.
*/
export const manifest = {
id: 'sap-bo.find-business-partner',
name: 'Najít obchodního partnera',
description:
'Dohledá partnera v SAP Business One podle kódu, IČO nebo názvu. ' +
'Nic nezakládá, výsledek slouží k větvení stromu.',
inputs: [
{
id: 'cardCode',
label: 'Kód partnera',
type: 'string',
required: false,
hint: 'Například C00123. Nejpřesnější, hledá se podle něj první.',
},
{
id: 'taxId',
label: 'IČO',
type: 'string',
required: false,
hint: 'Porovnává se s polem FederalTaxID.',
},
{
id: 'name',
label: 'Název',
type: 'string',
required: false,
hint: 'Stačí část názvu, hledá se podřetězec.',
},
],
outputs: [
{ id: 'found', label: 'Partner nalezen', type: 'boolean', required: true },
{ id: 'cardCode', label: 'Kód partnera', type: 'string', required: false },
{ id: 'cardName', label: 'Název partnera', type: 'string', required: false },
{ id: 'taxId', label: 'IČO', type: 'string', required: false },
{ id: 'currency', label: 'Měna', type: 'string', required: false },
{ id: 'matchedBy', label: 'Podle čeho se našel', type: 'string', required: true },
],
timeoutMs: 30000,
};
const notFound = {
found: false,
cardCode: null,
cardName: null,
taxId: null,
currency: null,
matchedBy: 'nenalezeno',
};
export async function run(inputs, ctx) {
const { pick, text } = ctx.util;
if (!inputs.cardCode && !inputs.taxId && !inputs.name) {
ctx.fail('Vyplňte kód partnera, IČO nebo název, jinak není podle čeho hledat.');
}
// Poradi je zamer: kod je klic, ICO je jednoznacne, nazev se pise pokazde jinak.
const attempts = [];
if (inputs.cardCode) attempts.push([`CardCode eq ${ctx.util.quote(inputs.cardCode)}`, 'kód']);
if (inputs.taxId) attempts.push([`FederalTaxID eq ${ctx.util.quote(inputs.taxId)}`, 'IČO']);
if (inputs.name) attempts.push([`contains(CardName,${ctx.util.quote(inputs.name)})`, 'název']);
for (const [filter, label] of attempts) {
const { body } = await ctx.http.get('/api/business-partners', {
query: {
$filter: filter,
$select: 'CardCode,CardName,FederalTaxID,Currency,CardType',
$top: 5,
},
});
const raw = pick(body, 'value', 'data', 'items');
const items = Array.isArray(raw) ? raw : Array.isArray(body) ? body : [];
if (items.length === 0) {
ctx.log(`Podle ${label} se nic nenašlo.`);
continue;
}
if (items.length > 1) ctx.log(`Podle ${label} vyšlo ${items.length} partnerů, bere se první.`);
const partner = items[0];
return {
found: true,
cardCode: text(pick(partner, 'CardCode')),
cardName: text(pick(partner, 'CardName')),
taxId: text(pick(partner, 'FederalTaxID')),
currency: text(pick(partner, 'Currency')),
matchedBy: label,
};
}
return notFound;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* SAP Business One: nacteni prodejnich objednavek.
*
* Sluzba: https://services.csbot.cz/apps/sap-bo
* Endpoint: GET /api/orders?$filter=...
*
* Filtr se sklada z toho, co je vyplnene. Poslat prazdny filtr by znamenalo
* stahnout cely archiv objednavek - u SAPu jsou to desetitisice radku, ktere
* by krok stromu stejne neunesl.
*/
export const manifest = {
id: 'sap-bo.list-orders',
name: 'Načíst objednávky',
description: 'Vrátí prodejní objednávky partnera nebo za období. Nic nemění, jen čte.',
inputs: [
{
id: 'cardCode',
label: 'Kód partnera',
type: 'string',
required: false,
hint: 'Vrací ho krok Najít obchodního partnera.',
},
{ id: 'dateFrom', label: 'Od data', type: 'date', required: false },
{ id: 'dateTo', label: 'Do data', type: 'date', required: false },
{
id: 'openOnly',
label: 'Jen otevřené',
type: 'boolean',
required: false,
default: true,
hint: 'Vypnuté vrátí i uzavřené a stornované objednávky.',
},
{
id: 'limit',
label: 'Nejvýše kolik objednávek',
type: 'number',
required: false,
default: 50,
},
],
outputs: [
{ id: 'count', label: 'Počet objednávek', type: 'number', required: true },
{ id: 'orders', label: 'Objednávky', type: 'list', required: true },
{ id: 'total', label: 'Součet částek', type: 'number', required: true },
],
timeoutMs: 30000,
};
export async function run(inputs, ctx) {
const { pick, num, text, round, quote } = ctx.util;
// Apostrof v OData filtru se zdvojuje, to dela `quote`.
const conditions = [];
if (inputs.cardCode) conditions.push(`CardCode eq ${quote(inputs.cardCode)}`);
if (inputs.dateFrom) conditions.push(`DocDate ge ${quote(inputs.dateFrom.slice(0, 10))}`);
if (inputs.dateTo) conditions.push(`DocDate le ${quote(inputs.dateTo.slice(0, 10))}`);
if (inputs.openOnly) conditions.push("DocumentStatus eq 'bost_Open'");
if (conditions.length === 0) {
ctx.fail('Vyplňte partnera nebo období, jinak by se stahoval celý archiv objednávek.');
}
const { body } = await ctx.http.get('/api/orders', {
query: {
$filter: conditions.join(' and '),
$select:
'DocEntry,DocNum,CardCode,CardName,DocDate,DocDueDate,DocTotal,DocCurrency,DocumentStatus',
$orderby: 'DocDate desc',
$top: inputs.limit,
},
});
const raw = pick(body, 'value', 'data', 'items');
const items = Array.isArray(raw) ? raw : Array.isArray(body) ? body : [];
let total = 0;
const orders = items.map((order) => {
const amount = num(pick(order, 'DocTotal'), 0);
total += amount;
return {
docEntry: num(pick(order, 'DocEntry')),
docNum: num(pick(order, 'DocNum')),
cardCode: text(pick(order, 'CardCode')),
cardName: text(pick(order, 'CardName')),
date: text(pick(order, 'DocDate')),
dueDate: text(pick(order, 'DocDueDate')),
total: amount,
currency: text(pick(order, 'DocCurrency')),
status: text(pick(order, 'DocumentStatus')),
};
});
return { count: orders.length, orders, total: round(total, 2) };
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Google Search Console: vykon ve vyhledavani.
*
* Sluzba: https://services.csbot.cz/apps/analytics
* Endpoint: POST /gsc/searchAnalytics/query?siteUrl=...
*
* Search Console vraci dimenze jako pole `keys` bez nazvu - poradi odpovida
* tomu, co jsme si vyzadali. Skript je proto pojmenuje zpatky, jinak by ve
* strome zbyly hodnoty, u kterych nikdo nevi, co znamenaji.
*/
export const manifest = {
id: 'search-console.run-report',
name: 'Načíst výkon ve vyhledávání',
description: 'Vrátí dotazy, prokliky, zobrazení, CTR a průměrnou pozici za období. Nic nemění.',
inputs: [
{
id: 'siteUrl',
label: 'Web',
type: 'string',
required: true,
hint: 'Buď https://example.com/ (property s prefixem), nebo sc-domain:example.com (doménová).',
},
{
id: 'startDate',
label: 'Od',
type: 'date',
required: true,
hint: 'Search Console má data se zpožděním dvou až tří dnů.',
},
{ id: 'endDate', label: 'Do', type: 'date', required: true },
{
id: 'dimensions',
label: 'Dimenze',
type: 'string',
required: false,
default: 'query',
hint: 'Oddělené čárkou: query, page, country, device, date.',
},
{
id: 'limit',
label: 'Nejvýše kolik řádků',
type: 'number',
required: false,
default: 100,
},
],
outputs: [
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
],
timeoutMs: 45000,
};
export async function run(inputs, ctx) {
const { pick, num, round } = ctx.util;
const dimensions = String(inputs.dimensions ?? '')
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '');
const { body } = await ctx.http.post(
'/gsc/searchAnalytics/query',
{
startDate: inputs.startDate.slice(0, 10),
endDate: inputs.endDate.slice(0, 10),
...(dimensions.length > 0 ? { dimensions } : {}),
rowLimit: inputs.limit,
},
{ query: { siteUrl: inputs.siteUrl } },
);
const raw = pick(body, 'rows');
let clicks = 0;
let impressions = 0;
const rows = (Array.isArray(raw) ? raw : []).map((row) => {
const keys = pick(row, 'keys');
const flat = {};
dimensions.forEach((name, index) => {
flat[name] = Array.isArray(keys) ? (keys[index] ?? null) : null;
});
flat.clicks = num(pick(row, 'clicks'), 0);
flat.impressions = num(pick(row, 'impressions'), 0);
// CTR chodi jako podil, lidi ho ctou v procentech.
flat.ctr = round(num(pick(row, 'ctr'), 0) * 100, 2);
flat.position = round(num(pick(row, 'position'), 0), 1);
clicks += flat.clicks;
impressions += flat.impressions;
return flat;
});
return { count: rows.length, rows, clicks, impressions };
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Sklik: statistiky kampani.
*
* Sluzba: https://services.csbot.cz/apps/analytics
* Endpoint: POST /sklik/report/campaigns
*
* Sklik prijima parametry jako **pole argumentu**, ne jako objekt - je to
* zbytek jeho RPC rozhrani. Prvni prvek je filtr obdobi, druhy nastaveni
* vypisu. Skript to sklada za uzivatele, aby se do kroku stromu nemusel psat
* cizi tvar volani.
*
* Castky jsou u Skliku v halerich. Prepocitavaji se tady, at se v dalsim kroku
* nedeli stem znovu a nekde spatne.
*/
export const manifest = {
id: 'sklik.campaign-report',
name: 'Načíst výkon kampaní',
description: 'Stáhne statistiky kampaní ze Skliku za období. Nic nemění.',
inputs: [
{ id: 'dateFrom', label: 'Od', type: 'date', required: true },
{ id: 'dateTo', label: 'Do', type: 'date', required: true },
{
id: 'granularity',
label: 'Podrobnost',
type: 'string',
required: false,
default: 'total',
options: [
{ value: 'total', label: 'Souhrn za období' },
{ value: 'daily', label: 'Po dnech' },
{ value: 'weekly', label: 'Po týdnech' },
{ value: 'monthly', label: 'Po měsících' },
],
},
],
outputs: [
{ id: 'count', label: 'Počet řádků', type: 'number', required: true },
{ id: 'rows', label: 'Řádky reportu', type: 'list', required: true },
{ id: 'cost', label: 'Útrata celkem', type: 'number', required: true },
{ id: 'clicks', label: 'Prokliky celkem', type: 'number', required: true },
{ id: 'impressions', label: 'Zobrazení celkem', type: 'number', required: true },
],
timeoutMs: 60000,
};
/**
* Statistiky z odpovedi Skliku.
*
* Report chodi jako `report[].stats[]`, souhrn jako `stats[]` a nekdy jen
* jako pole. Vsechny tri tvary vraci tatáž metoda podle podrobnosti, takze
* hadat jeden by znamenalo, ze pri prepnuti na denni report vyjde nula.
*/
function statRows(ctx, body) {
const { pick } = ctx.util;
if (Array.isArray(body)) return body;
const report = pick(body, 'report');
if (Array.isArray(report)) {
return report.flatMap((item) => {
const stats = pick(item, 'stats');
return Array.isArray(stats) ? stats : [item];
});
}
return ctx.util.list(body, 'stats') ?? [];
}
export async function run(inputs, ctx) {
const { pick, num, round } = ctx.util;
const { body } = await ctx.http.post('/sklik/report/campaigns', [
{
dateFrom: inputs.dateFrom.slice(0, 10),
dateTo: inputs.dateTo.slice(0, 10),
statGranularity: inputs.granularity,
},
{ statGranularity: inputs.granularity },
]);
let cost = 0;
let clicks = 0;
let impressions = 0;
const rows = statRows(ctx, body).map((row) => {
// Halere na koruny.
const rowCost = round(num(pick(row, 'money', 'price', 'cost'), 0) / 100, 2);
const rowClicks = num(pick(row, 'clicks'), 0);
const rowImpressions = num(pick(row, 'impressions'), 0);
cost += rowCost;
clicks += rowClicks;
impressions += rowImpressions;
return {
date: pick(row, 'date') ?? null,
campaignId: pick(row, 'campaignId', 'id') ?? null,
campaignName: pick(row, 'name', 'campaignName') ?? null,
impressions: rowImpressions,
clicks: rowClicks,
cost: rowCost,
conversions: num(pick(row, 'conversions'), 0),
};
});
return { count: rows.length, rows, cost: round(cost, 2), clicks, impressions };
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Prepis hovoru: dva enginy naraz a sloucení jejich vysledku.
*
* Sluzba: https://services.csbot.cz/apps/audio-transcription
* Endpoint: POST /dual-with-merge (multipart/form-data)
*
* Vzor **volani, ktere vraci tri verze teze veci**. Sluzba prepise nahravku
* Deepgramem i Whisperem a treti odpoved slozi z obou. Vraci se vsechny tri,
* protoze kdyz jeden engine vypadne, druhy porad neco rekne - a strom se pak
* ma podle ceho rozhodnout, misto aby cely krok spadl.
*/
export const manifest = {
id: 'transcription.transcribe',
name: 'Přepsat nahrávku',
description:
'Přepíše nahrávku Deepgramem i Whisperem a oba přepisy sloučí do jednoho. ' +
'Sloučený výsledek rozlišuje mluvčí a je přesnější než každý engine zvlášť.',
inputs: [
{
id: 'fileName',
label: 'Název nahrávky',
type: 'string',
required: true,
hint: 'Včetně přípony, například hovor.mp3.',
},
{
id: 'contentBase64',
label: 'Nahrávka (Base64)',
type: 'string',
required: true,
multiline: true,
hint: 'Obsah zakódovaný v Base64.',
},
{
id: 'language',
label: 'Jazyk nahrávky',
type: 'string',
required: false,
default: 'cs',
hint: 'ISO kód jazyka, například cs.',
},
{
id: 'diarize',
label: 'Rozlišovat mluvčí',
type: 'boolean',
required: false,
default: true,
hint: 'Zapnuté přidá do přepisu štítky Mluvčí 0, Mluvčí 1.',
},
{
id: 'combinePrompt',
label: 'Instrukce pro sloučení',
type: 'string',
required: false,
multiline: true,
hint: 'Bez vyplnění se použije výchozí instrukce služby, která je psaná pro české hovory.',
},
{
id: 'chatModel',
label: 'Model pro sloučení',
type: 'string',
required: false,
hint: 'Bez vyplnění rozhoduje služba. Slučování dělá OpenAI chat model.',
},
],
outputs: [
{ id: 'text', label: 'Sloučený přepis', type: 'string', required: true },
{ id: 'deepgram', label: 'Přepis z Deepgramu', type: 'string', required: false },
{ id: 'whisper', label: 'Přepis z Whisperu', type: 'string', required: false },
{ id: 'partial', label: 'Část přepisu selhala', type: 'boolean', required: true },
{ id: 'problems', label: 'Co selhalo', type: 'string', required: false },
],
// Dva prepisy a sloucení za sebou. Kratsi limit by utnul i kratkou nahravku.
timeoutMs: 120000,
};
const byExtension = {
mp3: 'audio/mpeg',
mpga: 'audio/mpeg',
mp4: 'audio/mp4',
m4a: 'audio/mp4',
wav: 'audio/wav',
webm: 'audio/webm',
ogg: 'audio/ogg',
flac: 'audio/flac',
};
export async function run(inputs, ctx) {
const { pick, text } = ctx.util;
const parts = inputs.fileName.split('.');
const extension = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
const { body } = await ctx.http.postForm('/dual-with-merge', {
language: inputs.language,
diarize: inputs.diarize,
combine_prompt: inputs.combinePrompt,
chat_model: inputs.chatModel,
file: {
filename: inputs.fileName,
base64: inputs.contentBase64.trim(),
contentType: byExtension[extension] ?? 'application/octet-stream',
},
});
const merged = text(pick(body, 'merge'), '');
const deepgram = text(pick(body, 'deepgram'), '');
const whisper = text(pick(body, 'gpt'), '');
const errors = pick(body, 'errors');
const problems = Array.isArray(errors) && errors.length > 0 ? errors.join(' | ') : null;
if (problems) ctx.log(`Část přepisu selhala: ${problems}`);
// Kdyz sloucení selhalo, prazdny vystup by strom poslal dal jako platny text.
// Radsi se sahne po tom, co prislo - a nahlas se rekne, ze je to nahradnik.
const fallback = deepgram !== '' ? deepgram : whisper;
if (merged === '' && fallback === '') {
ctx.fail('Služba nevrátila žádný přepis.', problems ?? body);
}
if (merged === '') ctx.log('Sloučení nevyšlo, vrací se přepis z jednoho enginu.');
return {
text: merged !== '' ? merged : fallback,
deepgram: deepgram !== '' ? deepgram : null,
whisper: whisper !== '' ? whisper : null,
partial: problems !== null || merged === '',
problems,
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Transformace dat, rezim 1: pole na pole.
*
* Prijde objekt z jedne sluzby a vypadne objekt pro druhou. Kazde pravidlo rika
* "vezmi tuhle cestu ve zdroji, projed temito prevody a uloz to sem".
*
* Typicky pripad: objednavka ze Shoptetu a doklad pro iDoklad maji uplne jina
* jmena poli. Mezi nimi je tenhle krok.
*
* Rezim 2 je `transform.to-json` - tam se napise cely cilovy objekt a dosadi se
* do nej hodnoty. Volba mezi rezimy je o tom, cehoz je vic: kdyz se prevadi
* hodnoty, jsou lepsi pravidla. Kdyz se sklada tvar, je lepsi sablona.
*
* Sluzba je obecna, takze skript nic nevola a nepotrebuje konektor.
*/
export const manifest = {
id: 'transform.map-fields',
name: 'Přemapovat pole',
description:
'Převede objekt na jiný objekt podle pravidel. U každého cílového pole se ' +
'určí cesta ve zdroji a případné převody.',
inputs: [
{
id: 'source',
label: 'Zdrojová data',
type: 'object',
required: true,
hint: 'Objekt z předchozího kroku, například objednávka.',
},
{
id: 'rules',
label: 'Pravidla',
type: 'list',
required: true,
control: 'mapping',
hint: 'Seznam pravidel. V builderu se klikají, pod tím je vidět JSON.',
},
],
outputs: [
{ id: 'result', label: 'Výsledek', type: 'object', required: true },
{ id: 'fieldCount', label: 'Počet vyplněných polí', type: 'number', required: true },
],
};
export async function run(inputs, ctx) {
const rules = inputs.rules;
if (!Array.isArray(rules) || rules.length === 0) {
ctx.fail('Nejsou zadaná žádná pravidla, transformace by nic nevytvořila.');
}
// Mapovaci engine je na kontextu, ne v tomhle souboru. Pouziva ho i rezim 2
// a mel by byt na jednom miste, ne ve dvou skriptech.
const result = ctx.util.applyRules(inputs.source, rules);
const fieldCount = Object.keys(result).length;
ctx.log(`Přemapováno ${fieldCount} polí podle ${rules.length} pravidel.`);
return { result, fieldCount };
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Transformace dat, rezim 2: sablona JSON.
*
* Napise se cely cilovy objekt tak, jak ma vypadat, a na mistech hodnot se
* odkaze na zdroj pres `${cesta}`. Hodi se, kdyz je hlavni prace ve **tvaru**
* cilove struktury - zanoreni, seznamy, pevna pole.
*
* Rezim 1 je `transform.map-fields`. Ten je lepsi, kdyz je hlavni prace
* v prevodech hodnot.
*
* Proc `${cesta}` a ne `{{cesta}}`: sablony kroku se dosazuji driv, nez se krok
* spusti. `{{order.total}}` by tedy stihl vyhodnotit strom, zadny parametr toho
* jmena by nenasel a dosadil by prazdno. Jiny marker to nemuze splest.
*
* Kdyz je hodnota **cely** retezec, zachova se puvodni typ:
* `"total": "${order.total}"` vyrobi cislo, ne text. Bez toho by cizi sluzba
* dostala castku jako retezec a vetsina jich to odmitne.
*/
export const manifest = {
id: 'transform.to-json',
name: 'Poskládat objekt ze šablony',
description:
'Vezme šablonu JSON a dosadí do ní hodnoty ze zdroje přes ${cesta}. ' +
'Hodí se, když je potřeba postavit strukturu se zanořením a seznamy.',
inputs: [
{
id: 'source',
label: 'Zdrojová data',
type: 'object',
required: true,
hint: 'Objekt z předchozího kroku.',
},
{
id: 'template',
label: 'Šablona JSON',
type: 'string',
required: true,
multiline: true,
control: 'json',
hint: 'Cílový objekt. Hodnoty ze zdroje se vkládají jako ${cesta.k.poli}.',
},
],
outputs: [{ id: 'result', label: 'Výsledek', type: 'object', required: true }],
};
export async function run(inputs, ctx) {
let template;
try {
template = JSON.parse(String(inputs.template));
} catch (err) {
// Rozbita sablona je chyba nastaveni, opakovat ji nema smysl.
ctx.fail(`Šablona není platný JSON: ${err instanceof Error ? err.message : String(err)}`);
}
if (template === null || typeof template !== 'object' || Array.isArray(template)) {
ctx.fail('Šablona musí být objekt, tedy začínat složenou závorkou.');
}
const result = ctx.util.fillJson(template, inputs.source);
ctx.log(`Poskládán objekt s ${Object.keys(result).length} polemi.`);
return { result };
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Transformace dat: prevod na XML.
*
* Cizi systemy si XML porad rikaji casto - ISDOC, EDI, banky, statni sluzby.
* Bez tohohle by se muselo sestavovat retezcem v HTTP kroku, a tam se na
* escapovani zapomene pokazde.
*
* Vstupem je **objekt nebo seznam**, ne sablona. Tvar se resi krokem pred tim
* (`transform.map-fields` nebo `transform.to-json`), tady uz jde jen o zapis.
* Jedna vec na krok: jinak by tenhle skript musel umet i premapovani a byly by
* dva zpusoby, jak udelat totez.
*/
export const manifest = {
id: 'transform.to-xml',
name: 'Převést na XML',
description:
'Zapíše objekt nebo seznam jako XML. Tvar dat se řeší předchozím krokem, ' +
'tady jde jen o zápis včetně escapování.',
inputs: [
{
id: 'source',
label: 'Zdrojová data',
type: 'object',
required: true,
hint: 'Objekt z předchozího kroku. Seznam zabalte do objektu, například {"polozky": [...]}.',
},
{
id: 'root',
label: 'Kořenový prvek',
type: 'string',
required: false,
default: 'data',
pattern: '^[A-Za-z_][A-Za-z0-9._-]*$',
},
{
id: 'itemElement',
label: 'Název prvku pro položky seznamu',
type: 'string',
required: false,
default: 'item',
hint: 'XML nezná pole, takže každá položka potřebuje svůj prvek.',
pattern: '^[A-Za-z_][A-Za-z0-9._-]*$',
},
{
id: 'declaration',
label: 'Přidat hlavičku XML',
type: 'boolean',
required: false,
default: true,
hint: 'Řádek s verzí a kódováním. Některé služby ho vyžadují.',
},
{
id: 'indent',
label: 'Odsazovat',
type: 'boolean',
required: false,
default: true,
hint: 'Vypněte u velkých dávek, odsazení jen zvětšuje přenos.',
},
{
id: 'skipEmpty',
label: 'Vynechat prázdné hodnoty',
type: 'boolean',
required: false,
default: false,
hint: 'Zapnuté nezapíše pole, která jsou null nebo prázdná.',
},
],
outputs: [
{ id: 'xml', label: 'XML', type: 'string', required: true },
{ id: 'elements', label: 'Počet zapsaných prvků', type: 'number', required: true },
{ id: 'renamed', label: 'Přejmenované klíče', type: 'string', required: false },
],
};
/** Znaky, ktere v textu meni vyznam. Bez nahrady vznikne nevalidni XML. */
function escapeText(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* Nazev prvku podle pravidel XML.
*
* Klice z ciziho JSONu casto zacinaji cislem nebo maji mezeru (`order number`,
* `2024`). Takovy nazev by udelal nevalidni dokument, ktery protistrana odmitne
* az na svem parseru - tedy pozde. Radsi se prejmenuje a rekne se to nahlas.
*/
function elementName(key, renamed) {
let name = String(key)
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^A-Za-z0-9._-]/g, '-');
/*
* Nazev nesmi zacinat cislici. Odriznout ji nejde: `2024` i `2025` by pak
* byly totez a v dokumentu by se srazily. Podtrzitko je platny prvni znak,
* takze se jen predsadi a klic zustane rozpoznatelny.
*/
if (/^[^A-Za-z_]/.test(name)) name = `_${name}`;
if (name === '' || name === '_') name = 'pole';
if (name !== String(key)) renamed.set(String(key), name);
return name;
}
export async function run(inputs, ctx) {
const renamed = new Map();
let elements = 0;
const nl = inputs.indent ? '\n' : '';
const pad = (depth) => (inputs.indent ? ' '.repeat(depth) : '');
/** Je hodnota prazdna? `0` a `false` prazdne nejsou, ty nesou informaci. */
const empty = (value) =>
value === null || value === undefined || (typeof value === 'string' && value.trim() === '');
function render(key, value, depth) {
if (inputs.skipEmpty && empty(value)) return '';
// Seznam: kazda polozka dostane svuj prvek, obalene do prvku podle klice.
if (Array.isArray(value)) {
const items = value
.map((item) => render(inputs.itemElement, item, depth + 1))
.filter(Boolean)
.join('');
elements += 1;
return `${pad(depth)}<${key}>${nl}${items}${pad(depth)}</${key}>${nl}`;
}
if (value !== null && typeof value === 'object') {
const inner = Object.entries(value)
.map(([childKey, childValue]) =>
render(elementName(childKey, renamed), childValue, depth + 1),
)
.filter(Boolean)
.join('');
elements += 1;
// Prazdny objekt se zapise jako samouzaviraci prvek, ne jako dvojice.
if (inner === '') return `${pad(depth)}<${key} />${nl}`;
return `${pad(depth)}<${key}>${nl}${inner}${pad(depth)}</${key}>${nl}`;
}
elements += 1;
if (value === null || value === undefined) return `${pad(depth)}<${key} />${nl}`;
return `${pad(depth)}<${key}>${escapeText(value)}</${key}>${nl}`;
}
const root = elementName(inputs.root, renamed);
const body = render(root, inputs.source, 0);
const header = inputs.declaration ? `<?xml version="1.0" encoding="UTF-8"?>${nl}` : '';
const xml = (header + body).trimEnd();
if (renamed.size > 0) {
const list = [...renamed.entries()].map(([from, to]) => `${from} -> ${to}`);
ctx.log(`Přejmenováno kvůli pravidlům XML: ${list.join(', ')}`);
}
return {
xml,
elements,
renamed: renamed.size > 0 ? [...renamed.keys()].join(', ') : null,
};
}