ATkuzu — Takuzu puzzles. Your data stays on your own PDS
ATkuzu
ATkuzu
Verified

Takuzu puzzles. Your data stays on your own PDS

Takuzu puzzles. Your data stays on your own PDS

A Takuzu game native to the AT Protocol.

Screenshots
Reviews
(0)

Be the first to review this product.

Updates
I reverse-engineered my volleyball club booking app.A friend and I were fed up with the booking system of the MonClub app our volleyball club uses. A slot only opens for booking exactly six days (144 hours) in advance, at a precise time. Spots are limited and fill up almost instantly, so you have to rush onto the app the very moment booking opens. In practice that meant being glued to our phones at an exact minute, six days ahead. And we would regularly miss out anyway, because we were busy, away from our phones, or simply forgot. So I did the reasonable thing: I reverse-engineered the app's booking flow and built my own tool to talk to it directly, a terminal CLI and a Discord bot that book, cancel and manage sessions without ever opening the app. No more being glued to a phone at a precise minute. This is how it works. Listening in on the app MonClub does not expose a public API, so there was nothing to read. I had to watch the app talk to its server. I intercepted the app's HTTPS traffic using Proxyman on macOS, with an iPhone as the client. Proxyman acts as a man-in-the-middle proxy: once you trust its CA certificate on the device, it can decrypt the TLS traffic and show you every request and response in clear text. From there it was a matter of using the app like a normal human (opening the "Sessions" tab, tapping "Book", tapping "Cancel") and watching which requests fired. Every interesting capture got saved so I could study it offline. The IDs that glue everything together The first thing that became obvious is that every request is built out of a handful of opaque MongoDB ObjectIds (24-character hex strings). There are four that matter: userId identifies the logged-in user. It comes back in the auth response. customId identifies the club (the tenant). It shows up in every request, sometimes in the body and sometimes as a query parameter. It is stable and never changes for a given club, so I can treat it as a constant. sessionId identifies a specific session slot. It is the _id of a session in the listing response. bookingId identifies a booking record. It is the _id of a booking and is required to cancel one. customId aside, the others are fetched at runtime: log in, list the sessions, list the bookings. Authenticating like the real app The traffic revealed a two-step authentication flow. Step 1: does this account exist? POST /users/custom/authenticate/email/v2 sends only the email address. The server most likely uses this to check whether the account exists and what login method it should use. Step 2: credentials and a fake phone POST /users/custom/authenticate/v2 sends the credentials plus some device metadata. It returns a raw JWT (no Bearer prefix) and the userId . The device metadata in the body turned out to be completely cosmetic. The server accepts arbitrary values, so the bot just hardcodes a plausible-looking phone: { "os": "Android 14", "model": "Phone (2)", "brand": "Nothing", "version": "3.6.0" } After that, the JWT goes out as Authorization: <token> on every subsequent request (again, no Bearer prefix). The nice surprise is that the token's expiry is about a year, so the bot can simply re-authenticate on every run and never bother with token refresh logic. Finding the slots Listing sessions is POST /nearfilters/favorite/myclub . It returns all upcoming sessions for the user's clubs, and the tagName: "myclub" filter scopes the results to clubs the user is actually a member of. I found it by capturing the traffic while opening the "Sessions" tab. Each session in the response carries its own _id , which is the sessionId I need to book it. To see what is already booked, there is: GET /bookings/user/:userId?category=ondemand&temporality=fromToday This returns the user's upcoming bookings. Each entry has a nested session array with the session details, and a top-level _id which is exactly the bookingId required for cancellation. Swapping the query to temporality=beforeToday returns past bookings instead. Handy detail: the session object also includes yesParticipants (an array of user ID strings) and totalQuantityFree (the capacity as an integer), which is how the app shows the "X / Y spots taken" count. Booking (and un-booking) One endpoint, two meanings This is the part I like the most. Booking and cancelling are the same endpoint: POST /sessions/book/licenseeFromClub . What decides between the two is the isPresent field inside the participant object: "yes" creates a booking. "no" cancels one. In that case the participant object also needs the bookingId . { "isPresent": "yes" // "no" to cancel (also needs bookingId) } So the whole "grab my slot" action boils down to: authenticate, find the right sessionId , and POST it with isPresent: "yes" . Why 200 OK does not mean booked There is one subtlety I only spotted after a booking silently failed: a 200 OK on this endpoint does not mean the booking went through. The real outcome lives in the response body, not the HTTP status. A confirmed booking comes back either as a record with an _id or with status: "success" . A soft rejection returns a different status plus a message, for example status: "noCredits" when the account has hit its reservation limit. So the bot treats only an explicit non- success status as a failure, and it does not retry those, because retrying a "you have no credits left" answer never helps. The waiting list you never asked for Months later the same class of problem bit me again, in a nastier way. Book a session that is already full and the API does not refuse. It answers 200 , hands back a booking record with an _id , and by every check I had it looks exactly like a confirmed booking. It is not. The server has quietly parked you on the session's waiting list, and no spot is being held for you. The tell is in the session, not in the booking response. A session keeps two lists: yesParticipants for the people who actually have a spot, and maybeParticipants for the waiting list. A full session puts you in the second one. So after a booking that looks successful, the bot re-reads the session detail and checks which list it landed in; maybeParticipants is reported as "not booked" rather than as a win. Annoyingly, the booking endpoint echoes the session back in several shapes depending on the case: sometimes at the top level, sometimes under session , sometimes under sessions , and either as one object or as an array of them. The check looks in all of them rather than trusting one. This distinction matters more than it looks, because it splits failures in two. "No credits left" will never fix itself, so the bot gives up on the spot. "The session is full" fixes itself the moment somebody unbooks, so it is worth waiting for. The timing problem, solved by a status code My first worry was timing: the app insists a slot only opens 144 hours before the session, so I assumed I would have to fire a perfectly-timed request at the exact second. The reverse-engineering gave me a calmer answer. If you POST a booking for a session the server is not ready to accept yet, it replies with 409 Conflict . That is not really an error in my case; it just means "not open yet". 409 Conflict on the booking endpoint means the slot is not yet open. This is expected for sessions that open at a specific time, which is why the bot retries on 409. That single status code turns a stressful timing game into a boring loop. Instead of firing one perfectly-timed request at the exact second, my booking flow just submits, and on a 409 it retries every few seconds until a deadline I set. The first time the server stops returning 409, the booking has gone through. I never have to trust that my clock and the server's clock agree down to the millisecond; the server itself tells me when I am allowed in. The best part: there is no real time limit Here is the kicker I did not expect. That "six days in advance" rule? It is only enforced in the app itself. The server does not check it at all. The 144-hour window is a client-side restriction: the app refuses to show you the button early, but the booking endpoint behind it has no such guard. So once I was talking to the API directly, the whole premise of the stress disappeared. I am not limited to the next six days anymore. I can book any session I want, whenever I want, with no time limit at all. The bot does not just win the race to the slot; it ignores the starting line entirely. What I actually built None of this is a screen-tapping macro. It is a small Rust program that speaks the API directly, and it comes in two flavours. The terminal CLI It logs in, then drops me into a menu: list every upcoming session, book one (with the 409-retry loop above), view or cancel my existing bookings, browse past sessions, or even compare the attendee lists of two sessions to see who else is coming. When more than one account is configured, it also asks who to book for, so a single run can grab the same slot for several people. There is also a prebook command for the rare genuinely time-gated slot: I pick a session and a target time, and it sleeps until then before running the same retry loop. An export command writes my upcoming sessions out as a calendar file, which I come back to at the end. The Discord bot Same powers, exposed as slash commands ( /list , /book , /cancel , /prebook , /bookings , a per-booking /booking detail view, and the /notify , /watchbook and /export commands below), so a friend and I can book straight from our group chat. If a single-target /book comes back with a 409, a background task keeps retrying and sends a follow-up message once the slot is confirmed. Booking for the whole group Several accounts, one command The feature that turned this from "my tool" into "our tool" is multi-user booking. Each of us has our own MonClub account, so the bot can hold several sets of credentials at once: the primary account comes from the environment, and extra people live in a small gitignored users.json that maps each account to a Discord user. Once that is set up, /book and /cancel take an optional list of people ( @tom @nils , raw Discord ids, labels, or @everyone for every configured account), and the bot books or cancels for all of them in one command. Cancelling is per-person: for each target it looks up that account's own booking for the session and cancels it. All or nothing Booking a group brought a problem a single booking never had: partial failure. If I book four people and the third one is out of credits, I do not want to end up with two friends booked and two not, especially for a slot where the remaining spots may already be gone. So group bookings are atomic . The bot books each account in turn, and the moment one fails, for any reason (no credits, an error, or the slot simply not being open yet), it rolls back by cancelling every booking it already made in that batch. Either the whole group gets in, or nobody does and the state is left exactly as it was. It is the same all-or-nothing guarantee a database transaction gives you, built out of two API calls and a bit of bookkeeping. Waiting instead of racing Ignoring the booking window solved the sessions that are merely early. It did nothing for the two cases where the honest answer is "not yet": a session whose window has not opened, and a session that is full right now. Both are a waiting game, so the bot waits. /notify, when I want to book it myself The smallest version is just an alert. /notify takes a session that is not bookable yet and pings me in the channel when it crosses its booking window, which the bot works out as the session's start minus BOOKING_WINDOW_HOURS (144 by default, the club's six days). It books nothing; it only tells me the door is open. /watchbook, when I want the spot /watchbook is /notify and /book welded together, and it waits through both reasons a session can turn you away. If the window is not open yet, it uses the fast 409 loop from earlier, because the server can still answer 409 right at the boundary. If the session is full, it
Jun 29, 2026