Plugin Events
Плагины реагируют на происходящее в Owncast, определяя обработчик для каждого события, которое их интересует. Определяйте только те обработчики, которые хотите: отсутствующий обработчик означает отсутствие подписки, а SDK получает список подписок манифеста из имеющихся обработчиков, поэтому ничего больше не нужно синхронизировать.
Код ниже показан для обоих SDK. Выберите свой язык с помощью вкладок, и ваш выбор будет сопровождать вас по всей документации. Новый в этом? Сначала ознакомьтесь со страницами настройки JavaScript или Python.
- JavaScript
- Python
const { definePlugin, owncast, filter } = require('@owncast/plugin-sdk');
module.exports = definePlugin({
onChatMessage(msg) {
/* react to a chat message */
},
onStreamStarted(info) {
/* react to the stream going live */
},
});
Обработчики — это методы на объекте, который вы передаете в definePlugin, названные в camelCase (onChatMessage, onStreamStarted и т. д.). Поля нагрузки также в camelCase (msg.user.displayName, msg.clientId).
from owncast_plugin import plugin, owncast, filter
@plugin.on_chat_message
def handle_chat(msg):
# react to a chat message
...
@plugin.on_stream_started
def handle_live(info):
# react to the stream going live
...
Обработчики — это декорированные функции (@plugin.on_chat_message, @plugin.on_stream_started и т. д.). Поля нагрузки имеют snake_case (msg.user.display_name, msg.client_id). Используйте msg.raw для базового словаря.
Нагрузки показываются в их форме передачи. Каждый SDK экспонирует поля идиоматично: JavaScript SDK — как есть, Python SDK — как атрибуты snake_case над тем же JSON (с доступным также сырьем словаря).
События чата
Строите плагин, сфокусированный на чате? Чат-плагины — это более дружелюбная отправная точка.
Сообщение чата: chat.message.received
Срабатывает один раз на каждое сообщение чата после выполнения фильтров, когда сообщение транслируется зрителям.
interface ChatMessage {
id: string;
user?: User; // full sender identity (see User below); absent for the rare message with no account
clientId?: number; // originating connection; pass to the chat send-to / reply-to APIs for private replies
body: string; // raw text, not HTML-rendered markup
timestamp: string; // RFC3339Nano / ISO-8601, e.g. "2026-05-28T14:00:00.123456789Z"
}
- JavaScript
- Python
module.exports = definePlugin({
onChatMessage(msg) {
if (msg.user?.scopes?.includes('MODERATOR')) {
owncast.chat.send(`hi mod ${msg.user.displayName}`);
}
},
});
@plugin.on_chat_message
def greet_mods(msg):
if msg.user and "MODERATOR" in (msg.user.scopes or []):
owncast.chat.send(f"hi mod {msg.user.display_name}")
user несет полную идентичность отправителя, поэтому ключ каждого пользователя должен основываться на стабильном user.id и запрещать поведение только для модераторов на user.scopes (например, "MODERATOR"), а не совпадать с отображаемым именем. Чтобы ответить частным образом отправителю, используйте API ответа чата (см. Owncast API).
timestamp — это время по часам хоста для сообщения. Часы песочницы работают, но timestamp является детерминированным и правильным выбором при сравнении прошедшего времени между событиями или утверждении в тестах.
Нет разрешений, необходимых для подписки.
Старые хосты передавали
userкак простую строку отображаемого имени, а не объект идентичности. Если вы поддерживаете хосты, которые предшествовали полезной нагрузке идентичности, очень осторожно относитесь к нему. Смотрите свою страницу SDK для идиомы.
Пользователь чата присоединился / покинул: chat.user.joined, chat.user.parted
Срабатывает, когда пользователь чата подключается или отключается.
interface User {
id: string;
displayName: string;
displayColor: number; // index into the instance's user-color palette, not a literal color
previousNames?: string[];
createdAt?: string; // ISO-8601
disabledAt?: string; // ISO-8601 if banned, omitted otherwise
isBot?: boolean;
isAuthenticated?: boolean;
scopes?: string[];
}
- JavaScript
- Python
module.exports = definePlugin({
onChatUserJoined(user) {
owncast.chat.send(`welcome ${user.displayName}`);
},
onChatUserParted(user) {
/* … */
},
});
@plugin.on_chat_user_joined
def welcome(user):
owncast.chat.send(f"welcome {user.display_name}")
@plugin.on_chat_user_parted
def farewell(user):
...
Не требуется разрешение.
Пользователь чата переименован: chat.user.renamed
Срабатывает, когда пользователь чата меняет свое отображаемое имя.
interface { user: User; previousName: string }
Не требуется разрешение.
Сообщение модерировано: chat.message.moderated
Срабатывает, когда модератор скрывает или показывает сообщение чата.
interface { messageId: string; visible: boolean; moderator?: User }
Не требуется разрешение.
Жизненный цикл потока
Поток начался: stream.started
Срабатывает, когда начинается трансляция.
interface { startedAt?: string; title?: string; summary?: string }
- JavaScript
- Python
module.exports = definePlugin({
onStreamStarted(info) {
owncast.chat.send(`live now: ${info.title}`);
},
onStreamStopped(info) {
/* … */
},
onStreamTitleChanged(change) {
/* change.to */
},
});
@plugin.on_stream_started
def announce(info):
owncast.chat.send(f"live now: {info.title}")
@plugin.on_stream_stopped
def wrap_up(info):
...
@plugin.on_stream_title_changed
def retitle(change):
... # change.to
Не требуется разрешение.
Поток остановлен: stream.stopped
Срабатывает, когда трансляция заканчивается.
interface { stoppedAt?: string }
Не требуется разрешение.
Название потока изменено: stream.title.changed
Срабатывает, когда стример обновляет название в процессе трансляции.
interface { from: string; to: string }
from всегда пуст: событие изменения названия в Owncast несет только новое название.
Не требуется разрешение.
События федерации
Owncast exposes internal plugin event subscriptions for inbound Fediverse activity. Это события плагина, а не внешние HTTP вебхуки. Каждая подписка в этом разделе требует разрешения fediverse.inbound.
| Событие | Обработчик JavaScript | Обработчик Python | Нагрузка |
|---|---|---|---|
fediverse.follow | onFediverseFollow | @plugin.on_fediverse_follow | { actor } |
fediverse.like | onFediverseLike | @plugin.on_fediverse_like | { actor, target } |
fediverse.repost | onFediverseRepost | @plugin.on_fediverse_repost | { actor, target } |
fediverse.quote | onFediverseQuote | @plugin.on_fediverse_quote | FediverseQuote |
fediverse.mention | onFediverseMention | @plugin.on_fediverse_mention | FediverseInboundPost |
fediverse.reply | onFediverseReply | @plugin.on_fediverse_reply | FediverseInboundPost |
fediverse.activity | onFediverse | @plugin.on_fediverse | Сырой объект JSON ActivityPub |
Следите, лайкайте, репостите и цитируйте
interface FediverseActor {
name: string;
handle: string;
url?: string;
image?: string;
}
interface FediverseEngagement {
actor: FediverseActor;
target?: { url: string };
}
interface FediverseQuote extends FediverseEngagement {
target: { url: string }; // locally authored post being quoted
content?: string; // rendered HTML from the source instance
contentText?: string; // plain-text version
url: string; // remote quote post permalink
postedAt?: string; // ISO-8601
inReplyTo?: string;
attachments?: { url: string; mediaType: string; alt?: string }[];
language?: string;
}
Подписка содержит только actor. Likes and reposts also contain target.
A quote contains target for the locally authored post and url for the remote quote post. Content metadata is included when the requesting server embeds its quote Note in the QuoteRequest. Some servers send only the quote post IRI, so content, contentText, postedAt, inReplyTo, attachments, and language are optional.
- JavaScript
- Python
module.exports = definePlugin({
onFediverseFollow(event) {
owncast.chat.send(`new follower: ${event.actor.handle}`);
},
onFediverseQuote(event) {
console.log(`${event.actor.handle}: ${event.contentText ?? 'quoted your post'}`);
console.log(`quote: ${event.url}`);
},
});
@plugin.on_fediverse_follow
def thank(event):
owncast.chat.send(f"new follower: {event.actor.handle}")
@plugin.on_fediverse_quote
def record_quote(event):
print(f"{event.actor.handle}: {event.content_text or 'quoted your post'}")
print(f"quote: {event.url}")
actor.handle — это полностью квалифицированный адрес, например, @alice@fediverse.example. Примеры подписки также вызывают owncast.chat.send, для этого отдельно необходимо разрешение chat.send:
{ "permissions": ["fediverse.inbound", "chat.send"] }
Упоминание и ответ
Оба получают FediverseInboundPost:
interface FediverseInboundPost {
actor: FediverseActor;
content: string; // rendered HTML from the source instance
contentText: string; // plain-text version, usually what you want
url: string; // permalink on the source instance
postedAt: string; // ISO-8601
inReplyTo?: string; // parent post URL, set when this is a reply
attachments?: { url: string; mediaType: string; alt?: string }[];
language?: string;
}
Эти специализированные хуки принимают проверенную активность Create, содержащую ровно одну Note. Заметка должна быть атрибутирована актеру активности. Упоминание должно адресовать местному актеру Owncast. Ответ должен ссылаться на сообщение, хранимое локальным экземпляром Owncast.
Используйте contentText для анализа или трансляции в чат. Используйте content только тогда, когда вам нужно оригинальное форматирование, и очищайте его перед рендерингом.
Сырая входящая активность
fediverse.activity принимает проверенную входящую активность ActivityPub как свой сырой объект JSON. Owncast отправляет его после того, как HTTP подписка проходит проверку, и происхождение актера совпадает с происхождением владельца ключа подписи.
Общий обработчик запускается дополнительно к специализированному обработчику. Например, принятая цитата может вызывать как onFediverseQuote, так и onFediverse.
- JavaScript
- Python
module.exports = definePlugin({
onFediverse(activity) {
if (typeof activity.type === 'string') {
console.log(`inbound activity: ${activity.type}`);
}
},
});
@plugin.on_fediverse
def record_activity(activity):
if isinstance(activity.type, str):
print(f"inbound activity: {activity.type}")
Проверка подписи и происхождения актера устанавливает, откуда пришла активность. Они не делают его поля безопасными. Относитесь к сырому объекту как к ненадежному входу плагина. Проверьте типы полей и обязательные значения, очистите содержимое перед его отображением и проверьте URL перед их загрузкой.
Цепочка фильтров
Фильтры просматривают сообщения чата перед их трансляцией, с возможностью переписывать или отбрасывать их. Они работают последовательно в порядке приоритета (сначала самые низкие), и любой фильтр может прервать цепочку: drop завершает ее, а modify передает новую нагрузку следующему фильтру.
Фильтр сообщений чата: chat.message.received (фильтр)
Обработчик фильтра получает ту же структуру ChatMessage, что и событие chat-message, и возвращает один из трех результатов:
- pass: пропустить сообщение без изменения.
- modify: заменить сообщение новой нагрузкой, которая передается следующему фильтру.
- drop: заблокировать сообщение с указанием причины. Цепочка останавливается здесь.
- JavaScript
- Python
module.exports = definePlugin({
filterChatMessage(msg) {
if (msg.body.includes('spam')) return filter.drop('spam');
if (msg.body.includes('damn'))
return filter.modify({ ...msg, body: msg.body.replace('damn', '****') });
return filter.pass();
},
});
@plugin.filter_chat_message
def clean(msg):
if "spam" in msg.body:
return filter.drop("spam")
if "damn" in msg.body:
return filter.modify({**msg.raw, "body": msg.body.replace("damn", "****")})
return filter.pass_()
Требуется разрешение chat.filter. Чтение или переписывание каждого сообщения чата является значительным побочным эффектом, поэтому администратор должен увидеть разрешение для его предоставления. Хост отклоняет загрузку, если плагин определяет обработчик фильтра без объявления разрешения.
Приоритет фильтра (необязательный)
Каждый фильтр может объявить приоритет. Более низкие числа выполняются раньше (по умолчанию 100). Используйте это, когда поведение вашего плагина зависит от того, были ли уже выполнены другие фильтры (например, фильтр нецензурной лексики должен обычно работать перед переводчиком). Смотрите на странице вашего SDK, где его установить.
Безопасность фильтра
- Ошибки обрабатываются как пропуск. Фильтр, бросающий исключение, никогда не блокирует чат. Цепочка продолжается с оригинальным сообщением.
- Фильтры имеют ограничение по времени в 50 мс. Медленный фильтр отменяется и обрабатывается как пропуск.
- После 5 последовательных сбоев (ошибок или тайм-аутов) плагин автоматически отключается на оставшееся время сеанса с единственной строкой лога. Успешный вызов фильтра сбрасывает счетчик, чтобы временные сбои не накапливались. Перезапустите хост, чтобы повторно включить.
Таблицы команд
Объявите таблицу команд для псевдонимов, таймеров, контроля модераторов, разобранных аргументов и автоматических списков !help. Контроль доступа использует личность отправителя (user.scopes, user.id), а не предположение по отображаемому имени.
- JavaScript
- Python
module.exports = definePlugin({
commands: {
uptime: { description: "How long we've been live", run: ctx => ctx.reply('a while!') },
},
});
plugin.commands({
"uptime": {"description": "How long we've been live",
"run": lambda ctx: ctx.reply("a while!")},
})
Смотрите Команды чата для полного справочника по таблицам команд (псевдонимы, таймеры, контроль модераторов, !help).
HTTP-обработчик
HTTP-запрос
Срабатывает для каждого запроса к /plugins/\<your-slug>/*, который не соответствует статическому файлу в public/. Возвращает объект ответа.
interface IncomingHttpRequest {
method: string;
path: string; // relative to /plugins/<your-slug>/
query: Record<string, string>;
headers: Record<string, string>;
body: string;
remoteAddr: string;
authenticated: boolean; // came from any authenticated Owncast session, admin or viewer
user?: { id: string; displayName: string; scopes: string[] }; // user-token requests only
}
interface OutgoingHttpResponse {
status?: number; // default 200
headers?: Record<string, string>;
body?: string;
}
- JavaScript
- Python
module.exports = definePlugin({
onHttpRequest(req) {
if (req.path === '/status') return { status: 200, body: '{"ok":true}' };
return { status: 404 };
},
});
@plugin.get("/status")
def status(req):
return {"status": 200, "body": '{"ok":true}'}
Конечные точки по умолчанию являются публичными. Защитите функции администратора на req.authenticated. Paths matching a key in admin.pages are auth-gated by the host before your handler runs, so for those routes you don't need to check.
Требуется разрешение http.serve. JavaScript SDK предоставляет единственный onHttpRequest для всех. Python SDK добавляет декларативные маршруты на основе пути/метода ( @plugin.get, @plugin.route, … ). Смотрите Обслуживание HTTP для полной модели запросов.
Аутентификация
Auth check hook
Срабатывает только для включенного плагина auth.gate и только при загрузке страницы зрителя /, а не на горячем пути (сегменты видео, API, чат). К моменту его выполнения хост уже проверил куки сессии зрителя и определил их личность. Ваш обработчик решает, должна ли эта сессия продолжаться. Это необязательно: опустите это, и действительный токен достаточно до его истечения.
Верните один из трех вердиктов через вспомогательную функцию authCheck:
- ok: оставить сессию как есть.
- refresh: оставить ее и повторно выдать куки, возможно, с новым
ttlв секундах (плавающий срок действия). - deny: завершить сессию и вернуть зрителя на экран входа. Так вы отзывает доступ (пользователь удален или забанен на верхнем уровне).
interface AuthCheckRequest {
user: {
id: string;
displayName: string;
scopes?: string[];
isAuthenticated?: boolean;
};
}
type AuthCheckResult =
{ action: 'ok' } | { action: 'refresh'; ttl?: number } | { action: 'deny'; reason?: string };
- JavaScript
- Python
const { definePlugin, owncast, authCheck } = require('@owncast/plugin-sdk');
module.exports = definePlugin({
onAuthCheck(req) {
if (owncast.kv.get(`banned:${req.user.id}`)) {
return authCheck.deny('access revoked');
}
return authCheck.ok();
},
});
from owncast_plugin import plugin, owncast, auth_check
@plugin.on_auth_check
def check(req):
if owncast.kv.get(f"banned:{req.user.id}"):
return auth_check.deny("access revoked")
return auth_check.ok()
Требуется auth.gate, и это закрывается: если обработчик выдает ошибку или истекает время, хост считает загрузку этой страницы как отказ. Поскольку проверка выполняется только на /, зритель, доступ которого вы отзываете, продолжает работать с любыми открытыми вкладками, пока они не обновятся или куки не истекут. Время жизни сессии ttl является строгим ограничением.
Обработчики содержимого
These two handlers let a plugin generate tab or extra-page HTML at request time. Use them when content should be personalised per viewer or depend on live stream data. They're the dynamic counterpart to shipping a static HTML file via a tab value's content member or manifest.extraPageContent.content.
Оба обработчика принимают ContentRequest:
interface ContentRequest {
slug: string; // manifest.tabs object key or manifest.extraPageContent.slug
user?: User; // viewer's chat identity: present when authenticated, absent for anonymous viewers
}
Вернуть полную HTML-строку для блока содержимого. Если вы не распознаете слаг, верните пустую строку.
- JavaScript
- Python
module.exports = definePlugin({
onTabContent(ctx) {
if (ctx.slug === 'stats') {
return `<h1>Live stats for ${ctx.user?.displayName ?? 'viewer'}</h1>`;
}
return '';
},
onPageContent(ctx) {
return ctx.slug === 'banner' ? '<p>Welcome!</p>' : '';
},
});
@plugin.on_tab_content("stats")
def stats(ctx):
name = ctx.user.display_name if ctx.user else "viewer"
return f"<h1>Live stats for {name}</h1>"
@plugin.on_page_content("banner")
def banner(ctx):
return "<p>Welcome!</p>"
Содержимое вкладки
Called when a value in the manifest.tabs object has no static content file. The host passes that value's object key as slug, so a single plugin can serve multiple tabs. Для подписки не требуется разрешение. Любые API Owncast, которые вы вызываете внутри обработчика, требуют своих обычных разрешений.
Содержимое страницы
Вызывается, когда manifest.extraPageContent не имеет статического content-файла. Хост передает слаг из манифеста, чтобы обработчик знал, какой слот содержимого запрашивается. Те же правила разрешения, что и для содержимого вкладки.
Смотрите Участие в интерфейсе для стороны манифеста.
События подключения SSE
Когда браузер открывает или закрывает один из потоков /plugins/\<name>/_sse/\<channel> вашего плагина, Owncast генерирует события sse.connect и sse.disconnect. Используйте их для отслеживания того, кто подключен, например, чтобы поддерживать актуальное количество для оверлея. Смотрите Обновления в реальном времени для стороны отправки данных в эти браузеры.
Подключение / отключение: sse.connect, sse.disconnect
interface SSEConnectionEvent {
channel: string; // which _sse/<channel> stream the browser opened
connectionId: number; // unique per connection for the life of the host process
user?: User; // present only when the connection carried a chat identity
}
- JavaScript
- Python
module.exports = definePlugin({
onSseConnect(e) {
/* e.connectionId, e.channel */
},
onSseDisconnect(e) {
/* same connectionId as the matching connect */
},
});
@plugin.on_sse_connect
def joined(e):
... # e.connection_id, e.channel
@plugin.on_sse_disconnect
def left(e):
...
connectionId стабилен на протяжении всего соединения, поэтому вы можете сопоставить отключение с соответствующим подключением и отслеживать одного и того же зрителя через несколько вкладок. Оба обработчика требуют разрешение http.sse.
Тик
Owncast отправляет событие tick примерно раз в секунду любому плагину, который определяет обработчик тиков. Используйте его для периодической работы, такой как сброс счетчиков или обновление кэшированных данных. Определение обработчика позволяет вам подключиться, поэтому плагины, которые пропускают его, ничего не платят.
Периодический тик: tick
interface TickEvent {
now: number; // host wall-clock time in unix milliseconds when the tick fired
}
- JavaScript
- Python
module.exports = definePlugin({
onTick(e) {
/* e.now */
},
});
@plugin.on_tick
def each_second(e):
... # e.now
Для одноразового или по индивидуальному расписанию используйте таймеры (owncast.timer.setTimeout и setInterval) вместо тика. Разрешение не требуется.
События плагин-к-плагину
Custom events are directed hooks for plugin-to-plugin composition. A plugin declares a local hook name, and the host registers it as \<plugin-slug>.\<hook>. The slug comes from the receiving plugin's manifest, so another plugin cannot claim the same fully qualified hook. Declaring a hook requires no permission. Emitting to one requires events.emit.
- JavaScript
- Python
// In the plugin whose slug is "announcer":
module.exports = definePlugin({
on: {
'announcement.broadcast'(payload) {
/* react */
},
},
});
// Another plugin targets announcer's fully qualified hook:
owncast.events.emit('announcer.announcement.broadcast', { text: 'We are live' });
# In the plugin whose slug is "announcer":
@plugin.on("announcement.broadcast")
def announce(payload):
...
# Another plugin targets announcer's fully qualified hook:
owncast.events.emit(
"announcer.announcement.broadcast",
{"text": "We are live"},
)
The receiving handler uses only its local hook name. Emitters use the full
\<recipient-slug>.\<hook> target. Built-in event names remain canonical and
cannot be claimed as custom hooks.
Смотрите API Owncast для API emit.
Полное руководство по обработчикам
Каждая строка является событием времени выполнения. Имя обработчика следует конвенции вашего SDK: методы camelCase (onChatMessage) в JavaScript, декораторы @plugin.* (@plugin.on_chat_message) в Python.
| Событие | Пayload | Permission |
|---|---|---|
chat.message.received | ChatMessage | none |
chat.user.joined | Пользователь | none |
chat.user.parted | Пользователь | none |
chat.user.renamed | { user, previousName } | none |
chat.message.moderated | { messageId, visible, moderator} | none |
stream.started | { startedAt, title, summary } | none |
stream.stopped | { stoppedAt } | none |
stream.title.changed | { from, to } | none |
fediverse.follow | { actor } | fediverse.inbound |
fediverse.like | { actor, target } | fediverse.inbound |
fediverse.repost | { actor, target } | fediverse.inbound |
fediverse.quote | FediverseQuote | fediverse.inbound |
fediverse.mention | FediverseInboundPost | fediverse.inbound |
fediverse.reply | FediverseInboundPost | fediverse.inbound |
fediverse.activity | Сырой объект JSON ActivityPub | fediverse.inbound |
| фильтр сообщений чата | ChatMessage | chat.filter |
| HTTP запрос | IncomingHttpRequest | http.serve |
| проверка аутентификации | AuthCheckRequest | auth.gate |
sse.connect | SSEConnectionEvent | http.sse |
sse.disconnect | SSEConnectionEvent | http.sse |
tick | { now } | none |
| содержимое вкладки | ContentRequest | none. Какие бы API ни вызывал обработчик |
| содержимое страницы | ContentRequest | none. Какие бы API ни вызывал обработчик |
| custom hooks | (per-hook) | none to declare, events.emit to target one |
Subscribing to ungated built-in events and declaring custom hooks requires no permission. Ограниченные хуки требуют разрешения, указанного в таблице. Вызов API Owncast изнутри обработчика также требует разрешения API. Смотрите API Owncast для каталога методов и того, что каждое из них предоставляет.
Improve this page
See something missing or incorrect? Edit this page and improve the documentation for everyone.
