Criei uma pequena utilidade que sincroniza continuamente eventos de um feed iCalendar (ICS) em uma categoria do Discourse via API REST.
Este não é um plugin completo do Discourse — ele roda junto à sua instalação do Discourse — então pertence aqui em #extras. Se você quiser exibir eventos de calendário de uma fonte externa (por exemplo, Google Agenda, feeds de horário universitário, etc.) dentro de tópicos do Discourse, isso será útil.
Repositório
Como funciona
Lê eventos de um determinado feed ICS
Os compara com tópicos existentes (por UID ou como fallback para hora/local)
Cria ou atualiza tópicos em sua categoria escolhida
Pode rodar continuamente como um serviço systemd (seguro contra execução duplicada via flock)
Requisitos
Ubuntu 24.04 LTS (testado)
Python 3 (já presente no Ubuntu 24.04 LTS)
Uma chave de API do Discourse
Um ID de categoria para direcionar os tópicos de eventos
Exemplo de saída
Veja como fica a sincronização de um feed de horário universitário ICS no Discourse:
Obrigado novamente pelo compartilhamento, este calendário está evoluindo cada vez mais, ganhando novos recursos graças a pessoas como você. Fico imaginando como será daqui a 3-5 anos
Brilhante! Obrigado por testar. Mais alguém que queira tentar sincronizar um feed ICS no Discourse, adoraria receber feedback sobre se seus feeds se comportam da mesma forma.
Se eu tivesse tempo, provavelmente tentaria converter isso em um plugin adequado. Acho que não seria muito difícil criar algumas configurações, converter o Python em Ruby e colocá-lo em um job.
Outra ideia, que poderia ser útil para pessoas que estão hospedadas e querem usar isso, seria converter a tarefa em uma ação do GitHub e fazê-la rodar diariamente. Eu fiz isso para alguns scripts que um cliente hospedado precisava executar diariamente há um tempo e está funcionando muito bem. É ao mesmo tempo mais difícil (exige aprender fluxos de trabalho do GitHub e como lidar com segredos em vez de um bom e velho cron job) e mais fácil (você não precisa aprender a mexer na instalação de coisas em uma máquina por meio de uma interface de linha de comando).
Notas de comportamento de testes do ics_to_discourse.py
Tenho executado uma série de testes neste script (com e sem --time-only-dedupe) e pensei que seria útil documentar o fluxo de atualização/adoção em detalhes.
1. Como a exclusividade é determinada
Modo padrão: a adoção requer que início + fim + local correspondam exatamente.
Com --time-only-dedupe: a adoção requer apenas início + fim; o local é tratado como “próximo o suficiente”.
Se nenhum tópico existente corresponder a essas regras, um novo tópico é criado.
2. O papel do marcador UID
Cada tópico de evento recebe um marcador HTML oculto na primeira postagem:
<!-- ICSUID:xxxxxxxxxxxxxxxx -->
Em execuções subsequentes, o script procura primeiro por esse marcador.
Se encontrado, o tópico é considerado uma correspondência de UID e atualizado diretamente, independentemente de quão barulhento ou desatualizado o texto da DESCRIÇÃO possa ser.
Isso torna o UID a verdadeira chave de identidade. Campos de descrição visíveis não afetam a correspondência.
3. Fluxo de atualização com correspondência de UID
O script busca a primeira postagem e remove o marcador:
Se old_clean == fresh_clean: nenhuma atualização (evita rotatividade).
Se eles diferem: verifica se a alteração é “significativa”:
meaningful = (
_norm_time(old_attrs.get("start")) != _norm_time(new_attrs.get("start"))
or _norm_time(old_attrs.get("end")) != _norm_time(new_attrs.get("end"))
or _norm_loc(old_attrs.get("location")) != _norm_loc(new_attrs.get("location"))
)
Se meaningful = True → atualiza com bump (tópico sobe em “Mais Recentes”).
Se meaningful = False → atualiza silenciosamente (bypass_bump=True → apenas revisão, sem bump).
As tags são mescladas (garante que as tags estáticas/padrão estejam presentes, nunca remove as manuais/de moderador).
Título e categoria nunca são alterados na atualização.
Fluxo de atualização sem correspondência de UID
O script tenta a adoção:
• Constrói triplas candidatas de início/fim/local (ou apenas início/fim com --time-only-dedupe).
• Pesquisa em /search.json e /latest.json por um evento existente com atributos correspondentes.
• Se encontrado → adota esse tópico, adapta o marcador UID + tags (corpo deixado inalterado nesta fase).
• Se não encontrado → cria um tópico totalmente novo com o marcador e as tags.
Uma vez adotado ou criado, todas as sincronizações futuras serão resolvidas diretamente por UID.
⸻
Consequências práticas
• Alterações de horário
• Padrão: a adoção falha (horários diferem) → novo tópico criado.
• Com --time-only-dedupe: a adoção falha da mesma forma; novo tópico criado.
• Alterações de local
• Padrão: a adoção falha (local difere) → novo tópico criado.
• Com --time-only-dedupe: a adoção é bem-sucedida (horários correspondem), mas a diferença de local é sinalizada como “significativa” → atualiza com bump.
• Alterações de descrição
• Se o texto da DESCRIÇÃO muda, mas início/fim/local não mudam:
• O corpo é atualizado silenciosamente (bypass_bump=True).
• Revisão do tópico criada, mas sem bump em “Mais Recentes”.
• Se a DESCRIÇÃO não for alterada (ou apenas ruído como “Última Atualização:” que se normaliza), nenhuma atualização ocorre.
• Marcador UID
• Garante correspondência confiável em sincronizações futuras.
• Significa que campos de DESCRIÇÃO ruidosos não afetam se o tópico correto é encontrado.
⸻
Por que a DESCRIÇÃO às vezes “permanece a mesma”
O script compara todo o corpo (menos o marcador UID).
Se apenas uma linha volátil como “Última Atualização:” for diferente, mas se normalizar (por exemplo, espaços em branco, finais de linha, Unicode), old_clean e fresh_clean parecerão idênticos → nenhuma atualização é feita.
Isso é intencional, para evitar rotatividade de ruído do feed.
⸻
Resumo
• O horário define a exclusividade (sempre cria um novo tópico quando os horários mudam).
• Alterações de local → bump visível (para que os usuários notem atualizações de local).
• Alterações de descrição → atualização silenciosa (revisão, mas sem bump).
• Marcador UID = chave de identidade confiável, garante que o tópico correto seja sempre encontrado, mesmo que a DESCRIÇÃO esteja desatualizada ou ruidosa.
Isso atinge um bom equilíbrio: mudanças importantes aparecem em “Mais Recentes”, a rotatividade sem importância permanece invisível.
Olhando para trás, é hilário como toda essa saga se desenrolou. O próprio script importador agora é super confiável: marcadores de UID, lógica de deduplicação, atualizações significativas vs. silenciosas, namespaces de tags… tudo o que você realmente quer em produção. Os comportamentos se alinham perfeitamente com as notas que postei — tempos definem unicidade, locais disparam um aumento, descrições atualizam silenciosamente e marcadores de UID mantêm tudo ancorado. É elegante, é previsível, está pronto.
Enquanto isso, o pobre tópico do Meta que hospedou tudo… bem, estava condenado. Começou a vida respondendo como um sockpuppet (forte começo ), inchou para um tópico Frankenstein de dumps de código e capturas de tela, depois evoluiu para um pseudo-changelog com mais commits do que o próprio repositório. E assim que o script finalmente se tornou estável? Agendado para exclusão.
Honestamente, é poético. O propósito inteiro do script é impedir que eventos duplicados poluam seu fórum. O próprio tópico? Visto como um duplicado, silenciosamente marcado para coleta de lixo. O próprio destino que ele foi construído para prevenir tornou-se seu destino.
Então, um brinde ao tópico condenado: Você não aumentou o “Latest”, mas aumentou nossos corações.
Como você se saiu com a migração para um plugin do Discourse? Ou melhor ainda, como um PR no plugin existente Discourse Calendar (and Event)?
Estou relutante em mergulhar na configuração e manutenção necessárias para executar seu script de aparência incrível como está (e suspeito que muitos auto-hospedeiros estariam na mesma situação).
Status rápido: Atualmente estou executando três instâncias do meu importador Python ICS→Discourse (horário da Uni, reservas do Centro de Esportes e um calendário do Outlook). Comecei a empacotá-lo como um plugin do Discourse, mas a versão do plugin ficou aquém do conjunto de recursos do script — principalmente porque cada feed precisa de tratamento personalizado (excentricidades de UID, atualizações parciais, cancelamentos, revisões barulhentas, etc.). O plugin do Angus é ótimo para muitos casos; meus casos de uso parecem mais “específicos do feed”.
Também tenho um PR aberto contra o core com o objetivo de reduzir o ruído do botão azul “Mais Recentes” durante atualizações ICS grandes/intensas. Com feeds movimentados (como horários universitários), um lote de edições de baixo valor pode manter o “Mais Recentes” oscilando; o PR efetivamente desativa o botão “Novos Tópicos” quando o Mais Recentes ficou aberto enquanto um lote automatizado é executado. Ficarei feliz em cruzar o link desse PR aqui, se for útil.
A longo prazo: Estou atualmente no IONOS auto-hospedado. Se eu mudar para hospedagem oficial mais tarde, ainda adoraria uma maneira de manter o fluxo Python (ou um equivalente) sem precisar de recursos Enterprise, se o ICS inbound existir lá. Suspeito que uma solução genérica de core/plugin poderia funcionar se permitisse “adaptadores” plugáveis por feed, mantendo forte idempotência (UID ICS), tratamento de cancelamento e semântica de edição sem aumento.
Se houver interesse, posso esboçar uma interface de adaptador mínima e um caminho de migração do meu script Python para um job Ruby, ou contribuir com peças agnósticas de feed (mapeamento de UID, debounce/atualizações sem aumento, lógica de cancelamento) para o plugin de calendário/eventos.
Essa é uma boa pergunta, Nathan — e acho que definitivamente há espaço para uma abordagem mínima e agnóstica de feed que poderia viver como uma pequena extensão do plugin Calendar/Event ou como um job principal leve.
Para que um PR seja geralmente útil, a chave parece ser tornar o importador baseado em adaptadores em vez de específico de feed. Algo como:
Cada feed define um pequeno adaptador (pode ser Python, YAML ou Ruby) que mapeia campos ICS → campos de tópico do Discourse (title, body, tags, start, end, location, etc.).
O core lida com idempotência (mapeamento UID ↔ ID do tópico), cancelamento (STATUS:CANCELLED) e edições silenciosas (atualização sem impulsionar o Latest).
Plugins ou configurações do site poderiam configurar o intervalo de polling, mapeamentos de tags e política de impulsionamento (always, never, on major change).
Dessa forma, instituições com feeds barulhentos ou complexos (horários universitários, reservas de salas, calendários do Outlook, etc.) podem fornecer um adaptador adequado aos seus dados sem codificar nada no core.
Se houver interesse, ficaria feliz em delinear essa interface de adaptador ou prototipar o helper principal de “ICS upsert” como um job Ruby no qual outros possam se basear — para que isso possa evoluir gradualmente de scripts Python autônomos para algo mantenível e genérico dentro do ecossistema do Discourse.