Realne sluzby, OpenAI, odesilani e-mailu a helpdesk
Katalog srovnany s tim, co opravdu bezi na services.csbot.cz/apps: trinact sluzeb dostalo pristupove udaje a levne cteci overeni, opravena appId, ktera nikam nevedla (ppl, microsoft365, transcription), a GA4, Search Console, Google Ads i Sklik ted stoji na aplikaci analytics, kazda s vlastnimi udaji. Nove sluzby SAP Business One, Google Workspace a Meta Ads. K tomu 23 skriptu, ktere s nimi opravdu neco delaji. OpenAI jako prvni sluzba, ktera nebezi u nas: Service.baseUrl s absolutni adresou, prepis pres <SLUZBA>_BASE_URL nebo adresu u konektoru, predpona hlavicky u pole udaju (uzivatel vlepi holy klic, Bearer dopise runtime). Dotaz na model, nahrani souboru, otazka nad souborem, prepis zvuku. Skript umi odeslat soubor pres ctx.http.postForm (multipart, obsah Base64). Sluzba E-mail pres SMTP. Neni to skript, ale vnitrni krok - SMTP neni HTTP. Konektor nese schranku firmy, krok ma HTML telo, ve kterem se dosazene hodnoty escapuji (znacky autora sablony jsou zamer, ostre zavorky od zakaznika ne). Overeni konektoru se prihlasi na server a nic neodesle. Helpdesk: Ticket.helpdeskSourceId drzi firmu, ktera pozadavek poslala, vlastnikem zustava ta, ktera ho resi - jinak by ho resitel nemel ve sve fronte. Komu pozadavek pripadne, urcuje Tenant.helpdeskProviderId. Zadavatel vidi jen svoje pozadavky a smi k nim pripsat komentar. Opravy v portalu: - hlasky o ulozisti a odchozi IP vidi jen spravce platformy - typ ticketu se v automatizaci vybira ze seznamu firmy, nebo dosadi z dat - stav ticketu je otevreny naseptavac, ne ciselnik - ticket jde zalozit rucne, zakaznik u nej neni povinny - kanal se prejmenoval a parametry u webhooku jsou oznacene jako nepovinne - srovnane markdown tabulky v cele dokumentaci Co z teto davky jeste neni: prepinac firmy je porad jen stav uvnitr stranky Prehled, takze se prepnuti neprojevi v Lidech ani jinde. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b25a149574
commit
a771834e57
+8
-1
@@ -58,7 +58,8 @@ export const manifest = {
|
||||
* @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 },
|
||||
* http: { get: Function, post: Function, patch: Function, put: Function, del: Function,
|
||||
* postForm: Function },
|
||||
* util: { unwrap: Function, pick: Function, first: Function, text: Function,
|
||||
* num: Function, bool: Function, date: Function, round: Function, need: Function },
|
||||
* log: Function, config: Record<string, string>, idempotencyKey: string,
|
||||
@@ -66,6 +67,12 @@ export const manifest = {
|
||||
* }} 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 },
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam z odpovedi banky.
|
||||
*
|
||||
* COBS obaluje kolekci pod vlastni nazev (`accounts`, `transactions`), jiny
|
||||
* standard pouziva `content` nebo `items`. Hadat jeden tvar by znamenalo, ze
|
||||
* pri zmene obalky krok tise vrati nula zaznamu misto chyby.
|
||||
*/
|
||||
function collection(ctx, body, ...names) {
|
||||
if (Array.isArray(body)) return body;
|
||||
const found = ctx.util.pick(body, ...names, 'data', 'content', 'items');
|
||||
if (Array.isArray(found)) return found;
|
||||
const unwrapped = ctx.util.unwrap(body);
|
||||
return Array.isArray(unwrapped) ? unwrapped : null;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, text, need } = ctx.util;
|
||||
|
||||
const { body: accountsBody } = await ctx.http.get('/accounts', { query: { size: 50 } });
|
||||
const accounts = collection(ctx, 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 = collection(ctx, 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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 se dela pres `TextEncoder` a `btoa`, ne pres Buffer: jsou to
|
||||
* standardni funkce, ktere budou k dispozici i az skripty 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,
|
||||
};
|
||||
|
||||
/** Adresy z jednoho radku. Clovek pise carky, Gmail chce jeden retezec. */
|
||||
function addresses(value) {
|
||||
return String(value ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
}
|
||||
|
||||
/** Base64 z textu. Pres bajty, aby diakritika prezila. */
|
||||
function base64(value) {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Base64url bez vypln, jak ho chce Gmail. */
|
||||
function base64Url(value) {
|
||||
return base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Hlavicka s diakritikou musi byt MIME slovo, jinak z ni zbydou otazniky. */
|
||||
function header(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 } = ctx.util;
|
||||
|
||||
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(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(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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
/** Seznam adres z jednoho radku. Graph chce pole objektu, clovek pise carky. */
|
||||
function addresses(value) {
|
||||
return String(value ?? '')
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((address) => ({ address }));
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
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 };
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(' '),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
/** Retezec do OData filtru. Apostrof se zdvojuje, jinak dotaz spadne. */
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
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 ${quote(inputs.cardCode)}`, 'kód']);
|
||||
if (inputs.taxId) attempts.push([`FederalTaxID eq ${quote(inputs.taxId)}`, 'IČO']);
|
||||
if (inputs.name) attempts.push([`contains(CardName,${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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export async function run(inputs, ctx) {
|
||||
const { pick, num, text, round } = ctx.util;
|
||||
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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];
|
||||
});
|
||||
}
|
||||
|
||||
const stats = pick(body, 'stats');
|
||||
if (Array.isArray(stats)) return stats;
|
||||
|
||||
const data = pick(body, 'data');
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user