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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ internet said.
| **See what it holds about you** | `/privacy` lists everything stored about you: facts, remembered conversation, scheduled questions, alerts, notification setting, voice minutes, attachments on your archived messages, suggestions, access tokens, archived message counts in the channels you can read now, and how many of your questions are traced. In a server channel it is private and shows counts and fact kinds only, with a button that sends the values by DM; in a DM it shows the values. It states how long questions and answers are traced (`TRACE_RETENTION_DAYS`), that admins can read them, and what survives a deletion, including backups (`BACKUP_RETENTION_DAYS`) |
| **Delete everything about you** | `/privacy` -> **Delete everything…** erases your messages and their attachments, facts, memory, tasks, alerts, notifications, suggestions, tokens and voice usage records, and schedules your traced questions for deletion, after you type `DELETE` (`APAGAR` in Portuguese). Choose to keep using the bot (new messages are archived, nothing older is re-imported) or to also stop being archived. It finishes even if the bot restarts midway, and says what is kept (see `docs/operations.md`) |
| **Scheduled questions** | `/schedule` asks something for you hourly to daily and messages you the answer — only when there is one. Off by default |
| **Feature requests** | `/suggest` records an idea in your own words (up to 1000 characters) and answers with its number; `/suggestions` lists yours and their status. A message starting "tenho uma sugestão", "sugestão:", "seria legal se você", "I have a feature request", "feature request:" or "it would be nice if you could" is offered with [Record suggestion] and [No, answer it], unless something else the bot does answers it. The team sees the text and your Discord name. Text with an email, phone number or wallet address is refused, resubmitting is idempotent, and five a day at most. Opt-out deletes them |
| **Feature requests** | `/suggest` records an idea in your own words (up to 1000 characters) and answers with its number; `/suggestions` lists yours and their status. A message starting "tenho uma sugestão", "sugestão:", "seria legal se você", "I have a feature request", "feature request:" or "it would be nice if you could" is offered with [Record suggestion] and [No, answer it], unless something else the bot does answers it. The team sees the text and your Discord name. Text with an email, phone number or wallet address is refused, resubmitting is idempotent, and five a day at most. Admins triage them in the console (every change audited); if you pressed Yes you get one DM per status change, unless you turned `/notifications` off or your DMs are closed. Opt-out deletes them |
| **Voice questions** | Send the bot a voice message in a DM and get an answer as if you had typed it, with a small quoted line of what it understood. Transcribed by `gpt-4o-mini-transcribe`; the audio is never stored. Hard monthly caps per person and for the server. Off by default |
| **Channel media (recording only)** | From `MEDIA_ENABLED_AT`, voice notes and images posted in indexed channels are recorded as pending rows: metadata and a CDN link, nothing downloaded and nothing searchable yet. Transcribing voice notes and reading images come later. Off by default |
| **Admin console** | A web console for federation, channels, retention, opt-outs, tokens, and usage and estimated cost per person, feature, model and tool, read live from Langfuse (a person's question text only for admins signed in through CyberdyneAuth, audited, and only after their disclosure notice) |
Expand Down
26 changes: 26 additions & 0 deletions console/src/console.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,28 @@ describe("the tokens screen", () => {
});
});

describe("the feature requests screen", () => {
beforeEach(() => open("Feature requests", "Tell me when someone mentions me"));

it("shows who suggested it and how many others did", () => {
expect(within(rowOf("Sam")).getByText("also suggested by 2 other(s)")).toBeTruthy();
});

it("sends only what the admin changed", async () => {
await fireEvent.click(button("Triage"));
await fireEvent.change(screen.getByLabelText("Status"), { target: { value: "planned" } });
await fireEvent.input(screen.getByLabelText("Note"), { target: { value: "Q4" } });
await fireEvent.click(button("Save"));
await screen.findByText("#12 saved.");
expect(api.writes).toEqual(['PATCH /api/feature-requests/12 {"status":"planned","admin_note":"Q4"}']);
});

it("filters by status, and says when a status has nothing", async () => {
await fireEvent.click(button("done"));
await screen.findByText("No done suggestions.");
});
});

describe("the audit screen", () => {
// The reason is shown only here; the setting's name is on the status screen too.
beforeEach(() => open("Audit", "credentials are read from the environment and cannot be stored"));
Expand Down Expand Up @@ -451,6 +473,10 @@ describe("signing in with CyberdyneAuth", () => {
await fireEvent.click(screen.getByRole("link", { name: "Tokens" }));
await screen.findByText("sam's laptop");
expect(screen.queryByRole("button", { name: "Revoke" })).toBeNull();

await fireEvent.click(screen.getByRole("link", { name: "Feature requests" }));
await screen.findByText("Tell me when someone mentions me");
expect(screen.queryByRole("button", { name: "Triage" })).toBeNull();
expect(api.writes).toEqual([]);
});

Expand Down
49 changes: 49 additions & 0 deletions console/src/domain/featureRequests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";

import { draftOf, draftProblem, patchFor } from "./featureRequests";
import type { FeatureRequest } from "./types";

const ROW: FeatureRequest = {
id: 12,
text: "dark mode",
language: "en",
status: "new",
admin_note: null,
duplicate_of: null,
source_kind: "command",
person: "Sam",
same_text_elsewhere: 0,
created_at: "2026-09-15T10:00:00+00:00",
updated_at: "2026-09-15T10:00:00+00:00",
updated_by: null,
};

describe("patchFor", () => {
it("sends nothing for an untouched draft", () => {
expect(patchFor(ROW, draftOf(ROW))).toEqual({});
const noted = { ...ROW, admin_note: "Q4", duplicate_of: 3 };
expect(patchFor(noted, draftOf(noted))).toEqual({});
});

it("sends only what changed", () => {
expect(patchFor(ROW, { ...draftOf(ROW), status: "planned" })).toEqual({ status: "planned" });
expect(patchFor(ROW, { ...draftOf(ROW), note: " Q4 " })).toEqual({ admin_note: "Q4" });
expect(patchFor(ROW, { ...draftOf(ROW), duplicateOf: "3" })).toEqual({ duplicate_of: 3 });
});

it("clears a note or a link emptied in the form", () => {
const noted = { ...ROW, admin_note: "Q4", duplicate_of: 3 };
expect(patchFor(noted, { ...draftOf(noted), note: " ", duplicateOf: "" })).toEqual({
admin_note: null,
duplicate_of: null,
});
});
});

describe("draftProblem", () => {
it("refuses a duplicate link that is not a number or is the row itself", () => {
expect(draftProblem({ ...draftOf(ROW), duplicateOf: "abc" })).toMatch(/number/);
expect(draftProblem({ ...draftOf(ROW), duplicateOf: "12" })).toMatch(/itself/);
expect(draftProblem({ ...draftOf(ROW), duplicateOf: "3" })).toBeNull();
});
});
55 changes: 55 additions & 0 deletions console/src/domain/featureRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Feature requests: the statuses, and what an admin's edit sends.
*
* Only the fields that changed are sent, so the change record holds one
* entry per thing the admin actually changed and an untouched note is never
* rewritten.
*/

import type { FeatureRequest, FeatureRequestPatch, FeatureRequestStatus } from "./types";

export const STATUSES: readonly FeatureRequestStatus[] = [
"new",
"triaged",
"planned",
"done",
"declined",
"duplicate",
];

/** The edit form's values, as typed. */
export interface Draft {
id: number;
status: FeatureRequestStatus;
note: string;
/** The original's number, or "" for none. */
duplicateOf: string;
}

export function draftOf(row: FeatureRequest): Draft {
return {
id: row.id,
status: row.status,
note: row.admin_note ?? "",
duplicateOf: row.duplicate_of === null ? "" : String(row.duplicate_of),
};
}

/** Why the draft cannot be sent, or null. */
export function draftProblem(draft: Draft): string | null {
const duplicate = draft.duplicateOf.trim();
if (duplicate !== "" && !/^\d+$/.test(duplicate)) return "Duplicate of must be a suggestion number.";
if (duplicate === String(draft.id)) return "A suggestion cannot be a duplicate of itself.";
return null;
}

/** The fields that differ from the row, as the API takes them. */
export function patchFor(row: FeatureRequest, draft: Draft): FeatureRequestPatch {
const patch: FeatureRequestPatch = {};
if (draft.status !== row.status) patch.status = draft.status;
const note = draft.note.trim() === "" ? null : draft.note.trim();
if (note !== row.admin_note) patch.admin_note = note;
const duplicate = draft.duplicateOf.trim() === "" ? null : Number(draft.duplicateOf.trim());
if (duplicate !== row.duplicate_of) patch.duplicate_of = duplicate;
return patch;
}
47 changes: 46 additions & 1 deletion console/src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*
* Two things are deliberately absent and their absence is the contract:
* nothing here carries message, document or ask text, and nothing here
* carries a secret -- not even a masked one. If a field for either ever
* carries a secret -- not even a masked one. A feature request's text is not
* an exception: it is the person's own suggestion, given to the team on
* purpose, and never something they said in a channel. If a field for either ever
* appears in this file, the console has started rendering something the API
* promised never to send.
*
Expand Down Expand Up @@ -183,6 +185,49 @@ export interface SignedOut {
end_session_url: string | null;
}

/** Where the team is with a suggestion. The API's `RequestStatus`. */
export type FeatureRequestStatus = "new" | "triaged" | "planned" | "done" | "declined" | "duplicate";

/**
* GET /api/feature-requests: one suggestion as the team sees it.
*
* `person` is their display name, never an account id; the console is not
* where anybody is contacted from. `same_text_elsewhere` counts the other
* people who suggested the same words.
*/
export interface FeatureRequest {
id: number;
text: string;
language: string | null;
status: FeatureRequestStatus;
admin_note: string | null;
duplicate_of: number | null;
source_kind: string;
person: string;
same_text_elsewhere: number;
created_at: string;
updated_at: string;
updated_by: string | null;
}

export interface FeatureRequestPage {
items: FeatureRequest[];
total: number;
page: number;
page_size: number;
}

/** PATCH /api/feature-requests/{id}: only the fields that change. */
export interface FeatureRequestPatch {
status?: FeatureRequestStatus;
admin_note?: string | null;
duplicate_of?: number | null;
}

export interface FeatureRequestTriaged extends Changed {
request: FeatureRequest;
}

/** How GET /api/usage/summary groups its rows. */
export type UsageGroup = "person" | "feature" | "model" | "tool";

Expand Down
12 changes: 12 additions & 0 deletions console/src/services/adminApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import type {
Changed,
Channel,
ChannelAdded,
FeatureRequestPage,
FeatureRequestPatch,
FeatureRequestStatus,
FeatureRequestTriaged,
FederationServer,
OptOut,
PersonQuestions,
Expand Down Expand Up @@ -74,6 +78,14 @@ export const adminApi = {

audit: () => send<AuditEntry[]>("GET", apiPath("audit")),

featureRequests: (status: FeatureRequestStatus | null, page: number) => {
const query = new URLSearchParams({ page: String(page) });
if (status !== null) query.set("status", status);
return send<FeatureRequestPage>("GET", `${apiPath("feature-requests")}?${query.toString()}`);
},
/** Admin only; the API records every change with who made it. */
triageFeatureRequest: (id: number, patch: FeatureRequestPatch) =>
send<FeatureRequestTriaged>("PATCH", apiPath("feature-requests", String(id)), patch),
usageSummary: (window: UsageWindow, group: UsageGroup) =>
send<UsageSummary>("GET", withQuery(apiPath("usage", "summary"), { ...window, group })),
/**
Expand Down
2 changes: 1 addition & 1 deletion console/src/services/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class ApiError extends Error {
/** What a refused credential says. One sentence for all four causes. */
export const REFUSED_MESSAGE = "That credential was refused.";

export type Method = "GET" | "POST" | "PUT" | "DELETE";
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

/** A cookie-mode request: the browser's session cookie, and no credential header. */
export function requestInit(method: Method, body?: unknown): RequestInit {
Expand Down
30 changes: 30 additions & 0 deletions console/src/test/stubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ function fixtures(): Json {
revoked_at: null,
},
],
featureRequests: [
{
id: 12,
text: "Tell me when someone mentions me",
language: "en",
status: "new",
admin_note: null,
duplicate_of: null,
source_kind: "command",
person: "Sam",
same_text_elsewhere: 2,
created_at: "2026-09-15T10:00:00+00:00",
updated_at: "2026-09-15T10:00:00+00:00",
updated_by: null,
},
],
usage: usageFixtures(),
questions: questionFixtures(),
audit: [
Expand Down Expand Up @@ -316,6 +332,13 @@ function allowTool(state: Json, body: Json): [number, unknown] {

type Reply = [status: number, payload: unknown];

/** GET /api/feature-requests, with its status filter, as the API pages it. */
function featurePage(state: Json, url: string): Json {
const status = new URL(url, "http://stub").searchParams.get("status");
const items = (state.featureRequests as Json[]).filter((row) => status === null || row.status === status);
return { items, total: items.length, page: 1, page_size: 50 };
}

/** One write route: the method, the path prefix, and what it does to the state. */
type WriteRoute = [method: string, prefix: string, handle: (state: Json, last: string, body: Json, path: string) => Reply];

Expand Down Expand Up @@ -365,6 +388,12 @@ const WRITES: WriteRoute[] = [
rows(state, "optouts").push({ person, since: "2026-09-16T10:00:00+00:00" });
return [200, { changed: `opt_out:${person}`, detail: "the exclusion is enforced in the database", removed: {} }];
}],
["PATCH", "/api/feature-requests/", (state, last, body) => {
const request = rows(state, "featureRequests").find((row) => String(row.id) === last);
if (request === undefined) return [404, { error: "no such suggestion" }];
Object.assign(request, body, { updated_by: "ana" });
return [200, { changed: `feature_request.${last}`, request }];
}],
["DELETE", "/api/optouts/", (state, _last, _body, path) => {
const [, , , platform, id] = path.split("/");
state.optouts = rows(state, "optouts").filter((row) => row.person !== `${platform}:${id}`);
Expand Down Expand Up @@ -531,6 +560,7 @@ export async function startStubApi(): Promise<StubApi> {
"/api/optouts": state.optouts,
"/api/tokens": state.tokens,
"/api/audit": state.audit,
"/api/feature-requests": featurePage(state, request.url ?? ""),
};
const found = reads[path];
reply(found === undefined ? 404 : 200, found ?? { error: "no such route" });
Expand Down
5 changes: 5 additions & 0 deletions console/src/viewmodels/console.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { SessionPort } from "../services/session";
import type { SessionApi } from "../services/sessionApi";
import { AuditVM } from "./audit.svelte";
import { ChannelsVM } from "./channels.svelte";
import { FeatureRequestsVM } from "./featureRequests.svelte";
import { FederationVM } from "./federation.svelte";
import { RetentionVM } from "./retention.svelte";
import { Router, type Route } from "./router.svelte";
Expand Down Expand Up @@ -64,6 +65,10 @@ export class ConsoleVM {
return new TokensVM(this.#api);
}

featureRequests(): FeatureRequestsVM {
return new FeatureRequestsVM(this.#api);
}

usage(): UsageVM {
return new UsageVM(this.#api, () => this.session.canReadQuestions);
}
Expand Down
Loading
Loading