[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-105327":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":9,"language":10,"languages":9,"totalLinesOfCode":9,"stars":11,"forks":12,"watchers":13,"openIssues":14,"contributorsCount":14,"subscribersCount":14,"size":14,"stars1d":15,"stars7d":15,"stars30d":15,"stars90d":14,"forks30d":14,"starsTrendScore":16,"compositeScore":17,"rankGlobal":9,"rankLanguage":9,"license":18,"archived":19,"fork":19,"defaultBranch":20,"hasWiki":21,"hasPages":19,"topics":22,"createdAt":9,"pushedAt":9,"updatedAt":23,"readmeContent":24,"aiSummary":9,"trendingCount":14,"starSnapshotCount":14,"syncStatus":25,"lastSyncTime":26,"discoverSource":27},105327,"unfairplay-bot","missuo\u002Funfairplay-bot","missuo","Private Telegram bot and API that downloads App Store IPAs with ipatool and decrypts them with UnfairPlay",null,"Go",114,17,58,0,14,42,68.17,"Apache License 2.0",false,"main",true,[],"2026-11-01 00:14:19","# unfairplay-bot\n\nA private Telegram bot and HTTP service that takes an App Store `bundle_id`\nor `app_id`,\ndownloads the build the App Store serves right now with\n[ipatool](https:\u002F\u002Fgithub.com\u002Fmajd\u002Fipatool), decrypts it with\n[UnfairPlay](https:\u002F\u002Fgithub.com\u002FDrWhax\u002Funfairplay), and returns a signed,\nexpiring download link.\n\nUse it only with apps your account lawfully acquired. Decrypted IPAs and the\nlinks to them are private.\n\n## How it works\n\n- **Tasks.** `POST \u002Fv1\u002Ftasks` creates a task. Up to 3 tasks run at once\n  (`UNFAIRPLAY_BOT_CONCURRENCY`); the rest wait in FIFO order and report their\n  queue position. Decryption is limited separately\n  (`UNFAIRPLAY_BOT_DECRYPT_CONCURRENCY`, default 1).\n- **Progress.** Stages are `queued → downloading → patching →\n  waiting_decrypt → decrypting → finalizing → ready` (or `failed` \u002F\n  `canceled`). While downloading, the task reports bytes, total, percent,\n  speed, and ETA. Follow it with `GET \u002Fv1\u002Ftasks\u002F{id}\u002Fevents` (Server-Sent\n  Events) or poll `GET \u002Fv1\u002Ftasks\u002F{id}`.\n- **Cache hits only for the current build.** Each request first asks the App\n  Store download endpoint for the latest external version ID, so it does not\n  depend on the iTunes lookup API, which can lag behind a release. The cache\n  key is `trackId:externalVersionId`. When the App Store moves from 8.0.79 to\n  8.0.80, the next request misses and downloads 8.0.80. Requests by\n  `bundle_id` and by `app_id` for the same app share the cache.\n- **Deduplication.** Submitting a build that is already queued or running\n  returns the existing task.\n- **No account data in the output.** Decryption runs\n  `unfairplay decrypt --strip-account-data`, which leaves out every `SC_Info`\n  license and the Apple ID and purchaser fields of `iTunesMetadata.plist`.\n  Each IPA is checked again before it is cached; a leftover fails the task.\n  The service refuses to start with an UnfairPlay that lacks the flag.\n- **Expiry.** An artifact lives 24 h (`UNFAIRPLAY_BOT_CACHE_TTL`) after its\n  most recent hit. Hits are API cache hits and full downloads. A signed link\n  stays valid until the artifact's expiry at the time the link was issued;\n  request the app again to get a fresh link. `UNFAIRPLAY_BOT_MAX_CACHE_BYTES`\n  optionally caps disk use, evicting the least recently hit artifacts first.\n\n## Telegram bot\n\nSet `UNFAIRPLAY_BOT_TELEGRAM_TOKEN` and `UNFAIRPLAY_BOT_TELEGRAM_ADMIN_IDS`.\nThe bot uses long polling, so it needs no public webhook. It only answers in\nprivate chats.\n\n- **Confirm first.** Send an App ID, a bundle ID, or an App Store link. The\n  bot replies with the name, developer, current version and release date,\n  size, price, and whether that build is already cached. Nothing is queued\n  until the user taps **✅** within 10 minutes.\n- **Search.** `\u002Fsearch \u003Cname>` lists up to 10 matches from the Apple ID's\n  storefront as buttons; tapping one opens the same preview. Neither search\n  nor a preview adds an app to the Apple ID; that happens only after **✅**.\n- **One live message per task.** The confirmation message turns into a\n  progress view: queue position, download bar with speed and ETA, then\n  decryption, then a download button. Users can cancel their own tasks;\n  admins can cancel any.\n- **Allowlist.** Admins always have access. Anyone else gets their Telegram\n  ID and a \"no access\" reply, and admins receive an access request with\n  **✅ Allow** \u002F **🚫 Ignore** buttons (at most once per 6 h per person). Admin\n  commands: `\u002Fallow \u003Cid> [note]`, `\u002Frevoke \u003Cid>`, `\u002Fusers` (with one-tap\n  removal), `\u002Fstatus` (Apple Account, queue, cache, versions, active\n  tasks), `\u002Fhealth` (what `ipatool auth info` shows, the session and\n  automatic sign-in state, and a live App Store request), and `\u002Fclear`\n  (after a confirmation, delete every cached IPA regardless of expiry; issued\n  links stop working, tasks in progress are unaffected). The allowlist is stored in `data\u002Ftelegram-allowlist.json`.\n- **Admin notices.** Every request by a non-admin (new task, joined an\n  in-progress task, or cache hit) and every completion, failure, or\n  cancellation is sent to the admins. Notices use a `🔔 Notice · …` header and a\n  quoted block, so they look different from replies to the admin's own\n  requests. The admin's own requests produce no notices. If the Apple session\n  stops working, see \"App Store health\" below.\n- **`\u002Flogin` (admins only).** Signs the App Store account in again with the\n  configured (or saved) credentials. If Apple asks for a 2FA code, only the\n  admin who sent `\u002Flogin` can answer it, within 10 minutes, by replying with\n  the 6 digits.\n\n## API\n\nAll `\u002Fv1` endpoints require `Authorization: Bearer $TOKEN`, or `?token=` for\nEventSource clients, which cannot set headers.\n\n```sh\n# Create (or reuse) a task.\ncurl -s -H \"Authorization: Bearer $TOKEN\" -d '{\"bundle_id\":\"com.example.app\"}' \\\n  https:\u002F\u002Fipa.example.com\u002Fv1\u002Ftasks\n# Body may also be {\"app_id\": 123456789}.\n\n# Follow progress live.\ncurl -N -H \"Authorization: Bearer $TOKEN\" https:\u002F\u002Fipa.example.com\u002Fv1\u002Ftasks\u002F$ID\u002Fevents\n```\n\n| Response | Meaning |\n|---|---|\n| `200 {\"cached\": true, \"artifact\": {...}}` | Current build is cached; `artifact.download_url` is ready |\n| `201 {\"cached\": false, \"task\": {...}}` | New task queued |\n| `200 {\"cached\": false, \"task\": {...}}` | Same build already in progress; existing task returned |\n| `429 queue_full` | More than `CONCURRENCY + MAX_QUEUE` tasks |\n| `503 auth_required` | ipatool session unusable; log in again (see below) |\n| `403 license_required` | Paid app not owned by the account |\n\nEach SSE event is `event: task` with the full task JSON. The stream ends after\n`ready`, `failed`, or `canceled`:\n\n```json\n{\"id\":\"…\",\"status\":\"downloading\",\"app\":{\"app_id\":123,\"bundle_id\":\"com.example.app\",\"external_version_id\":\"870123456\"},\n \"progress\":{\"bytes\":734003200,\"total\":2147483648,\"percent\":34.2,\"speed_bps\":18500000,\"eta_seconds\":76}}\n```\n\nA `ready` task contains `artifact.download_url`. Other endpoints:\n`GET \u002Fv1\u002Ftasks`, `DELETE \u002Fv1\u002Ftasks\u002F{id}` (cancel), `GET \u002Fv1\u002Fstatus` (queue,\ncache, dependency versions), `GET \u002Fhealthz`. Download links (`\u002Fd\u002F...`) need no\ntoken and support `Range` requests.\n\n## Deploy (Docker Compose)\n\n```sh\nmkdir -p \u002Fopt\u002Funfairplay-bot && cd \u002Fopt\u002Funfairplay-bot\n# copy compose.yaml, .env.example and deploy\u002F from this repository\ncp .env.example .env            # fill in the values\nmkdir -p data ipatool cache secrets\u002Fmodels canary\ncp \u002Fpath\u002Fto\u002Fyour-model.bin secrets\u002Fmodels\u002F\nsudo chown -R 10001:10001 data ipatool cache secrets\n# UnfairPlay refuses group\u002Fworld-readable models and model directories.\nsudo chmod 700 secrets\u002Fmodels && sudo chmod 600 secrets\u002Fmodels\u002F*.bin\ndocker compose up -d\n\n# With UNFAIRPLAY_BOT_APPLE_ID_* set, the service signs in by itself. For a\n# 2FA account, send \u002Flogin to the bot, or sign in inside the container:\n# docker compose exec -it bot ipatool auth login --email you@example.com \\\n#   --keychain-passphrase \"$UNFAIRPLAY_BOT_KEYCHAIN_PASSPHRASE\"\n```\n\n`secrets\u002Fmodels` is UnfairPlay's model registry: it may hold several `.bin`\nmodels, and UnfairPlay picks the one matching each IPA. Add a model by copying\nanother `.bin` file there; no restart is needed.\n\nPut a reverse proxy with TLS in front of `127.0.0.1:8080`. For SSE, disable\nresponse buffering (the service sends `X-Accel-Buffering: no` for nginx).\n\nThe compose file pins the container MAC address, because ipatool derives the\nApp Store device GUID from it. Do not change it after logging in.\n\n## Credentials\n\nThe service needs two separate private credentials:\n\n| | UnfairPlay model (`.bin`) | ipatool session |\n|---|---|---|\n| Purpose | Decrypts IPAs | Signs in to the App Store to download them |\n| Created by | `unfairplay setup` on an enrolled Mac | The service itself, from `UNFAIRPLAY_BOT_APPLE_ID_EMAIL` \u002F `_PASSWORD`, or `ipatool auth login` |\n| Portable to another machine | **Yes.** Copy the file securely; decryption is offline | **No.** On macOS it lives in the system Keychain; sign in again on the server |\n| Expiry | Never | The App Store token expires; the service signs in again automatically |\n\nBoth must belong to the same Apple ID: the model decrypts every IPA that\naccount downloads.\n\n**Automatic sign-in** (accounts without 2FA): with the Apple ID set in `.env`,\nthe service signs in on first start and whenever the session is missing,\nrevoked, or expired. Failed automatic attempts are at least 15 minutes apart,\nand after `UNFAIRPLAY_BOT_AUTO_LOGIN_MAX_ATTEMPTS` (default 3) consecutive\nfailures automatic sign-in stops, so a wrong password or a login flow Apple no\nlonger accepts cannot get the account locked. The count survives restarts\n(`data\u002Fauto-login.json`) and resets after any successful sign-in; a manual\n`\u002Flogin` is always allowed.\n\n## App Store health\n\nThe likeliest outage is on the App Store side: a lost session, or Apple\nchanging the login or download API under ipatool. With\n`UNFAIRPLAY_BOT_HEALTH_APP_ID` set to a free app the account owns, the\nservice asks the App Store for that app's latest version every\n`UNFAIRPLAY_BOT_HEALTH_INTERVAL` (30 min). That is the same request a\ndownload starts with, minus the file transfer. Any task or preview that fails\non the App Store side triggers an immediate re-check.\n\nAdmins get one notice when the check starts failing (with the error and what\nto do: `\u002Flogin` for a session problem, or wait for the automatic ipatool\nupdate for an API change) and one when it recovers. `\u002Fstatus` and\n`GET \u002Fv1\u002Fstatus` show the current state.\n\n## Deploy (systemd, no Docker)\n\nFor hosts where Docker is unwanted (for example a router whose iptables\nrules must not change), `deploy\u002Fsystemd\u002F` builds and runs the service\nnatively. It needs Go (the version in `go.mod` or newer), gcc, git, and\nCaddy.\n\n```sh\nuseradd --system --home-dir \u002Fvar\u002Flib\u002Funfairplay-bot --shell \u002Fusr\u002Fsbin\u002Fnologin unfairplay\ninstall -d -o unfairplay -g unfairplay -m 700 \u002Fvar\u002Flib\u002Funfairplay-bot\u002F{,data,models,canary}\ninstall -m 600 your-model.bin \u002Fvar\u002Flib\u002Funfairplay-bot\u002Fmodels\u002F && chown unfairplay: \u002Fvar\u002Flib\u002Funfairplay-bot\u002Fmodels\u002F*\ninstall -d -m 700 \u002Fetc\u002Funfairplay-bot     # put the settings from .env.example in \u002Fetc\u002Funfairplay-bot\u002Fenv (0600)\ngit clone https:\u002F\u002Fgithub.com\u002Fmissuo\u002Funfairplay-bot \u002Fopt\u002Funfairplay-bot\u002Fsrc\ncp \u002Fopt\u002Funfairplay-bot\u002Fsrc\u002Fdeploy\u002Fsystemd\u002F*.service \u002Fopt\u002Funfairplay-bot\u002Fsrc\u002Fdeploy\u002Fsystemd\u002F*.timer \u002Fetc\u002Fsystemd\u002Fsystem\u002F\nsystemctl daemon-reload && systemctl enable unfairplay-bot unfairplay-bot-update.timer\n\u002Fopt\u002Funfairplay-bot\u002Fsrc\u002Fdeploy\u002Fsystemd\u002Fupdate.sh   # first build and start\nsystemctl start unfairplay-bot-update.timer\n```\n\n`update.sh` runs hourly: it builds the newest `main` (which includes the\nCI's dependency bumps), runs the tests, then the same two canaries as the\nDocker updater before switching the `current` symlink and restarting while\nidle. Serve it with `deploy\u002Fsystemd\u002FCaddyfile`.\n\n## Automatic dependency updates\n\n`deps.env` sets how each dependency is tracked: `main`, `release`, or\n`prerelease`. ipatool is pinned in `go.mod`; UnfairPlay in `deps.env`.\n\n1. `.github\u002Fworkflows\u002Fupdate-deps.yml` runs every 6 hours. It resolves the\n   newest allowed versions (`scripts\u002Fupdate-deps.sh`), runs vet and race\n   tests, builds both CLIs, commits the bump, and publishes\n   `ghcr.io\u002F\u003Cowner>\u002Funfairplay-bot:latest`.\n2. On the server, `deploy\u002Funfairplay-bot-update.timer` runs\n   `deploy\u002Fupdate.sh` hourly. It pulls the image and runs two canaries with\n   the new image before switching:\n   - **App Store:** `unfairplay-bot check` with the existing session, inside\n     the running container's network namespace so the MAC address (ipatool's\n     device identity) is the same. It never signs in. If it fails while the\n     running version is healthy, the update is rejected.\n   - **UnfairPlay:** `unfairplay verify` on an IPA in `.\u002Fcanary` with the\n     private model, offline.\n\n   If both pass, the updater waits for the bot to be idle, then restarts it.\n   If the running version is already unhealthy, it restarts immediately. The\n   bot also drains queued and running tasks on SIGTERM.\n\n```sh\nsudo cp deploy\u002Funfairplay-bot-update.* \u002Fetc\u002Fsystemd\u002Fsystem\u002F\nsudo systemctl enable --now unfairplay-bot-update.timer\n```\n\nPublic CI cannot test real downloads or decryption, because they need the\nprivate session and model. The canary check is the gate for those.\n\n## Development\n\n```sh\ngo vet .\u002F... && go test -race .\u002F...\n```\n\nThe Linux build needs cgo (ipatool's keyring dependency), so build on Linux or\nwith the Dockerfile rather than cross-compiling from macOS.\n\n## License\n\nApache License 2.0; see [LICENSE](LICENSE). The Docker image also contains\n[ipatool](https:\u002F\u002Fgithub.com\u002Fmajd\u002Fipatool) and\n[UnfairPlay](https:\u002F\u002Fgithub.com\u002FDrWhax\u002Funfairplay), which remain under their\nown licenses.\n",2,"2026-10-31 04:26:27","CREATED_QUERY"]