{"id":"hakochanjp/google-form-kit","name":"google-form-kit","scope":"hakochanjp","platform":"roblox","description":"Fetch public Google Form structure and submit responses from Roblox, with per-user submission policies","version":"0.2.0","latest":"0.2.0","versions":["0.1.0","0.1.1","0.2.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"c15eb2488a0f784f0b16734ddd6c0ea6ca5dcacc86150553220fda318a92da69","likes":0,"downloads":0,"install":"forest install hakochanjp/google-form-kit","url":"https://forest.dev/p/roblox/hakochanjp/google-form-kit","files":"https://api.forest.dev/ai/package/roblox/hakochanjp/google-form-kit/files","readme":"# google-form-kit\r\n\r\nFetch the structure of a **public Google Form** from Roblox (Luau, server-side) and submit answers to it, with an optional DataStore-backed submission-policy layer (`Gate`) for rate limiting.\r\n\r\nThe core parses the `FB_PUBLIC_LOAD_DATA_` blob embedded in a form's public HTML — no Google authentication, no Apps Script, no API key. Only forms that anyone with the link can open and answer are supported.\r\n\r\n- **realm: `shared`** — the package can be `require`d from both server and client. The only work done at require time is acquiring the `HttpService` handle (client-safe); no HTTP or DataStore access happens until a function is actually called. Anything that touches `HttpService` or `DataStoreService` (`Kit.fetch`, `form:submit`, `Kit.createGate`, all `Gate` methods) is **server-only** and raises a hard `error()` if called from the client.\r\n- Zero runtime dependencies.\r\n- Reading responses back out of the linked spreadsheet is out of scope.\r\n\r\n## Installation\r\n\r\nAdd to your project's `wally.toml`:\r\n\r\n```toml\r\n[dependencies]\r\nGoogleFormKit = \"hakochanjp/google-form-kit@0.2.0\"\r\n```\r\n\r\n```bash\r\nwally install\r\n```\r\n\r\nThis makes `Packages.GoogleFormKit` available; `require` it from a `Script` or `ModuleScript`.\r\n\r\n## Quick Start (server)\r\n\r\n```lua\r\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\r\nlocal Kit = require(ReplicatedStorage.Packages.GoogleFormKit)\r\n\r\n-- Kit.fetch yields (HttpService:GetAsync under the hood). Server-only.\r\nlocal form, err = Kit.fetch(\"https://forms.gle/xxxx\")\r\nif not form then\r\n    warn(err.code, err.message)\r\n    return\r\nend\r\n\r\nprint(form.title)          -- string\r\nprint(form.description)    -- string?\r\nfor _, question in form.questions do\r\n    print(question.title, question.type, question.required)\r\nend\r\n\r\n-- form:submit yields (HttpService:RequestAsync POST). Server-only.\r\nlocal ok, submitErr = form:submit({\r\n    [\"Radio button\"] = \"Yes\",                    -- keyed by question title\r\n    [\"Checkboxes\"] = { \"Option 1\", \"Option 3\" },  -- checkbox answers are ARRAYS, never dictionaries\r\n    [\"Short answer\"] = \"some text\",\r\n    [\"Linear scale\"] = 4,                         -- numbers are accepted (stringified internally)\r\n    [\"entry.632003319\"] = \"Option 2\",             -- entry ID keys are also accepted\r\n})\r\n\r\n-- Questions with a built-in \"Other\" choice (question.hasOther == true) also accept\r\n-- an { other = \"free text\" } value:\r\nlocal ok2, err2 = form:submit({\r\n    [\"Radio button\"] = { other = \"something else\" },              -- radio: Other instead of a choice\r\n    [\"Checkboxes\"] = { \"Option 1\", { other = \"my own answer\" } }, -- checkbox: mix choices with at most ONE other\r\n})\r\nif not ok then\r\n    warn(submitErr.code, submitErr.message)\r\nend\r\n```\r\n\r\n- Answer keys accept either the exact **question title** or the literal `\"entry.<id>\"` string. If a title is duplicated across questions, `form:submit`/`form:validate` return a `ValidationFailed` error asking you to use the `entry.<id>` form instead.\r\n- Fallible calls (`Kit.fetch`, `form:validate`, `form:submit`, `gate:submitFor`, `gate:canSubmit`) return Lua's `value, err` two-value style; `err == nil` on success. The plain constructors/getters (`Kit.fromSchema`, `form:getSchema`) cannot fail and return a single value. `Kit.createGate` also returns a single value, but it is not failure-free: it hard-errors (not an `err` return) if called from the client — see the realm note above.\r\n- The empty string (`\"\"`) and the empty array (`{}`) both mean \"unanswered\" — required-question checks treat them as missing.\r\n\r\n### The \"Other\" choice (`hasOther`)\r\n\r\nRadio and checkbox questions can carry Google Forms' built-in **\"Other\"** choice with a free-text field. The parser reports it as `question.hasOther == true` (the \"Other\" pseudo-choice is **not** included in `question.choices`), and `hasOther` round-trips through `form:getSchema()` / `Kit.fromSchema` — on plain schemas without the field, `nil` simply means `false`.\r\n\r\n- Radio: pass `\"Choice A\"` **or** `{ other = \"free text\" }`.\r\n- Checkbox: pass an array mixing choice strings with **at most one** `{ other = \"free text\" }` item, e.g. `{ \"Choice A\", { other = \"free text\" } }`.\r\n- `other` must be a string. An empty `other` (`\"\"`) counts as \"unanswered\", consistent with the `\"\"` / `{}` convention above — a required question is satisfied by at least one regular choice **or** a non-empty `other` text.\r\n- Passing `{ other = ... }` to a question whose `hasOther` is not `true` is a `ValidationFailed`. Dropdowns can never have an \"Other\" choice (a Google Forms restriction), so they always reject it.\r\n- On the wire, an \"Other\" answer is encoded the way the real form does it: `entry.<id>=__other_option__` plus `entry.<id>.other_option_response=<text>`. Answers that use only regular choices produce byte-identical bodies to v0.1.x.\r\n- Migration note: v0.1.x mistakenly parsed the \"Other\" pseudo-choice as an **empty string inside `choices`**. 0.2.0 removes it and reports `hasOther` instead, so on forms that have an \"Other\" choice, `#question.choices` is one smaller than in v0.1.x.\r\n\r\n### Supported URL shapes\r\n\r\n`Kit.fetch` normalizes the URL before requesting it. All of these work as long as the form is public:\r\n\r\n| Shape | Example |\r\n| --- | --- |\r\n| `forms.gle` short link | `https://forms.gle/AbCdEfGh12345` |\r\n| `viewform` URL, with or without query string | `https://docs.google.com/forms/d/e/<id>/viewform?usp=sf_link` |\r\n| bare `/d/e/<id>` (no `/viewform` suffix) | `https://docs.google.com/forms/d/e/<id>` |\r\n| edit URL `/d/<docId>/edit` | `https://docs.google.com/forms/d/<docId>/edit` |\r\n\r\n`forms.gle` links resolve via `HttpService`'s own redirect following. `/d/e/<id>` and `/d/<docId>/edit` are both rewritten to `.../viewform` internally. In every case, **the target form must be public** (no login required) — a private, org-restricted, or \"not accepting responses\" form usually returns `ParseFailed` instead of a parsed schema (the HTML loads but has no `FB_PUBLIC_LOAD_DATA_` blob), but may instead surface as `HttpError` if Google rejects the request itself with a non-2xx response.\r\n\r\n## Client UI flow\r\n\r\nThe recommended flow keeps HTTP and DataStore access on the server while still letting the client build a form UI and give immediate feedback:\r\n\r\n```lua\r\n-- [server] fetch once, then hand a plain-data schema to the client\r\nlocal form = Kit.fetch(url)\r\nlocal schema = form:getSchema()   -- { title, description, questions } — plain data, no functions, no submitUrl\r\nremoteEvent:FireClient(player, schema)\r\n\r\n-- [client] rebuild a Form from the schema and build UI from it\r\nlocal clientForm = Kit.fromSchema(schema)\r\nfor _, question in clientForm.questions do\r\n    -- build UI per question.type\r\nend\r\n\r\n-- [client] validate locally before sending, for instant feedback\r\nlocal ok, err = clientForm:validate(answers)\r\nif not ok then\r\n    -- show err.message near the offending field\r\n    return\r\nend\r\nremoteEvent:FireServer(answers)\r\n-- clientForm:submit(...) would error(\"...server-only...\") if called here\r\n```\r\n\r\n```lua\r\n-- [server] receive answers and re-validate before actually submitting.\r\n-- Never trust client-side validation alone — the client is not authoritative.\r\nremoteEvent.OnServerEvent:Connect(function(player, answers)\r\n    local ok, err = gate:submitFor(player, answers)\r\n    if not ok then\r\n        -- ok is false and err.code is one of the Gate error codes below\r\n    end\r\nend)\r\n```\r\n\r\n`form:getSchema()` returns `{ title, description, questions }` only — it is client-safe **by design** and never needs sanitizing before you `FireClient` it. `submitUrl` (the `formResponse` POST endpoint) is deliberately excluded: it never appears in the schema, so it cannot be read off the wire by an exploiter inspecting `RemoteEvent`/`RemoteFunction` traffic. If you build a `FormSchema` by hand for server-side use (e.g. to call `form:submit` without going through `Kit.fetch`), `submitUrl` is optional on the type but required in practice for that path — `Gate` derives its DataStore scope hash from it, and `form:submit` posts to it; a `Form` without one raises a clear error naming `submitUrl` if you try to submit through it.\r\n\r\nThe kit does **not** ship any `RemoteEvent`/`RemoteFunction`. Wiring the client→server request path (naming, throttling the remote itself, `FireServer`/`OnServerEvent` vs `RemoteFunction`) is entirely up to the game.\r\n\r\n> **Security note**: the Google Form URL is kept server-side only and is never included in `getSchema()`'s output. Keep `FORM_URL` (or wherever you store the fetch URL) in a server-only script (`ServerScriptService`, etc.) — never in `ReplicatedStorage`. Because a public Google Form accepts submissions from anyone who knows its URL, keeping that URL secret from clients is what makes `Gate`'s rate limiting actually effective; if the URL leaked to the client, an exploiter could POST to Google directly and bypass `Gate` entirely.\r\n>\r\n> Always send **only** `form:getSchema()`'s return value to clients — never the `Form` instance itself. As defense in depth, the `Form` instance no longer even carries the URL as a table field (it's kept in an internal weak-keyed side table), so passing a whole `Form` through a remote by mistake would not leak it either — but that's a safety net, not a reason to do it; `getSchema()` is the documented and supported way to hand form data to a client.\r\n>\r\n> Everything `getSchema()` *does* include — question titles, entry IDs, choice lists — is harmless to expose: none of it lets a client submit anywhere without the form URL, and none of it is secret about the form itself (anyone who opens the public form in a browser sees the same thing).\r\n>\r\n> `Kit.createGate` also enforces this: it hard-errors (naming `submitUrl` in the message) unless the `form` holds a `submitUrl` internally — normally the `Form` returned by `Kit.fetch` (a hand-built schema that includes `submitUrl` passed to `Kit.fromSchema` also qualifies). A `Form` restored from `getSchema()` output has no URL, can never submit, and therefore can never be gated either. This check applies unconditionally, even if you inject a fake `store` for testing.\r\n\r\n## Submission policies (Gate)\r\n\r\n`Kit.createGate(form, policy?)` wraps a `Form` with rate limiting and a submission window, backed by `DataStoreService`. It does not modify `Form` itself — the underlying `form:submit` stays a plain primitive.\r\n\r\n```lua\r\nlocal gate = Kit.createGate(form, {\r\n    limit = \"daily\",\r\n    window = {   -- optional; omit for \"always accepting\"\r\n        startsAt = DateTime.fromIsoDate(\"2026-08-01T00:00:00+09:00\"),\r\n        endsAt   = DateTime.fromIsoDate(\"2026-08-31T23:59:59+09:00\"),\r\n    },\r\n    successCooldownSeconds = 600,\r\n    requestIntervalSeconds = 60,\r\n    dailyResetOffsetHours = 9,\r\n    dataStoreName = \"GoogleFormKit\",\r\n})\r\n\r\n-- Call from your own remote's receive handler.\r\nlocal ok, err = gate:submitFor(player, answers)\r\n\r\n-- Pre-check for UI purposes; issues no HTTP or DataStore write, only a read.\r\nlocal canSubmit, err = gate:canSubmit(player)  -- err.retryAfterSeconds has the remaining wait, if applicable\r\n```\r\n\r\n`gate:canSubmit`'s underlying DataStore read is cached per player for up to 30 seconds, per server — polling it in a loop does not hammer the DataStore, but it also means the verdict can be up to 30 seconds stale (e.g. a success recorded on another server, or via `submitFor` on this one, may not be reflected immediately).\r\n\r\n### Policy options\r\n\r\n| Option | Default | Meaning |\r\n| --- | --- | --- |\r\n| `limit` | `\"unlimited\"` | `\"once\"` (one successful submission ever, per user) / `\"daily\"` (one per JST-offset day) / `\"unlimited\"` |\r\n| `window` | `nil` (no restriction) | `{ startsAt: DateTime?, endsAt: DateTime? }` — accept submissions only within this range |\r\n| `successCooldownSeconds` | `600` (10 minutes) | Minimum time after a successful submission before another is allowed |\r\n| `requestIntervalSeconds` | `60` (1 minute) | Minimum time between `submitFor` calls from the same player |\r\n| `dailyResetOffsetHours` | `9` (JST midnight) | UTC offset in hours used to compute the \"daily\" boundary |\r\n| `dataStoreName` | `\"GoogleFormKit\"` | DataStore name Gate reads/writes success records in |\r\n\r\n### Requirements and cross-server behavior\r\n\r\n- Gate needs `DataStoreService` — enable **Studio Access to API Services** to test in Studio, and the game must have DataStores available in production (they're on by default for published places).\r\n- `limit` and `successCooldownSeconds` are enforced via DataStore and therefore apply **cross-server** (one Roblox universe, any number of servers).\r\n- `requestIntervalSeconds` is tracked in **per-server memory** only — a player switching servers resets their 1-minute throttle window.\r\n- If the DataStore **read** (the `once`/`daily`/cooldown check before submitting) fails, Gate fails **safe (closed)**: the submission is refused with `DataStoreError` and no HTTP request is sent, rather than risk letting a limit slip through.\r\n- If the DataStore **write** (recording success, right after `form:submit` already reached Google) fails, Gate does **not** fail closed — it cannot: the answer has already been submitted and cannot be un-sent. `gate:submitFor` still returns `true` (the submission genuinely succeeded), only `warn()`s `\"GoogleFormKit: success record write failed: ...\"`, and the success record is left stale/missing. In this case the user's `once`/`daily`/cooldown limit for this submission may not stick, and they could be able to submit again before it should be allowed.\r\n- DataStore key layout: store `dataStoreName` (default `\"GoogleFormKit\"`), scope `f_<hex8>` where `<hex8>` is a djb2 hash of the form's submit URL (keeps the scope short and separates records per form), key `tostring(player.UserId)`.\r\n\r\n## Error codes\r\n\r\nEvery fallible call returns `value, err` where `err = { code: string, message: string, retryAfterSeconds: number? }`.\r\n\r\n| Code | Raised by | Meaning | `retryAfterSeconds` |\r\n| --- | --- | --- | --- |\r\n| `HttpError` | `fetch`, `submit` | `HttpService` failed: HTTP requests disabled, connection failure, or a non-2xx response | — |\r\n| `ParseFailed` | `fetch` | `FB_PUBLIC_LOAD_DATA_` was not found in the HTML (private/login-required/closed form, or Google changed the internal format) | — |\r\n| `ValidationFailed` | `submit`, `validate`, Gate | Answer violates the validation rules (see below) | — |\r\n| `Throttled` | `Gate.submitFor` / `Gate.canSubmit` | Called again before `requestIntervalSeconds` has elapsed since the last call | `submitFor`: the **full interval** (calling `submitFor` re-arms the timer even on failure, so \"wait this long from now\" is always correct). `canSubmit`: the **actual remaining time** (it does not re-arm the timer) |\r\n| `OutsideWindow` | Gate | Current time (`DateTime.now()`, UTC) is before `window.startsAt` or after `window.endsAt` | seconds until `startsAt`, if before the window; absent if past `endsAt` |\r\n| `LimitReached` | Gate | `limit = \"once\"` already has a success record, or `limit = \"daily\"` already succeeded today | `once`: `nil` (no time will ever fix it). `daily`: seconds until the **later** of the next JST(-offset) midnight or the cooldown ending, whichever is later — this avoids the case where the daily boundary passes but the cooldown is still active |\r\n| `Cooldown` | Gate | Less than `successCooldownSeconds` has passed since the last success | seconds remaining until the cooldown ends |\r\n| `DataStoreError` | Gate | The DataStore **read** (limit/cooldown check, before submitting) failed; Gate refuses the submission rather than risk letting a limit slip through. Note: a failed DataStore **write** (recording success, after the answer already posted to Google) does *not* raise this code — `submitFor` still returns `true` and only `warn()`s, since the answer can't be un-sent | — |\r\n\r\n### Validation rules (checked before any HTTP request)\r\n\r\n| Rule | Example message |\r\n| --- | --- |\r\n| Required q","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/hakochanjp/roblox/google-form-kit/0.2.0/readme"}