Перейти к основному содержимому

Plugin Events

Плагины реагируют на происходящее в Owncast, определяя обработчик для каждого события, которое их интересует. Определяйте только те обработчики, которые хотите: отсутствующий обработчик означает отсутствие подписки, а SDK получает список подписок манифеста из имеющихся обработчиков, поэтому ничего больше не нужно синхронизировать.

Код ниже показан для обоих SDK. Выберите свой язык с помощью вкладок, и ваш выбор будет сопровождать вас по всей документации. Новый в этом? Сначала ознакомьтесь со страницами настройки 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).

Нагрузки показываются в их форме передачи. Каждый 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"
}
module.exports = definePlugin({
onChatMessage(msg) {
if (msg.user?.scopes?.includes('MODERATOR')) {
owncast.chat.send(`hi mod ${msg.user.displayName}`);
}
},
});

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[];
}
module.exports = definePlugin({
onChatUserJoined(user) {
owncast.chat.send(`welcome ${user.displayName}`);
},
onChatUserParted(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 }
module.exports = definePlugin({
onStreamStarted(info) {
owncast.chat.send(`live now: ${info.title}`);
},
onStreamStopped(info) {
/* … */
},
onStreamTitleChanged(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.followonFediverseFollow@plugin.on_fediverse_follow{ actor }
fediverse.likeonFediverseLike@plugin.on_fediverse_like{ actor, target }
fediverse.repostonFediverseRepost@plugin.on_fediverse_repost{ actor, target }
fediverse.quoteonFediverseQuote@plugin.on_fediverse_quoteFediverseQuote
fediverse.mentiononFediverseMention@plugin.on_fediverse_mentionFediverseInboundPost
fediverse.replyonFediverseReply@plugin.on_fediverse_replyFediverseInboundPost
fediverse.activityonFediverse@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.

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}`);
},
});

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.

module.exports = definePlugin({
onFediverse(activity) {
if (typeof activity.type === 'string') {
console.log(`inbound activity: ${activity.type}`);
}
},
});

Проверка подписи и происхождения актера устанавливает, откуда пришла активность. Они не делают его поля безопасными. Относитесь к сырому объекту как к ненадежному входу плагина. Проверьте типы полей и обязательные значения, очистите содержимое перед его отображением и проверьте URL перед их загрузкой.

Цепочка фильтров

Фильтры просматривают сообщения чата перед их трансляцией, с возможностью переписывать или отбрасывать их. Они работают последовательно в порядке приоритета (сначала самые низкие), и любой фильтр может прервать цепочку: drop завершает ее, а modify передает новую нагрузку следующему фильтру.

Фильтр сообщений чата: chat.message.received (фильтр)

Обработчик фильтра получает ту же структуру ChatMessage, что и событие chat-message, и возвращает один из трех результатов:

  • pass: пропустить сообщение без изменения.
  • modify: заменить сообщение новой нагрузкой, которая передается следующему фильтру.
  • drop: заблокировать сообщение с указанием причины. Цепочка останавливается здесь.
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();
},
});

Требуется разрешение chat.filter. Чтение или переписывание каждого сообщения чата является значительным побочным эффектом, поэтому администратор должен увидеть разрешение для его предоставления. Хост отклоняет загрузку, если плагин определяет обработчик фильтра без объявления разрешения.

Приоритет фильтра (необязательный)

Каждый фильтр может объявить приоритет. Более низкие числа выполняются раньше (по умолчанию 100). Используйте это, когда поведение вашего плагина зависит от того, были ли уже выполнены другие фильтры (например, фильтр нецензурной лексики должен обычно работать перед переводчиком). Смотрите на странице вашего SDK, где его установить.

Безопасность фильтра

  • Ошибки обрабатываются как пропуск. Фильтр, бросающий исключение, никогда не блокирует чат. Цепочка продолжается с оригинальным сообщением.
  • Фильтры имеют ограничение по времени в 50 мс. Медленный фильтр отменяется и обрабатывается как пропуск.
  • После 5 последовательных сбоев (ошибок или тайм-аутов) плагин автоматически отключается на оставшееся время сеанса с единственной строкой лога. Успешный вызов фильтра сбрасывает счетчик, чтобы временные сбои не накапливались. Перезапустите хост, чтобы повторно включить.

Таблицы команд

Объявите таблицу команд для псевдонимов, таймеров, контроля модераторов, разобранных аргументов и автоматических списков !help. Контроль доступа использует личность отправителя (user.scopes, user.id), а не предположение по отображаемому имени.

module.exports = definePlugin({
commands: {
uptime: { description: "How long we've been live", run: 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;
}
module.exports = definePlugin({
onHttpRequest(req) {
if (req.path === '/status') return { status: 200, body: '{"ok":true}' };
return { status: 404 };
},
});

Конечные точки по умолчанию являются публичными. Защитите функции администратора на 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 };
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();
},
});

Требуется 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-строку для блока содержимого. Если вы не распознаете слаг, верните пустую строку.

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>' : '';
},
});

Содержимое вкладки

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
}
module.exports = definePlugin({
onSseConnect(e) {
/* e.connectionId, e.channel */
},
onSseDisconnect(e) {
/* same connectionId as the matching connect */
},
});

connectionId стабилен на протяжении всего соединения, поэтому вы можете сопоставить отключение с соответствующим подключением и отслеживать одного и того же зрителя через несколько вкладок. Оба обработчика требуют разрешение http.sse.

Тик

Owncast отправляет событие tick примерно раз в секунду любому плагину, который определяет обработчик тиков. Используйте его для периодической работы, такой как сброс счетчиков или обновление кэшированных данных. Определение обработчика позволяет вам подключиться, поэтому плагины, которые пропускают его, ничего не платят.

Периодический тик: tick

interface TickEvent {
now: number; // host wall-clock time in unix milliseconds when the tick fired
}
module.exports = definePlugin({
onTick(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.

// 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' });

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.

СобытиеПayloadPermission
chat.message.receivedChatMessagenone
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.quoteFediverseQuotefediverse.inbound
fediverse.mentionFediverseInboundPostfediverse.inbound
fediverse.replyFediverseInboundPostfediverse.inbound
fediverse.activityСырой объект JSON ActivityPubfediverse.inbound
фильтр сообщений чатаChatMessagechat.filter
HTTP запросIncomingHttpRequesthttp.serve
проверка аутентификацииAuthCheckRequestauth.gate
sse.connectSSEConnectionEventhttp.sse
sse.disconnectSSEConnectionEventhttp.sse
tick{ now }none
содержимое вкладкиContentRequestnone. Какие бы API ни вызывал обработчик
содержимое страницыContentRequestnone. Какие бы 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.

Contributors to this documentation