Skip to content

Commit d96b665

Browse files
committed
feat: add webhook notifications for high-vote feedback items
When a feedback item's vote count crosses a configurable threshold, a POST is sent to one or more webhook URLs. Both Slack incoming webhooks and Discord webhooks are supported (payload includes both `text` and `content` keys). Configuration in `.suggestion-box/config.json`: { "webhooks": [ { "url": "https://hooks.slack.com/services/...", "voteThreshold": 3 }, { "url": "https://discord.com/api/webhooks/...", "voteThreshold": 5 } ] } voteThreshold defaults to 3. Each webhook fires exactly once when votes transition from below to at-or-above the threshold. Failures are logged to stderr but never surface as errors to the caller. - src/webhook.ts: new module with buildPayload, fireWebhook, maybeFireWebhooks - src/types.ts: add WebhookConfig interface; add webhooks field to SupervisorConfig - src/categories.ts: extract readConfigJson() helper; add getWebhooks() - src/store.ts: call maybeFireWebhooks in submitFeedback (dedup path) and upvote - src/mcp.ts: load webhooks via getWebhooks() and pass to createFeedbackStore - tests/webhook.test.ts: new tests for threshold logic, multi-webhook, payload, errors - tests/categories.test.ts: add getWebhooks test suite Closes #115
1 parent 478a602 commit d96b665

File tree

7 files changed

+492
-20
lines changed

7 files changed

+492
-20
lines changed

src/categories.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,70 @@
11
import { resolve, join } from "path";
22
import { existsSync, readFileSync } from "fs";
3+
import type { WebhookConfig } from "./types.js";
34

45
export const DEFAULT_CATEGORIES = ["friction", "feature_request", "observation"] as const;
56

7+
/**
8+
* Parse and return the raw config.json object, or null if absent/unparseable.
9+
*/
10+
function readConfigJson(dataDir?: string): Record<string, unknown> | null {
11+
const dir = dataDir ?? resolve(process.env.SUGGESTION_BOX_DIR ?? ".suggestion-box");
12+
const configPath = join(dir, "config.json");
13+
if (!existsSync(configPath)) return null;
14+
try {
15+
return JSON.parse(readFileSync(configPath, "utf-8"));
16+
} catch {
17+
return null;
18+
}
19+
}
20+
621
/**
722
* Load configured categories from `.suggestion-box/config.json`.
823
* Falls back to DEFAULT_CATEGORIES if the config file doesn't exist or
924
* doesn't contain a valid `categories` array.
1025
*/
1126
export function getCategories(): string[] {
12-
const dataDir = resolve(process.env.SUGGESTION_BOX_DIR ?? ".suggestion-box");
13-
const configPath = join(dataDir, "config.json");
14-
15-
if (!existsSync(configPath)) {
16-
return [...DEFAULT_CATEGORIES];
27+
const raw = readConfigJson();
28+
if (
29+
raw &&
30+
Array.isArray(raw.categories) &&
31+
raw.categories.length > 0 &&
32+
raw.categories.every((c: unknown) => typeof c === "string" && c.length > 0)
33+
) {
34+
return raw.categories as string[];
1735
}
36+
return [...DEFAULT_CATEGORIES];
37+
}
1838

19-
try {
20-
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
21-
if (
22-
Array.isArray(raw.categories) &&
23-
raw.categories.length > 0 &&
24-
raw.categories.every((c: unknown) => typeof c === "string" && c.length > 0)
25-
) {
26-
return raw.categories;
39+
/**
40+
* Load webhook configurations from `.suggestion-box/config.json`.
41+
* Returns an empty array if none are configured or the config is absent.
42+
*
43+
* Example config.json entry:
44+
* ```json
45+
* {
46+
* "webhooks": [
47+
* { "url": "https://hooks.slack.com/services/...", "voteThreshold": 3 },
48+
* { "url": "https://discord.com/api/webhooks/...", "voteThreshold": 5 }
49+
* ]
50+
* }
51+
* ```
52+
*/
53+
export function getWebhooks(): WebhookConfig[] {
54+
const raw = readConfigJson();
55+
if (!raw || !Array.isArray(raw.webhooks)) return [];
56+
57+
const result: WebhookConfig[] = [];
58+
for (const entry of raw.webhooks) {
59+
if (typeof entry !== "object" || entry === null) continue;
60+
const e = entry as Record<string, unknown>;
61+
if (typeof e.url !== "string" || !e.url) continue;
62+
63+
const wh: WebhookConfig = { url: e.url };
64+
if (typeof e.voteThreshold === "number" && e.voteThreshold > 0) {
65+
wh.voteThreshold = e.voteThreshold;
2766
}
28-
} catch {
29-
// Malformed config — fall back silently
67+
result.push(wh);
3068
}
31-
32-
return [...DEFAULT_CATEGORIES];
69+
return result;
3370
}

src/mcp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
publishToGithubSchema,
1212
triageSchema,
1313
} from "./schemas.js";
14-
import { getCategories } from "./categories.js";
14+
import { getCategories, getWebhooks } from "./categories.js";
1515
import { checkGhAuth, createGithubIssue } from "./github.js";
1616
import { assertValidConfig } from "./config.js";
1717
import { RateLimiter, RateLimitError } from "./rate-limiter.js";
@@ -27,11 +27,13 @@ export async function startMcpServer(): Promise<void> {
2727
sessionId,
2828
embed,
2929
persistent: true,
30+
webhooks,
3031
});
3132

3233
await store.init();
3334

3435
const categories = getCategories();
36+
const webhooks = getWebhooks();
3537
const submitFeedbackSchema = createSubmitFeedbackSchema(categories);
3638
const listFeedbackSchema = createListFeedbackSchema(categories);
3739

src/store.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { randomUUID } from "crypto";
33
import { execSync } from "child_process";
44
import { isTrigramMode, trigramSimilarity, DEFAULT_TRIGRAM_THRESHOLD } from "./embedder.js";
55
import { getSuggestionBoxVersion } from "./version.js";
6+
import { maybeFireWebhooks } from "./webhook.js";
67
import type {
78
SupervisorConfig,
9+
WebhookConfig,
810
Feedback,
911
FeedbackMetadata,
1012
FeedbackStatus,
@@ -104,6 +106,7 @@ export class FeedbackStore {
104106
private cachedDb: Database | null = null;
105107
private readonly useTrigramDedup: boolean;
106108
private readonly trigramThreshold: number;
109+
private readonly webhooks: WebhookConfig[];
107110

108111
constructor(config: SupervisorConfig) {
109112
this.dbPath = config.dbPath;
@@ -114,6 +117,7 @@ export class FeedbackStore {
114117
this.persistent = config.persistent ?? false;
115118
this.useTrigramDedup = isTrigramMode(config.embed);
116119
this.trigramThreshold = DEFAULT_TRIGRAM_THRESHOLD;
120+
this.webhooks = config.webhooks ?? [];
117121
}
118122

119123
/**
@@ -206,6 +210,7 @@ export class FeedbackStore {
206210
: await this.findSimilarByEmbedding(input.content, input.targetType, input.targetName);
207211

208212
if (duplicate) {
213+
const prevVotes = duplicate.votes;
209214
await this.withDb(async (db) => {
210215
await db.prepare(
211216
"UPDATE feedback SET votes = votes + 1, estimated_tokens_saved = COALESCE(estimated_tokens_saved, 0) + COALESCE(?, 0), estimated_time_saved_minutes = COALESCE(estimated_time_saved_minutes, 0) + COALESCE(?, 0), updated_at = ? WHERE id = ?"
@@ -217,10 +222,18 @@ export class FeedbackStore {
217222
});
218223

219224
const updated = await this.getFeedbackById(duplicate.id);
225+
const newVotes = updated?.votes ?? duplicate.votes + 1;
226+
227+
if (this.webhooks.length > 0 && updated) {
228+
maybeFireWebhooks(updated, prevVotes, this.webhooks).catch((e) =>
229+
console.error("[suggestion-box] webhook error:", e)
230+
);
231+
}
232+
220233
return {
221234
feedbackId: duplicate.id,
222235
isDuplicate: true,
223-
votes: updated?.votes ?? duplicate.votes + 1,
236+
votes: newVotes,
224237
};
225238
}
226239

@@ -354,6 +367,10 @@ export class FeedbackStore {
354367
await this.init();
355368
const now = Math.floor(Date.now() / 1000);
356369

370+
// Capture prevVotes before the update so we can detect threshold crossings.
371+
const before = await this.getFeedbackById(input.feedbackId);
372+
const prevVotes = before?.votes ?? 0;
373+
357374
await this.withDb(async (db) => {
358375
await db.prepare(
359376
"UPDATE feedback SET votes = votes + 1, estimated_tokens_saved = COALESCE(estimated_tokens_saved, 0) + COALESCE(?, 0), estimated_time_saved_minutes = COALESCE(estimated_time_saved_minutes, 0) + COALESCE(?, 0), updated_at = ? WHERE id = ?"
@@ -365,6 +382,13 @@ export class FeedbackStore {
365382
});
366383

367384
const feedback = await this.getFeedbackById(input.feedbackId);
385+
386+
if (this.webhooks.length > 0 && feedback) {
387+
maybeFireWebhooks(feedback, prevVotes, this.webhooks).catch((e) =>
388+
console.error("[suggestion-box] webhook error:", e)
389+
);
390+
}
391+
368392
return { votes: feedback?.votes ?? 0 };
369393
}
370394

src/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ export type FeedbackStatus = "open" | "published" | "dismissed";
1717

1818
export type SortBy = "votes" | "recent" | "impact";
1919

20+
export interface WebhookConfig {
21+
/** Webhook URL — supports Slack incoming webhooks, Discord webhooks, or any generic HTTP endpoint. */
22+
url: string;
23+
/**
24+
* Vote count threshold. The webhook fires the first time a feedback item's
25+
* vote count crosses this value (transitions from below to >= threshold).
26+
* Default: 3.
27+
*/
28+
voteThreshold?: number;
29+
}
30+
2031
export interface SupervisorConfig {
2132
/** Path to the Turso database file */
2233
dbPath: string;
@@ -30,6 +41,11 @@ export interface SupervisorConfig {
3041
dedupThreshold?: number;
3142
/** Use a persistent DB connection instead of open/close per operation (default: false) */
3243
persistent?: boolean;
44+
/**
45+
* Webhook endpoints to ping when feedback crosses a vote threshold.
46+
* Each webhook fires exactly once per threshold crossing.
47+
*/
48+
webhooks?: WebhookConfig[];
3349
}
3450

3551
/** Version metadata captured at feedback submission time. */

src/webhook.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Feedback, WebhookConfig } from "./types.js";
2+
3+
export type { WebhookConfig };
4+
5+
/**
6+
* Build a JSON payload compatible with both Slack and Discord incoming webhooks.
7+
* Slack expects `{ text }`, Discord expects `{ content }`. Sending both keys
8+
* works for both platforms; unknown keys are silently ignored.
9+
*/
10+
function buildPayload(feedback: Feedback): Record<string, unknown> {
11+
const title = feedback.title
12+
? `*${feedback.title}*`
13+
: `*[${feedback.category}]* ${feedback.targetType}/${feedback.targetName}`;
14+
15+
const preview =
16+
feedback.content.length > 200
17+
? feedback.content.slice(0, 197) + "..."
18+
: feedback.content;
19+
20+
const lines: string[] = [
21+
`:ballot_box_with_ballot: suggestion-box \u2014 high-vote item`,
22+
title,
23+
`Votes: ${feedback.votes} | Category: ${feedback.category} | Target: ${feedback.targetType}/${feedback.targetName}`,
24+
``,
25+
preview,
26+
``,
27+
`ID: \`${feedback.id}\``,
28+
];
29+
30+
const text = lines.join("\n");
31+
32+
// Slack: `text`, Discord: `content`
33+
return { text, content: text };
34+
}
35+
36+
/**
37+
* Fire a single webhook. Failures are non-fatal — errors are logged to stderr
38+
* but never thrown so they don't interrupt the feedback submission flow.
39+
*/
40+
export async function fireWebhook(url: string, payload: Record<string, unknown>): Promise<void> {
41+
try {
42+
const response = await fetch(url, {
43+
method: "POST",
44+
headers: { "Content-Type": "application/json" },
45+
body: JSON.stringify(payload),
46+
});
47+
48+
if (!response.ok) {
49+
console.error(
50+
`[suggestion-box] webhook POST to ${url} failed: HTTP ${response.status}`
51+
);
52+
}
53+
} catch (e: any) {
54+
console.error(`[suggestion-box] webhook POST to ${url} error: ${e.message}`);
55+
}
56+
}
57+
58+
/**
59+
* Check whether a vote transition crosses any configured webhook thresholds
60+
* and fire the matching webhooks.
61+
*
62+
* @param feedback The updated feedback item (with its new vote count).
63+
* @param prevVotes The vote count *before* this update.
64+
* @param webhooks The list of webhook configs to check.
65+
*/
66+
export async function maybeFireWebhooks(
67+
feedback: Feedback,
68+
prevVotes: number,
69+
webhooks: WebhookConfig[]
70+
): Promise<void> {
71+
if (webhooks.length === 0) return;
72+
73+
const newVotes = feedback.votes;
74+
const payload = buildPayload(feedback);
75+
76+
const promises: Promise<void>[] = [];
77+
for (const wh of webhooks) {
78+
const threshold = wh.voteThreshold ?? 3;
79+
// Fire exactly once when the vote count crosses the threshold from below.
80+
if (prevVotes < threshold && newVotes >= threshold) {
81+
promises.push(fireWebhook(wh.url, payload));
82+
}
83+
}
84+
85+
if (promises.length > 0) {
86+
await Promise.allSettled(promises);
87+
}
88+
}

0 commit comments

Comments
 (0)