Skip to main content
Glama
derikkruger

opengym-conector

by derikkruger
README.md
# opengym-conector

Conector MCP remoto para o [openGym](https://github.com/DuarteSantos8/openGym). Cada pessoa da
instância gera um **link pessoal** e cola no Claude (conector personalizado) ou no ChatGPT (modo
desenvolvedor). A partir daí dá para planejar treinos, registrar o que fez e analisar o progresso
conversando, e tudo é gravado pela **API oficial** do openGym, então aparece em todos os aparelhos
da pessoa como se tivesse sido feito no app.

## Licença e código do openGym

Este projeto é **AGPL-3.0-or-later** (texto em [LICENSE](LICENSE)), porque reaproveita código do
openGym, que é AGPL-3.0:

- upstream: <https://github.com/DuarteSantos8/openGym>, tag **v1.4.1**, commit `464355ef43d4c8f8a4e0bb6db9c2af7f240593b5`;
- o código do openGym **não está copiado neste repositório**: `scripts/baixar-opengym.mjs` faz um
  clone esparso da tag (pastas `mcp`, `frontend/src/lib`, `frontend/src/exercise-names`), confere
  o hash do commit e grava em `./upstream`, no build da imagem ou antes dos testes;
- **um único arquivo do openGym é substituído**: `mcp/src/state.js`, pela versão em
  [src/upstream-patch/state.js](src/upstream-patch/state.js). O original lê `./data/state-<uid>.json`
  do disco; o nosso recebe o estado lido da API a cada requisição (AsyncLocalStorage) e mantém
  todos os exports e o mesmo `defaultsShape()`. Nenhum outro arquivo do upstream é alterado;
- tudo que é usado do upstream está listado em [src/upstream.js](src/upstream.js).

Pela seção 13 da AGPL, quem usa o conector pela rede tem de poder obter o código-fonte. Publique
este repositório e defina `SOURCE_URL`: as páginas passam a mostrar o link "código-fonte".

## Variáveis de ambiente

| Variável | Padrão | Para quê |
|---|---|---|
| `OPENGYM_API` | obrigatória | URL **interna** da API do openGym, por exemplo `http://api:3000` na rede do compose |
| `PUBLIC_URL` | obrigatória | URL pública do conector, por exemplo `https://ia.gym.bruxo.app`; é o começo do link pessoal |
| `PORT` | `8787` | porta HTTP do container (o TLS fica no Traefik) |
| `DATA_DIR` | `/data` | onde fica `conexoes.json` (monte um volume) |
| `TZ` | `America/Sao_Paulo` na imagem | define o "hoje" de treinos e pesagens, como o relógio do aparelho |
| `SOURCE_URL` | vazio | link do código-fonte exibido nas páginas (AGPL) |

## Como um amigo conecta

1. No navegador, entra no site do openGym com a conta dele.
2. **Configurações › Conta** (a linha com o nome dele) **› Parear o aplicativo**. Aparece um código
   de 8 letras, que vale 5 minutos e só pode ser usado uma vez. (Rótulos conferidos no pacote pt-BR
   do openGym v1.4.1: `Settings` = Configurações, `Account` = Conta, `Pair the mobile app` =
   Parear o aplicativo. O item só aparece no site, não dentro do app de celular pareado.)
3. Abre `PUBLIC_URL/conectar`, cola o código e recebe o link pessoal `PUBLIC_URL/mcp/<chave>`.
4. **Claude**: Configurações › Conectores › Adicionar conector personalizado (Add custom connector),
   cola o link; na conversa, ativa no botão **+**.
   **ChatGPT**: Configurações › Apps › Configurações avançadas › Modo desenvolvedor; cria um
   conector com o link e autenticação "Sem autenticação" (No authentication).

Para cancelar: **Sair de todos os dispositivos** no openGym invalida o token, e o conector passa
a responder com erro pedindo para conectar de novo.

## Ferramentas

Leitura (as nove do MCP oficial do openGym, sem alteração, todas com `readOnlyHint: true`):
`list_routines`, `get_routine`, `preview_session`, `get_week_plan`, `list_workouts`,
`get_workout`, `get_bodyweight`, `estimate_1rm`, `muscle_balance`.

Novas:

| Ferramenta | O que faz | Anotações |
|---|---|---|
| `search_exercises` | busca na biblioteca do app (~5600) e nos personalizados, por nome em inglês ou pt-BR, músculo, equipamento | leitura |
| `create_routine` | cria rotina (séries, reps, faixa, carga em kg, descanso, regra de progressão); recusa nome repetido | escrita |
| `update_routine` | renomeia, troca a lista de exercícios (mantendo nota, aquecimento e progressão de quem continua) e a progressão | escrita, `destructiveHint` |
| `delete_routine` | apaga a rotina e tira dos dias da semana, como o app | escrita, `destructiveHint` |
| `set_week_schedule` | põe rotinas nos dias da semana fixa; em modo rodízio recusa e explica, a menos que `switch_to_fixed_week` | escrita, idempotente |
| `log_workout` | registra treino já feito pelo caminho "Registrar um treino anterior" do app; repetição idêntica não duplica | escrita, idempotente |
| `log_bodyweight` | pesagem do dia; dia que já tem pesagem é corrigido | escrita, idempotente |

Pesos entram em kg e são convertidos com as funções do app quando o perfil está em lb.

## Como a sincronização é respeitada

O openGym mescla aparelhos campo a campo, com carimbo por mudança e registro de remoções
(`deleted`, `edited`, `_f`, `_ts`; `frontend/src/lib/sync-merge.js`). Cada gravação do conector
([src/sincronizar.js](src/sincronizar.js)) faz o que o `update()` e o `doPush()` do app fazem
(`frontend/src/store/useStore.js`):

1. `GET /api/data` (documento, `rev` e `_wid`), sem cache entre chamadas;
2. aplica a operação numa cópia, com as funções do próprio app (`deleteRoutine`, `chooseFixedWeek`,
   `buildCombinedEntries`, `buildCompletedWorkout`, `completeBackfill`, `rebuildPrHistory`,
   `refillAfter`, ...);
3. carimba com `stampChange(anterior, novo)` do app e põe o tempo em `_ts`;
4. `PUT /api/data` com `baseRev`, `baseWid` e `stamped: true`;
5. no 409 a API devolve o documento atual e a operação é reaplicada sobre ele, até 3 vezes.
   Gravações do mesmo perfil passam por uma fila, então o 409 só vem de outro aparelho.

Os testes provam com o `mergeStates` do app que um aparelho com cópia velha mantém as gravações do
conector e que nada apagado (por ele ou pelo conector) volta.

## Segurança

- A chave pessoal são 32 bytes aleatórios (base64url) e só existe no link. No disco,
  `conexoes.json` guarda `sha256(chave)` para achar o registro e o token do openGym cifrado com
  AES-256-GCM, numa chave derivada por HKDF da própria chave pessoal. O arquivo vazado sozinho não
  abre nenhum token.
- O link funciona como senha (é um bearer na URL). Quem tiver o link lê e altera os treinos daquela
  pessoa, e só dela. A página avisa isso.
- Nunca vão para o log: chave, token, código de pareamento, corpo de requisição. **Atenção ao
  Traefik**: se o access log estiver ligado, ele grava o caminho `/mcp/<chave>`. Desligue o access
  log desse roteador ou filtre o caminho.
- Limites: 60 pedidos MCP por minuto por link, 10 pareamentos por minuto por endereço
  (último item do `X-Forwarded-For`), corpo MCP até 1 MB.
- O token do openGym vale 90 dias (`SESSION_DAYS`); passados ~40, o conector consulta
  `GET /api/me` no máximo uma vez por hora e guarda o token novo que a API devolve depois de 45.
- "Sair de todos os dispositivos" no openGym encerra todos os links da pessoa.

## Rodando junto do openGym (compose)

O conector precisa estar na mesma rede Docker do serviço `api` do openGym (porta 3000):

```yaml
services:
  conector:
    build: ./opengym-conector
    restart: unless-stopped
    depends_on:
      - api
    environment:
      OPENGYM_API: http://api:3000
      PUBLIC_URL: https://ia.gym.bruxo.app
      DATA_DIR: /data
      PORT: "8787"
      TZ: America/Sao_Paulo
      # SOURCE_URL: https://endereco-publico-deste-repositorio
    volumes:
      - conector-data:/data
    labels:
      - traefik.enable=true
      - traefik.http.routers.opengym-ia.rule=Host(`ia.gym.bruxo.app`)
      - traefik.http.routers.opengym-ia.entrypoints=https
      - traefik.http.routers.opengym-ia.tls=true
      - traefik.http.services.opengym-ia.loadbalancer.server.port=8787

volumes:
  conector-data:
```

Se o openGym estiver em outro projeto compose, ligue os dois a uma rede externa em comum e use o
nome do container da API em `OPENGYM_API`. Prefira volume nomeado: o processo roda como `node`
(uid 1000), e um bind mount precisa pertencer a esse uid.

## Desenvolvimento

```sh
npm ci
npm test
```

`npm test` baixa o openGym v1.4.1 com `api/` (`npm ci --omit=optional` dentro dela), sobe
`api/server.js` de verdade na porta 3999 numa pasta temporária, cria o perfil sem navegador (como
os testes do próprio openGym: `db.json` semeado e sessão assinada com o `secret`), gera um código
de pareamento, pareia, chama cada ferramenta pelo cliente MCP do SDK e confere no `GET /api/data`.
`CONECTOR_RAIZ=<pasta>` roda o mesmo teste contra uma cópia montada como a imagem Docker.