Miniapps compartilhados
Use uma tela quando vários participantes precisam acompanhar o mesmo estado. Para uma pergunta individual, prefira formulários privados. O exemplo complementa o tutorial e exige commands e miniapps; ele não precisa de publish_voice.
Referência: BotScreen, BotScreenCreate, BotScreenPatch, BotScreenActionEvent e BotScreenBridge.

Telas programáveis compartilhadas
Uma tela é um miniapp HTML/CSS/JavaScript apresentado no palco de voz. Quem está na sala recebe um convite no mesmo canto dos avisos de compartilhamento de tela e escolhe se quer visualizar. Não há card de miniapp no chat nem abertura automática. Diferentemente dos formulários privados de ctx.prompt(), ele aceita vários participantes e continua ativo depois que o handler do comando termina. O /jogo-da-velha do MonkyBot demonstra dois jogadores e espectadores; as regras continuam no bot, não no JavaScript de quem está vendo a tela.
O cartão permanece no palco junto de câmeras e compartilhamentos, mesmo com a visualização fechada. Abrir miniapp inicia a visualização local e coloca o miniapp em foco no palco, assim como abrir pelo convite, para qualquer participante. Sair do miniapp a encerra e devolve o cartão ao estado fechado, sem encerrar o miniapp para os demais. Antes disso, o miniapp pode perguntar algo, como se a pessoa quer salvar a partida (veja Ciclo de vida da visualização). Focar ou voltar à grade só altera o layout: não recarrega a tela nem muda quem ocupa as vagas de jogador. Navegar pelo Monky também não fecha a visualização: enquanto a pessoa continua na chamada, o miniapp fica aberto em segundo plano (veja Segundo plano). Abrir uma tela para assistir não equivale a entrar na partida.
Encerrar miniapp é outra ação: remove a instância, o cartão e os convites para todos e fecha as visualizações abertas. Só aparece para quem invocou o comando criador ou para um administrador (ADMINISTRATOR, incluindo o dono do servidor). O servidor verifica a mesma autorização e a presença desta conexão na sala; ocultar o botão não é a proteção. O criador é a identidade autenticada creatorUserId, preservada ao entrar por outro dispositivo ou reconectar, e não o ID do bot nem um campo fornecido pelo miniapp. Telas criadas sem invocação não têm criador humano; somente administradores podem encerrá-las pelo cliente.
As outras visualizações abertas exibem um aviso temporário com o nome do miniapp, no idioma do cliente. Quem apenas recebeu o convite ou já saiu da visualização não recebe esse aviso.
Dentro de um comando, ctx.createScreen() consulta a sala de voz atual de quem chamou e associa o miniapp àquela sala e à invocação, inclusive em sala privada autorizada. screen.channelId é sempre um canal de voz, não ctx.channelId (o canal de texto do comando). Sem voz, a criação é recusada. O bot não precisa estar conectado ao áudio para oferecer um miniapp. A API avulsa bot.createScreen(serverId, input) exige acesso do próprio bot; passar invocationId permite a autorização específica da invocação. Isso não concede acesso geral a mensagens privadas.
bot.command({
name: 'tela',
description: 'Abre uma tela compartilhada',
voiceRequirement: 'joined',
handler: async (ctx) => {
await ctx.createScreen({
title: ctx.locale === 'en' ? 'Shared screen' : 'Tela compartilhada',
html: `<main id="message"></main><script>
window.monkyScreen.onState(state => {
document.getElementById('message').textContent = state.message;
});
</script>`,
state: { message: ctx.locale === 'en' ? 'Hello, everyone!' : 'Olá, pessoal!' },
});
},
});No documento isolado, o bridge window.monkyScreen fornece:
| API da tela | Comportamento |
|---|---|
viewer | Contexto local imutável com id, nickname e locale (pt-BR ou en) da pessoa que abriu a tela |
onState((state, revision) => ...) | Entrega o estado inicial e as atualizações; retorna uma função de unsubscribe |
sendAction(action, payload) | Envia uma intenção vinculada à revisão atual; retorna se o bridge aceitou o envio, não se o bot aceitou a ação |
onBeforeClose(handler) | Roda antes de um fechamento pedido por esta pessoa; devolver false mantém a visualização aberta. Veja Ciclo de vida da visualização |
onClose(handler) | Roda em todo fechamento, com a visualização já escondida e muda, por até 5 segundos |
onVisibilityChange(handler) | Diz na hora, e de novo a cada mudança, se a pessoa consegue ver a visualização. Veja Segundo plano |
storage | Dados guardados neste computador, quando o bot tem device_storage e a pessoa permite; veja Armazenamento do bot. Ausente em clientes antigos |
Leia window.monkyScreen.viewer.locale dentro do callback de onState() para traduzir os controles de cada pessoa. Trocar o idioma no aplicativo também aciona esse callback, sem mudar o estado/revisão compartilhados nem recriar o iframe. Não escolha o idioma dos controles a partir do estado público ou do idioma de quem criou a tela.
O evento screenAction do SDK entrega { serverId, screenId, instanceId, channelId, userId, userNickname, action, payload, revision, actionId }. Use a identidade autenticada desse envelope, nunca um jogador/usuário informado em payload. Valide a ação e suas regras no bot antes de chamar await bot.updateScreen(serverId, screen, { state, expectedRevision }). Passe o snapshot recebido, ou um BotScreenRef com { id, instanceId }, não apenas o ID textual. Uma atualização aceita incrementa revision e chega aos participantes; uma revisão antiga é rejeitada em vez de sobrescrever uma alteração concorrente. O HTML permanece o mesmo durante as atualizações de estado.
Use listScreens(serverId, channelId) com o ID da sala de voz para obter snapshots atuais e closeScreen(serverId, screen) para o próprio bot encerrar. O instanceId é gerado pelo servidor: mesmo reutilizando id, uma nova tela ganha outra instância. Updates, ações, encerramentos e eventos antigos não podem atingir a substituta. O END humano não exige a última revisão do estado; uma atualização concorrente não impede o encerramento da instância correta.
O evento screenRemoved entrega { serverId, id, instanceId, channelId, reason }. Quando reason === 'ended', também inclui endedByUserId, autenticado pelo servidor. Os demais motivos são closed, access_revoked, bot_disconnected e view_revoked (revogação de uma visualização local, não encerramento global). Remova o estado correspondente no bot comparando servidor, ID e instância: cancele timers/expiração, aborte trabalho pendente e libere as vagas do jogo. Se a instância controla música, encerre também a fonte/fila que ela possui e libere seus recursos; o SDK não pode adivinhar a regra de domínio do bot. Não use um erro de instância inexistente como sinal para recriá-la.
Depois de um END, a invocação criadora ainda em execução é cancelada, incluindo seus prompts e trabalho pendente; ela não pode criar outra tela. Um novo comando é necessário. Invocações concluídas/expiradas continuam inválidas para criação. O SDK rejeita leituras/updates cuja resposta foi ultrapassada por uma remoção, em vez de devolver um snapshot aparentemente ativo. Depois de qualquer await, confira se a sessão do jogo ainda é a mesma antes de armazenar o resultado. ctx.signal é abortado se a invocação ainda estiver ativa, mas não acompanha a tela depois que o handler termina: use screenRemoved para o teardown do miniapp.
Registre listeners uma vez e remova-os ao encerrar. O cliente recupera os miniapps ativos ao entrar na sala; sair, mudar de sala ou desconectar fecha a visualização local e revoga ações. Sair do miniapp não envia END, não apaga o estado compartilhado e não libera automaticamente uma vaga de jogador.
Estado compartilhado, sem segredos: os participantes autorizados que estão naquela sala de voz recebem o HTML e o estado JSON. O servidor também verifica a presença na sala para listar e interagir; estar em outro canal ou em voz em outro dispositivo não autoriza esta conexão. Interações exigem USE_BOT_COMMANDS. Não inclua tokens, caminhos locais ou informações secretas de um jogador. A tela não recebe Node.js, preload, IPC nem acesso ao DOM do cliente.
Ciclo de vida da visualização
Os ganchos onBeforeClose e onClose avisam o miniapp de que a visualização desta pessoa vai fechar. O Monky só informa o evento; o miniapp decide o que fazer, como perguntar se a pessoa quer salvar a partida.
| Gancho | Quando roda | Como o Monky espera |
|---|---|---|
onBeforeClose(({ reason }) => ...) | Antes de Sair do miniapp (leave) ou Encerrar miniapp (end), quando esta pessoa pede | A visualização continua aberta e pode perguntar o que quiser. Os handlers rodam em ordem; o primeiro que devolver false, direto ou por uma promise, mantém o miniapp aberto |
onClose(({ reason }) => ...) | Em todo fechamento, inclusive depois de um onBeforeClose | Até 5 segundos, com a visualização já escondida e muda, sem estado novo e sem sendAction; o storage continua funcionando. Os handlers rodam em paralelo |
Os dois devolvem uma função que cancela o registro. Registre-os enquanto o documento carrega: o Monky só espera os ganchos que já conhece. O reason do onClose diz por que a visualização fechou:
reason | Situação |
|---|---|
leave | A pessoa clicou em Sair do miniapp ou em Fechar agora |
end | A pessoa encerrou o miniapp para todos |
ended | O miniapp deixou de existir: outra pessoa o encerrou, o bot o fechou, desconectou ou perdeu o acesso à sala |
revoked | Esta visualização perdeu o acesso: a pessoa saiu da sala ou entrou em outra, mesmo em outro servidor, desconectou ou perdeu permissão. Navegar pelo Monky não fecha a visualização |
Enquanto o onBeforeClose responde, o botão do cartão vira Fechar agora (ou Encerrar agora), e um cartão em miniatura entra em foco para a pergunta ficar legível e clicável. A pessoa sempre consegue sair: o bridge confirma o recebimento do pedido sozinho e, se o documento não confirmar em 3 segundos por estar travado, o Monky fecha a visualização. Depois da confirmação não há limite de tempo enquanto a pessoa responde. Uma exceção em um handler aparece no console do miniapp e não impede o fechamento.
Em qualquer fechamento, a tela some, o áudio silencia e as ações são cortadas na hora; revogações e encerramentos feitos por outras pessoas fazem isso sem esperar o onBeforeClose. Só então o onClose roda, invisível, por no máximo 5 segundos; se não terminar, o documento é descartado. Como no ajuste de volume, o silêncio não alcança iframes filhos, como players incorporados: pare-os no começo do onClose. Nesse intervalo o documento mantém a própria rede, e o serviço do bot continua responsável por autorizar o que recebe. Sair do Monky ou recarregá-lo não dá tempo a nenhum gancho: só há o evento síncrono pagehide do navegador. Um travamento do aplicativo não avisa nada.
const tela = window.monkyScreen;
let progressoPendente = false;
// Clientes anteriores aos ganchos não têm esses métodos.
tela.onBeforeClose?.(async () => {
if (!progressoPendente) return;
const escolha = await perguntarNoMiniapp(); // 'salvar' | 'descartar' | 'voltar'
if (escolha === 'voltar') return false;
if (escolha === 'salvar') await salvar();
progressoPendente = false;
});
tela.onClose?.(async () => {
// Sem tela e sem som: salve sem perguntar.
if (progressoPendente) await salvar();
});Como o onClose roda sem tela, não pergunte nada à pessoa nem inicie downloads nele: salve no serviço do bot ou, se a pessoa permitiu, no armazenamento do computador, e mantenha também um salvamento periódico para os casos sem gancho. Já um download que a pessoa inicia num botão do miniapp antes de o onBeforeClose responder chega ao navegador antes de a visualização fechar e continua depois disso.
Segundo plano
Navegar pelo Monky não fecha a visualização. Se a pessoa continua na chamada e abre um canal de texto, o Início, outro servidor ou o palco de outra sala, o miniapp fica aberto em segundo plano: escondido, mas com o mesmo documento, recebendo o estado e enviando ações. Um aviso na barra lateral leva de volta ao palco, sem recarregar nem pedir a permissão de novo. Sair da sala, entrar em outra, desconectar ou perder a permissão continuam fechando a visualização com revoked.
onVisibilityChange((visible) => ...) diz se a pessoa consegue ver a visualização. O handler roda na hora do registro, com o valor atual, e depois a cada mudança. visible fica false com o miniapp em segundo plano, com o cartão fora da área visível do palco (rolado para fora da faixa, por exemplo) e com a janela do Monky minimizada ou na bandeja, e volta a true quando ele reaparece. Prefira esse gancho ao evento visibilitychange: com o Monky minimizado, a página continua visible. Depois que o fechamento começa, o handler não roda mais, porque o onClose já diz que a visualização sumiu.
Escondido, o documento não é desenhado: requestAnimationFrame para e os timers passam a rodar cerca de uma vez por segundo, então um jogo movido por eles congela no meio da partida. O áudio continua no volume escolhido, como uma transmissão de tela assistida fora do palco: <audio>, <video> e o Web Audio seguem tocando. Pause o jogo, e o som dele, enquanto a pessoa não vê.
const tela = window.monkyScreen;
// Clientes anteriores aos ganchos não têm esse método.
tela.onVisibilityChange?.((visivel) => {
if (visivel) jogo.continuar();
else jogo.pausar(); // inclusive o som
});Recursos web do miniapp
O administrador do servidor autoriza a capacidade miniapps do bot na revisão de permissões. Quem abre uma visualização recebe apenas um aviso informativo, com Continuar e abrir ou Cancelar; não escolhe capacidades nem concede novas permissões ao bot. O aviso descreve rede (inclusive rede local), armazenamento web, áudio/vídeo, arquivos escolhidos manualmente, downloads, tela cheia, captura do cursor e os dados fornecidos pelo bridge. Selecionar um arquivo permite que o app o leia e eventualmente o envie pela rede.
Se o administrador aprovou device_storage, o mesmo aviso pergunta se o miniapp pode guardar dados no computador de quem abre: Não guardar (padrão), Só nesta sessão ou Sempre. A escolha vale só para esse computador e não muda as permissões do bot no servidor. Veja Armazenamento do bot.
Cancelar não cria o documento nem executa seu código. Sair, perder acesso, mudar de sala/servidor ou desconectar encerra a visualização e revoga as ações pelo bridge; abrir novamente exibe o aviso. Confirmar a abertura não ignora permissões negadas pelo administrador, nem confirma em nome de outros participantes. Execução local de ferramentas continua com suas próprias confirmações no computador executor.
Quem hospeda um bot com o CLI do SDK confirma separadamente a execução na máquina/VPS. Esse consentimento não concede capacidades em servidores Monky e não cria uma sandbox do sistema operacional.
Páginas externas dentro do miniapp podem registrar Service Workers que continuam executando depois de fechar a visualização. O descarte da origem monky-miniapp://… não remove armazenamento ou workers de outras origens. Portanto, fechar a visualização não é uma revogação completa dos recursos web. O aviso informa esse comportamento sem prometer isolamento de todos os recursos externos.
O aviso não adiciona restrições artificiais de rede: WebRTC não é coberto por connect-src. Quem cancela não executa o miniapp. Câmera, microfone e outros recursos não suportados continuam bloqueados mesmo após a confirmação.
O documento tem uma origem própria e aleatória monky-miniapp://…, separada do Monky e de outras visualizações. Não é um srcdoc com a origem do aplicativo hospedeiro. É possível usar scripts e estilos externos, fetch, WebSocket, WebRTC, WebAssembly, Workers, canvas/WebGL, áudio/vídeo, formulários, armazenamento web, seleção de arquivos e downloads. Fullscreen e pointer lock seguem as regras de interação do navegador. Câmera, microfone, captura de tela, clipboard e dispositivos continuam sem autorização automática; o miniapp não herda as permissões do cliente.
Os recursos externos seguem CORS e as regras do navegador. Configure o serviço para aceitar a origem do miniapp e use HTTPS/WSS em produção. A origem é nova a cada abertura: não use o armazenamento web dela como persistência da partida; para isso existe monkyScreen.storage. Recursos grandes e tráfego em tempo real podem ir diretamente ao serviço do app; não precisam passar por state ou sendAction. Desse modo, o limite do envelope do Monky não limita o tamanho de uma engine, mídia ou arquivo carregado pela aplicação.
Ao navegar o próprio frame, a página de destino permanece isolada, mas não herda o bridge autenticado monkyScreen; para mantê-lo, use navegação interna no documento ou um iframe filho. Popups e navegação da janela do Monky continuam bloqueados. O armazenamento da origem é descartado ao fechar a visualização.
Cada participante ajusta o volume do miniapp no próprio card, de 0 a 200%, e pode mutá-lo, com o mesmo controle de uma transmissão de tela. O botão fica sobre o canto inferior direito do miniapp: não coloque controles essenciais nesse canto. O canto inferior esquerdo recebe o aviso de armazenamento quando o espaço do bot está quase cheio. O Monky aplica o ajuste dentro do documento isolado, antes dos scripts do bot, a <audio>/<video> e à saída (destination) de cada AudioContext. O app continua lendo nos elementos o volume que definiu. Acima de 100%, só o Web Audio é amplificado: o navegador limita <audio>/<video> a 100%. OfflineAudioContext não é alterado. Iframes filhos, como players incorporados, e páginas para onde o frame navegar não recebem o ajuste; toque o áudio no próprio documento do miniapp.
Autentique conexões externas a partir de uma ação recebida pelo bot, sem confiar em um userId enviado pelo browser e sem publicar tokens no estado compartilhado. Encerre conexões, Workers, áudio e timers ao sair da visualização, no onClose (ou no pagehide, em clientes anteriores aos ganchos). Se o app utiliza um serviço externo, verifique sua disponibilidade e mostre um erro claro antes de iniciar a atividade.
Quando o próprio bot serve os recursos do miniapp numa porta dele, declare-a em monkyBot.requirements (em geral exposure: "public" e when: "on-demand") e chame handleReachabilityProbe(request, response) no início do handler HTTP. Assim, quem hospeda vê qual porta abrir, e o doctor consegue comprovar o acesso.
Os limites do envelope de controle são 128 KiB de HTML inicial, 64 KiB de estado e 8 KiB por ação; JSON aceita até 12 níveis e 8.192 nós. Há até quatro miniapps por sala de voz, 16 por bot e 64 por servidor, com limites de frequência e deduplicação de ações. Eles vivem em memória e são removidos ao reiniciar/desconectar o bot, perder a autorização de acesso à sala ou receber um encerramento autorizado. Sair da sala, inclusive deixá-la vazia, não apaga automaticamente o estado. A expiração do jogo é responsabilidade do bot. Se persistir partidas, persista também seu encerramento: reiniciar o bot não deve recuperar uma partida explicitamente encerrada.
