mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): Mini App V4 — Today brief, native approvals, live data, bot tier, chat bridges (#576)
* feat(tg): P0 — dev mock bridge + Telegram-native foundations
Mini App V4 phase 0. The (tg) shell gains the groundwork every later
phase builds on:
- Dev mock bridge: outside Telegram, a development build falls back to a
no-op WebApp object and skips the webapp-auth POST (the regular panel
session cookie authorizes API calls), so the cockpit is workable in a
plain browser. Production keeps the "Open from Telegram" wall.
- Telegram theme adoption: themeParams map onto the shadcn CSS variables
scoped to #tg-shell (desktop dashboard untouched), colorScheme drives
the dark class, themeChanged re-applies live. Non-hex values are
dropped at the trust boundary.
- Viewport/swipe correctness: shell height rides Telegram's own
--tg-viewport-stable-height (100dvh fallback), vertical swipe-to-close
disabled so list scrolling can't dismiss the app.
- Native chrome bindings: TgWebAppProvider context plus useMainButton /
useBackButton declarative hooks and a null-safe haptics helper —
consumers never touch window.Telegram directly.
* feat(tg): P1 — Today home tab + one-round-trip /telegram/today brief
Mini App V4 phase 1: the cockpit now opens on a "Today" brief answering
"does anything need me?" in one glance.
Backend: GET /api/telegram/today (CEO-gated, rate-limited) returns the
whole brief in one round trip via the new TgCockpitService — needs-you
items (awaiting-CEO + blocked tasks capped for a phone screen, held-draft
counts across release/X/video/roadmap queues), a fleet snapshot with
per-agent current-task titles, today's spend from the day rollup
(degrading to zeros on a usage hiccup, mirroring the CEO overview), and
ship state. Deliberately DB-only: no live GitHub calls, no readiness
snapshot (that path clones), no orchestrator singleton — the CI
red/green proxy is the set of open ci_watch fix tasks.
Panel: TgTodayTab is the new default tab (Gauge icon) — needs-you rows
and draft chips deep-link into the tab that acts on them (with a haptic
tap), fleet/spend/ship render as dense cards, 45s refetch until the P3
WebSocket wiring lands.
* fix(tg): dev mock engages when the CDN bridge loads outside Telegram
Live browser smoke caught it: a bare tab still loads telegram-web-app.js,
so window.Telegram.WebApp EXISTS outside Telegram — just with empty
initData. The dev fallback keyed on a null bridge only, so a dev browser
went down the real-auth path and posted empty initData instead of
mounting the mock. The fallback now treats bridge-with-no-initData the
same as no bridge (a real Telegram launch always carries initData);
production behavior is unchanged.
* feat(tg): P2 — native approvals card stack
Mini App V4 phase 2: the Approvals tab stops stacking the four desktop
queue cards and becomes a phone-native flow — one normalized list across
release proposal / X drafts / video drafts / proposed roadmap items, and
a full-context detail per item:
- Release: version/bump/gate badges, changelog draft, gaps, migration
notes, in-flight + failed-execute banners; approve runs the fail-closed
executor, reject requires a substantive change request (10 chars).
- X: editable body with the live 280 counter, replied-to mention quoted;
approve sends the edited body only when actually edited.
- Video: cut-toggled player (blob-fetched through the authed client — a
bare <video src> would 401), per-platform caption edits with 280/2200
counters; approve sends only checked-in edits.
- Roadmap: the PO's full pitch (description, rationale, ACs); approve
materializes into the backlog per item.
The detail's primary action rides Telegram's native MainButton and back
navigation rides the BackButton, with visible fallbacks outside Telegram
(dev mock, old clients). Haptics fire on outcomes. An acted-on item
vanishes from the refetched queue, popping back to the list by
construction. A failed queue source is surfaced ("list may be
incomplete" / "couldn't load") instead of masquerading as an empty
queue — caught live in the browser smoke.
* feat(tg): dev demo mode — /tg?demo=1 renders canned cockpit data
Development-only: with the flag param present, the Today brief and the
four approval queues resolve typed fixtures (dynamically imported, so
production bundles never carry them) instead of hitting the backend —
the cockpit is fully browsable with zero stack running. Mutations still
go to the real API and fail loudly; it's a showroom, not a simulator.
* feat(tg): P3 — cockpit rides /ws/system live
Chat adopts the desktop A2A invalidate-on-frame idiom over the shared
ref-counted /ws/system socket: every a2a.message frame refreshes the
conversation list and the affected thread, missed-frame gaps are healed
by a reconnect refetch, and the 10s thread poll turns off entirely while
the socket is up (it remains the fallback). The Today brief refreshes on
each USAGE_SNAPSHOT push so the spend line tracks the sweeper live, with
the 45s poll as the socket-down fallback. No new sockets, no backend
changes — the WS gate already accepts the cloud-auth session cookie.
* feat(tg): P4 — deterministic bot command tier + self-syncing menu
Mini App V4 phase 4 (deterministic half): three new bot commands beside
/status /queue /task —
- /agents: who's mid-task right now, from the same TgCockpitService
fleet snapshot the Today brief renders (now public `fleet()`).
- /usage: today's spend from the day rollup.
- /blocked: awaiting-you + blocked tasks, deep-linked into the panel,
capped per section, titles HTML-escaped.
BOT_COMMANDS is the single registry driving /help AND a once-per-process
Bot API setMyCommands sync on the first poll cycle (new client method,
best-effort), so the Telegram command menu can never drift from what the
code implements. The interactive tier (/secretary, /newtask riding a
live Intake interview in-thread) is specced but not in this commit.
* feat(tg): direction-C styling pass — Telegram palette, RoboCo voice
The cockpit stops wearing default-shadcn and gets its own visual
language on top of the P0 themeParams bridge (colors stay CSS-variable
driven, so inside Telegram everything still adopts the user's theme):
- Shared primitives (components/tg/ui.tsx): TgSection grouped cards with
tracked micro-label headers, TgRow list rows (44px targets, press
feedback, 1/2-line clamp), TgRowIcon glyph tiles, TgStat tabular-nums
figures. Every tab composes the same three, so density and rhythm are
identical across the surface.
- Shell renders a centered 430px column (sm:border-x) — the phone UI no
longer stretches across a desktop dev browser.
- Tab bar: tighter type, active stroke-weight shift, backdrop blur.
- Today: needs-you count badge, divided task rows with inline blocked
marker, fleet as mono-named rows, spend/ship as stat tiles.
- Approvals rows as icon-tile cards; detail header gains the kind glyph.
- Inbox/Chat rows aligned to the same card language.
* feat(tg): P5 — /secretary and /newtask live-chat bridges
The bot's interactive tier: both commands bridge the CEO's Telegram chat
into the same in-process runtimes the panel drives — the persistent
Secretary container and the scoped Intake interview.
There is no synchronous send→reply seam (replies land on the session's
single-consumer relay queue), so each bridged session runs one long-lived
consumer task (roboco/services/telegram_bridge.py) that drains
PrompterLiveRegistry.stream and pushes one Telegram message per completed
turn. While a session is live, plain chat text IS the conversation;
/end closes it.
/newtask resolves the intake scope (single project auto-picked, multiple
offered as a tap-to-pick keyboard holding the initial text), and the
interview happens in-thread. A draft proposal renders as a card with
Send-to-Board / Discard buttons: confirm routes through the normal
board-review path (PrompterService.confirm_live_draft, route=board) and
PARKS the session — board feedback later streams straight back into the
same thread, closing the redraft loop from the phone. MegaTask batches
still confirm in the panel only.
The consumer's open stream arms the registry's 60s keepalive, so the
bridge runs its own idle TTL (same setting, parked sessions exempt).
State is per-process in-memory by design (the _PENDING_REPLIES posture);
intake/secretary containers are process-wide singletons, so a bridged
session preempts a live panel session of the same kind by construction.
* feat(tg): cockpit skin — RoboCo dark deck with a constant amber accent
The cockpit no longer inherits the dashboard's white default outside
Telegram: #tg-shell carries its own standing skin (deep slate surfaces,
amber primary) so the Mini App looks like RoboCo everywhere. Inside
Telegram the themeParams bridge now overrides SURFACE tokens only —
background/card/text/hint/border repaint to the user's Telegram theme
while --primary/--ring stay RoboCo amber: Telegram's surfaces, RoboCo's
voice. Demo fixtures also rewritten to neutral content (they previously
depicted unbuilt forge work and already-shipped roadmap items as live).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,12 @@ import Script from "next/script";
|
||||
* `afterInteractive` strategy instead — `waitForTelegramWebApp` (in
|
||||
* lib/telegram/webapp.ts) briefly polls for `window.Telegram.WebApp` to
|
||||
* absorb the resulting load race rather than assuming it's present on mount.
|
||||
*
|
||||
* Height reads `--tg-viewport-stable-height`, a :root variable the Telegram
|
||||
* script itself maintains (steady during keyboard/panel animations, unlike
|
||||
* dvh inside the webview); outside Telegram it's unset and 100dvh applies.
|
||||
* `#tg-shell` is the hook the page uses to scope Telegram theme variables
|
||||
* to this surface only.
|
||||
*/
|
||||
export default function TelegramLayout({
|
||||
children,
|
||||
@@ -18,7 +24,11 @@ export default function TelegramLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<div
|
||||
id="tg-shell"
|
||||
className="mx-auto flex w-full max-w-[430px] flex-col overflow-hidden bg-background text-foreground sm:border-x"
|
||||
style={{ height: "var(--tg-viewport-stable-height, 100dvh)" }}
|
||||
>
|
||||
<Script
|
||||
src="https://telegram.org/js/telegram-web-app.js"
|
||||
strategy="afterInteractive"
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { waitForTelegramWebApp } = vi.hoisted(() => ({
|
||||
waitForTelegramWebApp: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/telegram/webapp", () => ({ waitForTelegramWebApp }));
|
||||
// Keep the real dev-mock helpers (createDevMockWebApp / isDevMockWebApp) —
|
||||
// only the bridge resolver is faked.
|
||||
vi.mock("@/lib/telegram/webapp", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
waitForTelegramWebApp,
|
||||
}));
|
||||
|
||||
const { startTelegramThemeSync } = vi.hoisted(() => ({
|
||||
startTelegramThemeSync: vi.fn(() => () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/telegram/theme", () => ({ startTelegramThemeSync }));
|
||||
|
||||
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
@@ -19,6 +29,9 @@ vi.mock("@/lib/api/client", () => ({
|
||||
vi.mock("@/components/tg/tg-tab-bar", () => ({
|
||||
TgTabBar: () => <div data-testid="tg-tab-bar" />,
|
||||
}));
|
||||
vi.mock("@/components/tg/tg-today-tab", () => ({
|
||||
TgTodayTab: () => <div data-testid="tg-today-tab" />,
|
||||
}));
|
||||
vi.mock("@/components/tg/tg-approvals-tab", () => ({
|
||||
TgApprovalsTab: () => <div data-testid="tg-approvals-tab" />,
|
||||
}));
|
||||
@@ -35,15 +48,25 @@ vi.mock("@/components/tg/tg-chat-tab", () => ({
|
||||
import TelegramMiniAppPage from "../page";
|
||||
|
||||
function mockWebApp(initData = "abc123") {
|
||||
return { ready: vi.fn(), expand: vi.fn(), initData };
|
||||
return {
|
||||
ready: vi.fn(),
|
||||
expand: vi.fn(),
|
||||
disableVerticalSwipes: vi.fn(),
|
||||
initData,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TelegramMiniAppPage — auth bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
waitForTelegramWebApp.mockReset();
|
||||
startTelegramThemeSync.mockClear();
|
||||
post.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("shows a spinner while validating", () => {
|
||||
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
|
||||
render(<TelegramMiniAppPage />);
|
||||
@@ -72,11 +95,12 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
|
||||
);
|
||||
expect(webApp.ready).toHaveBeenCalledTimes(1);
|
||||
expect(webApp.expand).toHaveBeenCalledTimes(1);
|
||||
expect(webApp.disableVerticalSwipes).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
|
||||
init_data: "real-init-data",
|
||||
});
|
||||
// Default tab is Approvals.
|
||||
expect(screen.getByTestId("tg-approvals-tab")).toBeInTheDocument();
|
||||
// Default tab is Today.
|
||||
expect(screen.getByTestId("tg-today-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error screen with the server's message when auth is refused", async () => {
|
||||
@@ -91,4 +115,52 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
|
||||
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tg-tab-bar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the dev mock outside Telegram in development — no auth POST", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
waitForTelegramWebApp.mockResolvedValue(null);
|
||||
|
||||
render(<TelegramMiniAppPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByText(/open from telegram/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dev mock also engages when the CDN bridge loaded with empty initData", async () => {
|
||||
// A bare browser tab still loads telegram-web-app.js, so the bridge
|
||||
// object exists — only a real Telegram launch carries initData.
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
waitForTelegramWebApp.mockResolvedValue(mockWebApp(""));
|
||||
|
||||
render(<TelegramMiniAppPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts Telegram theme sync against the #tg-shell element once ready", async () => {
|
||||
const shell = document.createElement("div");
|
||||
shell.id = "tg-shell";
|
||||
document.body.appendChild(shell);
|
||||
try {
|
||||
const webApp = mockWebApp();
|
||||
waitForTelegramWebApp.mockResolvedValue(webApp);
|
||||
post.mockResolvedValue({ data: { ok: true } });
|
||||
|
||||
render(<TelegramMiniAppPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(startTelegramThemeSync).toHaveBeenCalledWith(webApp, shell),
|
||||
);
|
||||
} finally {
|
||||
shell.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import api, { getErrorMessage } from "@/lib/api/client";
|
||||
import { waitForTelegramWebApp } from "@/lib/telegram/webapp";
|
||||
import {
|
||||
createDevMockWebApp,
|
||||
isDevMockWebApp,
|
||||
waitForTelegramWebApp,
|
||||
type TelegramWebApp,
|
||||
} from "@/lib/telegram/webapp";
|
||||
import { startTelegramThemeSync } from "@/lib/telegram/theme";
|
||||
import { TgWebAppProvider } from "@/lib/telegram/hooks";
|
||||
import { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar";
|
||||
import { TgTodayTab } from "@/components/tg/tg-today-tab";
|
||||
import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
|
||||
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
|
||||
import { TgBoardTab } from "@/components/tg/tg-board-tab";
|
||||
@@ -12,7 +20,7 @@ import { Loader2, AlertTriangle, ExternalLink } from "lucide-react";
|
||||
|
||||
type BootstrapState =
|
||||
| { kind: "validating" }
|
||||
| { kind: "ready" }
|
||||
| { kind: "ready"; webApp: TelegramWebApp }
|
||||
| { kind: "not_in_telegram" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
@@ -31,27 +39,44 @@ function CenteredMessage({ children }: { children: React.ReactNode }) {
|
||||
* before rendering the tabbed cockpit. There's no way to read the resulting
|
||||
* httponly session cookie client-side to skip this on a warm reload, so it
|
||||
* always runs; it's cheap and the backend contract says so explicitly.
|
||||
*
|
||||
* Outside Telegram, a development build falls back to the dev mock bridge
|
||||
* (skipping the auth POST — the regular panel session cookie authorizes the
|
||||
* API calls) so the shell is workable in a plain browser; production keeps
|
||||
* the "Open from Telegram" wall.
|
||||
*/
|
||||
export default function TelegramMiniAppPage() {
|
||||
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
|
||||
const [tab, setTab] = useState<TgTab>("approvals");
|
||||
const [tab, setTab] = useState<TgTab>("today");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const webApp = await waitForTelegramWebApp();
|
||||
let webApp = await waitForTelegramWebApp();
|
||||
if (cancelled) return;
|
||||
// Outside Telegram the CDN script still loads and defines a bridge
|
||||
// object — just with empty initData (a real launch always carries
|
||||
// it). Either shape means "not a Telegram launch" for the dev
|
||||
// fallback.
|
||||
if (!webApp?.initData && process.env.NODE_ENV === "development") {
|
||||
webApp = createDevMockWebApp();
|
||||
}
|
||||
if (!webApp) {
|
||||
setState({ kind: "not_in_telegram" });
|
||||
return;
|
||||
}
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
webApp.disableVerticalSwipes?.();
|
||||
if (isDevMockWebApp(webApp)) {
|
||||
setState({ kind: "ready", webApp });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post("/telegram/webapp-auth", {
|
||||
init_data: webApp.initData ?? "",
|
||||
});
|
||||
if (!cancelled) setState({ kind: "ready" });
|
||||
if (!cancelled) setState({ kind: "ready", webApp });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({ kind: "error", message: getErrorMessage(err) });
|
||||
@@ -63,6 +88,17 @@ export default function TelegramMiniAppPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Adopt the user's Telegram palette for the whole shell (and track live
|
||||
// theme switches). Scoped to #tg-shell so the desktop dashboard is
|
||||
// untouched; the dev mock carries empty themeParams, so a dev browser
|
||||
// keeps the panel's own theme.
|
||||
useEffect(() => {
|
||||
if (state.kind !== "ready") return;
|
||||
const shell = document.getElementById("tg-shell");
|
||||
if (!shell) return;
|
||||
return startTelegramThemeSync(state.webApp, shell);
|
||||
}, [state]);
|
||||
|
||||
if (state.kind === "validating") {
|
||||
return (
|
||||
<CenteredMessage>
|
||||
@@ -96,12 +132,15 @@ export default function TelegramMiniAppPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 pb-20">
|
||||
{tab === "approvals" && <TgApprovalsTab />}
|
||||
{tab === "inbox" && <TgInboxTab />}
|
||||
{tab === "board" && <TgBoardTab />}
|
||||
{tab === "chat" && <TgChatTab />}
|
||||
<TgTabBar active={tab} onChange={setTab} />
|
||||
</div>
|
||||
<TgWebAppProvider webApp={state.webApp}>
|
||||
<div className="p-3 pb-20">
|
||||
{tab === "today" && <TgTodayTab onNavigate={setTab} />}
|
||||
{tab === "approvals" && <TgApprovalsTab />}
|
||||
{tab === "inbox" && <TgInboxTab />}
|
||||
{tab === "board" && <TgBoardTab />}
|
||||
{tab === "chat" && <TgChatTab />}
|
||||
<TgTabBar active={tab} onChange={setTab} />
|
||||
</div>
|
||||
</TgWebAppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Telegram Mini App cockpit skin. #tg-shell carries RoboCo's own look —
|
||||
deep slate surfaces with a constant amber accent — independent of the
|
||||
dashboard theme. Inside Telegram, the themeParams bridge inline-overrides
|
||||
the SURFACE tokens (background/card/text/hint/border) to the user's own
|
||||
Telegram palette, while the accent tokens stay RoboCo's: Telegram's
|
||||
surfaces, RoboCo's voice. */
|
||||
#tg-shell {
|
||||
color-scheme: dark;
|
||||
--background: oklch(0.16 0.015 260);
|
||||
--foreground: oklch(0.93 0.005 260);
|
||||
--card: oklch(0.205 0.02 260);
|
||||
--card-foreground: oklch(0.93 0.005 260);
|
||||
--popover: oklch(0.205 0.02 260);
|
||||
--popover-foreground: oklch(0.93 0.005 260);
|
||||
--primary: oklch(0.8 0.13 78);
|
||||
--primary-foreground: oklch(0.22 0.04 78);
|
||||
--secondary: oklch(0.26 0.02 260);
|
||||
--secondary-foreground: oklch(0.93 0.005 260);
|
||||
--muted: oklch(0.26 0.02 260);
|
||||
--muted-foreground: oklch(0.68 0.012 260);
|
||||
--accent: oklch(0.26 0.02 260);
|
||||
--accent-foreground: oklch(0.93 0.005 260);
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.8 0.13 78);
|
||||
}
|
||||
|
||||
/* Collapse/expand and other transform/opacity transitions (animate-in,
|
||||
animate-out, transition-transform, ...) become instant for users who
|
||||
asked the OS for reduced motion — the content still opens/closes, it
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TgApprovalsTab } from "../tg-approvals-tab";
|
||||
|
||||
const { releaseApi, xApi, videoApi, roadmapApi } = vi.hoisted(() => ({
|
||||
releaseApi: { getProposal: vi.fn(), approve: vi.fn(), reject: vi.fn() },
|
||||
xApi: { listPosts: vi.fn(), approve: vi.fn(), reject: vi.fn() },
|
||||
videoApi: {
|
||||
listPosts: vi.fn(),
|
||||
approve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
getMediaBlob: vi.fn(),
|
||||
},
|
||||
roadmapApi: {
|
||||
listCycles: vi.fn(),
|
||||
approveItem: vi.fn(),
|
||||
rejectItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/api/release", () => ({ releaseApi }));
|
||||
vi.mock("@/lib/api/x", () => ({ xApi }));
|
||||
vi.mock("@/lib/api/video", () => ({ videoApi }));
|
||||
vi.mock("@/lib/api/roadmap", () => ({ roadmapApi }));
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
getErrorMessage: (err: unknown) =>
|
||||
(err as { message?: string } | undefined)?.message ?? "Unknown error",
|
||||
}));
|
||||
|
||||
function xPost(body = "Shipped a thing.") {
|
||||
return {
|
||||
task_id: "x-1",
|
||||
source: "x_post",
|
||||
title: "Release post",
|
||||
status: "pending",
|
||||
body,
|
||||
char_count: body.length,
|
||||
release_version: "0.25.0",
|
||||
};
|
||||
}
|
||||
|
||||
function roadmapCycle() {
|
||||
return {
|
||||
task_id: "cycle-1",
|
||||
title: "Cycle",
|
||||
status: "pending",
|
||||
goal: "Close friction",
|
||||
items: [
|
||||
{
|
||||
id: "item-0",
|
||||
title: "Better onboarding",
|
||||
description: "Make the first run smoother.",
|
||||
acceptance_criteria: ["one-command setup"],
|
||||
project_slug: "roboco",
|
||||
team: "backend",
|
||||
priority: 1,
|
||||
rationale: "Most churn happens on day one.",
|
||||
status: "proposed",
|
||||
},
|
||||
{
|
||||
id: "item-1",
|
||||
title: "Already decided",
|
||||
description: "d",
|
||||
acceptance_criteria: [],
|
||||
project_slug: "roboco",
|
||||
team: "backend",
|
||||
priority: 2,
|
||||
rationale: "r",
|
||||
status: "approved",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TgApprovalsTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TgApprovalsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
releaseApi.getProposal.mockResolvedValue(null);
|
||||
xApi.listPosts.mockResolvedValue([]);
|
||||
videoApi.listPosts.mockResolvedValue([]);
|
||||
roadmapApi.listCycles.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("shows the clear state when every queue is empty", async () => {
|
||||
renderTab();
|
||||
expect(await screen.findByText(/queue is clear/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists items across queues — only proposed roadmap items count", async () => {
|
||||
xApi.listPosts.mockResolvedValue([xPost()]);
|
||||
roadmapApi.listCycles.mockResolvedValue([roadmapCycle()]);
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(await screen.findByText("Shipped a thing.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Better onboarding")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Already decided")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens an X draft, edits it, and approves with the edited body", async () => {
|
||||
xApi.listPosts.mockResolvedValue([xPost("Original")]);
|
||||
xApi.approve.mockResolvedValue({ status: "posted", detail: "ok" });
|
||||
|
||||
renderTab();
|
||||
await userEvent.click(await screen.findByText("Original"));
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, "Edited body");
|
||||
await userEvent.click(screen.getByRole("button", { name: /post to x/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(xApi.approve).toHaveBeenCalledWith("x-1", "Edited body"),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables posting when the edit exceeds 280 characters", async () => {
|
||||
xApi.listPosts.mockResolvedValue([xPost("x".repeat(281))]);
|
||||
renderTab();
|
||||
await userEvent.click(await screen.findByText(/^x+$/));
|
||||
|
||||
expect(screen.getByText("281/280")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /post to x/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("rejects a roadmap item with a reason and pops back to the list", async () => {
|
||||
roadmapApi.listCycles.mockResolvedValue([roadmapCycle()]);
|
||||
roadmapApi.rejectItem.mockResolvedValue({
|
||||
status: "rejected",
|
||||
item_id: "item-0",
|
||||
detail: "ok",
|
||||
});
|
||||
|
||||
renderTab();
|
||||
await userEvent.click(await screen.findByText("Better onboarding"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /reject…/i }));
|
||||
|
||||
const submit = screen.getByRole("button", { name: /^reject$/i });
|
||||
expect(submit).toBeDisabled();
|
||||
await userEvent.type(screen.getByRole("textbox"), "not now");
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(roadmapApi.rejectItem).toHaveBeenCalledWith(
|
||||
"cycle-1",
|
||||
"item-0",
|
||||
"not now",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("back button in the detail returns to the list", async () => {
|
||||
xApi.listPosts.mockResolvedValue([xPost()]);
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByText("Shipped a thing."));
|
||||
expect(screen.getByRole("button", { name: /post to x/i })).toBeInTheDocument();
|
||||
|
||||
// Outside Telegram there's no native BackButton — the visible fallback
|
||||
// arrow renders instead.
|
||||
const buttons = screen.getAllByRole("button");
|
||||
await userEvent.click(buttons[0]);
|
||||
expect(await screen.findByText("Shipped a thing.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TgTodayTab } from "../tg-today-tab";
|
||||
|
||||
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock("@/lib/api/client", () => ({ default: { get } }));
|
||||
|
||||
function renderTab(onNavigate = vi.fn()) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TgTodayTab onNavigate={onNavigate} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onNavigate;
|
||||
}
|
||||
|
||||
function brief(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
needs_you: {
|
||||
total: 0,
|
||||
awaiting_ceo_count: 0,
|
||||
awaiting_ceo: [],
|
||||
blocked_count: 0,
|
||||
blocked: [],
|
||||
held_drafts: {
|
||||
release_proposals: 0,
|
||||
x_posts: 0,
|
||||
video_posts: 0,
|
||||
roadmap_items: 0,
|
||||
},
|
||||
},
|
||||
fleet: { total: 3, by_status: { active: 3 }, working: [] },
|
||||
spend: { tokens_today: 1_234_000, cost_today_usd: 12.34 },
|
||||
ship: { version: "0.25.0", open_release_proposal: false, ci_fix_tasks: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TgTodayTab", () => {
|
||||
// Block body on purpose: returning the mock from beforeEach would make
|
||||
// vitest call it as an after-test teardown hook.
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
});
|
||||
|
||||
it("shows skeletons while loading", () => {
|
||||
get.mockReturnValue(new Promise(() => {}));
|
||||
renderTab();
|
||||
expect(document.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the all-clear state and the spend/ship numbers", async () => {
|
||||
get.mockResolvedValue({ data: brief() });
|
||||
renderTab();
|
||||
|
||||
expect(await screen.findByText(/all clear/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("$12.34")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.2M tokens/)).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.25.0")).toBeInTheDocument();
|
||||
expect(screen.getByText(/no release pending/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 agents/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders needs-you items and deep-links taps into the right tab", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: brief({
|
||||
needs_you: {
|
||||
total: 3,
|
||||
awaiting_ceo_count: 1,
|
||||
awaiting_ceo: [
|
||||
{
|
||||
id: "t1",
|
||||
title: "Root PR ready",
|
||||
status: "awaiting_ceo_approval",
|
||||
team: "backend",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
blocked_count: 0,
|
||||
blocked: [],
|
||||
held_drafts: {
|
||||
release_proposals: 1,
|
||||
x_posts: 1,
|
||||
video_posts: 0,
|
||||
roadmap_items: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const onNavigate = vi.fn();
|
||||
renderTab(onNavigate);
|
||||
|
||||
await userEvent.click(await screen.findByText(/Release · 1/));
|
||||
expect(onNavigate).toHaveBeenCalledWith("approvals");
|
||||
|
||||
await userEvent.click(screen.getByText("Root PR ready"));
|
||||
expect(onNavigate).toHaveBeenCalledWith("board");
|
||||
});
|
||||
|
||||
it("shows an error state when the brief fails to load", async () => {
|
||||
get.mockRejectedValue(new Error("boom"));
|
||||
renderTab();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/couldn.t load/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useMainButton } from "@/lib/telegram/hooks";
|
||||
import { useTgWebApp } from "@/lib/telegram/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The focused card's one primary action. Inside Telegram it drives the
|
||||
* native MainButton (and renders nothing); outside — the dev mock, an old
|
||||
* client — it falls back to a visible full-width button. Mount at most one
|
||||
* per screen: the MainButton is global singleton chrome.
|
||||
*/
|
||||
export function PrimaryAction({
|
||||
text,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}: {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const webApp = useTgWebApp();
|
||||
useMainButton({ text, visible: true, disabled, loading, onClick });
|
||||
if (webApp?.MainButton) return null;
|
||||
return (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
>
|
||||
{loading && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
/**
|
||||
* Collapsible reject-with-reason form shared by every approval detail.
|
||||
* The minimum length mirrors each queue's server guard (release wants a
|
||||
* substantive change request, the rest a short reason).
|
||||
*/
|
||||
export function RejectForm({
|
||||
minChars,
|
||||
placeholder,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
minChars: number;
|
||||
placeholder: string;
|
||||
pending: boolean;
|
||||
onSubmit: (reason: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const tooShort = reason.trim().length < minChars;
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Reject…
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={pending || tooShort}
|
||||
onClick={() => onSubmit(reason.trim())}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{tooShort && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
At least {minChars} characters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { releaseApi, type ReleaseProposal } from "@/lib/api/release";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
|
||||
const MIN_REJECT_CHARS = 10;
|
||||
|
||||
/**
|
||||
* Focused release proposal: version/gate at a glance, the drafted
|
||||
* changelog, gaps and migration notes, then approve (runs the fail-closed
|
||||
* ~40min executor server-side) or reject-with-required-changes.
|
||||
*/
|
||||
export function ReleaseDetail({
|
||||
proposal,
|
||||
onDone,
|
||||
}: {
|
||||
proposal: ReleaseProposal;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const report = proposal.report;
|
||||
const inFlight = proposal.execute_in_flight === true;
|
||||
const executeFailed = !inFlight && !!proposal.execute_status;
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => releaseApi.approve(),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
|
||||
if (result.status === "published") {
|
||||
haptics.success();
|
||||
toast.success(`Published v${result.version}`);
|
||||
onDone();
|
||||
} else if (result.status === "accepted") {
|
||||
haptics.success();
|
||||
toast.info("Approved — executing in the background (~40 min).");
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(`Release halted (${result.status}): ${result.detail}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (changes: string) => releaseApi.reject(changes),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
|
||||
haptics.success();
|
||||
toast.success("Rejected — changes requested.");
|
||||
onDone();
|
||||
},
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge>v{report.proposed_version}</Badge>
|
||||
<Badge variant="secondary">{report.bump_kind}</Badge>
|
||||
<Badge
|
||||
variant={report.gate_state === "green" ? "secondary" : "destructive"}
|
||||
>
|
||||
gate: {report.gate_state}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{inFlight && (
|
||||
<p className="rounded-md bg-primary/10 p-2 text-xs">
|
||||
Execute is running in the background (~40 min). This card updates
|
||||
itself.
|
||||
</p>
|
||||
)}
|
||||
{executeFailed && (
|
||||
<p className="rounded-md bg-destructive/10 p-2 text-xs text-destructive">
|
||||
Last execute failed ({proposal.execute_status})
|
||||
{proposal.execute_detail ? `: ${proposal.execute_detail}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report.gaps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Gaps
|
||||
</h3>
|
||||
{report.gaps.map((gap, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
[{gap.category}] {gap.detail}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Changelog draft
|
||||
</h3>
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-muted p-2 text-xs leading-relaxed">
|
||||
{report.drafted_changelog}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{report.migration_notes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Migrations
|
||||
</h3>
|
||||
{report.migration_notes.map((note, i) => (
|
||||
<p key={i} className="text-xs">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PrimaryAction
|
||||
text={executeFailed ? "Retry approve & publish" : "Approve & publish"}
|
||||
disabled={inFlight}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="What must change before this ships?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { roadmapApi, type RoadmapItem } from "@/lib/api/roadmap";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
|
||||
const MIN_REJECT_CHARS = 4;
|
||||
const PRIORITY_LABELS = ["critical", "high", "medium", "low"];
|
||||
|
||||
/** Focused roadmap item: the PO's pitch in full (description, rationale,
|
||||
* acceptance criteria), then approve into the backlog or reject. */
|
||||
export function RoadmapItemDetail({
|
||||
cycleId,
|
||||
item,
|
||||
onDone,
|
||||
}: {
|
||||
cycleId: string;
|
||||
item: RoadmapItem;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const finish = (ok: boolean, message: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["roadmap", "cycles"] });
|
||||
if (ok) {
|
||||
haptics.success();
|
||||
toast.success(message);
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(message);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => roadmapApi.approveItem(cycleId, item.id),
|
||||
onSuccess: (result) =>
|
||||
finish(
|
||||
result.status === "approved" || result.status === "already_approved",
|
||||
result.status === "approved"
|
||||
? "Approved — added to the backlog."
|
||||
: result.detail,
|
||||
),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) =>
|
||||
roadmapApi.rejectItem(cycleId, item.id, reason),
|
||||
onSuccess: () => finish(true, "Item rejected."),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{item.team}</Badge>
|
||||
<Badge variant="outline">{item.project_slug}</Badge>
|
||||
<Badge>
|
||||
P{item.priority} · {PRIORITY_LABELS[item.priority] ?? "?"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed">{item.description}</p>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Why
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{item.rationale}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Acceptance criteria
|
||||
</h3>
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-xs">
|
||||
{item.acceptance_criteria.map((ac, i) => (
|
||||
<li key={i}>{ac}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PrimaryAction
|
||||
text="Approve → backlog"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="Why not this one?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One normalized queue over the four held-draft sources (release proposal,
|
||||
* X posts, video posts, roadmap items) so the cockpit renders a single
|
||||
* card stack. Query keys and cadence match the desktop queue components —
|
||||
* the two surfaces share the cache when both are open.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { releaseApi, type ReleaseProposal } from "@/lib/api/release";
|
||||
import { xApi, type XPost } from "@/lib/api/x";
|
||||
import { videoApi, type VideoPost } from "@/lib/api/video";
|
||||
import { roadmapApi, type RoadmapItem } from "@/lib/api/roadmap";
|
||||
import { isTgDemoMode } from "@/lib/telegram/demo";
|
||||
|
||||
const demo = () => import("@/lib/telegram/demo-data");
|
||||
|
||||
export type ApprovalItem =
|
||||
| { kind: "release"; id: string; proposal: ReleaseProposal }
|
||||
| { kind: "x_post"; id: string; post: XPost }
|
||||
| { kind: "video_post"; id: string; post: VideoPost }
|
||||
| { kind: "roadmap"; id: string; cycleId: string; item: RoadmapItem };
|
||||
|
||||
const REFETCH_MS = 30_000;
|
||||
|
||||
export function useApprovalQueue() {
|
||||
const release = useQuery({
|
||||
queryKey: ["release", "proposal"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_RELEASE : releaseApi.getProposal(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
const xPosts = useQuery({
|
||||
queryKey: ["x", "posts"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_X_POSTS : xApi.listPosts(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
const videoPosts = useQuery({
|
||||
queryKey: ["video", "posts"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_VIDEO_POSTS : videoApi.listPosts(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
const roadmap = useQuery({
|
||||
queryKey: ["roadmap", "cycles"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_ROADMAP : roadmapApi.listCycles(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
|
||||
const items = useMemo<ApprovalItem[]>(() => {
|
||||
const out: ApprovalItem[] = [];
|
||||
if (release.data) {
|
||||
out.push({
|
||||
kind: "release",
|
||||
id: release.data.task_id,
|
||||
proposal: release.data,
|
||||
});
|
||||
}
|
||||
for (const post of xPosts.data ?? []) {
|
||||
out.push({ kind: "x_post", id: post.task_id, post });
|
||||
}
|
||||
for (const post of videoPosts.data ?? []) {
|
||||
out.push({ kind: "video_post", id: post.task_id, post });
|
||||
}
|
||||
for (const cycle of roadmap.data ?? []) {
|
||||
for (const item of cycle.items) {
|
||||
if (item.status === "proposed") {
|
||||
out.push({
|
||||
kind: "roadmap",
|
||||
// Item ids are only unique within their cycle.
|
||||
id: `${cycle.task_id}:${item.id}`,
|
||||
cycleId: cycle.task_id,
|
||||
item,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [release.data, xPosts.data, videoPosts.data, roadmap.data]);
|
||||
|
||||
const queries = [release, xPosts, videoPosts, roadmap];
|
||||
return {
|
||||
items,
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
// A failed source contributes no items, which would silently read as
|
||||
// "queue is clear" — surface it so an outage never masquerades as an
|
||||
// empty queue.
|
||||
anyFailed: queries.some((q) => q.isError),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { videoApi, type VideoCut, type VideoPost } from "@/lib/api/video";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_X_CAPTION_CHARS = 280;
|
||||
const MAX_TIKTOK_CAPTION_CHARS = 2200;
|
||||
const MIN_REJECT_CHARS = 4;
|
||||
|
||||
const CUT_LABELS: Record<VideoCut, string> = {
|
||||
vertical: "9:16",
|
||||
square: "1:1",
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendered-cut player. The media route is authenticated, so a bare
|
||||
* `<video src>` would 401 — fetch the MP4 as a blob through the authed
|
||||
* client and play the object URL (the desktop queue's exact pattern).
|
||||
*/
|
||||
function CutPlayer({ post }: { post: VideoPost }) {
|
||||
const paths = post.mp4_paths ?? {};
|
||||
const [cut, setCut] = useState<VideoCut>(paths.vertical ? "vertical" : "square");
|
||||
// url === null means the fetch for that cut failed; a stale entry for a
|
||||
// different cut is simply ignored in render, so no synchronous state
|
||||
// reset is needed when the cut changes.
|
||||
const [loaded, setLoaded] = useState<{
|
||||
cut: VideoCut;
|
||||
url: string | null;
|
||||
} | null>(null);
|
||||
// A stable string dep — depending on the paths object would re-run the
|
||||
// effect (and refetch the MP4) on every render.
|
||||
const mediaPath = paths[cut];
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaPath) return;
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
void videoApi
|
||||
.getMediaBlob(post.task_id, cut)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setLoaded({ cut, url: objectUrl });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoaded({ cut, url: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [post.task_id, cut, mediaPath]);
|
||||
|
||||
const src = loaded?.cut === cut ? loaded.url : null;
|
||||
const failed = loaded?.cut === cut && loaded.url === null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1.5">
|
||||
{(Object.keys(CUT_LABELS) as VideoCut[]).map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
size="sm"
|
||||
variant={cut === c ? "default" : "outline"}
|
||||
disabled={!paths[c]}
|
||||
onClick={() => setCut(c)}
|
||||
>
|
||||
{CUT_LABELS[c]}
|
||||
{!paths[c] && " (missing)"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{src ? (
|
||||
<video
|
||||
src={src}
|
||||
controls
|
||||
playsInline
|
||||
className={cn(
|
||||
"w-full rounded-md bg-black",
|
||||
cut === "vertical" ? "aspect-[9/16] max-h-[60dvh]" : "aspect-square",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground">
|
||||
{!mediaPath
|
||||
? "This cut wasn't rendered"
|
||||
: failed
|
||||
? "Preview failed to load"
|
||||
: "Loading preview…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionEditor({
|
||||
platform,
|
||||
stored,
|
||||
maxChars,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
platform: string;
|
||||
stored: string;
|
||||
maxChars: number;
|
||||
value: string | null;
|
||||
onChange: (v: string | null) => void;
|
||||
}) {
|
||||
const editing = value !== null;
|
||||
const caption = value ?? stored;
|
||||
const overLimit = caption.length > maxChars;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-xs font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing}
|
||||
onChange={(e) => onChange(e.target.checked ? stored : null)}
|
||||
/>
|
||||
Edit {platform} caption
|
||||
</label>
|
||||
<Textarea
|
||||
value={caption}
|
||||
disabled={!editing}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={cn("text-sm", editing && overLimit && "border-destructive")}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-right text-[11px] tabular-nums",
|
||||
editing && overLimit ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{caption.length}/{maxChars}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Focused video draft: cut-toggled player, per-platform caption edits,
|
||||
* then post (X + TikTok as configured) or reject. */
|
||||
export function VideoPostDetail({
|
||||
post,
|
||||
onDone,
|
||||
}: {
|
||||
post: VideoPost;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [xCaption, setXCaption] = useState<string | null>(null);
|
||||
const [tiktokCaption, setTiktokCaption] = useState<string | null>(null);
|
||||
|
||||
const overLimit =
|
||||
(xCaption !== null && xCaption.length > MAX_X_CAPTION_CHARS) ||
|
||||
(tiktokCaption !== null && tiktokCaption.length > MAX_TIKTOK_CAPTION_CHARS);
|
||||
|
||||
const finish = (ok: boolean, message: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
|
||||
if (ok) {
|
||||
haptics.success();
|
||||
toast.success(message);
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(message);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
videoApi.approve(post.task_id, {
|
||||
...(xCaption !== null && { x_caption: xCaption }),
|
||||
...(tiktokCaption !== null && { tiktok_caption: tiktokCaption }),
|
||||
}),
|
||||
onSuccess: (result) =>
|
||||
finish(
|
||||
result.status === "posted" || result.status === "already_posted",
|
||||
result.status === "posted" ? "Video posted." : result.detail,
|
||||
),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) => videoApi.reject(post.task_id, reason),
|
||||
onSuccess: () => finish(true, "Draft rejected — feedback goes back to the author."),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{post.occasion}</Badge>
|
||||
{post.platforms.map((p) => (
|
||||
<Badge key={p} variant="outline">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
{post.render_status === "failed" && (
|
||||
<Badge variant="destructive">render failed</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CutPlayer post={post} />
|
||||
|
||||
{post.platforms.includes("x") && (
|
||||
<CaptionEditor
|
||||
platform="X"
|
||||
stored={post.x_caption ?? ""}
|
||||
maxChars={MAX_X_CAPTION_CHARS}
|
||||
value={xCaption}
|
||||
onChange={setXCaption}
|
||||
/>
|
||||
)}
|
||||
{post.platforms.includes("tiktok") && (
|
||||
<CaptionEditor
|
||||
platform="TikTok"
|
||||
stored={post.tiktok_caption ?? ""}
|
||||
maxChars={MAX_TIKTOK_CAPTION_CHARS}
|
||||
value={tiktokCaption}
|
||||
onChange={setTiktokCaption}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PrimaryAction
|
||||
text="Post video"
|
||||
disabled={overLimit}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="What's wrong with this video?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { xApi, type XPost } from "@/lib/api/x";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_TWEET_CHARS = 280;
|
||||
const MIN_REJECT_CHARS = 4;
|
||||
|
||||
const SOURCE_LABELS: Record<XPost["source"], string> = {
|
||||
x_post: "Release post",
|
||||
x_reply: "Reply",
|
||||
x_feature: "Feature spotlight",
|
||||
};
|
||||
|
||||
/** Focused X draft: editable body with the live 280 counter, the mention
|
||||
* being replied to when there is one, then post or reject. */
|
||||
export function XPostDetail({
|
||||
post,
|
||||
onDone,
|
||||
}: {
|
||||
post: XPost;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [edited, setEdited] = useState<string | null>(null);
|
||||
const body = edited ?? post.body;
|
||||
const overLimit = body.length > MAX_TWEET_CHARS;
|
||||
|
||||
const finish = (ok: boolean, message: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["x", "posts"] });
|
||||
if (ok) {
|
||||
haptics.success();
|
||||
toast.success(message);
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(message);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
xApi.approve(post.task_id, edited !== null ? body : undefined),
|
||||
onSuccess: (result) =>
|
||||
finish(
|
||||
result.status === "posted" || result.status === "already_posted",
|
||||
result.status === "posted" ? "Posted to X." : result.detail,
|
||||
),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) => xApi.reject(post.task_id, reason),
|
||||
onSuccess: () => finish(true, "Draft rejected."),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{SOURCE_LABELS[post.source]}</Badge>
|
||||
{post.release_version && <Badge>v{post.release_version}</Badge>}
|
||||
{post.project_name && (
|
||||
<Badge variant="outline">{post.project_name}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.mention && (
|
||||
<blockquote className="border-l-2 pl-2 text-xs text-muted-foreground">
|
||||
{post.mention.text}
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setEdited(e.target.value)}
|
||||
rows={5}
|
||||
className={cn("text-sm", overLimit && "border-destructive")}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-right text-[11px] tabular-nums",
|
||||
overLimit ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{body.length}/{MAX_TWEET_CHARS}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PrimaryAction
|
||||
text="Post to X"
|
||||
disabled={overLimit || body.trim().length === 0}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="Why is this draft wrong?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { ReleaseProposalCard } from "@/components/dashboard/release-proposal-card";
|
||||
import { XPostQueue } from "@/components/dashboard/x-post-queue";
|
||||
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
|
||||
import { RoadmapReviewQueue } from "@/components/dashboard/roadmap-review-queue";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useApprovalQueue,
|
||||
type ApprovalItem,
|
||||
} from "@/components/tg/approvals/use-approval-queue";
|
||||
import { ReleaseDetail } from "@/components/tg/approvals/release-detail";
|
||||
import { XPostDetail } from "@/components/tg/approvals/x-post-detail";
|
||||
import { VideoPostDetail } from "@/components/tg/approvals/video-post-detail";
|
||||
import { RoadmapItemDetail } from "@/components/tg/approvals/roadmap-item-detail";
|
||||
import { useBackButton, useTgWebApp } from "@/lib/telegram/hooks";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TgRow, TgRowIcon } from "@/components/tg/ui";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clapperboard,
|
||||
Map as MapIcon,
|
||||
MessageCircle,
|
||||
Rocket,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* The CEO's held-artifact stack, vertically stacked for a single thumb
|
||||
* scroll column. Every card is the exact same self-contained
|
||||
* dashboard-layout-independent component the desktop dashboard renders —
|
||||
* each already fetches its own data and no-ops (renders nothing useful) when
|
||||
* empty, so there's nothing to compose here beyond stacking them.
|
||||
*/
|
||||
export function TgApprovalsTab() {
|
||||
const KIND_META: Record<
|
||||
ApprovalItem["kind"],
|
||||
{ label: string; icon: typeof Rocket }
|
||||
> = {
|
||||
release: { label: "Release", icon: Rocket },
|
||||
x_post: { label: "X post", icon: MessageCircle },
|
||||
video_post: { label: "Video", icon: Clapperboard },
|
||||
roadmap: { label: "Roadmap", icon: MapIcon },
|
||||
};
|
||||
|
||||
function itemTitle(item: ApprovalItem): string {
|
||||
switch (item.kind) {
|
||||
case "release":
|
||||
return `Release v${item.proposal.report.proposed_version}`;
|
||||
case "x_post":
|
||||
return item.post.body;
|
||||
case "video_post":
|
||||
return item.post.title;
|
||||
case "roadmap":
|
||||
return item.item.title;
|
||||
}
|
||||
}
|
||||
|
||||
function ItemRow({
|
||||
item,
|
||||
onOpen,
|
||||
}: {
|
||||
item: ApprovalItem;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const meta = KIND_META[item.kind];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReleaseProposalCard />
|
||||
<XPostQueue />
|
||||
<VideoPostQueue />
|
||||
<RoadmapReviewQueue />
|
||||
<div className="rounded-xl border bg-card text-card-foreground">
|
||||
<TgRow
|
||||
leading={<TgRowIcon icon={meta.icon} />}
|
||||
title={itemTitle(item)}
|
||||
lines={2}
|
||||
meta={meta.label}
|
||||
onPress={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ item, onDone }: { item: ApprovalItem; onDone: () => void }) {
|
||||
switch (item.kind) {
|
||||
case "release":
|
||||
return <ReleaseDetail proposal={item.proposal} onDone={onDone} />;
|
||||
case "x_post":
|
||||
return <XPostDetail post={item.post} onDone={onDone} />;
|
||||
case "video_post":
|
||||
return <VideoPostDetail post={item.post} onDone={onDone} />;
|
||||
case "roadmap":
|
||||
return (
|
||||
<RoadmapItemDetail
|
||||
cycleId={item.cycleId}
|
||||
item={item.item}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The approvals card stack: every held draft across the four queues as one
|
||||
* list; tapping focuses a full-context detail whose primary action rides
|
||||
* Telegram's MainButton and whose back navigation rides the native
|
||||
* BackButton (with visible fallbacks outside Telegram). An acted-on item
|
||||
* vanishes from the refetched queue, which pops the view back to the list
|
||||
* by construction.
|
||||
*/
|
||||
export function TgApprovalsTab() {
|
||||
const { items, isLoading, anyFailed } = useApprovalQueue();
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const webApp = useTgWebApp();
|
||||
|
||||
const focused = items.find((i) => i.id === focusedId) ?? null;
|
||||
const back = () => setFocusedId(null);
|
||||
useBackButton(focused ? back : null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (anyFailed && items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
|
||||
<AlertTriangle className="h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">Couldn't load the queues</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (focused) {
|
||||
const meta = KIND_META[focused.kind];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!webApp?.BackButton && (
|
||||
<Button variant="ghost" size="sm" className="-ml-1.5 px-1.5" onClick={back}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<meta.icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
<Detail item={focused} onDone={back} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
|
||||
<CheckCircle2 className="h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">Queue is clear</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{anyFailed && (
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Some queues couldn't load — this list may be incomplete.
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={() => {
|
||||
haptics.tap();
|
||||
setFocusedId(item.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
a2aLiveKeys,
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
useCreateCeoConversation,
|
||||
useSendCeoMessage,
|
||||
} from "@/hooks/use-a2a-live";
|
||||
import { useA2ALiveStream } from "@/hooks/use-websocket";
|
||||
import { CEO_SLUG } from "@/components/a2a/a2a-utils";
|
||||
import { AgentSelector } from "@/components/agents/agent-selector";
|
||||
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
|
||||
@@ -20,8 +23,8 @@ import { formatDistanceToNow } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Thread polling cadence — the /tg cockpit has no WS wiring (unlike the
|
||||
* desktop A2A page), so the actively-viewed thread polls instead. */
|
||||
/** Fallback cadence for the actively-viewed thread when the /ws/system
|
||||
* socket is down — live frames drive refresh whenever it's connected. */
|
||||
const THREAD_POLL_MS = 10_000;
|
||||
|
||||
function ConversationList({
|
||||
@@ -61,18 +64,18 @@ function ConversationList({
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(c.id, peerLabel)}
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg border p-3 text-left"
|
||||
className="flex w-full flex-col gap-0.5 rounded-xl border bg-card p-3 text-left text-card-foreground transition-colors active:bg-muted"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{peerLabel}</span>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium">{peerLabel}</span>
|
||||
{c.last_message_at && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(c.last_message_at))} ago
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c.last_message_preview && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<p className="truncate text-xs leading-snug text-muted-foreground">
|
||||
{c.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
@@ -146,14 +149,16 @@ function ComposeNewChat({
|
||||
function ThreadView({
|
||||
conversationId,
|
||||
peerLabel,
|
||||
live,
|
||||
onBack,
|
||||
}: {
|
||||
conversationId: string;
|
||||
peerLabel: string;
|
||||
live: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { data, isLoading } = useA2AMessages(conversationId, {
|
||||
refetchInterval: THREAD_POLL_MS,
|
||||
refetchInterval: live ? false : THREAD_POLL_MS,
|
||||
});
|
||||
const [draft, setDraft] = useState("");
|
||||
const send = useSendCeoMessage();
|
||||
@@ -240,6 +245,33 @@ type ChatView =
|
||||
*/
|
||||
export function TgChatTab() {
|
||||
const [view, setView] = useState<ChatView>({ mode: "list" });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Live wiring (the desktop A2A idiom): every persisted message announces
|
||||
// itself on /ws/system; invalidate-on-frame keeps REST the source of
|
||||
// truth. While the socket is up the thread poll switches off entirely.
|
||||
const { lastMessage, isConnected } = useA2ALiveStream();
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type !== "a2a.message") return;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.conversations,
|
||||
});
|
||||
if (lastMessage.conversation_id) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.messages(lastMessage.conversation_id),
|
||||
});
|
||||
}
|
||||
}, [lastMessage, queryClient]);
|
||||
|
||||
// Events missed during a disconnect never replay — refetch everything on
|
||||
// reconnect (false → true only; initial mount doesn't fire).
|
||||
const prevConnected = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (prevConnected.current === false && isConnected) {
|
||||
void queryClient.invalidateQueries({ queryKey: a2aLiveKeys.all });
|
||||
}
|
||||
prevConnected.current = isConnected;
|
||||
}, [isConnected, queryClient]);
|
||||
|
||||
if (view.mode === "compose") {
|
||||
return (
|
||||
@@ -257,6 +289,7 @@ export function TgChatTab() {
|
||||
<ThreadView
|
||||
conversationId={view.id}
|
||||
peerLabel={view.peer}
|
||||
live={isConnected}
|
||||
onBack={() => setView({ mode: "list" })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ function TgNotificationRow({ notification }: { notification: Notification }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-3",
|
||||
"rounded-xl border bg-card p-3 text-card-foreground",
|
||||
notification.is_read ? "opacity-70" : "border-l-4 border-l-primary",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
|
||||
import { Gauge, CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TgTab = "approvals" | "inbox" | "board" | "chat";
|
||||
export type TgTab = "today" | "approvals" | "inbox" | "board" | "chat";
|
||||
|
||||
const TABS: ReadonlyArray<{
|
||||
id: TgTab;
|
||||
label: string;
|
||||
icon: typeof CheckSquare;
|
||||
}> = [
|
||||
{ id: "today", label: "Today", icon: Gauge },
|
||||
{ id: "approvals", label: "Approvals", icon: CheckSquare },
|
||||
{ id: "inbox", label: "Inbox", icon: Bell },
|
||||
{ id: "board", label: "Board", icon: Kanban },
|
||||
@@ -30,7 +31,7 @@ export function TgTabBar({ active, onChange }: TgTabBarProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Cockpit"
|
||||
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)]"
|
||||
className="fixed inset-x-0 bottom-0 z-40 mx-auto flex w-full max-w-[430px] border-t bg-background/90 pb-[env(safe-area-inset-bottom)] backdrop-blur"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = active === tab.id;
|
||||
@@ -41,11 +42,14 @@ export function TgTabBar({ active, onChange }: TgTabBarProps) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center gap-1 py-2.5 text-xs font-medium transition-colors",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
"flex flex-1 flex-col items-center gap-0.5 pb-2 pt-2.5 text-[10px] font-medium tracking-wide transition-colors",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-6 w-6" />
|
||||
<tab.icon
|
||||
className="h-5 w-5"
|
||||
strokeWidth={isActive ? 2.25 : 1.75}
|
||||
/>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api/client";
|
||||
import { isTgDemoMode } from "@/lib/telegram/demo";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import type { TgTab } from "@/components/tg/tg-tab-bar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TgRow, TgSection, TgStat } from "@/components/tg/ui";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
Rocket,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export interface TodayTaskItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
team: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface TodayBrief {
|
||||
needs_you: {
|
||||
total: number;
|
||||
awaiting_ceo_count: number;
|
||||
awaiting_ceo: TodayTaskItem[];
|
||||
blocked_count: number;
|
||||
blocked: TodayTaskItem[];
|
||||
held_drafts: Record<string, number>;
|
||||
};
|
||||
fleet: {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
working: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
team: string | null;
|
||||
task_title: string | null;
|
||||
}>;
|
||||
};
|
||||
spend: { tokens_today: number; cost_today_usd: number };
|
||||
ship: {
|
||||
version: string;
|
||||
open_release_proposal: boolean;
|
||||
ci_fix_tasks: number;
|
||||
};
|
||||
}
|
||||
|
||||
const REFETCH_MS = 45_000;
|
||||
|
||||
const DRAFT_LABELS: Record<string, string> = {
|
||||
release_proposals: "Release",
|
||||
x_posts: "X posts",
|
||||
video_posts: "Videos",
|
||||
roadmap_items: "Roadmap",
|
||||
};
|
||||
|
||||
const compactNumber = new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
function taskMeta(task: TodayTaskItem): string {
|
||||
const parts = [task.team ?? "—"];
|
||||
if (task.updated_at) {
|
||||
parts.push(`${formatDistanceToNow(new Date(task.updated_at))} ago`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The cockpit's home screen: one glance answering "does anything need me?"
|
||||
* — capped needs-you items, held-draft counts, fleet, today's spend, and
|
||||
* ship state, off the single aggregated `/telegram/today` round trip.
|
||||
* Row taps deep-link into the tab that acts on the item.
|
||||
*/
|
||||
export function TgTodayTab({
|
||||
onNavigate,
|
||||
}: {
|
||||
onNavigate: (tab: TgTab) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError } = useQuery<TodayBrief>({
|
||||
queryKey: ["tg-today"],
|
||||
queryFn: async () => {
|
||||
if (isTgDemoMode()) {
|
||||
return (await import("@/lib/telegram/demo-data")).DEMO_TODAY;
|
||||
}
|
||||
return (await api.get<TodayBrief>("/telegram/today")).data;
|
||||
},
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
|
||||
// Rides the shared /ws/system socket (ref-counted — no extra connection):
|
||||
// each USAGE_SNAPSHOT push refreshes the brief so the spend line tracks
|
||||
// the sweeper live; the poll above stays as the socket-down fallback.
|
||||
const { lastMessage } = useWebSocket<{ type?: string }>("/system");
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type !== "USAGE_SNAPSHOT") return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["tg-today"] });
|
||||
}, [lastMessage, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
|
||||
<AlertTriangle className="h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">Couldn't load the brief</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { needs_you: needs, fleet, spend, ship } = data;
|
||||
const go = (tab: TgTab) => {
|
||||
haptics.tap();
|
||||
onNavigate(tab);
|
||||
};
|
||||
const heldEntries = Object.entries(needs.held_drafts).filter(
|
||||
([, count]) => count > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<TgSection
|
||||
icon={CheckCircle2}
|
||||
title="Needs you"
|
||||
trailing={
|
||||
needs.total > 0 ? (
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold tabular-nums text-primary-foreground">
|
||||
{needs.total}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{needs.total === 0 ? (
|
||||
<p className="py-1.5 text-sm text-muted-foreground">
|
||||
All clear — nothing is waiting on you.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{heldEntries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-0.5">
|
||||
{heldEntries.map(([key, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => go("approvals")}
|
||||
className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium tabular-nums text-primary transition-colors active:bg-primary/20"
|
||||
>
|
||||
{DRAFT_LABELS[key] ?? key} · {count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="-mx-1.5 divide-y divide-border/60">
|
||||
{needs.awaiting_ceo.map((t) => (
|
||||
<TgRow
|
||||
key={t.id}
|
||||
title={t.title}
|
||||
meta={taskMeta(t)}
|
||||
onPress={() => go("board")}
|
||||
/>
|
||||
))}
|
||||
{needs.blocked.map((t) => (
|
||||
<TgRow
|
||||
key={t.id}
|
||||
title={t.title}
|
||||
meta={
|
||||
<>
|
||||
<span className="font-medium text-destructive">
|
||||
blocked
|
||||
</span>
|
||||
{" · "}
|
||||
{taskMeta(t)}
|
||||
</>
|
||||
}
|
||||
onPress={() => go("board")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TgSection>
|
||||
|
||||
<TgSection
|
||||
icon={Users}
|
||||
title="Fleet"
|
||||
trailing={
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{fleet.total} agents
|
||||
{Object.entries(fleet.by_status).map(
|
||||
([status, count]) => ` · ${count} ${status}`,
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{fleet.working.length === 0 ? (
|
||||
<p className="py-1 text-sm text-muted-foreground">
|
||||
No one is mid-task.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{fleet.working.map((agent) => (
|
||||
<li
|
||||
key={agent.name}
|
||||
className="flex items-baseline gap-2 text-[13px] leading-snug"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.task_title && (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{agent.task_title}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TgSection>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TgSection icon={CircleDollarSign} title="Spend today">
|
||||
<TgStat
|
||||
value={`$${spend.cost_today_usd.toFixed(2)}`}
|
||||
caption={`${compactNumber.format(spend.tokens_today)} tokens`}
|
||||
/>
|
||||
</TgSection>
|
||||
<TgSection icon={Rocket} title="Ship">
|
||||
<TgStat
|
||||
value={`v${ship.version}`}
|
||||
tone={ship.open_release_proposal ? "attention" : "default"}
|
||||
caption={
|
||||
ship.open_release_proposal
|
||||
? "Release proposal waiting"
|
||||
: ship.ci_fix_tasks > 0
|
||||
? `${ship.ci_fix_tasks} CI fix open`
|
||||
: "No release pending"
|
||||
}
|
||||
/>
|
||||
</TgSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The cockpit's shared visual language (direction: Telegram palette under
|
||||
* RoboCo's typographic voice). Every tab composes these three primitives so
|
||||
* density, rhythm, and press-feedback stay identical everywhere:
|
||||
* TgSection (a grouped card with a tracked micro-label header), TgRow (a
|
||||
* tappable list row with a fixed 44px minimum target), TgStat (a big
|
||||
* tabular-nums figure with a caption). Colors always come from the CSS
|
||||
* variables — inside Telegram those are the user's own theme (P0's
|
||||
* themeParams bridge), so nothing here names a literal color.
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export function TgSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
trailing,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
trailing?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 px-3 pb-1 pt-2.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{Icon && <Icon className="h-3.5 w-3.5" />}
|
||||
{title}
|
||||
</h2>
|
||||
{trailing}
|
||||
</header>
|
||||
<div className="px-3 pb-2.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TgRow({
|
||||
leading,
|
||||
title,
|
||||
meta,
|
||||
lines = 1,
|
||||
onPress,
|
||||
trailing,
|
||||
}: {
|
||||
leading?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
/** Title clamp — 1 for tight lists, 2 when the title IS the content
|
||||
* (e.g. an X draft body). */
|
||||
lines?: 1 | 2;
|
||||
onPress: () => void;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPress}
|
||||
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-1.5 py-2 text-left transition-colors active:bg-muted"
|
||||
>
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium leading-snug",
|
||||
lines === 1 ? "truncate" : "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{meta && (
|
||||
<p className="mt-0.5 truncate text-[11px] leading-tight text-muted-foreground">
|
||||
{meta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{trailing ?? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Leading icon tile for rows — the grouped-list glyph square. */
|
||||
export function TgRowIcon({ icon: Icon }: { icon: LucideIcon }) {
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TgStat({
|
||||
value,
|
||||
caption,
|
||||
tone = "default",
|
||||
}: {
|
||||
value: React.ReactNode;
|
||||
caption: React.ReactNode;
|
||||
tone?: "default" | "attention";
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold leading-tight tracking-tight tabular-nums",
|
||||
tone === "attention" && "text-primary",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] leading-tight text-muted-foreground">
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
TgWebAppProvider,
|
||||
useMainButton,
|
||||
useBackButton,
|
||||
type MainButtonOptions,
|
||||
} from "../hooks";
|
||||
import type { TelegramWebApp } from "../webapp";
|
||||
|
||||
function fakeMainButton() {
|
||||
return {
|
||||
setText: vi.fn(),
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
showProgress: vi.fn(),
|
||||
hideProgress: vi.fn(),
|
||||
onClick: vi.fn(),
|
||||
offClick: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBackButton() {
|
||||
return {
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
onClick: vi.fn(),
|
||||
offClick: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function webAppWith(overrides: Partial<TelegramWebApp>): TelegramWebApp {
|
||||
return {
|
||||
ready: () => undefined,
|
||||
expand: () => undefined,
|
||||
initData: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function MainButtonHarness(props: MainButtonOptions) {
|
||||
useMainButton(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
function BackButtonHarness({ onBack }: { onBack: (() => void) | null }) {
|
||||
useBackButton(onBack);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useMainButton", () => {
|
||||
let mainButton: ReturnType<typeof fakeMainButton>;
|
||||
let webApp: TelegramWebApp;
|
||||
|
||||
beforeEach(() => {
|
||||
mainButton = fakeMainButton();
|
||||
webApp = webAppWith({ MainButton: mainButton });
|
||||
});
|
||||
|
||||
it("configures and shows the button declaratively", () => {
|
||||
render(
|
||||
<TgWebAppProvider webApp={webApp}>
|
||||
<MainButtonHarness text="Approve" visible onClick={() => undefined} />
|
||||
</TgWebAppProvider>,
|
||||
);
|
||||
expect(mainButton.setText).toHaveBeenCalledWith("Approve");
|
||||
expect(mainButton.enable).toHaveBeenCalled();
|
||||
expect(mainButton.hideProgress).toHaveBeenCalled();
|
||||
expect(mainButton.show).toHaveBeenCalled();
|
||||
expect(mainButton.onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reflects loading/disabled and invokes the latest onClick closure", () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const { rerender } = render(
|
||||
<TgWebAppProvider webApp={webApp}>
|
||||
<MainButtonHarness text="Approve" visible onClick={first} />
|
||||
</TgWebAppProvider>,
|
||||
);
|
||||
rerender(
|
||||
<TgWebAppProvider webApp={webApp}>
|
||||
<MainButtonHarness text="Approve" visible loading disabled onClick={second} />
|
||||
</TgWebAppProvider>,
|
||||
);
|
||||
expect(mainButton.showProgress).toHaveBeenCalled();
|
||||
expect(mainButton.disable).toHaveBeenCalled();
|
||||
// Same subscribed handler survives rerenders but calls the fresh closure.
|
||||
expect(mainButton.onClick).toHaveBeenCalledTimes(1);
|
||||
const handler = mainButton.onClick.mock.calls[0][0] as () => void;
|
||||
handler();
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unhooks and hides on unmount", () => {
|
||||
const { unmount } = render(
|
||||
<TgWebAppProvider webApp={webApp}>
|
||||
<MainButtonHarness text="Approve" visible onClick={() => undefined} />
|
||||
</TgWebAppProvider>,
|
||||
);
|
||||
unmount();
|
||||
expect(mainButton.offClick).toHaveBeenCalledTimes(1);
|
||||
expect(mainButton.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops without a provider (outside Telegram)", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<MainButtonHarness text="Approve" visible onClick={() => undefined} />,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBackButton", () => {
|
||||
it("shows while a handler is set, hides when null, unhooks on unmount", () => {
|
||||
const backButton = fakeBackButton();
|
||||
const webApp = webAppWith({ BackButton: backButton });
|
||||
const onBack = vi.fn();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<TgWebAppProvider webApp={webApp}>
|
||||
<BackButtonHarness onBack={onBack} />
|
||||
</TgWebAppProvider>,
|
||||
);
|
||||
expect(backButton.show).toHaveBeenCalled();
|
||||
|
||||
const handler = backButton.onClick.mock.calls[0][0] as () => void;
|
||||
handler();
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<TgWebAppProvider webApp={webApp}>
|
||||
<BackButtonHarness onBack={null} />
|
||||
</TgWebAppProvider>,
|
||||
);
|
||||
expect(backButton.hide).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
expect(backButton.offClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applyTelegramTheme, startTelegramThemeSync } from "../theme";
|
||||
import type { TelegramWebApp } from "../webapp";
|
||||
|
||||
function webAppWith(overrides: Partial<TelegramWebApp>): TelegramWebApp {
|
||||
return {
|
||||
ready: () => undefined,
|
||||
expand: () => undefined,
|
||||
initData: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyTelegramTheme", () => {
|
||||
it("toggles the dark class from colorScheme", () => {
|
||||
const el = document.createElement("div");
|
||||
applyTelegramTheme(webAppWith({ colorScheme: "dark" }), el);
|
||||
expect(el.classList.contains("dark")).toBe(true);
|
||||
applyTelegramTheme(webAppWith({ colorScheme: "light" }), el);
|
||||
expect(el.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps surface themeParams onto the panel CSS variables — never the accent", () => {
|
||||
const el = document.createElement("div");
|
||||
applyTelegramTheme(
|
||||
webAppWith({
|
||||
themeParams: {
|
||||
bg_color: "#17212b",
|
||||
text_color: "#f5f5f5",
|
||||
hint_color: "#708499",
|
||||
button_color: "#5288c1",
|
||||
button_text_color: "#ffffff",
|
||||
},
|
||||
}),
|
||||
el,
|
||||
);
|
||||
expect(el.style.getPropertyValue("--background")).toBe("#17212b");
|
||||
expect(el.style.getPropertyValue("--card")).toBe("#17212b");
|
||||
expect(el.style.getPropertyValue("--foreground")).toBe("#f5f5f5");
|
||||
expect(el.style.getPropertyValue("--muted-foreground")).toBe("#708499");
|
||||
// The accent stays RoboCo's (#tg-shell skin) — button colors are
|
||||
// deliberately not adopted.
|
||||
expect(el.style.getPropertyValue("--primary")).toBe("");
|
||||
expect(el.style.getPropertyValue("--primary-foreground")).toBe("");
|
||||
});
|
||||
|
||||
it("prefers the more specific section/secondary keys when present", () => {
|
||||
const el = document.createElement("div");
|
||||
applyTelegramTheme(
|
||||
webAppWith({
|
||||
themeParams: {
|
||||
bg_color: "#111111",
|
||||
secondary_bg_color: "#222222",
|
||||
section_bg_color: "#333333",
|
||||
},
|
||||
}),
|
||||
el,
|
||||
);
|
||||
expect(el.style.getPropertyValue("--background")).toBe("#222222");
|
||||
expect(el.style.getPropertyValue("--card")).toBe("#333333");
|
||||
});
|
||||
|
||||
it("drops non-hex values instead of injecting them into style", () => {
|
||||
const el = document.createElement("div");
|
||||
applyTelegramTheme(
|
||||
webAppWith({
|
||||
themeParams: {
|
||||
bg_color: "url(javascript:alert(1))",
|
||||
text_color: "#abc",
|
||||
},
|
||||
}),
|
||||
el,
|
||||
);
|
||||
expect(el.style.getPropertyValue("--background")).toBe("");
|
||||
expect(el.style.getPropertyValue("--foreground")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startTelegramThemeSync", () => {
|
||||
it("applies immediately, re-applies on themeChanged, and unsubscribes on cleanup", () => {
|
||||
const el = document.createElement("div");
|
||||
const listeners = new Map<string, () => void>();
|
||||
const webApp = webAppWith({
|
||||
colorScheme: "light",
|
||||
themeParams: { bg_color: "#ffffff" },
|
||||
onEvent: vi.fn((event: string, cb: () => void) => {
|
||||
listeners.set(event, cb);
|
||||
}),
|
||||
offEvent: vi.fn((event: string) => {
|
||||
listeners.delete(event);
|
||||
}),
|
||||
});
|
||||
|
||||
const stop = startTelegramThemeSync(webApp, el);
|
||||
expect(el.style.getPropertyValue("--background")).toBe("#ffffff");
|
||||
|
||||
webApp.colorScheme = "dark";
|
||||
webApp.themeParams = { bg_color: "#17212b" };
|
||||
listeners.get("themeChanged")?.();
|
||||
expect(el.style.getPropertyValue("--background")).toBe("#17212b");
|
||||
expect(el.classList.contains("dark")).toBe(true);
|
||||
|
||||
stop();
|
||||
expect(webApp.offEvent).toHaveBeenCalledTimes(1);
|
||||
expect(listeners.has("themeChanged")).toBe(false);
|
||||
});
|
||||
|
||||
it("is a one-shot apply with no-op cleanup when the bridge lacks events", () => {
|
||||
const el = document.createElement("div");
|
||||
const stop = startTelegramThemeSync(
|
||||
webAppWith({ themeParams: { bg_color: "#123456" } }),
|
||||
el,
|
||||
);
|
||||
expect(el.style.getPropertyValue("--background")).toBe("#123456");
|
||||
expect(stop).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Canned cockpit fixtures for `/tg?demo=1` (see demo.ts). Typed against
|
||||
* the real API modules so drift breaks typecheck, flavored like a live
|
||||
* RoboCo day so the UI reads true.
|
||||
*/
|
||||
|
||||
import type { ReleaseProposal } from "@/lib/api/release";
|
||||
import type { XPost } from "@/lib/api/x";
|
||||
import type { VideoPost } from "@/lib/api/video";
|
||||
import type { RoadmapCycle } from "@/lib/api/roadmap";
|
||||
import type { TodayBrief } from "@/components/tg/tg-today-tab";
|
||||
|
||||
export const DEMO_RELEASE: ReleaseProposal = {
|
||||
task_id: "demo-release",
|
||||
title: "Release proposal 0.26.0",
|
||||
status: "awaiting_ceo_approval",
|
||||
report: {
|
||||
proposed_version: "0.26.0",
|
||||
bump_kind: "minor",
|
||||
change_summary: [
|
||||
"feat(tg): Mini App V4 — Today brief + native approvals",
|
||||
"fix(git): hard-sync target branch to origin",
|
||||
],
|
||||
drafted_changelog:
|
||||
"## 0.26.0\n\n### Added\n- Telegram Mini App V4: Today brief, native approvals card stack.\n\n### Fixed\n- CEO approve-and-merge no longer fails on a diverged workspace clone.",
|
||||
version_bump_plan: ["pyproject.toml", "roboco/__init__.py", "panel/package.json"],
|
||||
gaps: [{ category: "docs", detail: "docs.roboco.tech Mini App page not yet updated" }],
|
||||
migration_notes: [],
|
||||
gate_state: "green",
|
||||
},
|
||||
};
|
||||
|
||||
export const DEMO_X_POSTS: XPost[] = [
|
||||
{
|
||||
task_id: "demo-x-1",
|
||||
source: "x_feature",
|
||||
title: "Feature spotlight: findings ledger",
|
||||
status: "pending",
|
||||
body: "Every QA bounce in RoboCo is now a structured finding — file, line, severity, expected vs actual — not prose. Reviewers stamp them, devs resolve them by id, and nothing gets lost between rounds. The ledger closed our noisiest feedback loop.",
|
||||
char_count: 243,
|
||||
project_name: "RoboCo",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_VIDEO_POSTS: VideoPost[] = [
|
||||
{
|
||||
task_id: "demo-video-1",
|
||||
source: "video_post",
|
||||
title: "v0.25.0 release motion",
|
||||
status: "pending",
|
||||
occasion: "release",
|
||||
script: "Three features, three scenes, twelve seconds.",
|
||||
platforms: ["x", "tiktok"],
|
||||
x_caption: "RoboCo 0.25.0 — the org now cuts its own releases. 🤖",
|
||||
tiktok_caption:
|
||||
"An AI company that ships itself: RoboCo 0.25.0 was proposed, gated, and published by the fleet — the human just tapped approve.",
|
||||
mp4_paths: {},
|
||||
render_status: "rendered",
|
||||
project_name: "RoboCo",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_ROADMAP: RoadmapCycle[] = [
|
||||
{
|
||||
task_id: "demo-cycle-1",
|
||||
title: "Roadmap cycle — operational polish",
|
||||
status: "pending",
|
||||
goal: "Close the small frictions the metrics keep surfacing",
|
||||
items: [
|
||||
{
|
||||
id: "item-digest",
|
||||
title: "Weekly cost digest email",
|
||||
description:
|
||||
"One Monday-morning email: last week's spend by team, top burners, and the trend line.",
|
||||
acceptance_criteria: [
|
||||
"Digest renders from existing usage rollups",
|
||||
"Opt-out via panel settings",
|
||||
],
|
||||
project_slug: "roboco",
|
||||
team: "backend",
|
||||
priority: 2,
|
||||
rationale: "The usage dashboard exists but nobody opens it on Mondays.",
|
||||
status: "proposed",
|
||||
},
|
||||
{
|
||||
id: "item-export",
|
||||
title: "Task history CSV export",
|
||||
description:
|
||||
"Export a project's completed-task history with cycle times for offline analysis.",
|
||||
acceptance_criteria: ["One-click export from the project page"],
|
||||
project_slug: "roboco",
|
||||
team: "frontend",
|
||||
priority: 3,
|
||||
rationale: "Asked for twice during board reviews.",
|
||||
status: "proposed",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_TODAY: TodayBrief = {
|
||||
needs_you: {
|
||||
total: 6,
|
||||
awaiting_ceo_count: 1,
|
||||
awaiting_ceo: [
|
||||
{
|
||||
id: "demo-t1",
|
||||
title: "Webhook rate limiting — root PR",
|
||||
status: "awaiting_ceo_approval",
|
||||
team: "backend",
|
||||
updated_at: new Date(Date.now() - 40 * 60_000).toISOString(),
|
||||
},
|
||||
],
|
||||
blocked_count: 1,
|
||||
blocked: [
|
||||
{
|
||||
id: "demo-t2",
|
||||
title: "Docs search index rebuild",
|
||||
status: "blocked",
|
||||
team: "frontend",
|
||||
updated_at: new Date(Date.now() - 3 * 3600_000).toISOString(),
|
||||
},
|
||||
],
|
||||
held_drafts: {
|
||||
release_proposals: 1,
|
||||
x_posts: 1,
|
||||
video_posts: 1,
|
||||
roadmap_items: 2,
|
||||
},
|
||||
},
|
||||
fleet: {
|
||||
total: 26,
|
||||
by_status: { active: 4, idle: 22 },
|
||||
working: [
|
||||
{ name: "be-dev-1", role: "developer", team: "backend", task_title: "Webhook rate limiting" },
|
||||
{ name: "fe-qa", role: "qa", team: "frontend", task_title: "Metrics time-series review" },
|
||||
{ name: "ux-dev-2", role: "developer", team: "ux_ui", task_title: "v0.26.0 release motion" },
|
||||
],
|
||||
},
|
||||
spend: { tokens_today: 2_400_000, cost_today_usd: 18.72 },
|
||||
ship: { version: "0.25.0", open_release_proposal: true, ci_fix_tasks: 0 },
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Dev-only demo switch for the Mini App: `/tg?demo=1` makes the cockpit's
|
||||
* queries return canned fixtures (see demo-data.ts, loaded dynamically so
|
||||
* production bundles never carry it) instead of hitting the backend —
|
||||
* lets the UI be seen and styled with zero stack running. Mutations still
|
||||
* go to the real API and will fail loudly; demo mode is a showroom, not a
|
||||
* simulator. The NODE_ENV check folds to false in production builds, so
|
||||
* the whole branch (and the fixture chunk) is dead-code-eliminated.
|
||||
*/
|
||||
export function isTgDemoMode(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
typeof window !== "undefined" &&
|
||||
new URLSearchParams(window.location.search).has("demo")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* React bindings for the Telegram WebApp bridge. The page bootstraps the
|
||||
* bridge once (real object or dev mock) and provides it here; components
|
||||
* reach native chrome (MainButton, BackButton) through these hooks and
|
||||
* never touch `window.Telegram` directly — that keeps every consumer
|
||||
* null-safe outside Telegram by construction.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useRef } from "react";
|
||||
import type { TelegramWebApp } from "./webapp";
|
||||
|
||||
const TgWebAppContext = createContext<TelegramWebApp | null>(null);
|
||||
|
||||
export function TgWebAppProvider({
|
||||
webApp,
|
||||
children,
|
||||
}: {
|
||||
webApp: TelegramWebApp | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TgWebAppContext.Provider value={webApp}>
|
||||
{children}
|
||||
</TgWebAppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The bootstrapped bridge, or null when rendered outside the provider
|
||||
* (tests) or before bootstrap resolves. */
|
||||
export function useTgWebApp(): TelegramWebApp | null {
|
||||
return useContext(TgWebAppContext);
|
||||
}
|
||||
|
||||
export interface MainButtonOptions {
|
||||
text: string;
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
/** Shows Telegram's spinner on the button while a mutation is in flight. */
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives Telegram's native bottom action button declaratively. The button is
|
||||
* global singleton chrome, so exactly one mounted component should own it at
|
||||
* a time (the focused card, not every card). Hidden + unhooked on unmount.
|
||||
* No-ops when the bridge (or its MainButton) is absent — callers that need a
|
||||
* fallback can render their own button when `useTgWebApp()?.MainButton` is
|
||||
* missing.
|
||||
*/
|
||||
export function useMainButton({
|
||||
text,
|
||||
visible,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onClick,
|
||||
}: MainButtonOptions): void {
|
||||
const webApp = useTgWebApp();
|
||||
const mainButton = webApp?.MainButton;
|
||||
const onClickRef = useRef(onClick);
|
||||
useEffect(() => {
|
||||
onClickRef.current = onClick;
|
||||
}, [onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainButton) return;
|
||||
const handler = () => onClickRef.current();
|
||||
mainButton.onClick(handler);
|
||||
return () => {
|
||||
mainButton.offClick(handler);
|
||||
mainButton.hide();
|
||||
};
|
||||
}, [mainButton]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainButton) return;
|
||||
mainButton.setText(text);
|
||||
if (disabled) {
|
||||
mainButton.disable();
|
||||
} else {
|
||||
mainButton.enable();
|
||||
}
|
||||
if (loading) {
|
||||
mainButton.showProgress();
|
||||
} else {
|
||||
mainButton.hideProgress();
|
||||
}
|
||||
if (visible) {
|
||||
mainButton.show();
|
||||
} else {
|
||||
mainButton.hide();
|
||||
}
|
||||
}, [mainButton, text, visible, disabled, loading]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows Telegram's native header back button while `onBack` is non-null and
|
||||
* invokes it on tap. Pass null to hide (e.g. at the root of a card stack).
|
||||
*/
|
||||
export function useBackButton(onBack: (() => void) | null): void {
|
||||
const webApp = useTgWebApp();
|
||||
const backButton = webApp?.BackButton;
|
||||
const onBackRef = useRef(onBack);
|
||||
useEffect(() => {
|
||||
onBackRef.current = onBack;
|
||||
}, [onBack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backButton) return;
|
||||
const handler = () => onBackRef.current?.();
|
||||
backButton.onClick(handler);
|
||||
return () => {
|
||||
backButton.offClick(handler);
|
||||
backButton.hide();
|
||||
};
|
||||
}, [backButton]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backButton) return;
|
||||
if (onBack) {
|
||||
backButton.show();
|
||||
} else {
|
||||
backButton.hide();
|
||||
}
|
||||
}, [backButton, onBack]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Telegram theme → panel token bridge.
|
||||
*
|
||||
* Maps the launcher's `themeParams` colors onto the shadcn CSS variables the
|
||||
* whole component library reads, scoped to the `(tg)` shell element only —
|
||||
* the desktop dashboard keeps its own theme. Later map entries win by
|
||||
* re-setting the same variable, so a newer, more specific Telegram key
|
||||
* (e.g. `section_bg_color`) overrides the broader fallback before it.
|
||||
*/
|
||||
|
||||
import type { TelegramThemeParams, TelegramWebApp } from "./webapp";
|
||||
|
||||
/** [Telegram key, panel CSS variable] — applied in order. Surfaces only:
|
||||
* the accent tokens (--primary/--ring) deliberately stay RoboCo's amber
|
||||
* (the #tg-shell skin in globals.css) so the cockpit keeps its identity
|
||||
* inside any Telegram theme — Telegram's surfaces, RoboCo's voice. */
|
||||
const THEME_VAR_MAP: ReadonlyArray<[keyof TelegramThemeParams, string]> = [
|
||||
["bg_color", "--background"],
|
||||
["secondary_bg_color", "--background"],
|
||||
["bg_color", "--card"],
|
||||
["section_bg_color", "--card"],
|
||||
["bg_color", "--popover"],
|
||||
["section_bg_color", "--popover"],
|
||||
["text_color", "--foreground"],
|
||||
["text_color", "--card-foreground"],
|
||||
["text_color", "--popover-foreground"],
|
||||
["hint_color", "--muted-foreground"],
|
||||
["subtitle_text_color", "--muted-foreground"],
|
||||
["destructive_text_color", "--destructive"],
|
||||
["section_separator_color", "--border"],
|
||||
];
|
||||
|
||||
/** Telegram promises `#rrggbb`; anything else is dropped rather than
|
||||
* injected into a style attribute (the bridge object is still a trust
|
||||
* boundary — a malformed value must not become CSS). */
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
/**
|
||||
* Applies the WebApp's current colorScheme + themeParams to `root`: toggles
|
||||
* the `.dark` class (so unmapped tokens and `dark:` variants stay coherent)
|
||||
* and sets every validly-colored mapped variable inline (inline wins over
|
||||
* both `:root` and `.dark` definitions). Missing/invalid params are simply
|
||||
* skipped — the panel's own theme shows through, which is the right
|
||||
* degraded look.
|
||||
*/
|
||||
export function applyTelegramTheme(
|
||||
webApp: TelegramWebApp,
|
||||
root: HTMLElement,
|
||||
): void {
|
||||
root.classList.toggle("dark", webApp.colorScheme === "dark");
|
||||
const params = webApp.themeParams ?? {};
|
||||
for (const [key, cssVar] of THEME_VAR_MAP) {
|
||||
const value = params[key];
|
||||
if (value && HEX_COLOR.test(value)) {
|
||||
root.style.setProperty(cssVar, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the theme now and re-applies on every `themeChanged` bridge event
|
||||
* (the user switching Telegram themes mid-session). Returns a cleanup that
|
||||
* unsubscribes; safe when the bridge lacks onEvent/offEvent (older clients,
|
||||
* the dev mock) — then it's a one-shot apply with a no-op cleanup.
|
||||
*/
|
||||
export function startTelegramThemeSync(
|
||||
webApp: TelegramWebApp,
|
||||
root: HTMLElement,
|
||||
): () => void {
|
||||
applyTelegramTheme(webApp, root);
|
||||
if (!webApp.onEvent || !webApp.offEvent) return () => undefined;
|
||||
const handler = () => applyTelegramTheme(webApp, root);
|
||||
webApp.onEvent("themeChanged", handler);
|
||||
return () => webApp.offEvent?.("themeChanged", handler);
|
||||
}
|
||||
@@ -3,11 +3,54 @@
|
||||
*
|
||||
* Thin wrapper over the global `window.Telegram.WebApp` object injected by
|
||||
* https://telegram.org/js/telegram-web-app.js (loaded by the `(tg)` layout).
|
||||
* Only the handful of fields/methods the cockpit actually needs are typed —
|
||||
* the real object carries far more (haptics, theme params, main button,
|
||||
* etc.) that nothing here uses yet.
|
||||
* Only the fields/methods the cockpit actually uses are typed — the real
|
||||
* object carries far more.
|
||||
*/
|
||||
|
||||
/** Colors Telegram derives from the user's active Telegram theme. All hex
|
||||
* (`#rrggbb`), all optional — older clients omit the newer keys. */
|
||||
export interface TelegramThemeParams {
|
||||
bg_color?: string;
|
||||
secondary_bg_color?: string;
|
||||
section_bg_color?: string;
|
||||
section_separator_color?: string;
|
||||
text_color?: string;
|
||||
hint_color?: string;
|
||||
subtitle_text_color?: string;
|
||||
link_color?: string;
|
||||
accent_text_color?: string;
|
||||
button_color?: string;
|
||||
button_text_color?: string;
|
||||
destructive_text_color?: string;
|
||||
}
|
||||
|
||||
export interface TelegramMainButton {
|
||||
setText: (text: string) => void;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
enable: () => void;
|
||||
disable: () => void;
|
||||
showProgress: (leaveActive?: boolean) => void;
|
||||
hideProgress: () => void;
|
||||
onClick: (cb: () => void) => void;
|
||||
offClick: (cb: () => void) => void;
|
||||
}
|
||||
|
||||
export interface TelegramBackButton {
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
onClick: (cb: () => void) => void;
|
||||
offClick: (cb: () => void) => void;
|
||||
}
|
||||
|
||||
export interface TelegramHapticFeedback {
|
||||
impactOccurred: (
|
||||
style: "light" | "medium" | "heavy" | "rigid" | "soft",
|
||||
) => void;
|
||||
notificationOccurred: (type: "error" | "success" | "warning") => void;
|
||||
selectionChanged: () => void;
|
||||
}
|
||||
|
||||
export interface TelegramWebApp {
|
||||
/** Signals the Mini App is ready to be displayed — hides Telegram's own
|
||||
* loading placeholder. Safe to call more than once. */
|
||||
@@ -19,6 +62,18 @@ export interface TelegramWebApp {
|
||||
* when the WebApp object exists but wasn't launched with real init data
|
||||
* (e.g. a bare browser tab pointed at the URL). */
|
||||
initData: string;
|
||||
/** "light" | "dark" — tracks the user's Telegram theme. */
|
||||
colorScheme?: "light" | "dark";
|
||||
themeParams?: TelegramThemeParams;
|
||||
/** Subscribe/unsubscribe to bridge events ("themeChanged", …). */
|
||||
onEvent?: (event: string, cb: () => void) => void;
|
||||
offEvent?: (event: string, cb: () => void) => void;
|
||||
/** Bot API 7.7+ — stops vertical swipes from minimizing the app so
|
||||
* scrolling a list never accidentally dismisses the cockpit. */
|
||||
disableVerticalSwipes?: () => void;
|
||||
HapticFeedback?: TelegramHapticFeedback;
|
||||
MainButton?: TelegramMainButton;
|
||||
BackButton?: TelegramBackButton;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -41,6 +96,54 @@ export function getInitData(): string {
|
||||
return getTelegramWebApp()?.initData ?? "";
|
||||
}
|
||||
|
||||
const DEV_MOCK_MARKER = "__robocoDevMock";
|
||||
|
||||
/**
|
||||
* Dev-only stand-in for the real WebApp object so the cockpit shell renders
|
||||
* in a plain desktop browser (`pnpm dev` + a normal panel session). Every
|
||||
* bridge method is a no-op; `initData` is empty and the page skips the
|
||||
* webapp-auth POST for a mock (see `isDevMockWebApp`), riding the regular
|
||||
* session cookie instead. The caller gates on NODE_ENV === "development",
|
||||
* so production builds eliminate the branch entirely.
|
||||
*/
|
||||
export function createDevMockWebApp(): TelegramWebApp {
|
||||
const noop = () => undefined;
|
||||
const mock: TelegramWebApp & Record<string, unknown> = {
|
||||
ready: noop,
|
||||
expand: noop,
|
||||
initData: "",
|
||||
colorScheme: window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light",
|
||||
themeParams: {},
|
||||
[DEV_MOCK_MARKER]: true,
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
/** True for objects minted by `createDevMockWebApp` — never for the real
|
||||
* bridge, whose surface Telegram controls. */
|
||||
export function isDevMockWebApp(webApp: TelegramWebApp): boolean {
|
||||
return DEV_MOCK_MARKER in webApp;
|
||||
}
|
||||
|
||||
/** Null-safe haptic feedback — silently no-ops outside Telegram (and in the
|
||||
* dev mock, which has no HapticFeedback object). */
|
||||
export const haptics = {
|
||||
/** Light tap for selections/navigation. */
|
||||
tap(): void {
|
||||
getTelegramWebApp()?.HapticFeedback?.impactOccurred("light");
|
||||
},
|
||||
/** Success notification pulse after a mutation lands. */
|
||||
success(): void {
|
||||
getTelegramWebApp()?.HapticFeedback?.notificationOccurred("success");
|
||||
},
|
||||
/** Error notification pulse after a mutation fails. */
|
||||
error(): void {
|
||||
getTelegramWebApp()?.HapticFeedback?.notificationOccurred("error");
|
||||
},
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.telegram import (
|
||||
TelegramCredentialsSetRequest,
|
||||
TelegramCredentialsStatus,
|
||||
TelegramTodayResponse,
|
||||
TelegramWebAppAuthRequest,
|
||||
)
|
||||
from roboco.config import settings
|
||||
@@ -34,6 +35,7 @@ from roboco.services.telegram_credentials import (
|
||||
TelegramCredentialsValidationError,
|
||||
get_telegram_credentials_service,
|
||||
)
|
||||
from roboco.services.tg_cockpit import get_tg_cockpit_service
|
||||
from roboco.utils.telegram_initdata import validate_init_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -84,6 +86,20 @@ async def set_telegram_credentials(
|
||||
return TelegramCredentialsStatus(has_credentials=has_creds)
|
||||
|
||||
|
||||
@router.get("/today", response_model=TelegramTodayResponse)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
async def get_today_brief(
|
||||
db: DbSession, agent: CurrentAgentContext
|
||||
) -> TelegramTodayResponse:
|
||||
"""The Mini App's one-round-trip "Today" brief — needs-you items, fleet
|
||||
snapshot, today's spend, ship state. DB-only by construction (see
|
||||
``TgCockpitService``)."""
|
||||
require_ceo_role(agent.role, action="view the Today brief")
|
||||
return TelegramTodayResponse.model_validate(
|
||||
await get_tg_cockpit_service(db).today()
|
||||
)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Mini App sign-in — public, pre-auth. Conditionally mounted; see
|
||||
# ``mount_telegram_miniapp_auth`` below.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Schemas for the Telegram notifications bridge's CEO surface."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -25,3 +27,62 @@ class TelegramWebAppAuthRequest(BaseModel):
|
||||
"""
|
||||
|
||||
init_data: str = Field(min_length=1, max_length=4096)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Mini App "Today" brief — the cockpit's one-round-trip home screen.
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
class TodayTaskItem(BaseModel):
|
||||
"""One task row on the brief — just enough to recognize and jump."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
status: str
|
||||
team: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TodayNeedsYou(BaseModel):
|
||||
"""Everything currently waiting on the CEO, capped for a phone screen."""
|
||||
|
||||
total: int
|
||||
awaiting_ceo_count: int
|
||||
awaiting_ceo: list[TodayTaskItem]
|
||||
blocked_count: int
|
||||
blocked: list[TodayTaskItem]
|
||||
held_drafts: dict[str, int]
|
||||
|
||||
|
||||
class TodayFleetAgent(BaseModel):
|
||||
name: str
|
||||
role: str
|
||||
team: str | None = None
|
||||
task_title: str | None = None
|
||||
|
||||
|
||||
class TodayFleet(BaseModel):
|
||||
total: int
|
||||
by_status: dict[str, int]
|
||||
working: list[TodayFleetAgent]
|
||||
|
||||
|
||||
class TodaySpend(BaseModel):
|
||||
tokens_today: int
|
||||
cost_today_usd: float
|
||||
|
||||
|
||||
class TodayShip(BaseModel):
|
||||
version: str
|
||||
open_release_proposal: bool
|
||||
ci_fix_tasks: int
|
||||
|
||||
|
||||
class TelegramTodayResponse(BaseModel):
|
||||
"""The whole brief in one response — the Mini App opens on this."""
|
||||
|
||||
needs_you: TodayNeedsYou
|
||||
fleet: TodayFleet
|
||||
spend: TodaySpend
|
||||
ship: TodayShip
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Telegram ↔ live-chat bridge (Mini App V4 P5).
|
||||
|
||||
``/secretary`` and ``/newtask`` bridge the CEO's Telegram chat into the same
|
||||
in-process live-chat runtimes the panel drives: the persistent Secretary
|
||||
container and the scoped Intake (Prompter) interview. There is no
|
||||
synchronous send→reply seam — replies land on the session's single-consumer
|
||||
asyncio queue (``PrompterLiveRegistry.stream``) — so each bridged session
|
||||
runs one long-lived consumer task that accumulates streamed text and pushes
|
||||
one Telegram message per completed turn (``turn_end``). A ``draft`` event
|
||||
becomes a confirm/discard keyboard; confirm routes through the normal
|
||||
board-review path and PARKS the session, so board feedback later streams
|
||||
straight back into the same Telegram thread.
|
||||
|
||||
State is per-process in-memory (the ``_PENDING_REPLIES`` posture in
|
||||
``telegram_inbound``): an orchestrator restart drops the map and the CEO
|
||||
starts a fresh session. At most one bridged session per chat; the intake
|
||||
and secretary containers are process-wide singletons shared with the panel,
|
||||
so starting a bridged session preempts a live panel session of the same
|
||||
kind by construction.
|
||||
|
||||
The consumer holds the relay stream open, which arms the registry's 60s
|
||||
keepalive — so the registry's own idle reap never fires for bridged
|
||||
sessions. ``sweep_idle`` is therefore the bridge's own TTL (same setting),
|
||||
skipping parked sessions exactly like the registry does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import structlog
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.services.telegram_client import build_telegram_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.services.telegram_client import TelegramClient
|
||||
from roboco.services.telegram_credentials import TelegramCredentialsData
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
_DRAFT_PREVIEW_CHARS = 400
|
||||
_REPLY_CHAR_LIMIT = 4000 # under Telegram's 4096 cap, leaves tag headroom
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeSession:
|
||||
kind: str # "secretary" | "intake"
|
||||
session_id: str
|
||||
client: TelegramClient
|
||||
consumer: asyncio.Task[None] | None = None
|
||||
project_id: str | None = None # intake confirm scope (uuid str)
|
||||
pending_draft: dict[str, Any] | None = None
|
||||
parked: bool = False
|
||||
last_user_turn: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
# chat_id → live bridged session; chat_id → /newtask text awaiting a
|
||||
# project pick. Per-process, best-effort by design.
|
||||
_SESSIONS: dict[str, BridgeSession] = {}
|
||||
_PENDING_NEWTASK: dict[str, str] = {}
|
||||
|
||||
|
||||
def active_session(chat_id: str) -> BridgeSession | None:
|
||||
return _SESSIONS.get(chat_id)
|
||||
|
||||
|
||||
def remember_newtask_text(chat_id: str, text: str) -> None:
|
||||
_PENDING_NEWTASK[chat_id] = text
|
||||
|
||||
|
||||
def pop_newtask_text(chat_id: str) -> str:
|
||||
return _PENDING_NEWTASK.pop(chat_id, "")
|
||||
|
||||
|
||||
def discard_draft(chat_id: str) -> None:
|
||||
sess = _SESSIONS.get(chat_id)
|
||||
if sess is not None:
|
||||
sess.pending_draft = None
|
||||
|
||||
|
||||
def mark_parked(chat_id: str, task_id: str) -> None:
|
||||
"""Confirm succeeded: park the relay session against the task (the
|
||||
panel's own board-route behavior) so the agent survives board review
|
||||
and the redraft brief streams back into this thread."""
|
||||
from roboco.services.prompter_live import get_live_registry
|
||||
|
||||
sess = _SESSIONS.get(chat_id)
|
||||
if sess is None:
|
||||
return
|
||||
get_live_registry().park(sess.session_id, task_id)
|
||||
sess.parked = True
|
||||
sess.pending_draft = None
|
||||
|
||||
|
||||
def _orchestrator() -> Any | None:
|
||||
# Deferred api.deps import — the established service-layer pattern for
|
||||
# reaching the process-wide orchestrator handle (see TaskService).
|
||||
from roboco.api.deps import get_orchestrator_or_none
|
||||
|
||||
return get_orchestrator_or_none()
|
||||
|
||||
|
||||
def _build_client(creds: TelegramCredentialsData) -> TelegramClient:
|
||||
return build_telegram_client(creds, timeout=settings.telegram_timeout_seconds)
|
||||
|
||||
|
||||
async def start_secretary(
|
||||
chat_id: str, initial_text: str, creds: TelegramCredentialsData
|
||||
) -> str:
|
||||
orch = _orchestrator()
|
||||
if orch is None:
|
||||
return "Orchestrator isn't ready — try again in a minute."
|
||||
session_id = uuid4().hex
|
||||
sess = BridgeSession(
|
||||
kind="secretary", session_id=session_id, client=_build_client(creds)
|
||||
)
|
||||
_SESSIONS[chat_id] = sess
|
||||
await orch.start_secretary_session(session_id, initial_message=initial_text or None)
|
||||
sess.consumer = asyncio.create_task(_consume(chat_id, sess))
|
||||
return "🎩 On it…" if initial_text else "🎩 Secretary here — what do you need?"
|
||||
|
||||
|
||||
async def start_intake(
|
||||
chat_id: str,
|
||||
initial_text: str,
|
||||
creds: TelegramCredentialsData,
|
||||
*,
|
||||
project: Any,
|
||||
) -> str:
|
||||
"""``project`` is any object with ``id``/``slug``/``name`` — in practice
|
||||
the ProjectTable row the engine already resolved."""
|
||||
orch = _orchestrator()
|
||||
if orch is None:
|
||||
return "Orchestrator isn't ready — try again in a minute."
|
||||
session_id = uuid4().hex
|
||||
sess = BridgeSession(
|
||||
kind="intake",
|
||||
session_id=session_id,
|
||||
client=_build_client(creds),
|
||||
project_id=str(project.id),
|
||||
)
|
||||
_SESSIONS[chat_id] = sess
|
||||
await orch.start_intake_session(
|
||||
session_id,
|
||||
project_slug=project.slug,
|
||||
initial_message=initial_text or None,
|
||||
)
|
||||
sess.consumer = asyncio.create_task(_consume(chat_id, sess))
|
||||
return (
|
||||
f"📝 Intake on <b>{html.escape(str(project.name))}</b> — interviewing "
|
||||
"now, replies land here. /end to stop."
|
||||
)
|
||||
|
||||
|
||||
async def deliver_text(chat_id: str, text: str) -> str | None:
|
||||
"""Route free text into the chat's bridged session. None = no session
|
||||
(caller ignores the text, the pre-bridge behavior); "" = delivered;
|
||||
anything else is a user-facing error to send back."""
|
||||
from roboco.services.prompter_live import get_live_registry
|
||||
|
||||
sess = _SESSIONS.get(chat_id)
|
||||
if sess is None:
|
||||
return None
|
||||
sess.last_user_turn = time.monotonic()
|
||||
delivered = await get_live_registry().deliver(sess.session_id, text)
|
||||
if delivered:
|
||||
return ""
|
||||
return "Couldn't reach the agent — it may still be starting. Try again shortly."
|
||||
|
||||
|
||||
async def end_session(chat_id: str) -> str:
|
||||
sess = _SESSIONS.pop(chat_id, None)
|
||||
if sess is None:
|
||||
return "No active session."
|
||||
_PENDING_NEWTASK.pop(chat_id, None)
|
||||
orch = _orchestrator()
|
||||
if orch is not None:
|
||||
if sess.kind == "secretary":
|
||||
await orch.reap_secretary_session(sess.session_id)
|
||||
else:
|
||||
await orch.reap_intake_session(sess.session_id)
|
||||
return "Ended."
|
||||
|
||||
|
||||
async def sweep_idle() -> None:
|
||||
"""The bridge's own idle TTL (the consumer's open stream arms the
|
||||
registry keepalive, so the registry never reaps these itself). Parked
|
||||
sessions are exempt — they're awaiting board review."""
|
||||
ttl = settings.interactive_idle_reap_seconds
|
||||
now = time.monotonic()
|
||||
for chat_id, sess in list(_SESSIONS.items()):
|
||||
if not sess.parked and now - sess.last_user_turn > ttl:
|
||||
logger.info(
|
||||
"telegram bridge session idle-reaped",
|
||||
chat_id=chat_id,
|
||||
kind=sess.kind,
|
||||
)
|
||||
await end_session(chat_id)
|
||||
|
||||
|
||||
def _clip(text: str) -> str:
|
||||
if len(text) <= _REPLY_CHAR_LIMIT:
|
||||
return text
|
||||
return text[:_REPLY_CHAR_LIMIT] + "…"
|
||||
|
||||
|
||||
def _render_draft(draft: dict[str, Any]) -> str:
|
||||
title = html.escape(str(draft.get("title") or "Untitled"))
|
||||
lines = [f"<b>📝 Draft — {title}</b>"]
|
||||
team = draft.get("team")
|
||||
if team:
|
||||
lines.append(f"Team: {html.escape(str(team))}")
|
||||
description = str(draft.get("description") or "")
|
||||
if description:
|
||||
lines.append(html.escape(description[:_DRAFT_PREVIEW_CHARS]))
|
||||
criteria = draft.get("acceptance_criteria") or []
|
||||
if criteria:
|
||||
lines.append(f"{len(criteria)} acceptance criteria")
|
||||
lines.append("")
|
||||
lines.append("Send it to Board review?")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _draft_keyboard(session_id: str) -> dict[str, Any]:
|
||||
# Mirrors telegram_inbound's action:kind:id8 callback codec.
|
||||
id8 = session_id[:8]
|
||||
return {
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{"text": "Send to Board", "callback_data": f"apv:intake:{id8}"},
|
||||
{"text": "Discard", "callback_data": f"rej:intake:{id8}"},
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _forward_event(
|
||||
sess: BridgeSession, ev: dict[str, Any], buf: list[str]
|
||||
) -> None:
|
||||
kind = ev.get("kind")
|
||||
if kind == "text":
|
||||
buf.append(str(ev.get("text") or ""))
|
||||
elif kind == "turn_end":
|
||||
text = "".join(buf).strip()
|
||||
buf.clear()
|
||||
if text:
|
||||
await sess.client.send_message(_clip(html.escape(text)), parse_mode="HTML")
|
||||
elif kind == "draft":
|
||||
sess.pending_draft = dict(ev.get("data") or {})
|
||||
await sess.client.send_message(
|
||||
_render_draft(sess.pending_draft),
|
||||
parse_mode="HTML",
|
||||
reply_markup=_draft_keyboard(sess.session_id),
|
||||
)
|
||||
elif kind == "batch":
|
||||
await sess.client.send_message(
|
||||
"This became a MegaTask batch — those can't be confirmed from "
|
||||
"the phone yet. Finish it in the panel's intake chat."
|
||||
)
|
||||
elif kind == "error":
|
||||
await sess.client.send_message(
|
||||
f"⚠️ {html.escape(str(ev.get('text') or 'agent error'))}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
async def _consume(chat_id: str, sess: BridgeSession) -> None:
|
||||
"""Sole consumer of the session's relay stream — accumulates streamed
|
||||
text into one Telegram message per turn and surfaces draft/batch/error
|
||||
events. Ends when the relay closes (reap, /end, idle sweep)."""
|
||||
from roboco.services.prompter_live import get_live_registry
|
||||
|
||||
buf: list[str] = []
|
||||
try:
|
||||
async for ev in get_live_registry().stream(sess.session_id):
|
||||
await _forward_event(sess, ev, buf)
|
||||
except Exception:
|
||||
logger.exception("telegram bridge consumer crashed", chat_id=chat_id)
|
||||
finally:
|
||||
if _SESSIONS.get(chat_id) is sess:
|
||||
_SESSIONS.pop(chat_id, None)
|
||||
try:
|
||||
await sess.client.send_message("Session ended.")
|
||||
finally:
|
||||
await sess.client.close()
|
||||
@@ -89,6 +89,13 @@ class TelegramClient(ABC):
|
||||
disable_link_preview: bool = False,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def set_my_commands(self, commands: list[dict[str, str]]) -> None:
|
||||
"""Register the bot's command menu (``setMyCommands``) so the
|
||||
Telegram client UI lists exactly what the code implements.
|
||||
Best-effort — a failure only leaves the old menu in place."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Release transport resources; no-op when no transport exists."""
|
||||
@@ -100,6 +107,9 @@ class NullTelegramClient(TelegramClient):
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def set_my_commands(self, commands: list[dict[str, str]]) -> None:
|
||||
_ = commands
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return False
|
||||
@@ -242,6 +252,12 @@ class LiveTelegramClient(TelegramClient):
|
||||
result = data.get("result") if isinstance(data, dict) else None
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def set_my_commands(self, commands: list[dict[str, str]]) -> None:
|
||||
url = f"{_API_BASE}/bot{self._creds.bot_token}/setMyCommands"
|
||||
client = await self._http()
|
||||
with contextlib.suppress(httpx.HTTPError):
|
||||
await client.post(url, json={"commands": commands}, timeout=self._timeout)
|
||||
|
||||
async def answer_callback_query(
|
||||
self, callback_query_id: str, text: str = ""
|
||||
) -> None:
|
||||
|
||||
@@ -39,7 +39,7 @@ import html
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
@@ -52,7 +52,9 @@ from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.content.validators import reject_trivial
|
||||
from roboco.models.base import AgentStatus, TaskStatus
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services import telegram_bridge as bridge
|
||||
from roboco.services.base import BaseService, ValidationError
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.release_proposal import TaskAlreadyCompletedError as _ReleaseDone
|
||||
from roboco.services.release_proposal import (
|
||||
dispatch_approve,
|
||||
@@ -63,8 +65,10 @@ from roboco.services.settings import get_settings_service
|
||||
from roboco.services.task import get_task_service
|
||||
from roboco.services.telegram_client import TelegramClient, build_telegram_client
|
||||
from roboco.services.telegram_credentials import get_telegram_credentials_service
|
||||
from roboco.services.tg_cockpit import get_tg_cockpit_service
|
||||
from roboco.services.tiktok_client import build_tiktok_poster
|
||||
from roboco.services.tiktok_credentials import get_tiktok_credentials_service
|
||||
from roboco.services.usage import get_usage_service
|
||||
from roboco.services.video_post_service import TaskAlreadyCompletedError as _VideoDone
|
||||
from roboco.services.video_post_service import (
|
||||
VideoCaptionTooLongError,
|
||||
@@ -87,12 +91,16 @@ logger = structlog.get_logger()
|
||||
# Reuses the system_settings KV store instead of a dedicated table (see
|
||||
# `roboco.services.settings._VALIDATORS` for the write-side int validator).
|
||||
_OFFSET_KEY = "telegram_last_update_id"
|
||||
|
||||
_CEO_UUID = UUID(AGENT_UUIDS["ceo"])
|
||||
|
||||
_QUEUE_ITEM_CAP = 10
|
||||
_MESSAGE_CHAR_LIMIT = 4096 # Telegram's own sendMessage text cap
|
||||
|
||||
_VALID_KINDS = ("task", "release", "xpost", "video", "roadmap")
|
||||
_VALID_KINDS = ("task", "release", "xpost", "video", "roadmap", "intake", "proj")
|
||||
# "sel" is the project-picker tap for /newtask; apv/rej stay the approve/
|
||||
# reject pair everywhere else.
|
||||
_VALID_ACTIONS = ("apv", "rej", "sel")
|
||||
# Per-kind reject-reason floor, mirroring each HTTP route's request schema
|
||||
# (ReleaseRejectRequest min_length=10, XPostRejectRequest/VideoPostRejectRequest/
|
||||
# RoadmapRejectRequest min_length=4; ceo_reject has no route-level floor beyond
|
||||
@@ -135,14 +143,29 @@ _KIND_DISPLAY: dict[str, tuple[str, str]] = {
|
||||
"task": ("📋", "Task"),
|
||||
}
|
||||
|
||||
_HELP_TEXT = (
|
||||
"<b>RoboCo commands</b>\n"
|
||||
"/status — fleet snapshot\n"
|
||||
"/queue — approvals with one-tap buttons\n"
|
||||
"/task — task detail (id prefix or title)\n"
|
||||
"/help — this list"
|
||||
# The bot's command menu — single source for /help AND the Bot API
|
||||
# ``setMyCommands`` registration (synced once per process on the first poll
|
||||
# cycle), so the Telegram menu can never drift from what's implemented.
|
||||
BOT_COMMANDS: tuple[dict[str, str], ...] = (
|
||||
{"command": "status", "description": "Task pipeline snapshot"},
|
||||
{"command": "queue", "description": "Approvals with one-tap buttons"},
|
||||
{"command": "agents", "description": "Who's working on what"},
|
||||
{"command": "blocked", "description": "Blocked + awaiting-you tasks"},
|
||||
{"command": "usage", "description": "Today's token spend"},
|
||||
{"command": "task", "description": "Task detail (id prefix or title)"},
|
||||
{"command": "secretary", "description": "Chat with the chief-of-staff"},
|
||||
{"command": "newtask", "description": "Draft a task with Intake"},
|
||||
{"command": "end", "description": "End the current chat session"},
|
||||
{"command": "help", "description": "This list"},
|
||||
)
|
||||
|
||||
_HELP_TEXT = "<b>RoboCo commands</b>\n" + "\n".join(
|
||||
f"/{c['command']} — {c['description']}" for c in BOT_COMMANDS
|
||||
)
|
||||
|
||||
# Line caps for the /blocked reply — a phone screen, not a report.
|
||||
_BLOCKED_CAP = 8
|
||||
|
||||
|
||||
def _esc(value: object) -> str:
|
||||
"""HTML-escape any dynamic value before it lands in a Telegram HTML
|
||||
@@ -266,7 +289,7 @@ def parse_callback(data: str) -> ParsedCallback | None:
|
||||
if len(parts) not in (_CALLBACK_PARTS_NO_EXTRA, _CALLBACK_PARTS_WITH_EXTRA):
|
||||
return None
|
||||
action, kind, id8 = parts[0], parts[1], parts[2]
|
||||
if action not in ("apv", "rej") or kind not in _VALID_KINDS or not id8:
|
||||
if action not in _VALID_ACTIONS or kind not in _VALID_KINDS or not id8:
|
||||
return None
|
||||
extra = parts[3] if len(parts) == _CALLBACK_PARTS_WITH_EXTRA else ""
|
||||
return ParsedCallback(action=action, kind=kind, id8=id8, extra=extra)
|
||||
@@ -358,6 +381,18 @@ class _PendingAction:
|
||||
_PENDING_REPLIES: dict[tuple[str, int], _PendingAction] = {}
|
||||
|
||||
|
||||
def _callback_disposition(parsed: ParsedCallback) -> str:
|
||||
"""How a tapped button routes: "bridge" (intake confirm/discard + the
|
||||
/newtask project pick — no reason prompt), "prompt" (every reject, and a
|
||||
task approve, need free text a button can't carry → force_reply), or
|
||||
"approve" (act immediately)."""
|
||||
if parsed.kind in ("intake", "proj"):
|
||||
return "bridge"
|
||||
if parsed.action == "rej" or (parsed.action == "apv" and parsed.kind == "task"):
|
||||
return "prompt"
|
||||
return "approve"
|
||||
|
||||
|
||||
def _sweep_expired_replies() -> None:
|
||||
now = time.monotonic()
|
||||
for key in [k for k, v in _PENDING_REPLIES.items() if v.expires_at <= now]:
|
||||
@@ -369,12 +404,23 @@ class TelegramInboundEngine(BaseService):
|
||||
|
||||
service_name = "telegram_inbound"
|
||||
|
||||
# Once-per-process latch for the setMyCommands menu sync — class-level
|
||||
# because the engine is re-instantiated every poll cycle.
|
||||
_commands_synced: ClassVar[bool] = False
|
||||
|
||||
def __init__(
|
||||
self, session: AsyncSession, client: TelegramClient | None = None
|
||||
) -> None:
|
||||
super().__init__(session)
|
||||
self._injected_client = client
|
||||
|
||||
async def _ensure_commands_menu(self, client: TelegramClient) -> None:
|
||||
"""Sync the bot's command menu from BOT_COMMANDS, once per process."""
|
||||
if TelegramInboundEngine._commands_synced:
|
||||
return
|
||||
await client.set_my_commands(list(BOT_COMMANDS))
|
||||
TelegramInboundEngine._commands_synced = True
|
||||
|
||||
async def _client(self, creds: TelegramCredentialsData) -> TelegramClient:
|
||||
if self._injected_client is not None:
|
||||
return self._injected_client
|
||||
@@ -395,6 +441,7 @@ class TelegramInboundEngine(BaseService):
|
||||
client = await self._client(creds)
|
||||
if not client.configured:
|
||||
return
|
||||
await self._ensure_commands_menu(client)
|
||||
offset = await get_settings_service(self.session).get_int(_OFFSET_KEY, 0)
|
||||
cap = settings.telegram_max_updates_per_cycle
|
||||
updates = await client.get_updates(
|
||||
@@ -415,6 +462,7 @@ class TelegramInboundEngine(BaseService):
|
||||
if max_seen >= offset:
|
||||
await get_settings_service(self.session).set(_OFFSET_KEY, str(max_seen + 1))
|
||||
await self.session.commit()
|
||||
await bridge.sweep_idle()
|
||||
|
||||
async def _process_update(
|
||||
self,
|
||||
@@ -463,12 +511,31 @@ class TelegramInboundEngine(BaseService):
|
||||
return
|
||||
cmd, args = parse_command(text)
|
||||
if not cmd:
|
||||
await self._route_free_text(chat_id, text, client)
|
||||
return
|
||||
await self._dispatch_command(cmd, args, client)
|
||||
await self._dispatch_command(cmd, args, client, chat_id=chat_id, creds=creds)
|
||||
|
||||
async def _route_free_text(
|
||||
self, chat_id: str, text: str, client: TelegramClient
|
||||
) -> None:
|
||||
"""While a bridged /secretary or /newtask session is live, plain
|
||||
messages ARE the conversation; otherwise free text stays ignored."""
|
||||
routed = await bridge.deliver_text(chat_id, text)
|
||||
if routed:
|
||||
await client.send_message(_esc(routed), parse_mode="HTML")
|
||||
|
||||
async def _dispatch_command(
|
||||
self, cmd: str, args: str, client: TelegramClient
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
client: TelegramClient,
|
||||
chat_id: str = "",
|
||||
creds: TelegramCredentialsData | None = None,
|
||||
) -> None:
|
||||
if creds is not None and await self._dispatch_bridge_command(
|
||||
cmd, args, client, chat_id, creds
|
||||
):
|
||||
return
|
||||
if cmd in ("start", "help"):
|
||||
await client.send_message(_HELP_TEXT, parse_mode="HTML")
|
||||
elif cmd == "status":
|
||||
@@ -481,6 +548,16 @@ class TelegramInboundEngine(BaseService):
|
||||
parse_mode="HTML",
|
||||
disable_link_preview=True,
|
||||
)
|
||||
elif cmd == "agents":
|
||||
await client.send_message(await self._render_agents(), parse_mode="HTML")
|
||||
elif cmd == "usage":
|
||||
await client.send_message(await self._render_usage(), parse_mode="HTML")
|
||||
elif cmd == "blocked":
|
||||
await client.send_message(
|
||||
await self._render_blocked(),
|
||||
parse_mode="HTML",
|
||||
disable_link_preview=True,
|
||||
)
|
||||
else:
|
||||
await client.send_message(
|
||||
f"Unknown command /{_esc(cmd)}.\n\n{_HELP_TEXT}", parse_mode="HTML"
|
||||
@@ -512,6 +589,199 @@ class TelegramInboundEngine(BaseService):
|
||||
]
|
||||
return _truncate("\n".join(lines))
|
||||
|
||||
async def _dispatch_bridge_command(
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
client: TelegramClient,
|
||||
chat_id: str,
|
||||
creds: TelegramCredentialsData,
|
||||
) -> bool:
|
||||
"""The /secretary, /newtask, and /end verbs — True when handled."""
|
||||
if cmd == "secretary":
|
||||
await client.send_message(
|
||||
await self._cmd_secretary(chat_id, args.strip(), creds),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
if cmd == "newtask":
|
||||
await self._cmd_newtask(chat_id, args.strip(), creds, client)
|
||||
return True
|
||||
if cmd == "end":
|
||||
await client.send_message(
|
||||
_esc(await bridge.end_session(chat_id)), parse_mode="HTML"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _cmd_secretary(
|
||||
self, chat_id: str, text: str, creds: TelegramCredentialsData
|
||||
) -> str:
|
||||
sess = bridge.active_session(chat_id)
|
||||
if sess is not None and sess.kind == "secretary":
|
||||
if not text:
|
||||
return "Already chatting — just type."
|
||||
return await bridge.deliver_text(chat_id, text) or "Delivered."
|
||||
if sess is not None:
|
||||
return "You're mid /newtask interview — /end it first."
|
||||
return await bridge.start_secretary(chat_id, text, creds)
|
||||
|
||||
async def _cmd_newtask(
|
||||
self,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
creds: TelegramCredentialsData,
|
||||
client: TelegramClient,
|
||||
) -> None:
|
||||
if bridge.active_session(chat_id) is not None:
|
||||
await client.send_message(
|
||||
"A chat session is already active — /end it first.",
|
||||
)
|
||||
return
|
||||
projects = await get_project_service(self.session).list_all()
|
||||
if not projects:
|
||||
await client.send_message("No projects registered yet.")
|
||||
return
|
||||
if len(projects) == 1:
|
||||
message = await bridge.start_intake(
|
||||
chat_id, text, creds, project=projects[0]
|
||||
)
|
||||
await client.send_message(message, parse_mode="HTML")
|
||||
return
|
||||
bridge.remember_newtask_text(chat_id, text)
|
||||
keyboard = {
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{
|
||||
"text": p.name,
|
||||
"callback_data": build_callback("sel", "proj", str(p.id)[:8]),
|
||||
}
|
||||
]
|
||||
for p in projects[:_QUEUE_ITEM_CAP]
|
||||
]
|
||||
}
|
||||
await client.send_message("Which project?", reply_markup=keyboard)
|
||||
|
||||
async def _handle_bridge_callback(
|
||||
self,
|
||||
parsed: ParsedCallback,
|
||||
chat_id: str,
|
||||
origin_message_id: int | None,
|
||||
creds: TelegramCredentialsData,
|
||||
client: TelegramClient,
|
||||
) -> None:
|
||||
"""The intake draft confirm/discard + /newtask project-pick taps.
|
||||
The callback_query is already answered by the caller."""
|
||||
if parsed.kind == "proj":
|
||||
await self._start_picked_intake(parsed.id8, chat_id, creds, client)
|
||||
return
|
||||
if parsed.action == "apv":
|
||||
ok, text = await self._confirm_intake_draft(chat_id)
|
||||
await self._finish_action(client, origin_message_id, ok, text)
|
||||
return
|
||||
bridge.discard_draft(chat_id)
|
||||
await self._finish_action(
|
||||
client,
|
||||
origin_message_id,
|
||||
True,
|
||||
"Draft discarded — keep chatting to revise it, or /end.",
|
||||
)
|
||||
|
||||
async def _start_picked_intake(
|
||||
self,
|
||||
id8: str,
|
||||
chat_id: str,
|
||||
creds: TelegramCredentialsData,
|
||||
client: TelegramClient,
|
||||
) -> None:
|
||||
projects = await get_project_service(self.session).list_all()
|
||||
picked = next((p for p in projects if str(p.id).startswith(id8)), None)
|
||||
if picked is None:
|
||||
await client.send_message("That project no longer exists.")
|
||||
return
|
||||
message = await bridge.start_intake(
|
||||
chat_id, bridge.pop_newtask_text(chat_id), creds, project=picked
|
||||
)
|
||||
await client.send_message(message, parse_mode="HTML")
|
||||
|
||||
async def _confirm_intake_draft(self, chat_id: str) -> tuple[bool, str]:
|
||||
"""Route the pending draft through the normal board-review confirm
|
||||
(the panel's own service seam), then park the session so board
|
||||
feedback streams back into this thread."""
|
||||
from roboco.services.prompter import get_prompter_service
|
||||
|
||||
sess = bridge.active_session(chat_id)
|
||||
if sess is None or sess.pending_draft is None:
|
||||
return False, "No draft is pending."
|
||||
try:
|
||||
task_id = await get_prompter_service(self.session).confirm_live_draft(
|
||||
sess.pending_draft,
|
||||
_CEO_UUID,
|
||||
project_id=UUID(sess.project_id) if sess.project_id else None,
|
||||
route="board",
|
||||
)
|
||||
await self.session.commit()
|
||||
except Exception as exc:
|
||||
self.log.exception("telegram intake confirm failed", chat_id=chat_id)
|
||||
return False, f"Confirm failed: {exc}"
|
||||
bridge.mark_parked(chat_id, str(task_id))
|
||||
return True, (
|
||||
"Sent to Board review 📋 — the board's feedback will land right "
|
||||
"here in this chat."
|
||||
)
|
||||
|
||||
async def _render_agents(self) -> str:
|
||||
"""Who's mid-task right now — the Today brief's fleet block as text."""
|
||||
fleet = await get_tg_cockpit_service(self.session).fleet()
|
||||
by_status = ", ".join(
|
||||
f"{count} {status}"
|
||||
for status, count in sorted(fleet.get("by_status", {}).items())
|
||||
)
|
||||
header = f"<b>🤖 Agents</b> — {fleet.get('total', 0)} total"
|
||||
if by_status:
|
||||
header += f" ({_esc(by_status)})"
|
||||
lines = [header]
|
||||
working = fleet.get("working", [])
|
||||
if not working:
|
||||
lines.append("No one is mid-task.")
|
||||
for agent in working:
|
||||
task_title = agent.get("task_title")
|
||||
suffix = f" — {_esc(task_title)}" if task_title else ""
|
||||
lines.append(f"• <b>{_esc(agent.get('name', ''))}</b>{suffix}")
|
||||
return _truncate("\n".join(lines))
|
||||
|
||||
async def _render_usage(self) -> str:
|
||||
"""Today's spend from the day rollup — the Today brief's number."""
|
||||
summary = await get_usage_service(self.session).get_today_summary()
|
||||
tokens = int(summary.get("tokens_today", 0))
|
||||
cost = float(summary.get("cost_today_usd", 0.0))
|
||||
return f"<b>💸 Spend today</b>\n${cost:,.2f} · {tokens:,} tokens"
|
||||
|
||||
def _task_link_line(self, task: TaskTable) -> str:
|
||||
id8 = str(task.id)[:8]
|
||||
link = _deep_link("task", id8)
|
||||
title = _esc(task.title)
|
||||
label = f'<a href="{_esc_attr(link)}">{title}</a>' if link else title
|
||||
return f"• {label} <code>{id8}</code>"
|
||||
|
||||
async def _render_blocked(self) -> str:
|
||||
"""Blocked + awaiting-CEO tasks, deep-linked, capped per section."""
|
||||
tasks = get_task_service(self.session)
|
||||
awaiting = await tasks.list_awaiting_ceo_approval()
|
||||
blocked = await tasks.list_blocked()
|
||||
if not awaiting and not blocked:
|
||||
return "Nothing is blocked or awaiting you. 🎉"
|
||||
lines: list[str] = []
|
||||
if awaiting:
|
||||
lines.append("<b>🖊️ Awaiting you</b>")
|
||||
lines += [self._task_link_line(t) for t in awaiting[:_BLOCKED_CAP]]
|
||||
if blocked:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("<b>⛔ Blocked</b>")
|
||||
lines += [self._task_link_line(t) for t in blocked[:_BLOCKED_CAP]]
|
||||
return _truncate("\n".join(lines))
|
||||
|
||||
async def _collect_queue_items(self) -> list[tuple[str, str, str, str]]:
|
||||
"""``(kind, id8, extra, title)`` for everything awaiting the CEO —
|
||||
reuses the exact list/open methods the panel's own queues call. One
|
||||
@@ -688,14 +958,14 @@ class TelegramInboundEngine(BaseService):
|
||||
if parsed is None:
|
||||
await client.answer_callback_query(cq_id, "Unrecognized action")
|
||||
return
|
||||
# Every reject, and a task approve (mirrors ceo-approve's own >=20
|
||||
# char note requirement), need free text the button alone can't
|
||||
# carry — force_reply-prompt and park a pending action instead of
|
||||
# acting immediately.
|
||||
needs_reply = parsed.action == "rej" or (
|
||||
parsed.action == "apv" and parsed.kind == "task"
|
||||
)
|
||||
if needs_reply:
|
||||
disposition = _callback_disposition(parsed)
|
||||
if disposition == "bridge":
|
||||
await client.answer_callback_query(cq_id, "Working...")
|
||||
await self._handle_bridge_callback(
|
||||
parsed, chat_id, origin_message_id, creds, client
|
||||
)
|
||||
return
|
||||
if disposition == "prompt":
|
||||
await client.answer_callback_query(cq_id, "Reply with the reason")
|
||||
await self._prompt_for_reply(parsed, chat_id, origin_message_id, client)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""TgCockpitService — the Mini App's one-round-trip "Today" brief.
|
||||
|
||||
Composes existing read paths (TaskService listers, DashboardService's agent
|
||||
snapshot, UsageService's day rollup) into a single phone-sized payload
|
||||
answering "does anything need me?". Deliberately DB-only and cheap: no live
|
||||
GitHub calls, no release-readiness snapshot (that path clones + shells out to
|
||||
git), no orchestrator singleton — the ship-state red/green proxy is the set
|
||||
of open ci_watch fix tasks, which exists precisely when a watched repo went
|
||||
red.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.dashboard import get_dashboard_service
|
||||
from roboco.services.task import get_task_service
|
||||
from roboco.services.usage import get_usage_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
# Phone-screen caps: the brief shows the top few and a count, never a feed.
|
||||
_TASK_ITEM_CAP = 5
|
||||
_WORKING_AGENT_CAP = 8
|
||||
|
||||
|
||||
def _task_item(task: TaskTable) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"title": task.title,
|
||||
"status": task.status.value if task.status else "",
|
||||
"team": task.team.value if task.team else None,
|
||||
"updated_at": task.updated_at or task.created_at,
|
||||
}
|
||||
|
||||
|
||||
class TgCockpitService(BaseService):
|
||||
"""Read-only aggregate behind ``GET /api/telegram/today``."""
|
||||
|
||||
service_name = "tg_cockpit"
|
||||
|
||||
async def today(self) -> dict[str, Any]:
|
||||
tasks = get_task_service(self.session)
|
||||
needs_you = await self._needs_you(tasks)
|
||||
return {
|
||||
"needs_you": needs_you,
|
||||
"fleet": await self.fleet(),
|
||||
"spend": await self._spend(),
|
||||
"ship": {
|
||||
"version": settings.app_version,
|
||||
"open_release_proposal": needs_you["held_drafts"]["release_proposals"]
|
||||
> 0,
|
||||
"ci_fix_tasks": len(await tasks.list_open_ci_watch_tasks()),
|
||||
},
|
||||
}
|
||||
|
||||
async def _needs_you(self, tasks: TaskService) -> dict[str, Any]:
|
||||
awaiting = await tasks.list_awaiting_ceo_approval()
|
||||
blocked = await tasks.list_blocked()
|
||||
held = {
|
||||
"release_proposals": len(await tasks.list_open_release_proposals()),
|
||||
"x_posts": len(await tasks.list_open_x_posts()),
|
||||
"video_posts": len(await tasks.list_open_video_post_drafts()),
|
||||
"roadmap_items": await self._pending_roadmap_items(tasks),
|
||||
}
|
||||
return {
|
||||
"total": len(awaiting) + len(blocked) + sum(held.values()),
|
||||
"awaiting_ceo_count": len(awaiting),
|
||||
"awaiting_ceo": [_task_item(t) for t in awaiting[:_TASK_ITEM_CAP]],
|
||||
"blocked_count": len(blocked),
|
||||
"blocked": [_task_item(t) for t in blocked[:_TASK_ITEM_CAP]],
|
||||
"held_drafts": held,
|
||||
}
|
||||
|
||||
async def _pending_roadmap_items(self, tasks: TaskService) -> int:
|
||||
"""Proposed (still-undecided) items across every open roadmap cycle."""
|
||||
pending = 0
|
||||
for cycle in await tasks.list_open_roadmap_cycles():
|
||||
payload = markers.get_roadmap_cycle(cycle) or {}
|
||||
pending += sum(
|
||||
1
|
||||
for item in payload.get("items") or []
|
||||
if item.get("status") == "proposed"
|
||||
)
|
||||
return pending
|
||||
|
||||
async def fleet(self) -> dict[str, Any]:
|
||||
"""Live-agent snapshot with per-agent task titles — shared by the
|
||||
Today brief and the bot's ``/agents`` command."""
|
||||
snapshot = await get_dashboard_service(self.session).get_all_agent_status()
|
||||
agents: list[dict[str, Any]] = snapshot.get("agents", [])
|
||||
working = [a for a in agents if a.get("current_task_id")][:_WORKING_AGENT_CAP]
|
||||
titles = await self._task_titles([a["current_task_id"] for a in working])
|
||||
return {
|
||||
"total": snapshot.get("total", len(agents)),
|
||||
"by_status": snapshot.get("by_status", {}),
|
||||
"working": [
|
||||
{
|
||||
"name": a.get("name", ""),
|
||||
"role": a.get("role", ""),
|
||||
"team": a.get("team"),
|
||||
"task_title": titles.get(str(a["current_task_id"])),
|
||||
}
|
||||
for a in working
|
||||
],
|
||||
}
|
||||
|
||||
async def _task_titles(self, task_ids: list[Any]) -> dict[str, str]:
|
||||
if not task_ids:
|
||||
return {}
|
||||
ids: list[UUID] = [tid for tid in task_ids if tid is not None]
|
||||
result = await self.session.execute(
|
||||
select(TaskTable.id, TaskTable.title).where(TaskTable.id.in_(ids))
|
||||
)
|
||||
return {str(row.id): row.title for row in result}
|
||||
|
||||
async def _spend(self) -> dict[str, Any]:
|
||||
# Mirrors the CEO dashboard overview: a usage hiccup degrades the
|
||||
# brief to zeros instead of failing the whole endpoint.
|
||||
try:
|
||||
summary = await get_usage_service(self.session).get_today_summary()
|
||||
return {
|
||||
"tokens_today": int(summary.get("tokens_today", 0)),
|
||||
"cost_today_usd": float(summary.get("cost_today_usd", 0.0)),
|
||||
}
|
||||
except Exception: # pragma: no cover - defensive degradation
|
||||
self.log.warning("today-brief usage summary failed", exc_info=True)
|
||||
return {"tokens_today": 0, "cost_today_usd": 0.0}
|
||||
|
||||
|
||||
def get_tg_cockpit_service(session: AsyncSession) -> TgCockpitService:
|
||||
"""Construct a TgCockpitService bound to ``session``."""
|
||||
return TgCockpitService(session)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Telegram ↔ live-chat bridge (P5): session lifecycle, free-text routing,
|
||||
the stream consumer's turn/draft forwarding, idle sweep, and the engine's
|
||||
/secretary /newtask /end + intake-callback wiring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services import telegram_bridge as bridge
|
||||
from roboco.services import telegram_inbound as ti
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state() -> Any:
|
||||
bridge._SESSIONS.clear()
|
||||
bridge._PENDING_NEWTASK.clear()
|
||||
yield
|
||||
bridge._SESSIONS.clear()
|
||||
bridge._PENDING_NEWTASK.clear()
|
||||
|
||||
|
||||
def _fake_session_db() -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def _engine() -> ti.TelegramInboundEngine:
|
||||
return ti.TelegramInboundEngine(_fake_session_db())
|
||||
|
||||
|
||||
CREDS = SimpleNamespace(bot_token="123:ABC", chat_id="777")
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self, events: list[dict[str, Any]] | None = None) -> None:
|
||||
self.events = events or []
|
||||
self.delivered: list[tuple[str, str]] = []
|
||||
self.parked: list[tuple[str, str]] = []
|
||||
|
||||
async def deliver(self, session_id: str, text: str) -> bool:
|
||||
self.delivered.append((session_id, text))
|
||||
return True
|
||||
|
||||
def park(self, session_id: str, task_id: str) -> bool:
|
||||
self.parked.append((session_id, task_id))
|
||||
return True
|
||||
|
||||
async def stream(self, _session_id: str):
|
||||
for event in self.events:
|
||||
yield event
|
||||
|
||||
|
||||
def _bridge_session(
|
||||
kind: str = "secretary", *, parked: bool = False
|
||||
) -> bridge.BridgeSession:
|
||||
sess = bridge.BridgeSession(kind=kind, session_id=uuid4().hex, client=AsyncMock())
|
||||
sess.parked = parked
|
||||
return sess
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge module — lifecycle + routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_text_routes_only_bridged_chats(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
registry = FakeRegistry()
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.prompter_live.get_live_registry", lambda: registry
|
||||
)
|
||||
|
||||
assert await bridge.deliver_text("777", "hello") is None
|
||||
|
||||
sess = _bridge_session()
|
||||
bridge._SESSIONS["777"] = sess
|
||||
assert await bridge.deliver_text("777", "hello") == ""
|
||||
assert registry.delivered == [(sess.session_id, "hello")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_session_reaps_by_kind(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
orch = SimpleNamespace(
|
||||
reap_secretary_session=AsyncMock(), reap_intake_session=AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr(bridge, "_orchestrator", lambda: orch)
|
||||
|
||||
sess = _bridge_session("intake")
|
||||
bridge._SESSIONS["777"] = sess
|
||||
assert await bridge.end_session("777") == "Ended."
|
||||
orch.reap_intake_session.assert_awaited_once_with(sess.session_id)
|
||||
assert "777" not in bridge._SESSIONS
|
||||
|
||||
assert await bridge.end_session("777") == "No active session."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_idle_skips_parked_sessions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
orch = SimpleNamespace(
|
||||
reap_secretary_session=AsyncMock(), reap_intake_session=AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr(bridge, "_orchestrator", lambda: orch)
|
||||
monkeypatch.setattr(bridge.settings, "interactive_idle_reap_seconds", 100)
|
||||
|
||||
idle = _bridge_session("secretary")
|
||||
idle.last_user_turn -= 1000
|
||||
parked = _bridge_session("intake", parked=True)
|
||||
parked.last_user_turn -= 1000
|
||||
bridge._SESSIONS["idle-chat"] = idle
|
||||
bridge._SESSIONS["parked-chat"] = parked
|
||||
|
||||
await bridge.sweep_idle()
|
||||
|
||||
assert "idle-chat" not in bridge._SESSIONS
|
||||
assert "parked-chat" in bridge._SESSIONS
|
||||
orch.reap_secretary_session.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consumer_accumulates_turns_and_surfaces_drafts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
draft = {"title": "Fix <thing>", "team": "backend", "description": "d"}
|
||||
registry = FakeRegistry(
|
||||
[
|
||||
{"kind": "text", "text": "Hello "},
|
||||
{"kind": "text", "text": "CEO."},
|
||||
{"kind": "turn_end", "text": ""},
|
||||
{"kind": "draft", "text": "", "data": draft},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.prompter_live.get_live_registry", lambda: registry
|
||||
)
|
||||
|
||||
sess = _bridge_session("intake")
|
||||
bridge._SESSIONS["777"] = sess
|
||||
await bridge._consume("777", sess)
|
||||
|
||||
calls = sess.client.send_message.await_args_list
|
||||
# Turn text, then the draft card, then the end-of-session note.
|
||||
assert calls[0].args[0] == "Hello CEO."
|
||||
assert "Fix <thing>" in calls[1].args[0]
|
||||
keyboard = calls[1].kwargs["reply_markup"]["inline_keyboard"][0]
|
||||
assert keyboard[0]["callback_data"].startswith("apv:intake:")
|
||||
assert keyboard[1]["callback_data"].startswith("rej:intake:")
|
||||
assert sess.pending_draft == draft
|
||||
assert calls[-1].args[0] == "Session ended."
|
||||
# Stream ended → session evicted, client closed.
|
||||
assert "777" not in bridge._SESSIONS
|
||||
sess.client.close.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_secretary_opens_session_and_consumer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
orch = SimpleNamespace(start_secretary_session=AsyncMock())
|
||||
monkeypatch.setattr(bridge, "_orchestrator", lambda: orch)
|
||||
monkeypatch.setattr(bridge, "_build_client", lambda _c: AsyncMock())
|
||||
consume = AsyncMock()
|
||||
monkeypatch.setattr(bridge, "_consume", consume)
|
||||
|
||||
message = await bridge.start_secretary("777", "plan my day", CREDS)
|
||||
|
||||
assert "On it" in message
|
||||
sess = bridge._SESSIONS["777"]
|
||||
assert sess.kind == "secretary"
|
||||
orch.start_secretary_session.assert_awaited_once_with(
|
||||
sess.session_id, initial_message="plan my day"
|
||||
)
|
||||
await sess.consumer # the mocked _consume task completes cleanly
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# engine wiring — commands + callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secretary_command_starts_or_continues(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
start = AsyncMock(return_value="🎩 On it…")
|
||||
monkeypatch.setattr(bridge, "start_secretary", start)
|
||||
client = AsyncMock()
|
||||
|
||||
await _engine()._dispatch_command(
|
||||
"secretary", "plan", client, chat_id="777", creds=CREDS
|
||||
)
|
||||
start.assert_awaited_once_with("777", "plan", CREDS)
|
||||
|
||||
bridge._SESSIONS["777"] = _bridge_session("intake")
|
||||
await _engine()._dispatch_command(
|
||||
"secretary", "plan", client, chat_id="777", creds=CREDS
|
||||
)
|
||||
assert "mid /newtask" in client.send_message.await_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newtask_with_multiple_projects_asks_which(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
projects = [
|
||||
SimpleNamespace(id=uuid4(), slug="roboco", name="RoboCo"),
|
||||
SimpleNamespace(id=uuid4(), slug="website", name="Website"),
|
||||
]
|
||||
svc = MagicMock(list_all=AsyncMock(return_value=projects))
|
||||
monkeypatch.setattr(ti, "get_project_service", lambda _s: svc)
|
||||
client = AsyncMock()
|
||||
|
||||
await _engine()._dispatch_command(
|
||||
"newtask", "ship a thing", client, chat_id="777", creds=CREDS
|
||||
)
|
||||
|
||||
assert bridge._PENDING_NEWTASK["777"] == "ship a thing"
|
||||
keyboard = client.send_message.await_args.kwargs["reply_markup"]
|
||||
labels = [row[0]["text"] for row in keyboard["inline_keyboard"]]
|
||||
assert labels == ["RoboCo", "Website"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_pick_callback_starts_intake_with_stored_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project = SimpleNamespace(id=uuid4(), slug="roboco", name="RoboCo")
|
||||
svc = MagicMock(list_all=AsyncMock(return_value=[project]))
|
||||
monkeypatch.setattr(ti, "get_project_service", lambda _s: svc)
|
||||
start = AsyncMock(return_value="📝 Intake on RoboCo")
|
||||
monkeypatch.setattr(bridge, "start_intake", start)
|
||||
bridge._PENDING_NEWTASK["777"] = "ship a thing"
|
||||
client = AsyncMock()
|
||||
|
||||
parsed = ti.parse_callback(f"sel:proj:{str(project.id)[:8]}")
|
||||
assert parsed is not None
|
||||
await _engine()._handle_bridge_callback(parsed, "777", 5, CREDS, client)
|
||||
|
||||
start.assert_awaited_once_with("777", "ship a thing", CREDS, project=project)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intake_confirm_routes_board_and_parks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
task_id = uuid4()
|
||||
prompter = MagicMock(confirm_live_draft=AsyncMock(return_value=task_id))
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.prompter.get_prompter_service", lambda _s: prompter
|
||||
)
|
||||
registry = FakeRegistry()
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.prompter_live.get_live_registry", lambda: registry
|
||||
)
|
||||
|
||||
project_id = uuid4()
|
||||
sess = _bridge_session("intake")
|
||||
sess.project_id = str(project_id)
|
||||
sess.pending_draft = {"title": "T"}
|
||||
bridge._SESSIONS["777"] = sess
|
||||
|
||||
ok, text = await _engine()._confirm_intake_draft("777")
|
||||
|
||||
assert ok is True
|
||||
assert "Board review" in text
|
||||
prompter.confirm_live_draft.assert_awaited_once_with(
|
||||
{"title": "T"}, ti._CEO_UUID, project_id=project_id, route="board"
|
||||
)
|
||||
assert registry.parked == [(sess.session_id, str(task_id))]
|
||||
assert sess.parked is True
|
||||
assert sess.pending_draft is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intake_discard_keeps_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sess = _bridge_session("intake")
|
||||
sess.pending_draft = {"title": "T"}
|
||||
bridge._SESSIONS["777"] = sess
|
||||
engine = _engine()
|
||||
finish = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_finish_action", finish)
|
||||
client = AsyncMock()
|
||||
|
||||
parsed = ti.parse_callback(f"rej:intake:{sess.session_id[:8]}")
|
||||
assert parsed is not None
|
||||
await engine._handle_bridge_callback(parsed, "777", 5, CREDS, client)
|
||||
|
||||
assert sess.pending_draft is None
|
||||
assert "777" in bridge._SESSIONS
|
||||
assert finish.await_args.args[2] is True
|
||||
@@ -0,0 +1,175 @@
|
||||
"""V4 bot-command tier: the BOT_COMMANDS registry drives /help and the
|
||||
once-per-process setMyCommands sync; /agents, /usage, and /blocked render
|
||||
from the same services the panel reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services import telegram_inbound as ti
|
||||
|
||||
COMMAND_COUNT = len(ti.BOT_COMMANDS)
|
||||
|
||||
|
||||
def _fake_session() -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def _engine() -> ti.TelegramInboundEngine:
|
||||
return ti.TelegramInboundEngine(_fake_session())
|
||||
|
||||
|
||||
def _uuid_with_prefix(prefix: str) -> UUID:
|
||||
return UUID(hex=prefix + uuid4().hex[len(prefix) :])
|
||||
|
||||
|
||||
def _task(id8: str, title: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=_uuid_with_prefix(id8), title=title)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registry ↔ help ↔ dispatch coherence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_help_text_derives_from_the_registry() -> None:
|
||||
for entry in ti.BOT_COMMANDS:
|
||||
assert f"/{entry['command']} — {entry['description']}" in ti._HELP_TEXT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cmd", ["agents", "usage", "blocked"])
|
||||
async def test_registry_commands_dispatch_to_a_renderer(
|
||||
cmd: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
renderer = AsyncMock(return_value="rendered")
|
||||
monkeypatch.setattr(engine, f"_render_{cmd}", renderer)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._dispatch_command(cmd, "", client)
|
||||
|
||||
renderer.assert_awaited_once()
|
||||
assert client.send_message.await_args.args[0] == "rendered"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# setMyCommands sync — once per process
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_syncs_commands_exactly_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(ti.TelegramInboundEngine, "_commands_synced", False)
|
||||
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
|
||||
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
|
||||
|
||||
creds = SimpleNamespace(bot_token="123:ABC", chat_id="777")
|
||||
creds_svc = MagicMock(get_decrypted=AsyncMock(return_value=creds))
|
||||
monkeypatch.setattr(ti, "get_telegram_credentials_service", lambda _s: creds_svc)
|
||||
settings_svc = MagicMock(get_int=AsyncMock(return_value=0), set=AsyncMock())
|
||||
monkeypatch.setattr(ti, "get_settings_service", lambda _s: settings_svc)
|
||||
|
||||
client = AsyncMock()
|
||||
client.configured = True
|
||||
client.get_updates = AsyncMock(return_value=[])
|
||||
|
||||
engine = ti.TelegramInboundEngine(_fake_session(), client=client)
|
||||
await engine.run_cycle()
|
||||
await engine.run_cycle()
|
||||
|
||||
client.set_my_commands.assert_awaited_once_with(list(ti.BOT_COMMANDS))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_agents_lists_working_agents(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fleet: dict[str, Any] = {
|
||||
"total": 26,
|
||||
"by_status": {"active": 4, "idle": 22},
|
||||
"working": [
|
||||
{"name": "be-dev-1", "task_title": "GitProvider seam"},
|
||||
{"name": "fe-qa", "task_title": None},
|
||||
],
|
||||
}
|
||||
cockpit = MagicMock(fleet=AsyncMock(return_value=fleet))
|
||||
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
|
||||
|
||||
text = await _engine()._render_agents()
|
||||
|
||||
assert "26 total" in text
|
||||
assert "4 active" in text
|
||||
assert "be-dev-1" in text
|
||||
assert "GitProvider seam" in text
|
||||
assert "fe-qa" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_usage_formats_today_summary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
usage = MagicMock(
|
||||
get_today_summary=AsyncMock(
|
||||
return_value={"tokens_today": 2_400_000, "cost_today_usd": 18.7}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_usage_service", lambda _s: usage)
|
||||
|
||||
text = await _engine()._render_usage()
|
||||
|
||||
assert "$18.70" in text
|
||||
assert "2,400,000 tokens" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_blocked_sections_and_links(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tasks = MagicMock(
|
||||
list_awaiting_ceo_approval=AsyncMock(
|
||||
return_value=[_task("aaaa1111", "Root PR ready")]
|
||||
),
|
||||
list_blocked=AsyncMock(return_value=[_task("bbbb2222", "Infra <wedge>")]),
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _s: tasks)
|
||||
monkeypatch.setattr(ti.settings, "panel_base_url", "https://nas.example")
|
||||
|
||||
text = await _engine()._render_blocked()
|
||||
|
||||
assert "Awaiting you" in text
|
||||
assert "Blocked" in text
|
||||
assert '<a href="https://nas.example/tasks/aaaa1111">Root PR ready</a>' in text
|
||||
assert "aaaa1111" in text
|
||||
# HTML-escaped title, never raw.
|
||||
assert "Infra <wedge>" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_blocked_all_clear(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tasks = MagicMock(
|
||||
list_awaiting_ceo_approval=AsyncMock(return_value=[]),
|
||||
list_blocked=AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _s: tasks)
|
||||
|
||||
text = await _engine()._render_blocked()
|
||||
|
||||
assert "Nothing is blocked" in text
|
||||
@@ -95,7 +95,7 @@ async def test_authorized_chat_message_dispatches_command(
|
||||
{"chat": {"id": 777}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once_with("status", "", client)
|
||||
dispatch.assert_awaited_once_with("status", "", client, chat_id="777", creds=CREDS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -155,7 +155,7 @@ async def test_message_with_matching_sender_dispatches(
|
||||
{"chat": {"id": 777}, "from": {"id": 777}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once_with("status", "", client)
|
||||
dispatch.assert_awaited_once_with("status", "", client, chat_id="777", creds=CREDS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -173,7 +173,7 @@ async def test_message_without_from_keeps_prior_behavior(
|
||||
{"chat": {"id": 777}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once_with("status", "", client)
|
||||
dispatch.assert_awaited_once_with("status", "", client, chat_id="777", creds=CREDS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""TgCockpitService coverage: the /telegram/today aggregate composes
|
||||
needs-you counts, fleet snapshot, spend, and ship state from seeded rows —
|
||||
and degrades to an all-zeros brief on an empty company.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
Complexity,
|
||||
Team,
|
||||
)
|
||||
from roboco.models.base import TaskNature as TN
|
||||
from roboco.models.base import TaskStatus as TS
|
||||
from roboco.models.base import TaskType as TT
|
||||
from roboco.services.task import (
|
||||
RELEASE_MANAGER_SOURCE,
|
||||
ROADMAP_SOURCE,
|
||||
VIDEO_POST_SOURCE,
|
||||
X_POST_SOURCE,
|
||||
)
|
||||
from roboco.services.tg_cockpit import get_tg_cockpit_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
|
||||
CI_WATCH_SOURCE = "ci_watch"
|
||||
|
||||
# 1 awaiting + 1 blocked + 4 held drafts (release/x/video/roadmap-item).
|
||||
EXPECTED_NEEDS_YOU_TOTAL = 6
|
||||
|
||||
|
||||
async def _seed_system_agent(session: AsyncSession) -> None:
|
||||
if await session.get(AgentTable, SYSTEM_UUID) is None:
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=SYSTEM_UUID,
|
||||
name="system",
|
||||
slug="system",
|
||||
role=AgentRole.SYSTEM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
def _task(
|
||||
title: str,
|
||||
task_status: TS,
|
||||
*,
|
||||
source: str | None = None,
|
||||
team: Team = Team.BACKEND,
|
||||
) -> TaskTable:
|
||||
return TaskTable(
|
||||
id=uuid4(),
|
||||
title=title,
|
||||
description="A description long enough to satisfy any length floor.",
|
||||
acceptance_criteria=["it is visible on the Today brief"],
|
||||
status=task_status,
|
||||
priority=2,
|
||||
task_type=TT.ADMINISTRATIVE,
|
||||
nature=TN.NON_TECHNICAL,
|
||||
estimated_complexity=Complexity.LOW,
|
||||
created_by=SYSTEM_UUID,
|
||||
team=team,
|
||||
source=source,
|
||||
confirmed_by_human=True,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_working_agent(session: AsyncSession, current_task_id: UUID) -> None:
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=uuid4(),
|
||||
name="be-dev-1",
|
||||
slug="be-dev-1",
|
||||
role=AgentRole.DEVELOPER,
|
||||
team=Team.BACKEND,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
current_task_id=current_task_id,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_is_all_zeros_on_an_empty_company(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
brief = await get_tg_cockpit_service(db_session).today()
|
||||
|
||||
assert brief["needs_you"]["total"] == 0
|
||||
assert brief["needs_you"]["awaiting_ceo"] == []
|
||||
assert brief["needs_you"]["blocked"] == []
|
||||
assert brief["fleet"]["working"] == []
|
||||
assert brief["spend"] == {"tokens_today": 0, "cost_today_usd": 0.0}
|
||||
assert brief["ship"]["version"] == settings.app_version
|
||||
assert brief["ship"]["open_release_proposal"] is False
|
||||
assert brief["ship"]["ci_fix_tasks"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_composes_needs_you_fleet_and_ship(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await _seed_system_agent(db_session)
|
||||
|
||||
awaiting = _task("Root PR ready", TS.AWAITING_CEO_APPROVAL)
|
||||
blocked = _task("Stuck on infra", TS.BLOCKED)
|
||||
x_draft = _task("X draft", TS.PENDING, source=X_POST_SOURCE, team=Team.BOARD)
|
||||
video_draft = _task(
|
||||
"Video draft", TS.PENDING, source=VIDEO_POST_SOURCE, team=Team.BOARD
|
||||
)
|
||||
release_prop = _task(
|
||||
"Release 0.26.0", TS.PENDING, source=RELEASE_MANAGER_SOURCE, team=Team.BOARD
|
||||
)
|
||||
ci_fix = _task("Fix red CI", TS.PENDING, source=CI_WATCH_SOURCE)
|
||||
cycle = _task("Roadmap cycle", TS.PENDING, source=ROADMAP_SOURCE, team=Team.BOARD)
|
||||
for row in (
|
||||
awaiting,
|
||||
blocked,
|
||||
x_draft,
|
||||
video_draft,
|
||||
release_prop,
|
||||
ci_fix,
|
||||
cycle,
|
||||
):
|
||||
db_session.add(row)
|
||||
await db_session.flush()
|
||||
markers.set_roadmap_cycle(
|
||||
cycle,
|
||||
{
|
||||
"goal": "g",
|
||||
"items": [
|
||||
{"id": "item-0", "status": "proposed"},
|
||||
{"id": "item-1", "status": "approved"},
|
||||
],
|
||||
},
|
||||
)
|
||||
await _seed_working_agent(db_session, awaiting.id)
|
||||
|
||||
brief = await get_tg_cockpit_service(db_session).today()
|
||||
|
||||
needs = brief["needs_you"]
|
||||
assert needs["awaiting_ceo_count"] == 1
|
||||
assert needs["awaiting_ceo"][0]["title"] == "Root PR ready"
|
||||
assert needs["awaiting_ceo"][0]["status"] == "awaiting_ceo_approval"
|
||||
assert needs["blocked_count"] == 1
|
||||
assert needs["held_drafts"] == {
|
||||
"release_proposals": 1,
|
||||
"x_posts": 1,
|
||||
"video_posts": 1,
|
||||
"roadmap_items": 1,
|
||||
}
|
||||
assert needs["total"] == EXPECTED_NEEDS_YOU_TOTAL
|
||||
|
||||
working = brief["fleet"]["working"]
|
||||
assert len(working) == 1
|
||||
assert working[0]["name"] == "be-dev-1"
|
||||
assert working[0]["task_title"] == "Root PR ready"
|
||||
|
||||
assert brief["ship"]["open_release_proposal"] is True
|
||||
assert brief["ship"]["ci_fix_tasks"] == 1
|
||||
Reference in New Issue
Block a user