Skip to content

Commit a03bf04

Browse files
committed
fix: merge main into feat/issue-177 and fix cache key pattern
- Resolve merge conflicts: redisRateLimit import, fetchChannelCached error handling, discordCache comments, reputationCache parallel invalidation, and redis.test.js async reset - Fix cache key inconsistency: leaderboard:${guildId}* → leaderboard:${guildId}:* in config-listeners.js (caught by Greptile code review) - Update config-listeners.test.js to match corrected key pattern - Use Promise.allSettled for parallel cache invalidation in reputationCache
2 parents 600b870 + bcf04e2 commit a03bf04

34 files changed

Lines changed: 2023 additions & 24 deletions

.gitattributes

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Enforce LF line endings for all text files.
2+
# Overrides core.autocrlf on Windows checkouts.
3+
* text=auto eol=lf
4+
5+
# Explicitly mark binary formats as binary (no line-ending conversion).
6+
*.png binary
7+
*.jpg binary
8+
*.jpeg binary
9+
*.gif binary
10+
*.ico binary
11+
*.woff binary
12+
*.woff2 binary
13+
*.ttf binary
14+
*.eot binary
15+
*.otf binary
16+
*.zip binary
17+
*.tar binary
18+
*.gz binary

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ All configuration lives in `config.json` and can be updated at runtime via the `
200200
| `logging.channels.timeouts` | string | Channel for timeout events |
201201
| `logging.channels.purges` | string | Channel for purge events |
202202
| `logging.channels.locks` | string | Channel for lock/unlock events |
203+
| `protectRoles.enabled` | boolean | Enable role protection (prevents moderating admins/mods/owner) |
204+
| `protectRoles.includeServerOwner` | boolean | Include server owner in protection |
205+
| `protectRoles.includeAdmins` | boolean | Include admin role in protection |
206+
| `protectRoles.includeModerators` | boolean | Include moderator role in protection |
207+
| `protectRoles.roleIds` | string[] | Additional role IDs to protect from moderation |
203208

204209
**Escalation thresholds** are objects with: `warns` (count), `withinDays` (window), `action` ("timeout" or "ban"), `duration` (for timeout, e.g. "1h").
205210

config.json

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
{
22
"ai": {
33
"enabled": true,
4-
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
4+
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals \u2014 say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here \u2014 these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
55
"channels": [],
66
"historyLength": 20,
77
"historyTTLDays": 30,
88
"threadMode": {
99
"enabled": false,
1010
"autoArchiveMinutes": 60,
1111
"reuseWindowMinutes": 30
12+
},
13+
"blockedChannelIds": [],
14+
"feedback": {
15+
"enabled": false
1216
}
1317
},
1418
"triage": {
@@ -43,7 +47,7 @@
4347
"welcome": {
4448
"enabled": true,
4549
"channelId": "1438631182379253814",
46-
"message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask we're here to help. 💚",
50+
"message": "Welcome to Volvox, {user}! \ud83c\udf31 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask \u2014 we're here to help. \ud83d\udc9a",
4751
"dynamic": {
4852
"enabled": true,
4953
"timezone": "America/New_York",
@@ -104,6 +108,13 @@
104108
"purges": null,
105109
"locks": null
106110
}
111+
},
112+
"protectRoles": {
113+
"enabled": true,
114+
"roleIds": [],
115+
"includeAdmins": true,
116+
"includeModerators": true,
117+
"includeServerOwner": true
107118
}
108119
},
109120
"memory": {
@@ -213,19 +224,19 @@
213224
"activityBadges": [
214225
{
215226
"days": 90,
216-
"label": "👑 Legend"
227+
"label": "\ud83d\udc51 Legend"
217228
},
218229
{
219230
"days": 30,
220-
"label": "🌳 Veteran"
231+
"label": "\ud83c\udf33 Veteran"
221232
},
222233
{
223234
"days": 7,
224-
"label": "🌿 Regular"
235+
"label": "\ud83c\udf3f Regular"
225236
},
226237
{
227238
"days": 0,
228-
"label": "🌱 Newcomer"
239+
"label": "\ud83c\udf31 Newcomer"
229240
}
230241
]
231242
},
@@ -271,4 +282,4 @@
271282
"enabled": false,
272283
"maxPerUser": 25
273284
}
274-
}
285+
}

migrations/003_ai_feedback.cjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Migration 003: AI Response Feedback Table
3+
*
4+
* Stores 👍/👎 reactions from users on AI-generated messages.
5+
* Per-user per-message deduplication via UNIQUE constraint.
6+
* Gated behind ai.feedback.enabled in config (opt-in per guild).
7+
*/
8+
9+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
10+
exports.up = (pgm) => {
11+
pgm.sql(`
12+
CREATE TABLE IF NOT EXISTS ai_feedback (
13+
id SERIAL PRIMARY KEY,
14+
message_id TEXT NOT NULL,
15+
channel_id TEXT NOT NULL,
16+
guild_id TEXT NOT NULL,
17+
user_id TEXT NOT NULL,
18+
feedback_type TEXT NOT NULL CHECK (feedback_type IN ('positive', 'negative')),
19+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
20+
UNIQUE(message_id, user_id)
21+
)
22+
`);
23+
24+
pgm.sql(`
25+
CREATE INDEX IF NOT EXISTS idx_ai_feedback_guild_id
26+
ON ai_feedback(guild_id)
27+
`);
28+
29+
pgm.sql(`
30+
CREATE INDEX IF NOT EXISTS idx_ai_feedback_message_id
31+
ON ai_feedback(message_id)
32+
`);
33+
};
34+
35+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
36+
exports.down = (pgm) => {
37+
pgm.sql(`DROP INDEX IF EXISTS idx_ai_feedback_message_id`);
38+
pgm.sql(`DROP INDEX IF EXISTS idx_ai_feedback_guild_id`);
39+
pgm.sql(`DROP TABLE IF EXISTS ai_feedback`);
40+
};

src/api/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { Router } from 'express';
77
import { auditLogMiddleware } from './middleware/auditLog.js';
88
import { requireAuth } from './middleware/auth.js';
9+
import aiFeedbackRouter from './routes/ai-feedback.js';
910
import auditLogRouter from './routes/auditLog.js';
1011
import authRouter from './routes/auth.js';
1112
import communityRouter from './routes/community.js';
@@ -36,6 +37,9 @@ router.use('/config', requireAuth(), auditLogMiddleware(), configRouter);
3637
// (mounted before guilds to handle /:id/members/* before the basic guilds endpoint)
3738
router.use('/guilds', requireAuth(), auditLogMiddleware(), membersRouter);
3839

40+
// AI Feedback routes — require API secret or OAuth2 JWT
41+
router.use('/guilds/:id/ai-feedback', requireAuth(), auditLogMiddleware(), aiFeedbackRouter);
42+
3943
// Conversation routes — require API secret or OAuth2 JWT
4044
// (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint)
4145
router.use('/guilds/:id/conversations', requireAuth(), auditLogMiddleware(), conversationsRouter);

src/api/routes/ai-feedback.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* AI Feedback Routes
3+
* Endpoints for reading AI response feedback (👍/👎) stats.
4+
*
5+
* Mounted at /api/v1/guilds/:id/ai-feedback
6+
*/
7+
8+
import { Router } from 'express';
9+
import { getFeedbackStats, getFeedbackTrend, getRecentFeedback } from '../../modules/aiFeedback.js';
10+
import { rateLimit } from '../middleware/rateLimit.js';
11+
import { requireGuildAdmin, validateGuild } from './guilds.js';
12+
13+
const router = Router({ mergeParams: true });
14+
15+
/** Rate limiter: 60 requests / 1 min per IP */
16+
const feedbackRateLimit = rateLimit({ windowMs: 60 * 1000, max: 60 });
17+
18+
// ── GET /stats ─────────────────────────────────────────────────────────────
19+
20+
/**
21+
* @openapi
22+
* /guilds/{id}/ai-feedback/stats:
23+
* get:
24+
* tags:
25+
* - AI Feedback
26+
* summary: Get AI feedback statistics
27+
* description: Returns aggregate 👍/👎 feedback counts and daily trend for a guild.
28+
* security:
29+
* - ApiKeyAuth: []
30+
* - BearerAuth: []
31+
* parameters:
32+
* - in: path
33+
* name: id
34+
* required: true
35+
* schema:
36+
* type: string
37+
* description: Guild ID
38+
* - in: query
39+
* name: days
40+
* schema:
41+
* type: integer
42+
* default: 30
43+
* minimum: 1
44+
* maximum: 90
45+
* description: Number of days for the trend window
46+
* responses:
47+
* "200":
48+
* description: Feedback statistics
49+
* content:
50+
* application/json:
51+
* schema:
52+
* type: object
53+
* properties:
54+
* positive:
55+
* type: integer
56+
* negative:
57+
* type: integer
58+
* total:
59+
* type: integer
60+
* ratio:
61+
* type: integer
62+
* nullable: true
63+
* description: Positive feedback percentage (0–100), or null if no feedback
64+
* trend:
65+
* type: array
66+
* items:
67+
* type: object
68+
* properties:
69+
* date:
70+
* type: string
71+
* format: date
72+
* positive:
73+
* type: integer
74+
* negative:
75+
* type: integer
76+
* "401":
77+
* $ref: "#/components/responses/Unauthorized"
78+
* "403":
79+
* $ref: "#/components/responses/Forbidden"
80+
* "429":
81+
* $ref: "#/components/responses/RateLimited"
82+
* "500":
83+
* $ref: "#/components/responses/ServerError"
84+
* "503":
85+
* $ref: "#/components/responses/ServiceUnavailable"
86+
*/
87+
router.get(
88+
'/stats',
89+
feedbackRateLimit,
90+
requireGuildAdmin,
91+
validateGuild,
92+
async (req, res, next) => {
93+
try {
94+
const guildId = req.params.id;
95+
96+
let days = 30;
97+
if (req.query.days !== undefined) {
98+
const parsed = Number.parseInt(req.query.days, 10);
99+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 90) {
100+
days = parsed;
101+
}
102+
}
103+
104+
const [stats, trend] = await Promise.all([
105+
getFeedbackStats(guildId),
106+
getFeedbackTrend(guildId, days),
107+
]);
108+
109+
res.json({
110+
...stats,
111+
trend,
112+
});
113+
} catch (err) {
114+
next(err);
115+
}
116+
},
117+
);
118+
119+
// ── GET /recent ──────────────────────────────────────────────────────────────
120+
121+
/**
122+
* @openapi
123+
* /guilds/{id}/ai-feedback/recent:
124+
* get:
125+
* tags:
126+
* - AI Feedback
127+
* summary: Get recent feedback entries
128+
* description: Returns the most recent feedback entries for a guild (newest first).
129+
* security:
130+
* - ApiKeyAuth: []
131+
* - BearerAuth: []
132+
* parameters:
133+
* - in: path
134+
* name: id
135+
* required: true
136+
* schema:
137+
* type: string
138+
* description: Guild ID
139+
* - in: query
140+
* name: limit
141+
* schema:
142+
* type: integer
143+
* default: 25
144+
* maximum: 100
145+
* responses:
146+
* "200":
147+
* description: Recent feedback entries
148+
* content:
149+
* application/json:
150+
* schema:
151+
* type: object
152+
* properties:
153+
* feedback:
154+
* type: array
155+
* items:
156+
* type: object
157+
* properties:
158+
* id:
159+
* type: integer
160+
* messageId:
161+
* type: string
162+
* channelId:
163+
* type: string
164+
* userId:
165+
* type: string
166+
* feedbackType:
167+
* type: string
168+
* enum: [positive, negative]
169+
* createdAt:
170+
* type: string
171+
* format: date-time
172+
* "401":
173+
* $ref: "#/components/responses/Unauthorized"
174+
* "403":
175+
* $ref: "#/components/responses/Forbidden"
176+
* "429":
177+
* $ref: "#/components/responses/RateLimited"
178+
* "500":
179+
* $ref: "#/components/responses/ServerError"
180+
* "503":
181+
* $ref: "#/components/responses/ServiceUnavailable"
182+
*/
183+
router.get(
184+
'/recent',
185+
feedbackRateLimit,
186+
requireGuildAdmin,
187+
validateGuild,
188+
async (req, res, next) => {
189+
try {
190+
const guildId = req.params.id;
191+
192+
let limit = 25;
193+
if (req.query.limit !== undefined) {
194+
const parsed = Number.parseInt(req.query.limit, 10);
195+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 100) {
196+
limit = parsed;
197+
}
198+
}
199+
200+
const feedback = await getRecentFeedback(guildId, limit);
201+
res.json({ feedback });
202+
} catch (err) {
203+
next(err);
204+
}
205+
},
206+
);
207+
208+
export default router;

src/api/utils/configValidation.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const CONFIG_SCHEMA = {
1717
enabled: { type: 'boolean' },
1818
systemPrompt: { type: 'string' },
1919
channels: { type: 'array' },
20+
blockedChannelIds: { type: 'array' },
2021
historyLength: { type: 'number' },
2122
historyTTLDays: { type: 'number' },
2223
threadMode: {
@@ -110,6 +111,16 @@ export const CONFIG_SCHEMA = {
110111
},
111112
},
112113
},
114+
protectRoles: {
115+
type: 'object',
116+
properties: {
117+
enabled: { type: 'boolean' },
118+
roleIds: { type: 'array' },
119+
includeAdmins: { type: 'boolean' },
120+
includeModerators: { type: 'boolean' },
121+
includeServerOwner: { type: 'boolean' },
122+
},
123+
},
113124
},
114125
},
115126
triage: {

0 commit comments

Comments
 (0)