Модулі: як писати власні кроки й команди

Модуль це тека зі скриптом на JavaScript чи Lua (і/або власним сервером) та маніфестом module.json. Він додає в застосунок нові кроки дій (меню «Додати крок» → «Модулі») і команди чату. Мову вибирає поле language (js, lua чи python) у маніфесті; формат module.json один для всіх. Третя мова, python, працює інакше: модуль це власна програма із сервером, що віддає віджети для оверлеїв і сама відповідає на кроки, команди й події. Власний сервер може мати й js/lua-модуль (через Node чи Lua з вашого комп'ютера; розділи «Єдина модель» і «Модулі з сервером» нижче). Віджети для оверлеїв (секція widgets) можуть мати модулі всіх трьох мов.

Установка

Сторінка Модулі в бічній панелі: «Встановити з теки…» (тека з module.json) або покладіть теку в теку модулів (кнопка «Відкрити теку модулів») і поверніться на сторінку. Встановлений модуль вимкнений: кнопка «Увімкнути» показує, які дозволи він просить, і вмикає його лише після вашої згоди. Повторна установка того ж модуля потребує згоди знову.

Готові приклади з кодом і архівами: розділ «Приклади».

Маніфест

{
  "id": "dice",
  "name": "Кубик",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "js",
  "main": "main.js",
  "author": "...",
  "description": "...",
  "permissions": ["chat.send", "events", "overlay"],
  "steps": [
    { "id": "roll", "label": "Кинути кубик", "function": "roll",
      "props": [ { "key": "sides", "kind": "number", "default": "6", "label": "Граней", "min": 2, "max": 1000 } ] }
  ],
  "commands": [ { "name": "!кубик", "function": "onDice" } ],
  "events": [ { "on": "follow", "function": "onFollow" } ],
  "values": [ { "key": "last", "label": "Останній кидок" } ]
}
  • id: латиниця в нижньому регістрі, цифри й дефіс, 3–40 символів; тека модуля називається так само.
  • apiVersion: версія API, з якою писали модуль (зараз 1). Новішу, ніж знає застосунок, модуль не запустить.
  • main: файл скрипта поруч із маніфестом (.js).
  • steps[].props[].kind: text, number (з min/max), toggle, choice (з choices).
  • Не більше 50 кроків, 20 полів у кроці, 20 команд, 30 обробників подій, 50 значень. Усі помилки маніфесту видно на сторінці «Модулі».

Функції

Кожен крок і команда це функція вашого скрипта. Вона отримує один аргумент ctx:

ПолеЩо це
ctx.source"step" чи "command"
ctx.userхто запустив (логін)
ctx.inputтекст, з яким запустили (для команди все після її назви)
ctx.argsinput, розбитий за пробілами
ctx.propsзначення полів кроку (теги вже підставлено)

Крок: повернене значення стає $(result) для наступних кроків. Команда: повернений рядок іде в чат від імені бота (потрібен дозвіл chat.send). Помилка скрипта це збій кроку: її видно в історії дій і в журналі модуля.

Значення для оверлею

overlay.state(key, value) (дозвіл overlay) зберігає значення, яке стример показує в лейблі: джерело «Значення модуля» й ключ <id модуля>:<key>. Щоб редактор запропонував його зі списку, оголосіть ключ у values маніфесту (key і label). Оголошення лише підказка: ключ, не вказаний у values, теж працює, але його доведеться вписати руками.

Віджети для оверлеїв (js, lua, python)

Секція widgets маніфесту однакова для всіх мов, у редакторі оверлеїв вона дає тип Віджет модуля (список «Модуль: Віджет»). Стример не бачить різниці між мовами.

"widgets": [
  { "id": "last", "label": "Останній кидок", "path": "widget.html" }
]

id (латиниця, цифри, _), label (назва в редакторі), path, до 20 віджетів. Що таке path, залежить від мови:

jsluapython
pathфайл .html у теці модуля (widget.html, ui/w.html)те саместорінка на сервері модуля (/clock)
Хто віддає сторінкузастосунок, /modules/<id>/<path>те самесервер модуля
Власний процеснінітак (дозвіл process)
Живі даніoverlay.state + /modules/_sdk.jsте самесвої, або той самий SDK
Коли доступнийодразу після ввімкненняте самеколи сервер запущено

Дані. Сторінка js/lua-віджета підключає SDK і підписується на значення, які модуль виставляє через overlay.state(key, value) (дозвіл overlay): сторінка отримує їх при відкритті й після кожної зміни, ключі без префікса модуля.

<script src="/modules/_sdk.js"></script>
<script>
  Spalakh.onState(function (values) { document.body.textContent = values.last; });
</script>

Глобальний об’єкт називається Spalakh; старе ім’я BotValeriy лишається синонімом, тож наявні модулі працюють без змін.

Сторінка на іншому порту (python-сервер) підключає той самий SDK за повною адресою: <script src="http://localhost:5179/modules/_sdk.js" data-module="мій-модуль"> (порт оверлею показує сторінка «Оверлеї»). Статичні файли поруч (CSS, зображення, відео, шрифти) підключаються звичайними відносними адресами. Приклади: dice (js) і welcome-lua (lua), файл widget.html.

Безпека віддачі файлів. Віддаються лише файли всередині теки модуля й лише активного модуля (справний, увімкнений, дозволи підтверджені): вимкнений дає 404. Шляхи з .., \, : чи прихованими іменами (.git) і посилання (symlink, junction) назовні відхиляються. Типи вмісту лише з переліку: html, js, css, json, svg, png, jpg, gif, webp, mp3, ogg, wav, mp4, webm, woff, woff2, ttf. Скрипти .lua і будь-який інший тип не віддаються. Сторінка віддається з Content-Security-Policy: sandbox allow-scripts, тож не має походження оверлею й не дістає до його API; з тієї ж причини відповіді мають Access-Control-Allow-Origin: *. Пісочниця самих скриптів модуля не змінюється: сторінка віджета працює в браузері, не в застосунку.

Події каналу

Обробник events[] (потрібен дозвіл events) викликається, коли в каналі стається подія: follow, subscription, resub, gift-sub, raid, redeem, chat, donation, bits, power-up. Функція отримує ctx.source == "event" і ctx.event: kind, user, platform (twitch, kick, youtube; порожньо для донату зі стороннього сервісу) та решту даних події (наприклад amount, message). Тестові події з вкладки «Сповіщення» приходять із ctx.event.test == "true"; повтори з історії оверлею не приходять. Події модуля йдуть по одній, у порядку надходження; якщо обробник повільний і в черзі більше 50 подій, нові відкидаються (про це є запис у журналі). Чат (chat) надходить на кожне повідомлення, тож тримайте такий обробник коротким.

API

ФункціяДозвілЩо робить
chat.send(text)chat.sendповідомлення в чат
vars.get(name, scope), vars.set(name, value, scope)varsзмінні дій; scope: "global" (типово) чи "user" (змінні поточного глядача)
actions.run(id, input)actions.runзапустити дію з бібліотеки (не чекає її кінця)
overlay.state(key, value)overlayзберегти значення для оверлею; щоб показати його, у лейблі оберіть джерело «Значення модуля» й впишіть <id модуля>:<key> (наприклад dice:last)
store.get(key), store.set(key, value)немаєвласне сховище модуля (будь-який JSON), переживає перезапуск; до 500 записів
log.info/warn/error(text)немаєжурнал модуля на сторінці «Модулі»
sleep(ms)немаєпауза (до 5 с за виклик)

Виклик без підтвердженого дозволу кидає помилку з назвою дозволу. Секретів застосунку (токени, ключі) скрипт не бачить.

Lua

API, дозволи й ліміти ті самі, що в JS: у вашому скрипті ті самі глобальні таблиці chat, vars, actions, store, overlay, log і функція sleep. Різниця лише в мові:

  • ctx.args і будь-які масиви це таблиці Lua, що індексуються з 1: ctx.args[1] перший аргумент, #ctx.args їх кількість.
  • nil це «нічого» (у JS null). store.get для відсутнього ключа повертає nil.
  • Значення, які повертає функція чи кладеш у store: рядки, числа, true/false, nil і таблиці. Таблиця без ключів-рядків і з послідовними числовими ключами 1..n це масив, інакше об'єкт. Функції й цикли в таблицях не зберігаються (помилка).
  • Доступні лише бібліотеки math, string, table і базові функції. Немає os, io, debug, package, require, dofile, loadfile, load, collectgarbage, print (для виводу використовуйте log.info).
  • Глобальні змінні скрипта живуть між викликами, як і в JS; усе, що має пережити збій чи перезапуск, кладіть у store.
  • Ліміт пам'яті в Lua грубий: рахуються алокації за виклик (до 8 лімітів пам'яті). Скрипт, що одним махом будує гігантський рядок (s = s .. s у циклі), може на кілька секунд забрати гігабайти пам'яті, перш ніж .NET його зупинить. Пишіть без таких прийомів.

Єдина модель

Можливістьjsluapython
Кроки дій (steps)тактактак
Команди чату (commands)тактактак
Події каналу (events, дозвіл events)тактактак
Значення для оверлея (values)тактактак
Скрипт у пісочниці (main)тактакні
Власний сервер, віджети (server, дозвіл process)так (Node)так (Lua)так (Python)
Зворотні виклики сервера в застосунок (BOT_API_URL)тактактак

server це окремий процес (server.runtime: python, node чи lua; за замовчуванням за мовою модуля: python→python, js→node, lua→lua; можна й навпаки, наприклад "runtime": "node" у lua-модуля). Застосунок знаходить інтерпретатор у PATH, але сам його не встановлює. Модуль із server викликається HTTP-запитами (див. нижче).

Що обов'язково:

  • python: server (та server.entry); main заборонений.
  • js/lua: main або server, можна обидва. З main кроки, команди й події виконує скрипт у пісочниці, а сервер править лише за віджети й API; без main їх викликає сервер (POST /_bot/…).
  • Будь-який модуль із server потребує дозволу process: екран згоди каже, що модуль виконує довільний код. Без server дозвіл process заборонений.
  • server.entry відповідає середовищу: .py (python), .js/.mjs/.cjs (node), .lua (lua). server.requirements: .txt для python, package.json для node, для lua не буває.
  • Віджети модуля із server це сторінки на його сервері (path з /), без server файли .html у теці модуля.

Помилки маніфесту (зокрема ці) видно на картці модуля на сторінці «Модулі», а встановити такий модуль не вдасться.

Модулі з сервером (python, node, lua): власний сервер і віджети для оверлеїв

Для готових програм на Python, Node.js чи Lua (Flask, Express та інших), що віддають сторінки віджетів для OBS. Стример лише ставить модуль і дає згоду: застосунок сам знаходить інтерпретатор, готує залежності, запускає процес і зупиняє його разом з модулем. Усе інше (сторінка «Модулі», дозволи, тека modules\<id>) те саме, що в js/lua. Приклади: python-hello (лише стандартна бібліотека), python-flask (з requirements.txt) і node-hello (js-модуль із Node-сервером без залежностей). Прикладу на Lua нема: HTTP-сервер у чистому Lua неможливий без бібліотеки сокетів (LuaSocket, Pegasus тощо), а стандартна інсталяція lua їх не має. Lua-сервер має сенс, лише коли ваш lua/luajit уже має таку бібліотеку; застосунок її не ставить.

Кроки, команди й події модуля із сервером

Секції steps, commands, events, values пишуться як у js/lua (поле function це ім'я обробника). Застосунок викликає ваш сервер (BOT_PORT, лише 127.0.0.1) запитом POST /_bot/<source>/<function>, де source це step, command чи event:

POST /_bot/step/greet
{ "source": "step", "user": "alice", "input": "", "args": [], "props": { "greeting": "Привіт" }, "event": null }
→ { "result": "Привіт, alice!", "reply": "необов'язково" }
  • Контекст той самий, що ctx у js/lua (для події event містить kind і дані події).
  • Відповідь: JSON-об'єкт. Крок повертає result (рядок, число, булеве: його можна підставити далі в дії). Команда пише в чат reply (потрібен chat.send; без нього відповідь ігнорується), якщо reply нема, то result. Для події відповідь не використовується.
  • Ліміти й збої як у js/lua: час виклику 5 с; не-2xx, не-JSON або таймаут це збій ({"error": "…"} потрапляє в журнал модуля); 5 збоїв поспіль вимикають модуль. Поки сервер ще готується чи перезапускається, виклик відхиляється без запису збою.
  • Дозволи перевіряє застосунок: обробники подій потребують events, відповідь команди chat.send.

Приклади: python-hello (крок «Привітати (Python)» і команда !привіт), node-hello (те саме на Node).

Зворотні виклики в застосунок

Сервер модуля теж може кликати застосунок: ті самі можливості, що chat, vars, actions, overlay, store, log у js/lua. Застосунок дає процесу BOT_API_URL (http://127.0.0.1:<порт>/api, окремий слухач лише на loopback) і BOT_API_TOKEN. Токен випадковий, унікальний для кожного запуску процесу, прив'язаний до модуля й відкликається, щойно процес завершився (старий токен нічого не відкриває). Не друкуйте його в stdout: вивід іде в журнал модуля.

Кожен запит: POST, заголовок Authorization: Bearer <BOT_API_TOKEN>, тіло JSON-об'єкт до 64 КБ. Запити із заголовком Origin (із браузера) відхиляються, тож сторінка віджета не може кликати цей API: звертайтеся з серверного коду.

МаршрутТілоДозвілВідповідь
chat/send{"text": "…"} (1–500 символів)chat.send{"ok": true}
vars/get{"name", "scope"?: "global"\|"user", "user"?}vars{"value": "…"\|null}
vars/set{"name", "value", "scope"?, "user"?}vars{"ok": true}
actions/run{"id", "user"?, "input"?}: запускає дію з бібліотеки, не чекаючи кінцяactions.run{"ok": true}
overlay/state{"key", "value"}: ключ модуля module:<id>:<key>overlay{"ok": true}
store/get{"key"}нема{"value": <JSON>\|null}
store/set{"key", "value": <будь-який JSON>}нема{"ok": true}
log{"level"?: "info"\|"warn"\|"error", "text"}нема{"ok": true}

scope: "user" вимагає user (у js/lua це глядач, що викликав скрипт; у сервера такого контексту нема). Сховище store те саме, що в js/lua, з тими ж межами: ключ до 100 символів, значення до 100 000, до 500 записів.

Помилки приходять як {"error": "…"}:

КодКоли
400неправильне тіло чи поле, ліміти сховища
401нема токена, він чужий чи відкликаний
403нема дозволу (у відповіді ще й "permission": "chat.send"), модуль вимкнено чи є Origin
404 / 405невідомий маршрут / не POST
413тіло більше 64 КБ
429більше 30 запитів за секунду з одного токена

Дозволи перевіряє застосунок за тим, що власник підтвердив, а не сервер. Приклад (стандартна бібліотека), повний код у python-hello:

import json, os, urllib.request

def bot(route, **body):
    request = urllib.request.Request(
        os.environ["BOT_API_URL"] + "/" + route, json.dumps(body).encode(),
        {"Authorization": "Bearer " + os.environ["BOT_API_TOKEN"], "Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=5) as response:
        return json.load(response)

bot("chat/send", text="Привіт із сервера!")

Маніфест

{
  "id": "donor-goals",
  "name": "Донати й цілі",
  "version": "1.0.0",
  "language": "python",
  "permissions": ["process"],
  "server": {
    "runtime": "python",
    "entry": "bot.py",
    "requirements": "requirements.txt",
    "panelPath": "/"
  },
  "widgets": [
    { "id": "donor", "label": "Топ донатерів", "path": "/donor" },
    { "id": "goals", "label": "Цілі", "path": "/goals" }
  ]
}
  • Спільні поля (id, name, version, author, description, permissions) такі самі. steps, commands, events, values дозволені й для python (розділ «Єдина модель»), а main ні: замість скрипта є server.entry.
  • permissions має містити process: екран згоди каже, що модуль виконує довільний код на комп'ютері з вашими правами (пісочниці нема). Вмикайте лише модулі, чиєму авторові довіряєте.
  • server.runtime (необов'язково): python, node чи lua; без нього за мовою модуля. Для "language": "python" лише python.
  • server.entry: вхідний файл поруч із маніфестом (.py, .js/.mjs/.cjs, .lua). server.requirements (необов'язково): .txt для pip install -r (python) чи package.json для npm install (node).
  • widgets[]: та сама секція, що в js/lua (див. вище), path це сторінка на вашому сервері, з /.
  • server.panelPath (необов'язково): сторінка пульта; на картці модуля з'являється кнопка «Відкрити пульт».
  • Порт у маніфесті не вказується: застосунок щоразу видає вільний.

Що отримує процес

ЗміннаЗначення
BOT_PORTпорт, який треба слухати
BOT_HOST127.0.0.1: слухайте лише loopback, не 0.0.0.0
BOT_MODULE_IDid модуля
BOT_API_URL, BOT_API_TOKENзворотні виклики в застосунок (розділ вище)
BOT_DATA_DIRтека даних модуля (%LocalAppData%\Spalakh\modules-data\<id>; в установленнях до перейменування — %LocalAppData%\BotValeriy\…): налаштування, кеш, файли. Переживає оновлення модуля, на відміну від теки модуля
PYTHONUTF8, PYTHONUNBUFFEREDлише python: 1, UTF-8 і вивід без буфера

Робоча тека процесу це тека модуля. Усе, що процес друкує в stdout/stderr, іде в журнал модуля.

Життєвий цикл

1. Нічого не запускається, доки ви не натиснули «Увімкнути» й не погодились на дозволи. Увімкнений модуль стартує разом із застосунком. 2. Пошук інтерпретатора в PATH. Не знайдено: стан «Помилка» з поясненням; поставте його й натисніть «Перезапустити сервер». Самих інтерпретаторів застосунок не встановлює.

  • python: py -3, далі python, python3;
    • node: node (npm потрібен лише коли є package.json);
    • lua: lua, далі luajit.

3. Залежності: python створює .venv у BOT_DATA_DIR і робить pip install -r, node робить npm install --omit=dev у теці модуля (node_modules лежить поруч зі скриптом); обидва лише коли файл залежностей змінився з останнього успішного встановлення. Lua залежностей не ставить. 4. Запуск: .venv\Scripts\python.exe -u <entry>, node <entry>, lua <entry>; готовність це відповідь порту (до 30 с). 5. Падіння: перезапуск через 1, 2, 4, 8… с (до 30 с). Сервер, що пропрацював хвилину, вважається здоровим. Після 5 збоїв поспіль модуль вимикається (причина в журналі). 6. Вимкнення, видалення модуля чи закриття застосунку зупиняють процес з усім піддеревом (Job Object): сиріт не лишається, навіть якщо застосунок аварійно впав.

Стан («Готується / Запускається / Працює / Перезапускається / Помилка»), порт, кількість перезапусків і залежності видно на картці модуля.

Віджети в оверлеях

У редакторі оверлеїв: «Додати віджет» → Віджет модуля, потім у властивостях вибираєте зі списку «Модуль: Віджет» (список це віджети активних модулів). Сцена малює його як прозору сторінку з вашого сервера, тож в OBS однаково додається лише звичайне браузерне джерело сцени оверлея. Сервер не запущений: на місці віджета порожньо, і він з'являється сам, щойно сервер запрацює.

Безпека: сторінка вашого сервера відкривається у вкладеному iframe з власним походженням (127.0.0.1:<порт модуля>): до сторінки оверлея, її сокета й даних вона не дістається, але власному API звертається як звичайна сторінка (fetch('/api/...') працює).

Обмеження

  • Процес виконує будь-який код. Пісочниця js/lua тут не діє, захисту від зловмисного модуля нема: довіряйте автору.
  • Лише Windows, а інтерпретатор (Python 3, Node.js чи Lua) має бути в PATH. Залежності ставляться з PyPI чи npm, тож потрібен інтернет під час першого запуску й після змін файла залежностей. Node 18+ має fetch для зворотних викликів, на старішому доведеться свій HTTP-клієнт.
  • Lua-сервер працює лише з інтерпретатором, що вже має бібліотеку HTTP/сокетів; прикладу й вбудованої підтримки нема.
  • Сервер доступний лише на 127.0.0.1 і лише з цього комп'ютера; OBS на іншій машині його не побачить.
  • Версія застосунку без цієї можливості викидає невідомий тип віджета зі сцени.

Обмеження й безпека

  • Скрипт працює в пісочниці: без доступу до файлів, процесів, системи; без eval і new Function.
  • Ліміти одного виклику: 5 с часу, ~32 МБ пам'яті (у Lua грубо, див. вище), 2 млн операцій, рекурсія до 64 (у Lua глибина обмежена самим рушієм). Перевищення це збій кроку.
  • Після 5 збоїв поспіль модуль автоматично вимикається (причина в його журналі).
  • Команда одного глядача викликається не частіше, ніж раз на 2 секунди.
  • Стан скрипта (глобальні змінні) живе між викликами, але скидається при збої, перевстановленні чи «Перезавантажити». Усе, що має пережити це, зберігайте через store.
  • Мережі (net) поки нема в API; дозвіл існує для наступних версій.

Розробка

  • Змінили main.js? Наступний виклик сам перечитає скрипт, кнопка «Перезавантажити» не обов'язкова.
  • Журнал модуля на сторінці «Модулі» показує log.* і помилки.
  • Перевірте крок кнопкою «Тест» у дії, яка його використовує; команду напишіть у чаті.

Приклади

Кожен приклад це готовий модуль. Завантажте архів, розпакуйте його (вийде тека з назвою модуля), на сторінці «Модулі» натисніть «Встановити з теки…» й оберіть цю теку. Далі модуль треба ввімкнути, погодивши дозволи.

Кубик (JavaScript): крок і команда

Завантажити: dice.zip

dice/module.json

{
  "id": "dice",
  "name": "Кубик",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "js",
  "main": "main.js",
  "author": "Spalakh",
  "description": "Крок «Кинути кубик» і команда !кубик. Приклад модуля: рахує, скільки разів кидали, у власному сховищі.",
  "permissions": ["chat.send", "overlay"],
  "steps": [
    {
      "id": "roll",
      "label": "Кинути кубик",
      "hint": "Кидає кубик із заданою кількістю граней; результат лягає в $(result)",
      "function": "roll",
      "props": [
        { "key": "sides", "kind": "number", "default": "6", "label": "Граней", "min": 2, "max": 1000 }
      ]
    }
  ],
  "values": [ { "key": "last", "label": "Останній кидок" } ],
  "commands": [
    { "name": "!кубик", "function": "onDice" }
  ],
  "widgets": [
    { "id": "last", "label": "Останній кидок", "path": "widget.html" }
  ]
}

dice/main.js

// Приклад модуля «Кубик». / Example module "Dice".
//
// Функції отримують один аргумент ctx: { source, user, input, args, props }.
// Every function gets one argument, ctx: { source, user, input, args, props }.
//
// Повернене значення кроку стає $(result) для наступних кроків; повернений текст команди йде в чат (потрібен дозвіл chat.send).
// A step's return value becomes $(result) for the next steps; a command's returned text goes to chat (needs the chat.send permission).

function clampSides(value) {
  var sides = parseInt(value, 10);
  if (!sides || sides < 2) {
    return 6;
  }
  return Math.min(sides, 1000);
}

function rollDice(sides, user) {
  var value = 1 + Math.floor(Math.random() * sides);

  // Власне сховище модуля: дозволу не потребує й переживає перезапуск застосунку.
  // The module's own store: needs no permission and survives an app restart.
  var stats = store.get('stats') || { rolls: 0 };
  stats.rolls += 1;
  store.set('stats', stats);

  // Значення для оверлею: у лейблі джерело «Значення модуля» → dice:last (оголошено в module.json, values).
  // A value for the overlay: in a label pick the "Module value" source → dice:last (declared in module.json, values).
  overlay.state('last', String(value));

  log.info(user + ' кинув d' + sides + ' і отримав ' + value + ' (усього кидків: ' + stats.rolls + ')');
  return value;
}

// Крок дії «Кинути кубик»: число граней береться з поля кроку.
// The "Roll a die" action step: the number of sides comes from the step's field.
function roll(ctx) {
  return String(rollDice(clampSides(ctx.props.sides), ctx.user));
}

// Команда !кубик [граней]: відповідь іде в чат.
// The !кубик [sides] command: the reply goes to chat.
function onDice(ctx) {
  var sides = clampSides(ctx.args[0]);
  return ctx.user + ' кинув d' + sides + ': ' + rollDice(sides, ctx.user);
}

Кубик (Lua): те саме на Lua

Завантажити: dice-lua.zip

dice-lua/module.json

{
  "id": "dice-lua",
  "name": "Кубик (Lua)",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "lua",
  "main": "main.lua",
  "author": "Spalakh",
  "description": "Той самий кубик, що й у прикладі на JavaScript, але на Lua: крок «Кинути кубик (Lua)» і команда !кубик2.",
  "permissions": ["chat.send", "overlay"],
  "steps": [
    {
      "id": "roll",
      "label": "Кинути кубик (Lua)",
      "hint": "Кидає кубик із заданою кількістю граней; результат лягає в $(result)",
      "function": "roll",
      "props": [
        { "key": "sides", "kind": "number", "default": "6", "label": "Граней", "min": 2, "max": 1000 }
      ]
    }
  ],
  "values": [ { "key": "last", "label": "Останній кидок" } ],
  "commands": [
    { "name": "!кубик2", "function": "onDice" }
  ]
}

dice-lua/main.lua

-- Приклад модуля «Кубик» на Lua. / Example module "Dice" in Lua.
--
-- Функції отримують один аргумент ctx: { source, user, input, args, props }. args і props це таблиці;
-- args індексується з 1, як звичайні масиви Lua.
-- Every function gets one argument, ctx: { source, user, input, args, props }. args and props are tables;
-- args is indexed from 1, like ordinary Lua arrays.
--
-- Повернене значення кроку стає $(result); повернений текст команди йде в чат (потрібен дозвіл chat.send).
-- A step's return value becomes $(result); a command's returned text goes to chat (needs the chat.send permission).

local function clampSides(value)
  local sides = math.floor(tonumber(value) or 6)
  if sides < 2 then
    return 6
  end
  return math.min(sides, 1000)
end

local function rollDice(sides, user)
  local value = math.random(1, sides)

  -- Власне сховище модуля: дозволу не потребує й переживає перезапуск застосунку.
  -- The module's own store: needs no permission and survives an app restart.
  local stats = store.get('stats') or { rolls = 0 }
  stats.rolls = stats.rolls + 1
  store.set('stats', stats)

  -- Значення для оверлею: у лейблі джерело «Значення модуля» → dice-lua:last (оголошено в module.json, values).
  -- A value for the overlay: in a label pick the "Module value" source → dice-lua:last (declared in module.json, values).
  overlay.state('last', tostring(value))

  log.info(user .. ' кинув d' .. sides .. ' і отримав ' .. value .. ' (усього кидків: ' .. stats.rolls .. ')')
  return value
end

-- Крок дії «Кинути кубик (Lua)»: число граней береться з поля кроку.
-- The "Roll a die (Lua)" action step: the number of sides comes from the step's field.
function roll(ctx)
  return tostring(rollDice(clampSides(ctx.props.sides), ctx.user))
end

-- Команда !кубик2 [граней]: відповідь іде в чат.
-- The !кубик2 [sides] command: the reply goes to chat.
function onDice(ctx)
  local sides = clampSides(ctx.args[1])
  return ctx.user .. ' кинув d' .. sides .. ': ' .. rollDice(sides, ctx.user)
end

Вітання (JavaScript): події каналу й значення для оверлея

Завантажити: welcome.zip

welcome/module.json

{
  "id": "welcome",
  "name": "Вітання",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "js",
  "main": "main.js",
  "author": "Spalakh",
  "description": "Дякує в чаті за фол і привітає рейд. Приклад обробників подій: рахує нових глядачів і виставляє останнього фоловера для оверлея.",
  "permissions": ["chat.send", "events", "overlay"],
  "events": [
    { "on": "follow", "function": "onFollow" },
    { "on": "raid", "function": "onRaid" }
  ],
  "values": [
    { "key": "last-follower", "label": "Останній фоловер" }
  ]
}

welcome/main.js

// Приклад модуля «Вітання»: обробники подій каналу. / Example module "Welcome": channel event handlers.
//
// Подія приходить у ctx.event: { kind, user, ...решта даних події }. Тестові події з вкладки «Сповіщення» мають ctx.event.test == "true".
// The event arrives in ctx.event: { kind, user, ...the rest of the event data }. Test events from the Alerts tab have ctx.event.test == "true".
//
// Потрібні дозволи: events (читати події), chat.send (писати в чат), overlay (показати значення в лейблі оверлея).
// Permissions needed: events (read events), chat.send (write to chat), overlay (show a value in an overlay label).

function onFollow(ctx) {
  // Власне сховище переживає перезапуск застосунку.
  // The module's own store survives an app restart.
  var follows = (store.get('follows') || 0) + 1;
  store.set('follows', follows);

  // У лейблі оверлея: джерело «Значення модуля» → welcome:last-follower (оголошено в module.json, values).
  // In an overlay label: the "Module value" source → welcome:last-follower (declared in module.json, values).
  overlay.state('last-follower', ctx.event.user);

  chat.send('Дякуємо за фолов, ' + ctx.event.user + '! Нових глядачів: ' + follows + '.');
}

// Рейд: просто вітаємо в чаті.
// A raid: just greet in chat.
function onRaid(ctx) {
  chat.send('Рейд від ' + ctx.event.user + '! Ласкаво просимо!');
}

Вітання (Lua): те саме на Lua

Завантажити: welcome-lua.zip

welcome-lua/module.json

{
  "id": "welcome-lua",
  "name": "Вітання (Lua)",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "lua",
  "main": "main.lua",
  "author": "Spalakh",
  "description": "Дякує в чаті за фол і привітає рейд. Приклад обробників подій: рахує нових глядачів і виставляє останнього фоловера для оверлея.",
  "permissions": ["chat.send", "events", "overlay"],
  "events": [
    { "on": "follow", "function": "onFollow" },
    { "on": "raid", "function": "onRaid" }
  ],
  "values": [
    { "key": "last-follower", "label": "Останній фоловер" }
  ],
  "widgets": [
    { "id": "last", "label": "Останній фоловер", "path": "widget.html" }
  ]
}

welcome-lua/main.lua

-- Приклад модуля «Вітання» на Lua: обробники подій каналу. / Example module "Welcome" in Lua: channel event handlers.
--
-- Подія приходить у ctx.event: таблиця { kind, user, ... }. Тестові події з вкладки «Сповіщення» мають ctx.event.test == "true".
-- The event arrives in ctx.event: a table { kind, user, ... }. Test events from the Alerts tab have ctx.event.test == "true".
--
-- Потрібні дозволи: events (читати події), chat.send (писати в чат), overlay (показати значення в лейблі оверлея).
-- Permissions needed: events (read events), chat.send (write to chat), overlay (show a value in an overlay label).

function onFollow(ctx)
  -- Власне сховище переживає перезапуск застосунку.
  -- The module's own store survives an app restart.
  local follows = (store.get('follows') or 0) + 1
  store.set('follows', follows)

  -- У лейблі оверлея: джерело «Значення модуля» → welcome-lua:last-follower (оголошено в module.json, values).
  -- In an overlay label: the "Module value" source → welcome-lua:last-follower (declared in module.json, values).
  overlay.state('last-follower', ctx.event.user)

  chat.send('Дякуємо за фолов, ' .. ctx.event.user .. '! Нових глядачів: ' .. follows .. '.')
end

-- Рейд: просто вітаємо в чаті.
-- A raid: just greet in chat.
function onRaid(ctx)
  chat.send('Рейд від ' .. ctx.event.user .. '! Ласкаво просимо!')
end

Привіт (Python): сервер без залежностей, віджет, крок і команда

Завантажити: python-hello.zip

python-hello/module.json

{
  "id": "python-hello",
  "name": "Привіт (Python)",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "python",
  "author": "Spalakh",
  "description": "Мінімальний python-модуль без залежностей: сервер на стандартній бібліотеці віддає віджет-годинник, пульт, крок дії та команду чату.",
  "permissions": ["process", "chat.send"],
  "server": {
    "entry": "server.py",
    "panelPath": "/"
  },
  "widgets": [
    { "id": "clock", "label": "Годинник", "path": "/clock" }
  ],
  "steps": [
    { "id": "announce", "label": "Оголосити в чаті (Python)", "function": "announce",
      "hint": "Сервер сам пише в чат через API застосунку й веде лічильник у сховищі.",
      "props": [ { "key": "text", "kind": "text", "default": "Привіт із Python", "label": "Текст" } ] },
    { "id": "greet", "label": "Привітати (Python)", "function": "greet",
      "props": [ { "key": "greeting", "kind": "text", "default": "Привіт", "label": "Привітання" } ] }
  ],
  "commands": [
    { "name": "!привіт", "function": "hello" }
  ]
}

python-hello/server.py

"""Мінімальний python-модуль Spalakh: лише стандартна бібліотека.

Застосунок дає порт у BOT_PORT і просить слухати лише BOT_HOST (127.0.0.1).
"""
import json
import os
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

HOST = os.environ.get("BOT_HOST", "127.0.0.1")
PORT = int(os.environ.get("BOT_PORT", "8000"))
API_URL = os.environ.get("BOT_API_URL", "")
API_TOKEN = os.environ.get("BOT_API_TOKEN", "")


def bot(route, **body):
    """Зворотний виклик застосунку: POST BOT_API_URL/<route> з токеном. Помилка (наприклад, 403 без дозволу) кидає виняток."""
    request = urllib.request.Request(
        f"{API_URL}/{route}", json.dumps(body).encode("utf-8"),
        {"Authorization": f"Bearer {API_TOKEN}", "Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=5) as response:
        return json.load(response)

# Прозорий фон: сторінка лягає поверх сцени в OBS.
CLOCK = """<!doctype html><meta charset="utf-8">
<style>
  html, body { margin: 0; background: transparent; overflow: hidden; }
  #t { font: 700 64px/1.2 'Segoe UI', sans-serif; color: #fff; text-shadow: 0 2px 8px #000; text-align: center; }
</style>
<div id="t"></div>
<script>
  const t = document.getElementById('t');
  async function tick() { t.textContent = (await (await fetch('/api/time')).json()).time; }
  tick(); setInterval(tick, 1000);
</script>"""

PANEL = """<!doctype html><meta charset="utf-8"><title>Привіт (Python)</title>
<body style="font-family: sans-serif">
<h1>Модуль працює</h1>
<p>Віджет «Годинник» додайте в оверлей: редактор → «Додати віджет» → «Віджет модуля».</p>
<p><a href="/clock">Переглянути годинник</a></p>"""


# ctx: source, user, input, args, props, event. Відповідь: {"result": ..., "reply": "..."}.
# result повертається кроку (його можна використати далі в дії), reply команда пише в чат (дозвіл chat.send).
def announce(ctx):
    # Модуль сам пише в чат через API застосунку (дозвіл chat.send) і веде лічильник у своєму сховищі.
    count = (bot("store/get", key="announced")["value"] or 0) + 1
    bot("store/set", key="announced", value=count)
    bot("chat/send", text=f"{ctx['props'].get('text', 'Привіт із Python')} (#{count})")
    return {"result": count}


HANDLERS = {
    ("step", "announce"): announce,
    ("step", "greet"): lambda ctx: {"result": f"{ctx['props'].get('greeting', 'Привіт')}, {ctx['user']}!"},
    ("command", "hello"): lambda ctx: {"reply": f"Привіт, {ctx['user']}! (з Python)"},
}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/api/time":
            self.send(200, "application/json", json.dumps({"time": time.strftime("%H:%M:%S")}))
        elif self.path == "/clock":
            self.send(200, "text/html; charset=utf-8", CLOCK)
        elif self.path == "/":
            self.send(200, "text/html; charset=utf-8", PANEL)
        else:
            self.send(404, "text/plain", "not found")

    def do_POST(self):
        # Застосунок кличе крок, команду чи подію так: POST /_bot/<step|command|event>/<function> з контекстом у JSON.
        parts = self.path.split("/")
        if len(parts) == 4 and parts[1] == "_bot":
            size = int(self.headers.get("Content-Length", 0))
            ctx = json.loads(self.rfile.read(size) or b"{}")
            handler = HANDLERS.get((parts[2], parts[3]))
            if handler:
                self.send(200, "application/json", json.dumps(handler(ctx)))
                return
        self.send(404, "application/json", json.dumps({"error": "unknown handler"}))

    def send(self, status, content_type, body):
        data = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, fmt, *args):
        # Усе, що модуль друкує, потрапляє в його журнал на сторінці «Модулі».
        print(fmt % args, flush=True)


if __name__ == "__main__":
    print(f"Слухаю {HOST}:{PORT}", flush=True)
    ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

Flask (Python): сервер із requirements.txt

Завантажити: python-flask.zip

python-flask/module.json

{
  "id": "python-flask",
  "name": "Лічильник (Flask)",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "python",
  "author": "Spalakh",
  "description": "Python-модуль із залежністю: застосунок сам створить .venv і виконає pip install -r requirements.txt після вашої згоди. Лічильник зберігається в теці даних модуля.",
  "permissions": ["process"],
  "server": {
    "entry": "app.py",
    "requirements": "requirements.txt",
    "panelPath": "/"
  },
  "widgets": [
    { "id": "counter", "label": "Лічильник", "path": "/counter" }
  ]
}

python-flask/app.py

"""Python-модуль із залежністю (Flask). Налаштування й дані кладіть у BOT_DATA_DIR: тека модуля після оновлення стирається."""
import os

from flask import Flask, jsonify

app = Flask(__name__)
STORE = os.path.join(os.environ.get("BOT_DATA_DIR", "."), "count.txt")


def read():
    try:
        with open(STORE, encoding="utf-8") as f:
            return int(f.read())
    except (OSError, ValueError):
        return 0


@app.get("/api/count")
def get_count():
    return jsonify(count=read())


@app.post("/api/count")
def bump():
    value = read() + 1
    with open(STORE, "w", encoding="utf-8") as f:
        f.write(str(value))
    return jsonify(count=value)


@app.get("/counter")
def counter():
    return """<!doctype html><meta charset="utf-8">
<style>html,body{margin:0;background:transparent;overflow:hidden}
#c{font:700 96px 'Segoe UI',sans-serif;color:#fff;text-shadow:0 2px 8px #000;text-align:center}</style>
<div id="c">0</div>
<script>
  async function tick(){ document.getElementById('c').textContent = (await (await fetch('/api/count')).json()).count; }
  tick(); setInterval(tick, 1000);
</script>"""


@app.get("/")
def panel():
    return """<!doctype html><meta charset="utf-8"><title>Лічильник</title>
<body style="font-family:sans-serif"><h1>Лічильник</h1>
<button onclick="fetch('/api/count',{method:'POST'}).then(()=>location.reload())">+1</button>
<p>Віджет: <a href="/counter">/counter</a></p>"""


if __name__ == "__main__":
    app.run(host=os.environ.get("BOT_HOST", "127.0.0.1"), port=int(os.environ.get("BOT_PORT", "5000")))

Привіт (Node.js): js-модуль із Node-сервером, віджетом, кроком і командою

Завантажити: node-hello.zip

node-hello/module.json

{
  "id": "node-hello",
  "name": "Привіт (Node.js)",
  "version": "1.0.0",
  "apiVersion": 1,
  "language": "js",
  "author": "Spalakh",
  "description": "JavaScript-модуль із власним Node-сервером без залежностей: віджет-годинник, пульт, крок дії й команда чату. Потрібен Node.js у PATH.",
  "permissions": ["process", "chat.send"],
  "server": {
    "entry": "server.js",
    "panelPath": "/"
  },
  "widgets": [
    { "id": "clock", "label": "Годинник", "path": "/clock" }
  ],
  "steps": [
    { "id": "announce", "label": "Оголосити в чаті (Node)", "function": "announce",
      "hint": "Сервер сам пише в чат через API застосунку й веде лічильник у сховищі.",
      "props": [ { "key": "text", "kind": "text", "default": "Привіт із Node", "label": "Текст" } ] }
  ],
  "commands": [
    { "name": "!нод", "function": "hello" }
  ]
}

node-hello/server.js

// Мінімальний js-модуль із сервером Spalakh: лише вбудовані модулі Node (потрібен Node 18+ для fetch).
// Застосунок дає порт у BOT_PORT і просить слухати лише BOT_HOST (127.0.0.1).
const http = require('http');

const HOST = process.env.BOT_HOST || '127.0.0.1';
const PORT = Number(process.env.BOT_PORT || 8000);

// Зворотний виклик застосунку: POST BOT_API_URL/<route> з токеном. 403 без дозволу, 401 з чужим чи старим токеном.
async function bot(route, body) {
  const response = await fetch(`${process.env.BOT_API_URL}/${route}`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.BOT_API_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const json = await response.json();
  if (!response.ok) throw new Error(json.error || response.statusText);
  return json;
}

const CLOCK = `<!doctype html><meta charset="utf-8">
<style>
  html, body { margin: 0; background: transparent; overflow: hidden; }
  #t { font: 700 64px/1.2 'Segoe UI', sans-serif; color: #fff; text-shadow: 0 2px 8px #000; text-align: center; }
</style>
<div id="t"></div>
<script>
  const t = document.getElementById('t');
  async function tick() { t.textContent = (await (await fetch('/api/time')).json()).time; }
  tick(); setInterval(tick, 1000);
</script>`;

const PANEL = `<!doctype html><meta charset="utf-8"><title>Привіт (Node.js)</title>
<body style="font-family: sans-serif"><h1>Модуль працює</h1>
<p>Віджет «Годинник» додайте в оверлей: редактор → «Додати віджет» → «Віджет модуля».</p>
<p><a href="/clock">Переглянути годинник</a></p>`;

// ctx: source, user, input, args, props, event. Відповідь: { result, reply }.
const HANDLERS = {
  'step/announce': async (ctx) => {
    const count = ((await bot('store/get', { key: 'announced' })).value || 0) + 1;
    await bot('store/set', { key: 'announced', value: count });
    await bot('chat/send', { text: `${ctx.props.text || 'Привіт із Node'} (#${count})` });
    return { result: count };
  },
  'command/hello': async (ctx) => ({ reply: `Привіт, ${ctx.user}! (з Node)` }),
};

function send(res, status, type, body) {
  res.writeHead(status, { 'Content-Type': type });
  res.end(body);
}

http.createServer((req, res) => {
  if (req.method === 'GET') {
    if (req.url === '/api/time') return send(res, 200, 'application/json', JSON.stringify({ time: new Date().toLocaleTimeString('uk-UA') }));
    if (req.url === '/clock') return send(res, 200, 'text/html; charset=utf-8', CLOCK);
    if (req.url === '/') return send(res, 200, 'text/html; charset=utf-8', PANEL);
    return send(res, 404, 'text/plain', 'not found');
  }

  // Застосунок кличе крок, команду чи подію так: POST /_bot/<step|command|event>/<function> з контекстом у JSON.
  const match = /^\/_bot\/(step|command|event)\/(\w+)$/.exec(req.url);
  const handler = match && HANDLERS[`${match[1]}/${match[2]}`];
  if (req.method !== 'POST' || !handler) return send(res, 404, 'application/json', JSON.stringify({ error: 'unknown handler' }));
  let raw = '';
  req.on('data', (chunk) => (raw += chunk));
  req.on('end', async () => {
    try {
      send(res, 200, 'application/json', JSON.stringify(await handler(JSON.parse(raw || '{}'))));
    } catch (error) {
      send(res, 500, 'application/json', JSON.stringify({ error: String(error.message || error) }));
    }
  });
}).listen(PORT, HOST, () => console.log(`Слухаю ${HOST}:${PORT}`));
Модулі: свої кроки й команди · Spalakh Platform