Como outra extensão do Flarum 2 pode reagir à atividade do chat, publicar em canais, mudar quem pode fazer o quê, adicionar campos na API e colocar interface dentro do chat. Tudo aqui é um ponto de extensão público, usado pelo próprio chat.
- Pacote Composer:
ramon/chat
- Id da extensão:
ramon-chat
- Namespace PHP:
Ramon\Chat\
- Imports JS:
ext:ramon/chat/…
- Namespace de tradução:
ramon-chat.*
Visão geral
Três regras moldam todos os pontos de extensão abaixo.
- Um único caminho de escrita. Mensagens de usuário passam pelo
Service\MessageDispatcher, que valida, aplica rate limit, salva, vincula uploads, sincroniza menções e atualiza contadores de não lidas em uma só transação. Construa em cima dele em vez de salvar um Message por conta própria.
- Eventos após o commit. A maioria dos eventos dispara depois que a escrita no banco já é definitiva, então o listener pode enfileirar trabalho ou chamar outros serviços com segurança. Os poucos que disparam dentro da transação estão marcados abaixo.
- Policies se abstêm por padrão. Toda ability do chat retorna
null quando não tem opinião, então sua policy pode adicionar um deny() ou allow() sem brigar com a do chat.
As integrações do próprio chat (tags, realtime, menções, GDPR, audit, fof/upload) são opcionais. Faça a sua do mesmo jeito: declare ramon/chat em suggest no composer.json se sua extensão também funciona sem ele, e proteja os extenders com Extend\Conditional.
// extend.php
use Flarum\Extend;
return [
(new Extend\Conditional())
->whenExtensionEnabled('ramon-chat', fn () => [
(new Extend\Event())
->listen(\Ramon\Chat\Event\MessageWasSent::class, Listener\MirrorChatMessage::class),
]),
];
Ouvindo eventos
Todos os eventos ficam em Ramon\Chat\Event. São classes simples, com propriedades públicas promovidas no construtor. $actor é null quando a mudança veio do sistema: um webhook, um job de retenção ou um anúncio de bot.
Mensagens
MessageWasSent — Message $message, ?User $actor — após o commit
MessageWasEdited — Message $message, ?User $actor — após salvar
MessageWasDeleted — Message $message, ?User $actor (exclusão lógica) — após salvar
MessageWasRestored — Message $message, ?User $actor — após salvar
MessageWasPurged — int $messageId, Channel $channel, ?int $threadId, ?User $actor (a linha já não existe) — após excluir
ReactionToggled — Message $message, User $actor, string $emoji, bool $added — após salvar
MessagePinToggled — Message $message, ?User $actor — após salvar
MessageWasMoved — Message $message, Channel $from, Channel $to, ?User $actor, uma vez por mensagem — dentro da transação
MessagesWereMoved — array $messageIdsBySource, array $sources, Channel $target, array $threadIds, ?User $actor, uma vez por movimentação — após o commit
Tópicos e canais
ThreadWasCreated — Thread $thread, ?User $actor — após o commit
ThreadWasEdited — Thread $thread, ?User $actor, só quando algo mudou — após salvar
ChannelWasCreated — Channel $channel, ?User $actor, também para novos canais diretos — após o commit
ChannelWasEdited — Channel $channel, ?User $actor, também quando a imagem muda — após salvar
ChannelStatusChanged — Channel $channel, string $previousStatus, ?User $actor — após salvar
ChannelWasArchived — Channel $channel, Discussion $discussion, ?User $actor — dentro da transação
ChannelWasDeleted — Channel $channel, ?User $actor (exclusão lógica) — após salvar
ChannelModeratorChanged — Channel $channel, User $user, bool $isModerator, ?User $actor — após salvar
ChannelRanksChanged — Channel $channel, ?User $actor — após salvar
Participação, convites e propriedade
UserJoinedChannel — Channel $channel, User $user, ?User $actor, bool $hidden, bool $automatic, bool $acceptedInvite, ?User $invitedBy — após salvar
UserLeftChannel — Channel $channel, User $user, ?User $actor, bool $hidden — após salvar
UserWasInvited — Channel $channel, User $user, User $inviter, ChannelInvite $invite, uma vez por convite — após salvar
InviteWasCancelled — Channel $channel, User $user, User $actor, ?User $inviter — após excluir
InviteWasDeclined — Channel $channel, User $user, ?User $inviter — após excluir
OwnershipTransferRequested — Channel $channel, ChannelTransfer $transfer, User $from, User $to — após o commit
OwnershipTransferEnded — Channel $channel, ChannelTransfer $transfer, string $reason, ?User $actor. $reason é DECLINED, CANCELLED ou REPLACED; a linha já foi excluída — após o commit
ChannelOwnershipTransferred — Channel $channel, User $newOwner, ?User $previousOwner, ?ChannelTransfer $transfer, ?User $initiator, bool $inherited. $inherited é verdadeiro na sucessão, quando o dono saiu — após o commit
FlagsChanged — ?User $actor: a fila de moderação mudou — após salvar
Entradas em massa são silenciosas. Adicionar um grupo inteiro a um canal automaticamente (MembershipManager::addMany, os jobs de auto-join) não dispara UserJoinedChannel. Use esse evento para entradas feitas por pessoas, não como registro completo de participação.
Exemplo: espelhar mensagens para um log externo
// src/Listener/MirrorChatMessage.php
namespace Acme\ChatMirror\Listener;
use Illuminate\Contracts\Bus\Dispatcher;
use Ramon\Chat\Event\MessageWasSent;
use Acme\ChatMirror\Job\MirrorMessageJob;
class MirrorChatMessage
{
public function __construct(protected Dispatcher $bus)
{
}
public function handle(MessageWasSent $event): void
{
$message = $event->message;
if ($message->isSystem() || $message->channel->isPrivate()) {
return;
}
$this->bus->dispatch(new MirrorMessageJob((int) $message->id));
}
}
Passe ids para os jobs, não modelos nem conteúdo, e recarregue a mensagem dentro do job. O conteúdo de um canal privado não pode sair do fórum sem verificar quem tem permissão de ler.
Publicando mensagens pela sua extensão
Injete Ramon\Chat\Service\MessageDispatcher. Ele é resolvido automaticamente pelo container, então você também pode decorá-lo com $container->extend().
send(Channel $channel, User $actor, string $content, ?Thread $thread = null, ?Message $replyTo = null, array $uploadIds = [], bool $createThread = false): Message — uma mensagem escrita por uma pessoa. O conteúdo passa pelo formatter do fórum, então menções, emoji e BBCode funcionam.
sendSystem(Channel $channel, string $key, array $data = []): Message — um aviso de uma linha, sem autor, renderizado no cliente a partir de uma chave de tradução.
assertMayRevise(Channel $channel, User $actor, string $content): void — para rodar as mesmas checagens de conteúdo antes de uma edição que você mesmo faz.
O dispatcher não verifica permissões. Ele valida conteúdo, modo lento e rate limits, mas não se o ator pode publicar. Chame $actor->assertCan('postMessage', $channel) antes, como faz o próprio endpoint do chat.
use Ramon\Chat\Channel;
use Ramon\Chat\Service\MessageDispatcher;
$channel = Channel::whereVisibleTo($actor)->findOrFail($channelId);
$actor->assertCan('postMessage', $channel);
$message = $this->dispatcher->send($channel, $actor, 'Saiu a versão 2.4. Notas na tag de anúncios.');
Avisos do sistema
sendSystem() guarda uma chave e dados. O cliente renderiza com ramon-chat.forum.message.system.{key}, passando $data como parâmetros de tradução, que entram como texto. Distribua a chave no seu próprio arquivo de tradução, dentro do namespace do chat.
$this->dispatcher->sendSystem($channel, 'acme_deploy_finished', [
'version' => '2.4.0',
]);
# locale/pt-BR.yml
ramon-chat:
forum:
message:
system:
acme_deploy_finished: "A versão {version} foi publicada."
Prefixe suas chaves com o nome da sua extensão, para nunca colidirem com as do chat: user_joined, user_added, user_accepted_invite, user_left, channel_closed, channel_reopened, channel_archived, discussion_started.
As factories dos modelos (Message::build, buildSystem, buildBot, Channel::build, Thread::build) apenas montam um modelo não salvo. Elas não atualizam contadores, estado de não lidas nem menções, e não disparam eventos — é por isso que o dispatcher existe.
Canais, membros e convites
MembershipManager — join(Channel, User, ?int $notificationLevel = null, bool $hidden = false), leave(Channel, User, ?User $actor = null), updatePreferences(…), addMany(Channel, array $userIds). Sem eventos: dispare UserJoinedChannel / UserLeftChannel você mesmo se quiser que a sala fique sabendo.
InvitationManager — invite(Channel, iterable $users, User $inviter), accept, decline, cancel, mayEnter(User, Channel).
ChannelArchiver — archive(Channel, User, ?int $discussionId = null, ?string $title = null): Discussion, unarchive(Channel, User).
UnreadTracker — markChannelRead(Channel, User, ?int), totalUnreadFor(User), totalUnreadMentionsFor(User).
ChannelRanks — book(Channel), forUser(Channel, ?int $userId), assign(Channel, User $actor, User $user, array $rankIds). É um singleton.
Permissões e policies
O chat registra ChannelPolicy, MessagePolicy, ThreadPolicy e uma GlobalPolicy com Extend\Policy. Adicione sua própria policy para o mesmo modelo para restringir ou ampliar uma ability. O Flarum resolve forceDeny acima de forceAllow, acima de deny, acima de allow.
- Global —
useChat, startDirect, createChannel
Channel — view, postMessage, join, joinHidden, edit, close, archive, unarchive, delete, manageMembers, manageModerators, manageRanks, transferOwnership, mentionChannelWide, viewMembers
Message — view, edit, delete, forceDelete, restore, react, bookmark, flag, reply, createThread, move, pin, viewRevisions
Thread — view, postMessage, rename
Exemplo: horário de silêncio para quem não é moderador
// src/Access/QuietHoursPolicy.php
namespace Acme\QuietHours\Access;
use Carbon\Carbon;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
use Ramon\Chat\Channel;
class QuietHoursPolicy extends AbstractPolicy
{
public function postMessage(User $actor, Channel $channel)
{
if ($channel->isDirect() || $actor->hasPermission('ramon-chat.moderate')) {
return null;
}
return Carbon::now()->hour < 7 ? $this->deny() : null;
}
}
// extend.php
(new Extend\Policy())->modelPolicy(\Ramon\Chat\Channel::class, Access\QuietHoursPolicy::class),
Os recursos da API expõem o resultado como flags (canPostMessage, canEdit, canReact e assim por diante), então a interface do chat esconde o campo de escrita e os botões sem nenhuma mudança de frontend da sua parte. Entregas por webhook não têm ator e não passam por postMessage.
Strings de permissão
Verifique estas com $actor->hasPermission(…) quando precisar da concessão bruta em vez de uma ability.
ramon-chat.use (Membros) — abrir o chat
ramon-chat.accessPrivateChannels (ninguém por padrão) — ver canais de categorias privadas
ramon-chat.startDirect (Membros) — iniciar mensagens diretas
ramon-chat.upload (Membros) — anexar arquivos
ramon-chat.react (Membros) — reações
ramon-chat.createThread (Membros) — iniciar tópicos
ramon-chat.sendStickers (Membros) — shortcodes de figurinhas (com ramon/stickers)
ramon-chat.flagMessage (Membros) — denunciar mensagens
ramon-chat.mentionChannelWide (Moderadores) — @here e @all
ramon-chat.bypassSlowMode (Moderadores) — ignorar o modo lento
ramon-chat.moderate (Moderadores) — excluir, mover e restaurar mensagens, e a fila de denúncias
ramon-chat.inspectChannels (Moderadores) — entrar em canais privados de forma oculta
ramon-chat.pinMessage (ninguém por padrão) — fixar mensagens
ramon-chat.editChannel (ninguém por padrão) — editar qualquer canal
ramon-chat.createChannel (Membros, no modo "membros") — criar canais quando os membros são donos dos canais
ramon-chat.manageOwnChannels (Membros, no modo "membros") — gerenciar os canais que você criou
ramon-chat.editOwnChannels (Membros, no modo "membros") — editar os canais que você criou
A visibilidade de canais é garantida por escopos de visibilidade em Channel, Message, Thread e Upload. Carregue sempre os modelos do chat com whereVisibleTo($actor); um find() cru ignora canais privados e restrições de tag.
Recursos da API
O chat é um conjunto de classes AbstractDatabaseResource do Flarum 2, então Extend\ApiResource funciona nelas como funciona nas do core.
chat-channels — Api\Resource\ChannelResource — modelo Channel
chat-messages — Api\Resource\MessageResource — modelo Message
chat-threads — Api\Resource\ThreadResource — modelo Thread
chat-uploads — Api\Resource\UploadResource — modelo Upload
chat-message-flags — Api\Resource\MessageFlagResource — modelo MessageFlag
chat-webhooks — Api\Resource\WebhookResource — modelo Webhook (só admin)
Adicionando um campo
// extend.php
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Ramon\Chat\Api\Resource\ChannelResource;
use Ramon\Chat\Channel;
(new Extend\ApiResource(ChannelResource::class))
->fields(fn () => [
Schema\Boolean::make('acmeCanExport')
->get(fn (Channel $channel, Context $context) =>
$context->getActor()->can('acme.exportChat') && ! $channel->isDirect()
),
]),
O endpoint de listagem de canais devolve até 50 linhas, e o de mensagens até 100. Uma query dentro de ->get() roda uma vez por linha. Prefira dados que já estão no modelo, uma relação com eager loading ou uma coluna de contador.
Campos que o chat adiciona ao core
- Forum:
canUseChat, canCreateChatChannel, canStartChatDirect, canUploadChatFiles, canSendChatStickers, canFlagChatMessages, canModerateChat, canMentionChatChannelWide, canBypassChatSlowMode, canCurateChatChannels, chatOpenFlagsCount.
- User (apenas o próprio):
chatUnreadChannelsCount, chatUnreadMessagesCount, chatUnreadMentionsCount.
- Relações de User:
$user->chatChannels e $user->chatMessages.
Filtros
Usáveis como filter[…] nos endpoints de listagem.
/api/chat-channels — type, status, following, q
/api/chat-messages — channel, thread, lessThan, greaterThan, updatedSince, bookmarked, pinned, q
/api/chat-threads — channel, participating
/api/users — chatChannel (membros de um canal)
Realtime
Com o flarum/realtime ativo, o chat envia eventos para o canal privado de cada destinatário, private-user={id}. Os destinatários são os membros do canal que podem vê-lo; nada vai para um canal público.
ramonChat.message — a mensagem completa: id, channelId, threadId, contentHtml, user, uploads, mentionedUsers, authorRank e mais. Não é enviada para o autor.
ramonChat.messageChanged — mesmo formato, em edição, exclusão, restauração e fixação.
ramonChat.messagePurged — {id, channelId, threadId}
ramonChat.reaction — {messageId, channelId, emoji, userId, added}
ramonChat.thread — {threadId, channelId, originalMessageId, title, repliesCount, lastMessageId}
ramonChat.channel — {channelId, status, …}, com created: true ou deleted: true nessas mudanças.
ramonChat.membership — {channelId, userId, action, actorId, userCount, …}. action é um de invited, joined, left, invite_declined, invite_cancelled, promoted, demoted, owner_changed, transfer_requested, transfer_ended.
ramonChat.messagesMoved — {channelId, movedOut[]} para as origens, {channelId, movedIn[]} para o destino.
ramonChat.typing — {channelId, userId, username, typing, expiresIn}
ramonChat.ranks — {channelId, rankBook}
ramonChat.flags — vazio; a fila de denúncias mudou. Enviado aos moderadores.
Enviando seu próprio evento para os membros de um canal
Ramon\Chat\Realtime\ChatBroadcaster reaproveita a mesma filtragem de destinatários. Ele não faz nada quando o realtime não está configurado, então é seguro chamá-lo sem condição.
use Ramon\Chat\Realtime\ChatBroadcaster;
$this->broadcaster->toChannelMembers($channel, 'acme.pollUpdated', [
'channelId' => (int) $channel->id,
'pollId' => (int) $poll->id,
], exceptUserId: (int) $actor->id);
Envie ids e deixe o cliente buscar o resto pela API, que aplica a visibilidade de novo. Outros métodos: toUser(int $userId, string $event, array $payload) e toModerators(string $event, array $payload).
// cliente
const channel = app.websocket_channels?.user;
channel?.bind('acme.pollUpdated', (data) => {
app.store.find('acme-polls', String(data.pollId)).then(() => m.redraw());
});
Webhooks de entrada
Administradores criam um webhook por canal nas configurações do chat e recebem uma URL com uma chave secreta. Qualquer serviço capaz de enviar um payload no estilo do Slack consegue publicar no canal.
curl -X POST "https://forum.example.com/api/chat/hooks/<chave>" \
-H "Content-Type: application/json" \
-d '{"text": "Build 1842 passou na main."}'
- O texto é lido de
text, depois de data.attributes.text, depois dos campos fallback, text, title e pretext de attachments[]. Um corpo de formulário com payload=<json> também funciona.
- A mensagem não tem autor, é cortada no limite de tamanho do canal e dispara
MessageWasSent com ator null.
- Limites: 120 requisições por minuto por IP e 60 por minuto por webhook. A configuração
ramon-chat.webhooks_enabled desliga todos os webhooks com um 403.
- Uma entrega bem-sucedida responde
201 com o id da nova mensagem.
Para uma extensão do Flarum no mesmo fórum, chame o MessageDispatcher. Webhook é para serviços fora do fórum.
Frontend
A maior parte da interface do chat fica em um chunk carregado sob demanda, na primeira vez que o chat abre. O extend() e o override() do Flarum 2 aceitam um caminho de módulo para esse caso e aplicam sua mudança assim que o módulo carrega.
Exemplo: adicionar um botão em toda mensagem
// js/src/forum/index.tsx
import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import Button from 'flarum/common/components/Button';
app.initializers.add('acme-chat-translate', () => {
extend('ext:ramon/chat/forum/components/ChatMessage', 'actions', function (vnode, message) {
if (!vnode || message.isSystem() || message.isRedacted()) return;
vnode.children.push(
<Button
className="ChatMessage-action"
icon="fas fa-language"
aria-label={app.translator.trans('acme-chat-translate.forum.translate')}
onclick={() => translate(message)}
/>
);
});
});
actions() retorna null quando a mensagem não tem ações para quem está vendo, então cheque o vnode antes de anexar. O chat não tem ItemList próprio; os métodos abaixo são os lugares onde se enganchar. Todos os módulos ficam em ext:ramon/chat/forum/components/….
ChatMessage — content, actions, reactions, uploads, avatar, replyPreview, threadIndicator, systemRow, tombstone, editedMark
ChatComposer — placeholder, sendHint, contextBar, pendingAttachments, submit, onKeyDown, insertAtCursor
ChannelView — header, pinnedBar, typingIndicator, scrollDownButton
ChatSidebar — row(channel), createChannel
ChatDrawerPanel — channelMenu, floatingButton, body
ChatSelectionBar — moveControls, quote, copy, move
ChannelInfoModal — content, settings, moderation, memberTab, memberBadge, memberControls
ChatNavButton — view, open (no bundle principal)
Esses são métodos protegidos dos componentes do chat, não uma API versionada. Fixe ramon/chat em uma faixa de versão menor no seu composer.json e teste a cada novo lançamento.
Estado e modelos
O estado compartilhado é um singleton, usado pela gaveta, pela página inteira e pelos handlers de realtime.
import chatState from 'ext:ramon/chat/forum/state/chat';
const channel = chatState.channel(12);
await chatState.send(12, 'Daily em cinco minutos.', { threadId: null });
const stop = chatState.onMembershipChange((channelId) => {
console.debug('participação mudou no canal', channelId);
});
- Modelos da store:
chat-channels, chat-messages, chat-threads, chat-uploads, chat-message-flags, importáveis de ext:ramon/chat/common/models/…. Os métodos de atributo espelham os campos da API, mais auxiliares como Channel.isDirect(), Channel.hasUnread(), Message.isSystem() e Message.mentionsActor().
- Mensagens diretas:
startDirectMessage(user), do bundle principal, abre ou cria um canal direto com aquele usuário.
- Abrindo o chat:
ChatDrawer.open(), ou um link para as rotas /chat, /chat/c/{id} e /chat/c/{id}/t/{threadId}.
- Notificações: os tipos de notificação do chat são
chatMention (com e-mail), chatChannelInvite, chatChannelInviteDeclined, chatMessageFlagged, chatModeratorPromoted, chatOwnershipTransfer, chatOwnershipTransferDeclined e chatOwnershipInherited.
Formatação
As mensagens são parseadas e renderizadas pelo formatter compartilhado do fórum. Qualquer coisa que sua extensão adicione com Extend\Formatter (BBCode, embeds, tags customizadas) também aparece no chat. Se a renderização lançar erro, o chat cai para texto puro escapado naquela mensagem.
Configurações e payload
Valores que o fórum serializa para todo visitante, lidos com app.forum.attribute('ramon-chat.title'):
- int —
ramon-chat.maxMessageLength, ramon-chat.minMessageLength
- int, em bytes —
ramon-chat.maxUploadSize
- bool —
ramon-chat.allowUploads, ramon-chat.threadingDefault, ramon-chat.allowArchivingChannels, ramon-chat.showIcon
- string —
ramon-chat.title, ramon-chat.icon, ramon-chat.notificationSound
- string, int ou null —
ramon-chat.botName, ramon-chat.botAvatarUrl, ramon-chat.botUserId
Configurações só de servidor, lidas com SettingsRepositoryInterface: ramon-chat.channel_ownership (admin ou members), channel_retention_days, dm_retention_days, max_messages_per_second, message_edit_window_minutes, upload_storage, webhooks_enabled.
As preferências de usuário ficam sob chaves ramon-chat.* em users.preferences: enabled, sound, emailNotifications, openInDrawer, sendWithCtrlEnter, allowChannelWideMentions.
Os dados de usuários excluídos são tratados pelo tipo de dado do flarum/gdpr do próprio chat (ChatMessages). Se sua extensão guarda linhas que apontam para mensagens ou canais do chat, registre seu próprio tipo de GDPR para elas.
Limitações conhecidas
- Não há
ItemList na interface do chat. As ações de mensagem e os menus de canal são montados como arrays dentro de métodos, então você estende o vnode retornado.
- Não há registro para novos tipos de evento de realtime; envie os seus com o
ChatBroadcaster e faça o bind no cliente.
- Os campos
username e emoji de um webhook são guardados, mas ainda não aparecem nas mensagens entregues.
- Entradas em massa e automáticas não disparam
UserJoinedChannel.
- A limpeza por retenção remove mensagens antigas sem
MessageWasDeleted nem MessageWasPurged.
Cobre o código do ramon/chat em outubro de 2026. Nomes de classes, eventos e payloads foram lidos do código; nomes de métodos nos componentes de interface podem mudar entre versões menores.