Модулі: як писати власні кроки й команди
Модуль це тека зі скриптом на 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.args | input, розбитий за пробілами |
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, залежить від мови:
| js | lua | python | |
|---|---|---|---|
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це «нічого» (у JSnull).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 його зупинить. Пишіть без таких прийомів.
Єдина модель
| Можливість | js | lua | python |
|---|---|---|---|
Кроки дій (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_HOST | 127.0.0.1: слухайте лише loopback, не 0.0.0.0 |
BOT_MODULE_ID | id модуля |
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.
- node:
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}`));