Skip to content
Merged
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
1 change: 1 addition & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
export default [
index("routes/home.tsx"),
route("sign-in", "routes/auth.tsx"),
route("link", "routes/link.tsx"),
route("sign-up", "routes/sign-up.tsx"),
route("auth/verify", "routes/auth.verify.tsx"),
route("auth/reset", "routes/auth.reset.tsx"),
Expand Down
139 changes: 139 additions & 0 deletions app/routes/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* /link — connect a chat-bot account (Discord / Slack / Telegram) to the
* signed-in Gray Office user. The bot's /login command shows a short code; the
* user enters it here while signed in and we record the mapping in `bot_links`.
*/
import { Form, Link, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/link";
import { requireUserId } from "~/lib/auth.server";
import { listOrgsForUser } from "~/lib/org.server";
import { Button } from "~/components/ui/button";

export function meta() {
return [{ title: "Connect a bot | Gray Office" }];
}

const SOURCE_LABEL: Record<string, string> = {
discord: "Discord",
slack: "Slack",
telegram: "Telegram",
};

export async function loader({ request, context }: Route.LoaderArgs) {
const { DB, SESSION_SECRET } = context.cloudflare.env;
const userId = await requireUserId(request, SESSION_SECRET);
const orgs = await listOrgsForUser(DB, userId);
if (orgs.length === 0) throw redirect("/onboarding");
return { orgName: orgs[0].name };
}

export async function action({ request, context }: Route.ActionArgs) {
const { DB, SESSION_SECRET } = context.cloudflare.env;
const userId = await requireUserId(request, SESSION_SECRET);

const form = await request.formData();
const code = String(form.get("code") ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.replace(/^(.{3})(.{3})$/, "$1-$2");

if (!/^[A-Z0-9]{3}-[A-Z0-9]{3}$/.test(code))
return { error: "That doesn't look like a valid code. It looks like ABC-234." };

const row = await DB.prepare(
`SELECT source, external_user, display_name
FROM bot_link_codes WHERE code = ? AND expires_at > unixepoch()`,
)
.bind(code)
.first<{ source: string; external_user: string; display_name: string | null }>();

if (!row)
return { error: "That code is expired or wrong. Run /login in the bot again for a fresh one." };

const orgs = await listOrgsForUser(DB, userId);
if (orgs.length === 0) return { error: "Finish setting up your organization first." };

await DB.prepare(
`INSERT INTO bot_links (source, external_user, user_id, org_id, display_name)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (source, external_user) DO UPDATE SET
user_id = excluded.user_id,
org_id = excluded.org_id,
display_name = excluded.display_name,
created_at = unixepoch()`,
)
.bind(row.source, row.external_user, userId, orgs[0].id, row.display_name)
.run();

await DB.prepare("DELETE FROM bot_link_codes WHERE code = ?").bind(code).run();

return {
connected: {
source: SOURCE_LABEL[row.source] ?? row.source,
handle: row.display_name ?? row.external_user,
org: orgs[0].name,
},
};
}

export default function LinkPage({ loaderData, actionData }: Route.ComponentProps) {
const nav = useNavigation();
const busy = nav.state !== "idle";
const connected = actionData && "connected" in actionData ? actionData.connected : null;
const error = actionData && "error" in actionData ? actionData.error : null;

return (
<div className="mx-auto flex min-h-dvh max-w-md flex-col justify-center p-6">
<div className="rounded-2xl border border-border bg-surface p-6">
<h1 className="text-lg font-semibold tracking-tight text-foreground">
Connect a chat bot
</h1>

{connected ? (
<div className="mt-4 space-y-4">
<p className="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
✅ <b>{connected.source}</b> account <b>{connected.handle}</b> is now
linked to <b>{connected.org}</b>. Head back to the bot — your messages
run as you.
</p>
<Link
to="/dashboard"
className="inline-flex w-full items-center justify-center rounded-lg bg-foreground px-3 py-2 text-sm font-medium text-background hover:opacity-90"
>
Go to dashboard
</Link>
</div>
) : (
<>
<p className="mt-1 text-sm text-muted-foreground">
Run <code className="font-mono">/login</code> in the bot, then enter the
code it gives you.
</p>
<Form method="post" className="mt-4 space-y-3">
<input
name="code"
autoFocus
autoComplete="one-time-code"
placeholder="ABC-234"
className="w-full rounded-lg border border-border bg-transparent px-3 py-2 text-center font-mono text-lg tracking-widest text-foreground uppercase outline-none placeholder:text-muted-foreground focus-visible:border-ring"
/>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950/40 dark:text-red-300">
{error}
</p>
)}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "Connecting…" : "Connect"}
</Button>
</Form>
</>
)}
</div>
<p className="mt-4 text-center text-xs text-muted-foreground">
<Link to="/dashboard" className="hover:text-foreground">
← Back to Gray Office
</Link>
</p>
</div>
);
}
25 changes: 25 additions & 0 deletions migrations/0014_bot_link_compat.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Compatibility layer for the standalone chat bots (discord/telegram/slack.grayoffice).
-- Those repos POST to /api/bots/ingest and use /login codes; the dashboard-connect
-- webhook model (0013) doesn't cover them. These two tables back that flow.

-- Short-lived, single-use codes minted by a bot's /login command.
CREATE TABLE bot_link_codes (
code TEXT PRIMARY KEY, -- human-typed, e.g. 'K7Q-2F9'
source TEXT NOT NULL, -- 'discord' | 'slack' | 'telegram'
external_user TEXT NOT NULL, -- stable platform user id
display_name TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
expires_at INTEGER NOT NULL
);

-- Confirmed links. One (source, external_user) -> one Gray Office user + org.
CREATE TABLE bot_links (
source TEXT NOT NULL,
external_user TEXT NOT NULL,
user_id TEXT NOT NULL,
org_id TEXT NOT NULL,
display_name TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (source, external_user)
);
CREATE INDEX idx_bot_links_user ON bot_links (user_id);
2 changes: 1 addition & 1 deletion tsconfig.cloudflare.tsbuildinfo

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion workers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { Hono } from "hono";
import { createRequestHandler } from "react-router";
import { apiRoutes } from "./api";
import { botRoutes } from "./bots";
import { botCompatRoutes } from "./bot-compat";
import { paymentRoutes } from "./payments";
import { pdfToJson } from "./bot-router";
import { queueConsumer } from "./queue";
import { kbQueueConsumer, type KbJob } from "./kb-queue";

const app = new Hono<{ Bindings: Env & { TARGET_DOMAIN?: string } }>();

// API routes
// API routes. Compat first: /api/bots/ingest + /api/bots/link/* for the
// standalone bot repos, then the dashboard-connect webhooks (/:platform/:orgId).
app.route("/api/bots", botCompatRoutes);
app.route("/api/bots", botRoutes);
app.route("/api/payments", paymentRoutes);
app.route("/api", apiRoutes);
Expand Down
170 changes: 170 additions & 0 deletions workers/bot-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Compatibility endpoints for the standalone chat bots that live in their own
* repos (discord/telegram/slack.grayoffice). They POST normalised events to
* `/api/bots/ingest` with a shared bearer token and drive account linking with
* `/api/bots/link/*` + the `/link` page.
*
* This is mounted on `/api/bots` *before* the dashboard-connect webhook router
* (`bots.ts`), so `/ingest` and `/link/...` resolve here and everything else
* (`/:platform/:orgId`) falls through to that.
*/
import { Hono } from "hono";
import type { MiddlewareHandler } from "hono";
import { handleInbound, type InboundFile } from "./bot-router";

const SOURCES = ["telegram", "slack", "discord"] as const;
type Source = (typeof SOURCES)[number];
const isSource = (v: unknown): v is Source =>
typeof v === "string" && (SOURCES as readonly string[]).includes(v);

/** Codes people type by hand: no 0/O/1/I, grouped XXX-XXX. */
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_TTL_SECONDS = 15 * 60;

function newLinkCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(6));
const s = [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
return `${s.slice(0, 3)}-${s.slice(3)}`;
}

export const botCompatRoutes = new Hono<{ Bindings: Env }>();

/** Shared bearer-token gate — matches the token the bot repos send. */
const tokenGate: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
const token = c.env.BOT_INGEST_TOKEN;
if (token && c.req.header("authorization") !== `Bearer ${token}`)
return c.json({ error: "unauthorized" }, 401);
await next();
};
botCompatRoutes.use("/ingest", tokenGate);
botCompatRoutes.use("/link/*", tokenGate);

type IngestBody = {
source?: string;
externalUser?: string;
text?: string;
files?: { name?: string; url?: string; mime?: string }[];
externalEventId?: string;
};

botCompatRoutes.post("/ingest", async (c) => {
const b = await c.req.json<IngestBody>().catch(() => null);
if (!b || !isSource(b.source))
return c.json({ error: "source must be telegram | slack | discord" }, 400);
if (!b.text && !(b.files && b.files.length))
return c.json({ error: "provide text or files" }, 400);

// Idempotency: platforms retry. Re-use the 0013 ledger.
if (b.externalEventId) {
const res = await c.env.DB.prepare(
"INSERT OR IGNORE INTO bot_processed_events (platform, external_event_id, org_id) VALUES (?, ?, ?)",
)
.bind(b.source, b.externalEventId, null)
.run();
if ((res.meta.changes ?? 0) === 0)
return c.json({ id: "", route: "duplicate", status: "done", detail: { deduped: true } });
}

// Resolve the linked Gray Office user + org for this platform identity.
const link = b.externalUser
? await c.env.DB.prepare(
"SELECT user_id, org_id FROM bot_links WHERE source = ? AND external_user = ?",
)
.bind(b.source, b.externalUser)
.first<{ user_id: string; org_id: string }>()
: null;

const files: InboundFile[] = (b.files ?? []).map((f) => ({
name: f.name ?? "file",
url: f.url,
mime: f.mime,
}));

const result = await handleInbound(c.env, {
source: b.source,
orgId: link?.org_id ?? null,
userId: link?.user_id ?? null,
externalUser: b.externalUser ?? null,
text: b.text ?? "",
files,
});

return c.json(result);
});

botCompatRoutes.post("/link/start", async (c) => {
const b = await c.req
.json<{ source?: string; externalUser?: string; displayName?: string }>()
.catch(() => null);
if (!b || !isSource(b.source) || !b.externalUser)
return c.json({ error: "source and externalUser are required" }, 400);

await c.env.DB.prepare(
"DELETE FROM bot_link_codes WHERE source = ? AND external_user = ?",
)
.bind(b.source, b.externalUser)
.run();

let code = newLinkCode();
for (let attempt = 0; attempt < 5; attempt++) {
try {
await c.env.DB.prepare(
`INSERT INTO bot_link_codes (code, source, external_user, display_name, expires_at)
VALUES (?, ?, ?, ?, unixepoch() + ?)`,
)
.bind(code, b.source, b.externalUser, b.displayName ?? null, CODE_TTL_SECONDS)
.run();
break;
} catch (err) {
if (attempt === 4) throw err;
code = newLinkCode();
}
}

const base = (c.env.APP_URL ?? "").replace(/\/$/, "");
return c.json({ code, url: `${base}/link`, expiresInSeconds: CODE_TTL_SECONDS });
});

botCompatRoutes.get("/link/status", async (c) => {
const source = c.req.query("source");
const externalUser = c.req.query("externalUser");
if (!isSource(source) || !externalUser)
return c.json({ error: "source and externalUser are required" }, 400);

const row = await c.env.DB.prepare(
`SELECT u.email AS email, u.name AS name, l.created_at AS created_at
FROM bot_links l JOIN users u ON u.id = l.user_id
WHERE l.source = ? AND l.external_user = ?`,
)
.bind(source, externalUser)
.first<{ email: string; name: string | null; created_at: number }>();

if (!row) return c.json({ linked: false });
return c.json({
linked: true,
email: row.email,
name: row.name,
linkedAt: new Date(row.created_at * 1000).toISOString(),
});
});

botCompatRoutes.post("/link/revoke", async (c) => {
const b = await c.req
.json<{ source?: string; externalUser?: string }>()
.catch(() => null);
if (!b || !isSource(b.source) || !b.externalUser)
return c.json({ error: "source and externalUser are required" }, 400);

const res = await c.env.DB.prepare(
"DELETE FROM bot_links WHERE source = ? AND external_user = ?",
)
.bind(b.source, b.externalUser)
.run();
await c.env.DB.prepare(
"DELETE FROM bot_link_codes WHERE source = ? AND external_user = ?",
)
.bind(b.source, b.externalUser)
.run();

return c.json({ revoked: (res.meta.changes ?? 0) > 0 });
});
Loading