Demo de referência para envio de mensagens proativas 1:1 em massa via Microsoft Teams — validada com carga de até 50.000 mensagens e desenhada para respeitar rate limits do Power Platform, Graph API e Bot Framework.
⚠️ Este repositório é uma demo / prova de conceito. Antes de usar em produção, revise: segurança, escalabilidade, observabilidade, custos e conformidade. Veja DISCLAIMER.md e SUPPORT.md.
| Capacidade | Como |
|---|---|
| 🚀 Fan-out assíncrono | Streaming generator das refs + parallel batch flush no Service Bus (5 batches em vôo) |
| 🎯 Token bucket global | Lua script atômico no Redis. RATE_LIMIT_PER_SEC aplicado globalmente entre todas as réplicas do worker |
| 🎴 Adaptive Cards | POST /api/send aceita texto ou { type:"AdaptiveCard", content:<card> } |
| 🔁 Idempotência | messageId = jobId:md5(rowKey):repeatIndex (efetivo em SB Standard/Premium) |
| 🩺 Health probes | /healthz (liveness) + /readyz (Redis + Storage + Service Bus configurado) |
| 🔒 Hardened auth | x-api-key com timingSafeEqual |
| 📊 Job tracking | Counters atômicos em Redis (HINCRBY), TTL 24h, sem race condition |
| 🧪 Testes | Suíte Jest (22 testes) cobrindo retry / rate limit / validação |
Em cenários de comunicação corporativa em massa via Teams, as alternativas comumente tentadas têm comportamentos diferentes:
| Abordagem | Realidade |
|---|---|
| Power Automate | Limites por licença e conector, não projetado para processamento massivo; pode sofrer throttling e latência. |
| Microsoft Graph | Possui throttling multi-nível (app, tenant, user); adequado para integração, não para broadcast massivo. |
| Bot Framework | Canal nativo para notificações e proactive messaging; melhor suporte para escala, com rate limits dinâmicos. |
Esta demo não burla rate limits — usa o canal certo. O envio depende do Teams App estar instalado para cada usuário (org-wide via Admin Center), o que faz o bot capturar uma conversationReference por usuário e usá-la depois para mandar mensagens 1:1 sem nova interação.
- Arquitetura
- Componentes Azure
- Fluxo de funcionamento
- Endpoints da API
- Token Bucket — rate limit global
- Segurança
- Estrutura do projeto
- Testes
- Como iniciar (dev local)
- Deploy em Azure
- Deploy do Teams App
- Apresentação técnica compacta
- Troubleshooting
graph LR
Client[Aplicação chamadora]
subgraph "ACA Environment: aca-teams-msgs"
API[API ACA<br/>api-teams-msgs<br/>min=1, ingress externo]
W1[Worker ACA<br/>worker-teams-msgs<br/>0..10 réplicas KEDA]
end
subgraph "Data plane"
Table[(Table Storage<br/>stteamsmsgs / conversationrefs)]
Redis[(Redis<br/>redis-teams-msgs<br/>jobs + refs index<br/>+ msg cache + token bucket)]
SB[(Service Bus<br/>sb-teams-msgs<br/>queue: send-messages)]
end
subgraph "Support / control plane"
AzBot[Azure Bot Registration<br/>teams-proactive-msgs-bot]
ACR[Container Registry<br/>acrteamsmsgs]
Logs[Log Analytics<br/>workspace-rgmsgseYKC]
end
BF[Bot Framework]
Users[👥 Usuários do Teams]
Client -->|POST /api/send<br/>x-api-key| API
Client -->|GET /api/jobs/:id| API
API -->|streamRefs| Table
API -->|HMSET job + SCARD refs| Redis
API -->|fan-out batches paralelo| SB
SB -->|KEDA scaler| W1
W1 -->|HGET msg + acquireToken| Redis
W1 -->|continueConversation| BF
BF -->|1:1| Users
W1 -->|HINCRBY sent/failed| Redis
W1 -->|delete em 403/410| Table
Users -.->|conversationUpdate| AzBot
AzBot -.->|POST /api/messages| API
API -.->|saveRef + SADD| Table
ACR -.->|imagens| API
ACR -.->|imagens| W1
API -.->|logs| Logs
W1 -.->|logs| Logs
Princípios:
- Redis = caminho quente: counters atômicos (HINCRBY), index de refs ativos (SCARD), cache do payload da mensagem, token bucket global (Lua atomic).
- Table Storage = durabilidade: fonte da verdade das
conversationReferences. - Service Bus = fan-out: desacopla API de workers, permite KEDA scale-to-zero, dead-letter para falhas permanentes.
- ACA = compute: API com
minReplicas=1(sempre ouvindo eventos do Teams) + Worker comminReplicas=0(custo zero quando ocioso).
flowchart LR
M1["/api/send<br/>POST"] --> M2["validateMessage"]
M2 -->|"texto/card"| M3["createJob no Redis<br/>HMSET total/msg/messageType"]
M3 --> M4["for each ref<br/>streamRefs Table"]
M4 --> M5["batch.tryAddMessage<br/>messageId=jobId:md5:r"]
M5 -->|"batch cheio"| M6["parallel flush<br/>até 5 em vôo"]
M5 -->|"batch ok"| M4
M6 --> M7["KEDA escala worker<br/>0→10 réplicas"]
M7 --> M8["acquireToken<br/>Lua bucket"]
M8 -->|"grant"| M9["continueConversation"]
M8 -.->|"sem token"| M8
M9 --> M10{"outcome"}
M10 -->|"200"| M11["HINCRBY sent"]
M10 -->|"403/410"| M12["remove ref<br/>HINCRBY failed"]
M10 -->|"429/5xx"| M9
style M3 fill:#FFE082,color:#000
style M8 fill:#FFAB91,color:#000
style M11 fill:#A5D6A7,color:#000
style M12 fill:#EF9A9A,color:#000
| Recurso | Nome na demo | SKU | Função |
|---|---|---|---|
| App Registration | associado ao Azure Bot | Free | Identidade do bot (SingleTenant) |
| Azure Bot | teams-proactive-msgs-bot |
F0 | Registro Bot Framework + canal Teams |
| Container Apps Environment | aca-teams-msgs |
Consumption | Ambiente compartilhado da API e Worker |
| Container Apps (API) | api-teams-msgs |
Consumption | Ingress externo, minReplicas=1 |
| Container Apps (Worker) | worker-teams-msgs |
Consumption | KEDA scale-to-zero, 0–10 réplicas |
| Service Bus | sb-teams-msgs / send-messages |
Basic | Fila + dead-letter |
| Table Storage | stteamsmsgs / conversationrefs |
Standard LRS | Refs duráveis |
| Azure Cache for Redis | redis-teams-msgs |
C0 Basic | Counters + refs index + msg cache + token bucket |
| Container Registry | acrteamsmsgs |
Basic | Imagens API + Worker |
| Log Analytics | workspace-rgmsgseYKC |
Pay-per-GB | Logs ACA |
O diagrama separa data plane (Table/Redis/Service Bus) de support/control plane (Azure Bot, ACR e Log Analytics). ACR e Log Analytics não participam do envio de cada mensagem, mas são recursos reais necessários para build/deploy e operação da demo.
sequenceDiagram
participant Admin as Teams Admin
participant Teams as Microsoft Teams
participant AzBot as Azure Bot Registration
participant API as API ACA
participant Table as Table Storage
participant Redis as Redis
Admin->>Teams: Deploy do app (org-wide)
loop para cada usuário
Teams->>AzBot: conversationUpdate / install event
AzBot->>API: POST /api/messages
API->>API: extrai conversationReference
API->>Table: upsertEntity (refs partition)
API->>Redis: SADD refs:active rowKey
end
Note over Table,Redis: N refs duráveis<br/>+ index O(1) para contagem
sequenceDiagram
participant Client as App chamadora
participant API as API ACA
participant Table as Table Storage
participant Redis as Redis
participant SB as Service Bus
participant Worker as Worker (0→N)
participant Bot as Bot Framework
participant User as 👤
Client->>API: POST /api/send (x-api-key)<br/>{message | AdaptiveCard, repeat?}
API->>API: validateMessage()
API->>Redis: SCARD refs:active (estimativa O(1))
API->>Redis: HMSET job:id (msg, messageType, total, sent=0, failed=0)
Note over API,Table: Streaming generator<br/>(não materializa array)
loop para cada ref (Table)
API->>API: tryAddMessage(jobId+md5(rowKey)+repeat)
alt batch cheio
API->>SB: sendMessages(batch)<br/>(parallel, até 5 in-flight)
end
end
API->>Redis: updateJobTotal (reconcilia drift Table↔Redis)
API-->>Client: 202 {jobId, total, statusUrl}<br/>(após enqueue no Service Bus)
Note over SB,Worker: KEDA detecta queue depth<br/>→ escala 0→10
par workers em paralelo
Worker->>SB: receive
Worker->>Redis: HGET msg + messageType (cacheado 5min)
Worker->>Redis: acquireToken (Lua atomic)
Note over Worker,Redis: Bloqueia até obter 1 token<br/>~RATE_LIMIT_PER_SEC global
Worker->>Bot: continueConversation(ref, msg/card)
Bot->>User: mensagem 1:1
alt sucesso
Worker->>Redis: HINCRBY sent
else 403/410
Worker->>Table: deleteEntity rowKey
Worker->>Redis: SREM refs:active + HINCRBY failed
else 429/5xx
Worker->>Worker: Retry-After / backoff
end
end
Client->>API: GET /api/jobs/:id
API->>Redis: HGETALL job:id
API-->>Client: {progress, sent, failed, status, messageType}
flowchart TD
A[Worker recebe msg da fila] --> AT{acquireToken<br/>Lua bucket}
AT -->|grant| B{continueConversation}
AT -.->|sem token| AT
B -->|✅ 200| C[HINCRBY sent]
B -->|⚠️ 429| D[Sleep Retry-After] --> B
B -->|⚠️ 5xx| E[Backoff exponencial] --> B
B -->|❌ 403/410| F[Remove ref<br/>HINCRBY failed]
B -->|❌ 4xx outro| G[HINCRBY failed<br/>permanent]
B -->|❌ outro<br/>após retries| H[HINCRBY failed<br/>throw → SB redeliver]
style C fill:#A5D6A7,color:#000
style F fill:#FFCC80,color:#000
style G fill:#FFCC80,color:#000
style H fill:#EF9A9A,color:#000
| Método | Path | Auth | Descrição |
|---|---|---|---|
| POST | /api/messages |
Bot Framework token | Endpoint do Bot Framework (configure como Messaging Endpoint no Azure Bot) |
| POST | /api/send |
x-api-key |
Enfileira N mensagens no Service Bus e retorna 202 Accepted após concluir o fan-out |
| GET | /api/jobs/:id |
x-api-key |
Progresso do job (Redis) |
| GET | /api/status |
x-api-key |
Contagem de usuários registrados |
| GET | /healthz |
— | Liveness simples |
| GET | /readyz |
— | Readiness básica (Redis + Storage + Service Bus configurado) |
Aceita texto ou Adaptive Card.
Texto:
POST /api/send
Content-Type: application/json
x-api-key: <API_KEY>
{
"message": "📢 Comunicado importante para todos os colaboradores!",
"repeat": 1
}Adaptive Card:
POST /api/send
Content-Type: application/json
x-api-key: <API_KEY>
{
"message": {
"type": "AdaptiveCard",
"content": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{ "type": "TextBlock", "size": "Medium", "weight": "Bolder", "text": "Atualização" },
{ "type": "TextBlock", "text": "Conteúdo da mensagem.", "wrap": true }
],
"actions": [
{ "type": "Action.OpenUrl", "title": "Saiba mais", "url": "https://exemplo.com" }
]
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
message |
string | object | sim | Texto OU { type:"AdaptiveCard", content:<card json> } |
repeat |
int | não | Cópias por usuário (default 1). Útil para testes controlados de stress; use com cautela. |
HTTP/1.1 202 Accepted
{
"jobId": "d836...",
"refs": 50000,
"repeat": 1,
"total": 50000,
"enqueued": 50000,
"drops": 0,
"messageType": "text",
"status": "queued",
"statusUrl": "/api/jobs/d836..."
}
⚠️ repeatmultiplicarefs × repeatmensagens reais para o Bot Framework. Use com cuidado em produção — útil principalmente para load testing.Na implementação atual, o
202 Acceptedé retornado depois que a API termina o streaming das refs e enfileira as mensagens no Service Bus. Em volumes elevados, a conexão HTTP permanece aberta durante a fase de enqueue.
{
"jobId": "d836...",
"message": "📢 Comunicado importante para todos os colaboradores!",
"messageType": "text",
"total": 50000,
"sent": 49988,
"failed": 12,
"status": "completed",
"progress": 100,
"createdAt": "...",
"updatedAt": "...",
"errors": ["Usuário bloqueou/desinstalou o bot", "..."]
}status |
Significado |
|---|---|
queued |
Job criado, mensagens sendo enfileiradas |
processing |
Workers estão enviando |
completed |
Todas processadas (enviadas + falhadas = total) |
O worker chama acquireToken() antes de cada continueConversation. Implementação por Lua script atômico no Redis — todas as réplicas competem pela mesma chave, então o limite é global, não por worker.
graph TB
subgraph "Redis (refilado a cada chamada)"
BK[Hash: ratelimit:bot<br/>tokens=N, ts=lastMs]
end
W1[Worker A] -->|EVAL Lua<br/>capacity=50, rate=50/s| BK
W2[Worker B] -->|EVAL Lua| BK
W3[Worker C] -->|EVAL Lua| BK
W4[Worker N] -->|EVAL Lua| BK
BK -->|0 ou 1| W1
BK -->|0 ou 1| W2
BK -->|0 ou 1| W3
BK -->|0 ou 1| W4
Algoritmo (Lua atomic):
tokens, ts ← HMGET ratelimit:bot tokens tselapsed = (now − ts) / 1000tokens = min(capacity, tokens + elapsed × rate)- Se
tokens ≥ 1→ consome 1 e retorna1. Senão retorna0. HSET tokens ts; EXPIRE 60
Configuração:
| Env var | Default | Descrição |
|---|---|---|
RATE_LIMIT_ENABLED |
true |
Liga/desliga o bucket no worker |
RATE_LIMIT_PER_SEC |
50 |
Taxa de refill (tokens/s) — teto global de envios |
RATE_LIMIT_CAPACITY |
50 |
Burst máximo (tokens acumuláveis) |
RATE_LIMIT_KEY |
ratelimit:bot |
Namespace da chave (útil se compartilhar Redis) |
Quando ajustar:
RATE_LIMIT_PER_SEC=50: default seguro pra Bot Framework F0.- Se a Microsoft te garantir SLA superior, suba (ex.:
100ou200). - Para teste de fumaça sem rate limit:
RATE_LIMIT_ENABLED=false.
POST /api/send,GET /api/jobs/:id,GET /api/statusexigem headerx-api-keyquandoAPI_KEYestá definida.- Comparação com
crypto.timingSafeEqual(não vulnerável a timing attacks). - Em dev local, deixar
API_KEYvazia desliga a checagem (a API loga um warning ao iniciar). - Em produção, considere alternativas mais robustas (em ordem de robustez):
- Entra ID com client credentials + JWT bearer + middleware de validação;
- APIM como front-door com policies;
- ACA com ingress interno + APIM/AGW público;
- mTLS ou IP allowlist via NSG.
- Secrets sensíveis (
MICROSOFT_APP_PASSWORD,SERVICE_BUS_CONNECTION,STORAGE_CONNECTION,REDIS_CONNECTION,API_KEY) devem ir em ACA secrets ou Key Vault com Managed Identity, nunca em env vars planas.
teams_msgs/
├── src/ # API (ACA, ingress externo)
│ ├── index.ts # Express + endpoints + auth + healthz/readyz
│ ├── bot.ts # ProactiveBot (captura conversationReferences)
│ ├── table-store.ts # Refs duráveis em Table Storage + streamRefs()
│ ├── redis-tracker.ts # Job counters + refs index + msg cache + token bucket
│ ├── send-retry.ts # Helper puro de retry (testável)
│ └── validate-message.ts # Validador puro (text vs AdaptiveCard)
├── worker/ # Worker (ACA, KEDA scale-to-zero)
│ ├── src/
│ │ ├── index.ts # bootstrap
│ │ ├── worker.ts # SB consumer + Bot Framework sender + token bucket
│ │ ├── redis-tracker.ts # incrementSent/Failed + getJobMessage + acquireToken
│ │ └── table-store.ts # removeRefByRowKey (limpeza em 403/410)
│ └── Dockerfile
├── __tests__/ # Suite Jest (npm test)
│ ├── rate-limit.test.ts # Token bucket math (6 tests)
│ ├── send-retry.test.ts # Retry / 429 / 403 / 5xx (9 tests)
│ └── validate-message.test.ts # Validação text/AdaptiveCard (7 tests)
├── manifest/ # Pacote Teams App
│ ├── manifest.json # Substitua <MICROSOFT_APP_ID> e <your-api-fqdn>
│ ├── color.png # 192×192
│ └── outline.png # 32×32
├── load_test/
│ ├── run-50k.js # Single-job, N usuários simulados
│ └── run-waves.js # Waves sequenciais (cold start vs warm)
├── Dockerfile # API (com HEALTHCHECK)
├── jest.config.js
├── tsconfig.json
├── package.json # engines.node>=20, scripts.test=jest
├── .env.example
├── DISCLAIMER.md
├── SUPPORT.md
├── LICENSE
└── README.md
npm install
npm testPASS __tests__/validate-message.test.ts
PASS __tests__/send-retry.test.ts
PASS __tests__/rate-limit.test.ts
Test Suites: 3 passed, 3 total
Tests: 22 passed, 22 total
| Suíte | Cobertura |
|---|---|
rate-limit.test.ts |
Token bucket math (refill, cap, sustained rate ~50/s), Lua script sanity |
send-retry.test.ts |
200/429/403/410/5xx/4xx/transient, Retry-After header parsing |
validate-message.test.ts |
Texto vazio, número, null, AdaptiveCard malformado, AdaptiveCard válido |
Os helpers sendWithRetry e validateMessage foram extraídos para módulos puros (sem dependência do BotFrameworkAdapter), permitindo unit tests sem mocks pesados.
git clone https://github.com/EdneiMonteiro/teams_msgs.git
cd teams_msgs
npm install
cd worker && npm install && cd ..
cp .env.example .env # edite com seus valores
# Rodar testes
npm test
# Rodar API local
npm run dev # http://localhost:3978
# Em outro terminal — expor para o Bot Framework:
ngrok http 3978
# → atualize o Messaging Endpoint do Azure Bot para https://<ngrok>/api/messagesPara rodar o worker localmente (apontando para Redis/Service Bus reais):
cd worker
npm run build && npm startRG=rg-teams-msgs
LOC=eastus2
ACR=acrteamsmsgs
API_TAG=v8
WORKER_TAG=v6
# Resource group + recursos base
az group create -n $RG -l $LOC
az servicebus namespace create -g $RG -n sb-teams-msgs --sku Basic
az servicebus queue create -g $RG --namespace-name sb-teams-msgs \
-n send-messages --max-delivery-count 5
az storage account create -g $RG -n stteamsmsgs --sku Standard_LRS
az redis create -g $RG -n redis-teams-msgs -l $LOC --sku Basic --vm-size c0
az acr create -g $RG -n $ACR --sku Basic --admin-enabled true
# ACA Environment compartilhado
az containerapp env create -g $RG -n aca-teams-msgs -l $LOC
# Build das imagens
az acr build -r $ACR -t teams-msgs-api:$API_TAG -f Dockerfile .
az acr build -r $ACR -t teams-msgs-worker:$WORKER_TAG -f worker/Dockerfile worker/
# Deploy API (ingress externo, always-on)
az containerapp create -g $RG -n api-teams-msgs \
--environment aca-teams-msgs \
--image $ACR.azurecr.io/teams-msgs-api:$API_TAG \
--min-replicas 1 --max-replicas 3 \
--ingress external --target-port 3978 \
--secrets sb-conn=<sb> st-conn=<st> redis-conn=<redis> \
app-pwd=<bot> api-key=<random> \
--env-vars MICROSOFT_APP_ID=<id> MICROSOFT_APP_TENANT_ID=<tenant> \
PORT=3978 \
SEND_FLUSH_CONCURRENCY=5 \
SERVICE_BUS_CONNECTION=secretref:sb-conn \
STORAGE_CONNECTION=secretref:st-conn \
REDIS_CONNECTION=secretref:redis-conn \
MICROSOFT_APP_PASSWORD=secretref:app-pwd \
API_KEY=secretref:api-key
# Atualizar Messaging Endpoint do Azure Bot
az bot update -g $RG -n teams-proactive-msgs-bot \
--endpoint "https://<api-fqdn>/api/messages"
# Deploy Worker (KEDA scale-to-zero + token bucket)
az containerapp create -g $RG -n worker-teams-msgs \
--environment aca-teams-msgs \
--image $ACR.azurecr.io/teams-msgs-worker:$WORKER_TAG \
--min-replicas 0 --max-replicas 10 \
--secrets sb-conn=<sb> st-conn=<st> redis-conn=<redis> app-pwd=<bot> \
--env-vars MICROSOFT_APP_ID=<id> MICROSOFT_APP_TENANT_ID=<tenant> \
MAX_CONCURRENT=10 \
RATE_LIMIT_ENABLED=true \
RATE_LIMIT_PER_SEC=50 \
RATE_LIMIT_CAPACITY=50 \
SERVICE_BUS_CONNECTION=secretref:sb-conn \
STORAGE_CONNECTION=secretref:st-conn \
REDIS_CONNECTION=secretref:redis-conn \
MICROSOFT_APP_PASSWORD=secretref:app-pwd \
--scale-rule-name sb-queue-rule \
--scale-rule-type azure-servicebus \
--scale-rule-metadata queueName=send-messages messageCount=5 \
--scale-rule-auth connection=sb-connO ambiente de demonstração validado usa
teams-msgs-api:v8eteams-msgs-worker:v6. Para novos ambientes, mantenha tags explícitas por release e evite depender apenas delatest.
- Edite
manifest/manifest.jsonsubstituindo<MICROSOFT_APP_ID>e<your-api-fqdn>. - Empacote:
cd manifest && zip ../teams-app.zip manifest.json color.png outline.png - Suba em Teams Admin Center → Manage apps → Upload new app.
- Setup policies → Global → Installed apps → Add apps (org-wide).
- Propagação org-wide leva 24–48h. Para testes imediatos, instale manualmente em Apps → Built for your org.
O arquivo presentation-compact.html contém uma apresentação HTML curta, em formato de telas, para compartilhamento por email ou exportação para PDF. Ela resume:
- problema e escolha técnica;
- APIs sugeridas;
- componentes Azure sugeridos;
- arquitetura lógica;
- capacidade esperada com base em carga testada de até 50.000 mensagens;
- trechos técnicos principais.
| Sintoma | Causa | Solução |
|---|---|---|
Authorization denied no envio |
Service Principal ausente no tenant alvo | az ad sp create --id <app-id> |
Failed to decrypt conversation id |
Tipo do bot foi alterado depois das refs serem salvas | Limpe a tabela conversationrefs e reinstale o Teams app |
401 Unauthorized em /api/send |
x-api-key faltando ou divergente |
Veja env var API_KEY na ACA |
| Workers não escalam | Regra KEDA mal configurada | az containerapp show e verifique scale.rules |
| Throughput muito baixo (~3-4k msg/min) | RATE_LIMIT_PER_SEC ativo (comportamento esperado em v8) |
Para testes "nus", use RATE_LIMIT_ENABLED=false; para produção, ajuste conforme limite aprovado |
403 Forbidden em alguns usuários |
Usuário bloqueou ou desinstalou o bot | Normal — worker remove a ref automaticamente |
429 Too Many Requests em volume |
Throttling do Bot Framework apesar do rate limit | Reduza RATE_LIMIT_PER_SEC (ex.: 30) |
400 AdaptiveCard precisa de 'content' |
message.content ausente ou não-objeto |
Veja Endpoints da API — content tem que ter type:"AdaptiveCard" |
Length of 'messageId' property cannot be greater than 128 |
Custom messageId muito longo | Já corrigido em v6 (md5 do rowKey); rebaixe imagem se persistir |
/readyz retorna 503 |
Redis ou Storage não acessíveis, ou Service Bus não configurado | Cheque connection strings, secrets da ACA e regras de firewall |
| Job travado em < 100% indefinidamente | Mensagens na DLQ, payload expirado no Redis ou drift de refs | az servicebus queue show … countDetails, cheque deadLetterMessageCount e reconcilie refs:active com a Table |
npm test falha com erro de tipo |
Versão do TS/Node incorreta | engines.node>=20 no package.json — use Node 20+ |
- Sem SLA nem suporte oficial. Veja SUPPORT.md.
- Uso sujeito a DISCLAIMER.md.
- Não afiliado nem endossado pela Microsoft. Marcas usadas apenas para descrição.