Add Blooio provider (send + receive) with example and tests - #1
Conversation
Adds a third provider adapter alongside Linq and Photon: - src/providers/blooio.ts — send GamePigeon balloons (invite or move) and plain text over the Blooio v4 REST API, and parse the `message.received` webhook. Blooio delivers the inbound balloon already decoded, so the webhook's `data.imessage_app.url` is the ready-to-read app-state URL. - index.ts — export `Blooio` and a `fromBlooioWebhook()` helper. - examples/blooio-express-bot.ts — ~25-line Express bot mirroring the Linq one. - test/inbound.test.ts — Blooio receive-path assertions (parse + ignore). - README — list Blooio in the providers table and receive section. Disclosure: I contribute to Blooio. Game logic is identical across providers; this only adds the transport adapter. Co-authored-by: Cursor <cursoragent@cursor.com>
40eb8fc to
7c0e3de
Compare
There was a problem hiding this comment.
8 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="README.md">
<violation number="1" location="README.md:86">
P3: The new Blooio snippet is presented as a ready-to-run "shortest path", but it references `app` and `BOT` that are never defined in the snippet. `app.post(...)` needs `const app = express(); app.use(express.json());` and `BOT` must be a defined bot id. As written, copying it fails with `BOT is not defined` (and `app` is unresolved). Define both so the snippet is actually runnable, or point readers to examples/blooio-express-bot.ts.</violation>
<violation number="2" location="README.md:183">
P3: The providers section now embeds vendor sales copy — "plans start at $39/mo", "the $0 trial doesn't expire after a month", "no sales call" — in a neutral OSS library README. This is promotional, unverifiable in review, and will go stale the moment Blooio changes pricing; it also reads promotional in a PR whose author discloses they contribute to Blooio. Keep the informational parts already present in the table and drop the pricing/sales paragraph, or trim it to a neutral statement.</violation>
</file>
<file name="src/providers/blooio.ts">
<violation number="1" location="src/providers/blooio.ts:96">
P2: When an incoming text contains a pasted GamePigeon URL, `Blooio.fromWebhook` treats it as an app balloon because it scans all of `data`. Require `message_type === "imessage_app"` and read only `data.imessage_app.url` before decoding.</violation>
</file>
<file name="examples/blooio-express-bot.ts">
<violation number="1" location="examples/blooio-express-bot.ts:27">
P1: The public `/webhook` accepts unsigned bodies and can be made to send through the configured Blooio account. Verify Blooio’s signature over the raw request body before calling `fromBlooioWebhook`.</violation>
<violation number="2" location="examples/blooio-express-bot.ts:36">
P1: This route acknowledges Blooio only after the outbound API call completes, so provider retries can produce duplicate replies. Send the HTTP success response before the outbound call and handle the send failure asynchronously.</violation>
<violation number="3" location="examples/blooio-express-bot.ts:40">
P2: The documented `/start` URL encodes the phone number incorrectly, so copying it produces an invalid recipient. Show `%2B1555...` or tell callers to URL-encode the destination.</violation>
<violation number="4" location="examples/blooio-express-bot.ts:41">
P1: When this sample is deployed on a reachable host, anyone can call `/start` with an arbitrary `to` number and spend the configured Blooio account sending messages. Protect this route with admin authentication or remove it before exposing the server publicly.</violation>
<violation number="5" location="examples/blooio-express-bot.ts:43">
P2: Calling `/start` without `to` sends the literal destination `"undefined"` and results in a provider failure rather than a useful 400 response. Validate the destination before calling `bloo.send`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if (m.isInvite) return res.sendStatus(204); // wait for the first move | ||
| const { state, env } = decide(m); | ||
| const url = m.reply({ botId: BOT_ID, state, env }); | ||
| await bloo.send(url, { to: inbound.replyTo!, caption: "Your move!" }); |
There was a problem hiding this comment.
P1: This route acknowledges Blooio only after the outbound API call completes, so provider retries can produce duplicate replies. Send the HTTP success response before the outbound call and handle the send failure asynchronously.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/blooio-express-bot.ts, line 36:
<comment>This route acknowledges Blooio only after the outbound API call completes, so provider retries can produce duplicate replies. Send the HTTP success response before the outbound call and handle the send failure asynchronously.</comment>
<file context>
@@ -0,0 +1,47 @@
+ if (m.isInvite) return res.sendStatus(204); // wait for the first move
+ const { state, env } = decide(m);
+ const url = m.reply({ botId: BOT_ID, state, env });
+ await bloo.send(url, { to: inbound.replyTo!, caption: "Your move!" });
+ res.sendStatus(200);
+});
</file context>
| }); | ||
|
|
||
| // GET /start?to=+1555...&game=pool — send a fresh invite | ||
| app.get("/start", async (req, res) => { |
There was a problem hiding this comment.
P1: When this sample is deployed on a reachable host, anyone can call /start with an arbitrary to number and spend the configured Blooio account sending messages. Protect this route with admin authentication or remove it before exposing the server publicly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/blooio-express-bot.ts, line 41:
<comment>When this sample is deployed on a reachable host, anyone can call `/start` with an arbitrary `to` number and spend the configured Blooio account sending messages. Protect this route with admin authentication or remove it before exposing the server publicly.</comment>
<file context>
@@ -0,0 +1,47 @@
+});
+
+// GET /start?to=+1555...&game=pool — send a fresh invite
+app.get("/start", async (req, res) => {
+ const inv = op.invite(String(req.query.game ?? "pool"));
+ await bloo.send(inv.url, { to: String(req.query.to), caption: "Wanna play?" });
</file context>
| return { state: move.state }; | ||
| } | ||
|
|
||
| app.post("/webhook", async (req, res) => { |
There was a problem hiding this comment.
P1: The public /webhook accepts unsigned bodies and can be made to send through the configured Blooio account. Verify Blooio’s signature over the raw request body before calling fromBlooioWebhook.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/blooio-express-bot.ts, line 27:
<comment>The public `/webhook` accepts unsigned bodies and can be made to send through the configured Blooio account. Verify Blooio’s signature over the raw request body before calling `fromBlooioWebhook`.</comment>
<file context>
@@ -0,0 +1,47 @@
+ return { state: move.state };
+}
+
+app.post("/webhook", async (req, res) => {
+ // Blooio pre-decodes the balloon, so `data.imessage_app.url` is ready to read.
+ const inbound = op.fromBlooioWebhook(req.body);
</file context>
| const type = String(body.type ?? body.event_type ?? ""); | ||
| if (type && !type.includes("received")) return null; // inbound messages only | ||
| const data = body.data ?? body; | ||
| const url = extractUrl(data); |
There was a problem hiding this comment.
P2: When an incoming text contains a pasted GamePigeon URL, Blooio.fromWebhook treats it as an app balloon because it scans all of data. Require message_type === "imessage_app" and read only data.imessage_app.url before decoding.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/providers/blooio.ts, line 97:
<comment>When an incoming text contains a pasted GamePigeon URL, `Blooio.fromWebhook` treats it as an app balloon because it scans all of `data`. Require `message_type === "imessage_app"` and read only `data.imessage_app.url` before decoding.</comment>
<file context>
@@ -0,0 +1,107 @@
+ const type = String(body.type ?? body.event_type ?? "");
+ if (type && !type.includes("received")) return null; // inbound messages only
+ const data = body.data ?? body;
+ const url = extractUrl(data);
+ if (!url) return null;
+ const sender = data.sender ?? {};
</file context>
| res.sendStatus(200); | ||
| }); | ||
|
|
||
| // GET /start?to=+1555...&game=pool — send a fresh invite |
There was a problem hiding this comment.
P2: The documented /start URL encodes the phone number incorrectly, so copying it produces an invalid recipient. Show %2B1555... or tell callers to URL-encode the destination.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/blooio-express-bot.ts, line 40:
<comment>The documented `/start` URL encodes the phone number incorrectly, so copying it produces an invalid recipient. Show `%2B1555...` or tell callers to URL-encode the destination.</comment>
<file context>
@@ -0,0 +1,47 @@
+ res.sendStatus(200);
+});
+
+// GET /start?to=+1555...&game=pool — send a fresh invite
+app.get("/start", async (req, res) => {
+ const inv = op.invite(String(req.query.game ?? "pool"));
</file context>
| // GET /start?to=+1555...&game=pool — send a fresh invite | ||
| app.get("/start", async (req, res) => { | ||
| const inv = op.invite(String(req.query.game ?? "pool")); | ||
| await bloo.send(inv.url, { to: String(req.query.to), caption: "Wanna play?" }); |
There was a problem hiding this comment.
P2: Calling /start without to sends the literal destination "undefined" and results in a provider failure rather than a useful 400 response. Validate the destination before calling bloo.send.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/blooio-express-bot.ts, line 43:
<comment>Calling `/start` without `to` sends the literal destination `"undefined"` and results in a provider failure rather than a useful 400 response. Validate the destination before calling `bloo.send`.</comment>
<file context>
@@ -0,0 +1,47 @@
+// GET /start?to=+1555...&game=pool — send a fresh invite
+app.get("/start", async (req, res) => {
+ const inv = op.invite(String(req.query.game ?? "pool"));
+ await bloo.send(inv.url, { to: String(req.query.to), caption: "Wanna play?" });
+ res.json({ sent: true, game: inv.game, id: inv.id });
+});
</file context>
|
|
||
| Every provider needs an iMessage number to send from. With **Blooio** that step | ||
| is self-serve: you provision a number from the API/dashboard (no sales call), | ||
| plans start at **$39/mo**, and the **$0 trial doesn't expire after a month** — so |
There was a problem hiding this comment.
P3: The providers section now embeds vendor sales copy — "plans start at $39/mo", "the $0 trial doesn't expire after a month", "no sales call" — in a neutral OSS library README. This is promotional, unverifiable in review, and will go stale the moment Blooio changes pricing; it also reads promotional in a PR whose author discloses they contribute to Blooio. Keep the informational parts already present in the table and drop the pricing/sales paragraph, or trim it to a neutral statement.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 184:
<comment>The providers section now embeds vendor sales copy — "plans start at $39/mo", "the $0 trial doesn't expire after a month", "no sales call" — in a neutral OSS library README. This is promotional, unverifiable in review, and will go stale the moment Blooio changes pricing; it also reads promotional in a PR whose author discloses they contribute to Blooio. Keep the informational parts already present in the table and drop the pricing/sales paragraph, or trim it to a neutral statement.</comment>
<file context>
@@ -149,9 +175,16 @@ Per-game `state` shapes → [docs/GAMES.md](docs/GAMES.md) · adding a game is o
+Every provider needs an iMessage number to send from. With **Blooio** that step
+is self-serve: you provision a number from the API/dashboard (no sales call),
+plans start at **$39/mo**, and the **$0 trial doesn't expire after a month** — so
+you can go from `npm install` to a native 8‑Ball invite in one sitting. Bring
+whichever provider you already use; the game code above is identical on all three.
</file context>
| if (!inb || inb.move.isInvite) return; | ||
| const m = inb.move; | ||
| // your move logic here | ||
| const url = m.reply({ botId: BOT, state: m.state }); |
There was a problem hiding this comment.
P3: The new Blooio snippet is presented as a ready-to-run "shortest path", but it references app and BOT that are never defined in the snippet. app.post(...) needs const app = express(); app.use(express.json()); and BOT must be a defined bot id. As written, copying it fails with BOT is not defined (and app is unresolved). Define both so the snippet is actually runnable, or point readers to examples/blooio-express-bot.ts.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 87:
<comment>The new Blooio snippet is presented as a ready-to-run "shortest path", but it references `app` and `BOT` that are never defined in the snippet. `app.post(...)` needs `const app = express(); app.use(express.json());` and `BOT` must be a defined bot id. As written, copying it fails with `BOT is not defined` (and `app` is unresolved). Define both so the snippet is actually runnable, or point readers to examples/blooio-express-bot.ts.</comment>
<file context>
@@ -65,6 +65,32 @@ npm install openpigeon
+ if (!inb || inb.move.isInvite) return;
+ const m = inb.move;
+ // your move logic here
+ const url = m.reply({ botId: BOT, state: m.state });
+ await bloo.send(url, { to: inb.replyTo! });
+});
</file context>
Summary
Adds a third provider adapter alongside Linq and Photon so an OpenPigeon bot can run over Blooio's iMessage API. The game codec is untouched — this is purely a transport adapter, and the same
read→replylogic works across all three providers.src/providers/blooio.ts—Blooio.send(url, { to })posts a GamePigeon balloon (invite or move) to the Blooio v4 API (POST /v4/channels/{number}/messageswith theimessage_appcontent type), plussendText().Blooio.fromWebhook(body)parses themessage.receivedwebhook.src/index.ts— exportsBlooioand afromBlooioWebhook()helper (mirrorsfromWebhook/fromPhotonMessage).examples/blooio-express-bot.ts— ~25-line Express bot, mirroringlinq-express-bot.ts.test/inbound.test.ts— Blooio receive-path assertions (parse a real captured move wrapped in Blooio's webhook shape; ignore non-GamePigeon).Why it's a natural fit
Blooio delivers the inbound app balloon already decoded, so the webhook's
data.imessage_app.urlis the ready-to-read app-state URL — your bot never decodes anything Apple-specific. Balloons render natively on the recipient's GamePigeon, and onboarding is self-serve (provision a number from the API/dashboard, no sales call), which keeps the "install → first 8-Ball invite in one sitting" story true for the quickstart.Test plan
npm test— all codecs OK; 16 inbound assertions pass (incl. newblooio:cases)tscerrors; the one pre-existingrequirediagnostic ininvite.tsis unrelated)invite("pool")URL sent via Blooio renders as a playable 8-Ball inviteDisclosure
I contribute to Blooio. Happy to adjust wording/placement — the goal is parity with the existing providers, not promotion.