Skip to content

Add Blooio provider (send + receive) with example and tests - #1

Open
davext wants to merge 1 commit into
time-attack:mainfrom
davext:add-blooio-provider
Open

Add Blooio provider (send + receive) with example and tests#1
davext wants to merge 1 commit into
time-attack:mainfrom
davext:add-blooio-provider

Conversation

@davext

@davext davext commented Aug 15, 2026

Copy link
Copy Markdown

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 readreply logic works across all three providers.

  • src/providers/blooio.tsBlooio.send(url, { to }) posts a GamePigeon balloon (invite or move) to the Blooio v4 API (POST /v4/channels/{number}/messages with the imessage_app content type), plus sendText(). Blooio.fromWebhook(body) parses the message.received webhook.
  • src/index.ts — exports Blooio and a fromBlooioWebhook() helper (mirrors fromWebhook / fromPhotonMessage).
  • examples/blooio-express-bot.ts — ~25-line Express bot, mirroring linq-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).
  • README — Blooio row in the providers table + a receive snippet.

Why it's a natural fit

Blooio delivers the inbound app balloon already decoded, so the webhook's data.imessage_app.url is 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. new blooio: cases)
  • New provider typechecks (no new tsc errors; the one pre-existing require diagnostic in invite.ts is unrelated)
  • Verified end-to-end: a fresh invite("pool") URL sent via Blooio renders as a playable 8-Ball invite

Disclosure

I contribute to Blooio. Happy to adjust wording/placement — the goal is parity with the existing providers, not promotion.

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>
@davext
davext force-pushed the add-blooio-provider branch from 40eb8fc to 7c0e3de Compare August 15, 2026 00:57

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread src/providers/blooio.ts
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread README.md

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread README.md
if (!inb || inb.move.isInvite) return;
const m = inb.move;
// your move logic here
const url = m.reply({ botId: BOT, state: m.state });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant