Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Integrate GamePigeon iMessage games into your app in a few lines of TypeScript.**

Read the board as plain data. Send moves as a URL. Works over Linq & Photon — no AI, no SQLite, no Mac.
Read the board as plain data. Send moves as a URL. Works over Blooio, Linq & Photon — no AI, no SQLite, no Mac.

[![license](https://img.shields.io/badge/license-MIT-3178c6.svg)](LICENSE)
[![npm](https://img.shields.io/badge/npm-openpigeon-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/openpigeon)
Expand Down Expand Up @@ -65,6 +65,38 @@ npm install openpigeon

## Receive — from a webhook/stream, never SQLite

The shortest path is a plain HTTP webhook. Blooio delivers the balloon already
decoded, so `data.imessage_app.url` arrives ready to read:

```ts
import express from "express";
import * as op from "openpigeon";

const app = express();
app.use(express.json());
const BOT = "00000000-0000-4000-8000-OPENPIGEONBOT"; // stable id per bot

const bloo = new op.Blooio({
apiKey: process.env.BLOOIO_API_KEY!,
fromNumber: "+1...", // your Blooio iMessage number
});

app.post("/webhook", async (req, res) => {
res.sendStatus(200); // ack first; verify the signature in prod (see example below)
const inb = op.fromBlooioWebhook(req.body);
if (!inb || inb.move.isInvite) return;
const m = inb.move;
// your move logic here
const url = m.reply({ botId: BOT, state: m.state });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
await bloo.send(url, { to: inb.replyTo! });
});
```

Production-ready version with `X-Blooio-Signature` verification and an
admin-gated `/start`: [`examples/blooio-express-bot.ts`](examples/blooio-express-bot.ts).

Same three lines of game logic work on any provider:

<table>
<tr><th>Linq (webhook)</th><th>Photon (stream)</th></tr>
<tr valign="top"><td width="50%">
Expand Down Expand Up @@ -113,7 +145,7 @@ for await (const [space, message] of app.messages) {

</td></tr></table>

Full examples: [`examples/linq-express-bot.ts`](examples/linq-express-bot.ts) · [`examples/photon-bot.ts`](examples/photon-bot.ts) · [`examples/quickstart.ts`](examples/quickstart.ts)
Full examples: [`examples/blooio-express-bot.ts`](examples/blooio-express-bot.ts) · [`examples/linq-express-bot.ts`](examples/linq-express-bot.ts) · [`examples/photon-bot.ts`](examples/photon-bot.ts) · [`examples/quickstart.ts`](examples/quickstart.ts)

## See the board

Expand Down Expand Up @@ -149,9 +181,14 @@ Per-game `state` shapes → [docs/GAMES.md](docs/GAMES.md) · adding a game is o

| provider | send | receive | notes |
|---|:--:|:--:|---|
| **Blooio** | ✅ balloon + text | ✅ `message.received` webhook | pure-TS end to end; **native render**; inbound balloon delivered **already decoded** (`data.imessage_app.url`); self-serve number |
| **Linq** | ✅ balloon + text | ✅ `message.received` webhook | pure-TS end to end; **native render** (`liveLayoutInfo`) |
| **Photon** | ✅ via `spectrum-ts` | ✅ gRPC message stream | one process, no bridge; moves ride an https carrier |

Every provider needs an iMessage number to send from; with **Blooio** that step
is self-serve from its API/dashboard. Bring whichever provider you already use —
the game code above is identical on all three.

## 🚫 What OpenPigeon does *not* do

- **No AI / no move selection.** It reads and writes state; *you* decide moves. Not a game engine, runs no physics.
Expand All @@ -161,7 +198,7 @@ Per-game `state` shapes → [docs/GAMES.md](docs/GAMES.md) · adding a game is o
- **Decode-correct, not byte-identical** to a *captured* message — a rebuilt URL always decrypts to identical bytes (what the recipient parses), but the app's outer escaping is content-dependent and not reproduced verbatim.
- **A few field *semantics* are inferred** (flagged in each module): cup_pong trajectory samples kept as opaque tokens; darts bull (25/50) is a best guess; some state tuples are positional. The *encodings* are exact and reversible.
- **Photon sending happens in Node**, through `spectrum-ts` (a gRPC SDK). OpenPigeon gives you the `customizedMiniApp(...)` args, not the socket.
- **You bring the account + host** (a Linq/Photon account; a public webhook endpoint for Linq). Add Linq HMAC signature verification before exposing a webhook publicly.
- **You bring the account + host** (a Blooio/Linq/Photon account; a public webhook endpoint for the webhook providers). Verify the provider's webhook signature (Blooio and Linq both sign) before exposing an endpoint publicly.
- **Not affiliated with GamePigeon.** Independent, interoperable reimplementation from static analysis; ships **no** GamePigeon code or assets.

## How it works
Expand Down
109 changes: 109 additions & 0 deletions examples/blooio-express-bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// End-to-end GamePigeon bot over Blooio — an Express webhook server.
// No SQLite, no Mac, runs anywhere. Point your Blooio webhook at POST /webhook.
//
// npm i express openpigeon
// BLOOIO_API_KEY=api_xxx BLOOIO_FROM=+15551234567 \
// BLOOIO_WEBHOOK_SECRET=whsec_xxx ADMIN_TOKEN=secret \
// node examples/blooio-express-bot.ts
//
// This plays a trivial "pass the turn back" move so you can see the wire loop.
// Swap `decide()` for your own move selection — OpenPigeon has no AI.

import crypto from "node:crypto";
import express from "express";
import * as op from "openpigeon";

const app = express();

const bloo = new op.Blooio({
apiKey: process.env.BLOOIO_API_KEY!,
fromNumber: process.env.BLOOIO_FROM!, // your Blooio iMessage number
});
const BOT_ID = "00000000-0000-4000-8000-OPENPIGEONBOT"; // keep stable per bot
const WEBHOOK_SECRET = process.env.BLOOIO_WEBHOOK_SECRET ?? ""; // whsec_...

// Fail closed, but loudly: without the secret every webhook is rejected (401),
// so a bot that forgets this env var would run but never reply. Warn at startup
// (and again on the first rejected request) instead of dying silently.
if (!WEBHOOK_SECRET) {
console.warn(

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 console.warn runs on every rejected POST, but /webhook is public and unauthenticated by design, so anyone can spam the endpoint and generate unbounded log output. The surrounding comment says the warning fires "on the first rejected request," but the code logs unconditionally, and in the unset-secret case it repeats a message the startup warning already printed. Throttle the per-request log (e.g., once per interval) or rely on the startup warning alone.

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 29:

<comment>The new `console.warn` runs on every rejected POST, but `/webhook` is public and unauthenticated by design, so anyone can spam the endpoint and generate unbounded log output. The surrounding comment says the warning fires "on the first rejected request," but the code logs unconditionally, and in the unset-secret case it repeats a message the startup warning already printed. Throttle the per-request log (e.g., once per interval) or rely on the startup warning alone.</comment>

<file context>
@@ -22,6 +22,17 @@ const bloo = new op.Blooio({
+// so a bot that forgets this env var would run but never reply. Warn at startup
+// (and again on the first rejected request) instead of dying silently.
+if (!WEBHOOK_SECRET) {
+  console.warn(
+    "[blooio] BLOOIO_WEBHOOK_SECRET is not set — every /webhook request will be " +
+      "rejected with 401 and the bot will never reply. Set it to your whsec_... " +
</file context>

"[blooio] BLOOIO_WEBHOOK_SECRET is not set — every /webhook request will be " +
"rejected with 401 and the bot will never reply. Set it to your whsec_... " +
"signing secret (POST /v4/webhooks response) to receive moves.",
);
}

function decide(move: op.Move): { state?: any; env?: Record<string, string> } {
// re-send the board unchanged (a legal pass). Replace with your logic.
return { state: move.state };
}

// Verify Blooio's `X-Blooio-Signature: t=...,v1=...` header (HMAC-SHA256 over
// `${t}.${rawBody}`) before trusting a webhook — otherwise anyone could POST a
// body and make your bot spend your account. This needs the RAW body, so the
// route below uses express.raw (not express.json).
function verifyBlooioSignature(rawBody: Buffer, header: unknown): boolean {
if (!WEBHOOK_SECRET || typeof header !== "string") return false;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const t = header.split(",").find((p) => p.startsWith("t="))?.slice(2);
const v1 = header.split(",").find((p) => p.startsWith("v1="))?.slice(3);
if (!t || !v1) return false;
if (Math.floor(Date.now() / 1000) - Number(t) > 300) return false; // replay guard
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${t}.${rawBody.toString()}`)
.digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verifyBlooioSignature(req.body as Buffer, req.headers["x-blooio-signature"])) {
console.warn(
WEBHOOK_SECRET
? "[blooio] rejected /webhook: invalid or missing X-Blooio-Signature"

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: When a webhook is rejected because its t= timestamp is older than 300s, the new log reports 'invalid or missing X-Blooio-Signature' even though the signature is present and valid — only the timestamp expired. Rephrase the message to cover all rejection paths (e.g. 'missing, invalid, or expired signature') or have verifyBlooioSignature return a reason so the log distinguishes the expired-timestamp case.

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 67:

<comment>When a webhook is rejected because its `t=` timestamp is older than 300s, the new log reports 'invalid or missing X-Blooio-Signature' even though the signature is present and valid — only the timestamp expired. Rephrase the message to cover all rejection paths (e.g. 'missing, invalid, or expired signature') or have verifyBlooioSignature return a reason so the log distinguishes the expired-timestamp case.</comment>

<file context>
@@ -51,6 +62,11 @@ app.post(
     if (!verifyBlooioSignature(req.body as Buffer, req.headers["x-blooio-signature"])) {
+      console.warn(
+        WEBHOOK_SECRET
+          ? "[blooio] rejected /webhook: invalid or missing X-Blooio-Signature"
+          : "[blooio] rejected /webhook: BLOOIO_WEBHOOK_SECRET is unset (see startup warning)",
+      );
</file context>
Suggested change
? "[blooio] rejected /webhook: invalid or missing X-Blooio-Signature"
? "[blooio] rejected /webhook: X-Blooio-Signature missing, invalid, or expired (t > 300s old)"

: "[blooio] rejected /webhook: BLOOIO_WEBHOOK_SECRET is unset (see startup warning)",
);
return res.sendStatus(401);
}
// Ack immediately so a provider retry can't double-fire our reply, then do
// the outbound send after responding and log (don't throw) on failure.
res.sendStatus(200);
const inbound = op.fromBlooioWebhook(JSON.parse((req.body as Buffer).toString()));
if (!inbound || inbound.move.isInvite) return; // wait for the first real move
const m = inbound.move;
console.log(`received ${m.game} move #${m.num} from ${inbound.replyTo}`);
const { state, env } = decide(m);
const url = m.reply({ botId: BOT_ID, state, env });
bloo
.send(url, { to: inbound.replyTo!, caption: "Your move!" })
.catch((err) => console.error("reply send failed:", err));
},
);

// GET /start?to=%2B15551234567&game=pool — send a fresh invite.
// Admin-only: guard with a bearer token so a public host can't spend your
// account. `to` must be URL-encoded (a leading + becomes %2B) and is required.
app.get("/start", async (req, res) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (!process.env.ADMIN_TOKEN || req.headers.authorization !== `Bearer ${process.env.ADMIN_TOKEN}`) {
return res.sendStatus(401);
}
const to = String(req.query.to ?? "");
if (!/^\+[1-9]\d{6,14}$/.test(to)) {
return res
.status(400)
.json({ error: "`to` must be an E.164 number, URL-encoded (e.g. ?to=%2B15551234567)" });
}
const inv = op.invite(String(req.query.game ?? "pool"));
try {
await bloo.send(inv.url, { to, caption: "Wanna play?" });
} catch (err) {
return res.status(502).json({ error: String(err) });
}
res.json({ sent: true, game: inv.game, id: inv.id });
});

app.listen(8000, () => console.log("listening on :8000"));
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* OpenPigeon — integrate GamePigeon into any iMessage agent, in a few lines.
*
* Provider-agnostic codec for GamePigeon iMessage games + send/receive adapters
* for Linq and Photon. No AI, no SQLite, no Mac: you receive moves from your
* provider's webhook/stream and reply with a URL.
* for Blooio, Linq, and Photon. No AI, no SQLite, no Mac: you receive moves from
* your provider's webhook/stream and reply with a URL.
*
* import * as op from "openpigeon";
*
Expand All @@ -23,6 +23,7 @@
import "./games/index.ts"; // registers all game specs

import { Inbound } from "./inbound.ts";
import { Blooio } from "./providers/blooio.ts";
import { Linq } from "./providers/linq.ts";
import { Photon } from "./providers/photon.ts";
import { allSpecs } from "./registry.ts";
Expand All @@ -34,7 +35,7 @@ export type { Invite } from "./invite.ts";
export { Inbound, extractUrl, fromObject } from "./inbound.ts";
export { register, byName, byToken, allSpecs } from "./registry.ts";
export type { GameSpec } from "./registry.ts";
export { Linq, Photon };
export { Blooio, Linq, Photon };
export * as identity from "./identity.ts";
export * as games from "./games/index.ts";

Expand All @@ -54,3 +55,8 @@ export function fromWebhook(body: unknown): Inbound | null {
export function fromPhotonMessage(message: unknown): Inbound | null {
return Photon.fromMessage(message);
}

/** Parse a Blooio webhook body into an Inbound (or null if not GamePigeon). */
export function fromBlooioWebhook(body: unknown): Inbound | null {
return Blooio.fromWebhook(body);
}
113 changes: 113 additions & 0 deletions src/providers/blooio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Blooio provider — send GamePigeon balloons over the Blooio v4 REST API and
* parse the `message.received` webhook. Uses the global `fetch` (Node 18+).
*
* const bloo = new Blooio({ apiKey: "api_...", fromNumber: "+1..." });
* await bloo.send(url, { to: "+1..." });
*
* const inbound = Blooio.fromWebhook(req.body);
* if (inbound) await bloo.send(inbound.move.reply({ botId, state }), {
* to: inbound.replyTo!,
* });
*
* Blooio delivers inbound app balloons already decoded: the webhook's
* `data.imessage_app.url` is the app-state URL your bot reads, so you don't have
* to decode anything Apple-specific yourself. Balloons render natively on the
* recipient's own GamePigeon extension. A Blooio iMessage number is self-serve,
* so the send path below works as soon as you have an API key.
*/

import { APP_NAME, BUNDLE_ID, TEAM_ID } from "../identity.ts";
import { Inbound, looksLikeGamePigeon } from "../inbound.ts";
import { read } from "../move.ts";

const BASE = "https://api.blooio.com/v4";

/**
* The `imessage_app` content carrying a GamePigeon balloon (Blooio v4 shape).
* Blooio is transport-only: it forwards the app-state `url` under your app's
* identity and the recipient's GamePigeon draws the board — no image attached.
*/
export function appPart(url: string, caption = "GamePigeon"): Record<string, unknown> {
return {
bundle_id: BUNDLE_ID,
team_id: TEAM_ID,
url,
app_name: APP_NAME,
caption,
};
}

export interface SendOpts {
to?: string;
caption?: string;
/** Override the sending channel for this message (defaults to `fromNumber`). */
from?: string;
}

export class Blooio {
private apiKey: string;
private fromNumber: string;
constructor(opts: { apiKey: string; fromNumber: string }) {
if (!opts?.apiKey) throw new Error("Blooio: `apiKey` is required");
if (!opts?.fromNumber) throw new Error("Blooio: `fromNumber` is required");
this.apiKey = opts.apiKey;
this.fromNumber = opts.fromNumber;
}

private async post(channel: string, body: unknown): Promise<any> {
const res = await fetch(`${BASE}/channels/${encodeURIComponent(channel)}/messages`, {
method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`Blooio ${res.status}: ${text}`);
return text ? JSON.parse(text) : {};
}

/** Send any GamePigeon URL (invite or move) as a balloon. */
async send(url: string, opts: SendOpts = {}): Promise<any> {
if (!opts.to) throw new Error("Blooio.send: `to` is required");
return this.post(opts.from ?? this.fromNumber, {
to: opts.to,
imessage_app: appPart(url, opts.caption ?? "GamePigeon"),
});
}

/** Send a plain text message (e.g. an agent's chat reply). */
async sendText(text: string, opts: SendOpts = {}): Promise<any> {
if (!opts.to) throw new Error("Blooio.sendText: `to` is required");
return this.post(opts.from ?? this.fromNumber, { to: opts.to, text });
}

/**
* Parse a Blooio webhook body into an Inbound, or null if not a GamePigeon
* move. Blooio delivers the balloon already decoded, so `data.imessage_app.url`
* carries the app-state URL (event `message.received`, `message_type`
* "imessage_app"); `data.sender` is the reply target and `data.chat_id` the
* conversation.
*/
static fromWebhook(body: any): Inbound | null {
if (!body || typeof body !== "object") return null;
const type = String(body.type ?? body.event_type ?? "");
if (type && !type.includes("received")) return null; // inbound messages only
const data = body.data ?? body;
// Only decode a genuine app balloon — never a plain text message that merely
// quotes a GamePigeon URL. Blooio labels balloons `message_type:
// "imessage_app"` and puts the decoded app-state URL on `data.imessage_app.url`,
// so read that field directly instead of scanning the whole payload.
if (data.message_type && data.message_type !== "imessage_app") return null;
const app = data.imessage_app;
const url =
app && typeof app === "object" && typeof app.url === "string" ? app.url : null;
if (!url || !looksLikeGamePigeon(url)) return null;
const sender = data.sender ?? {};
const replyTo =
(typeof sender === "object" ? sender.identifier ?? sender.handle : sender) ??
data.contact?.identifier ??
null;
const chatId = data.chat_id ?? null;
return new Inbound(read(url), replyTo, chatId, url, "blooio");
}
}
23 changes: 23 additions & 0 deletions test/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ function assert(cond: boolean, name: string) {
assert(inb!.chatId === "chat-1", "linq: chatId");
}

// Blooio webhook (message.received) — balloon pre-decoded into data.imessage_app.url
{
const body = {
type: "message.received", created_at: 1730000000000, organization_id: "org_1",
data: {
kind: "received", message_type: "imessage_app", text: "Your turn!",
sender: "+15551234567", recipient: "+15557654321", chat_id: "chat_1",
message_id: "msg_1", protocol: "imessage",
imessage_app: {
bundle_id: "com.gamerdelights.gamepigeon.ext", team_id: "EWFNLB79LQ",
url: aMove("connect4"), caption: "Your turn!",
},
},
};
const inb = op.fromBlooioWebhook(body);
assert(!!inb && inb.provider === "blooio", "blooio: parsed");
assert(inb!.move.game === "connect4", "blooio: game");
assert(inb!.replyTo === "+15551234567", "blooio: replyTo");
assert(inb!.chatId === "chat_1", "blooio: chatId");
}

// Photon richlink message
{
const msg = {
Expand All @@ -50,6 +71,8 @@ assert(op.fromWebhook({ event_type: "message.received", data: { parts: [{ type:
"linq: ignores non-gamepigeon");
assert(op.fromPhotonMessage({ content: { type: "text", text: "hi" } }) === null,
"photon: ignores non-gamepigeon");
assert(op.fromBlooioWebhook({ type: "message.received", data: { message_type: "text", text: "hi" } }) === null,
"blooio: ignores non-gamepigeon");

// https carrier reply decodes back
{
Expand Down