Add poll chat command with reaction voting

This commit is contained in:
Fisto
2026-09-11 15:59:08 +02:00
parent 8e8c15e8bc
commit ace0bb18ff
16 changed files with 291 additions and 9 deletions
+20
View File
@@ -187,6 +187,26 @@ export function parseTier(value: string): TrustTier | undefined {
return value === "user" || value === "trusted" || value === "admin" ? value : undefined;
}
/* Polls */
export const POLL_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"];
export interface ParsedPoll {
question: string;
options: string[];
}
/** Parse `question | option 1 | option 2 [| option 3 [| option 4]]`. */
export function parsePoll(text: string): ParsedPoll | undefined {
const parts = text.split("|").map(part => part.trim()).filter(Boolean);
if (parts.length < 3 || parts.length > 5) return undefined;
const [question, ...options] = parts;
if (!question || options.length < 2 || options.some(option => option.length > 80)) return undefined;
return { question: question.slice(0, 200), options: options.map(option => option.slice(0, 80)) };
}
/* Secret redaction for persistent logs. Keep it minimal. */
const SECRET_PATTERNS = [
+1
View File
@@ -179,6 +179,7 @@ function buildServer(): McpServer {
contextChars: z.number().int().min(200).max(20000).optional(),
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
continuationSeconds: z.number().int().min(0).max(300).optional(),
pollSeconds: z.number().int().min(10).max(3600).optional(),
responsePolicy: z
.object({
defaultMode: z.enum(["channel", "dm"]).optional(),
+5 -1
View File
@@ -120,7 +120,11 @@ export class CoreClient {
}
async sendMessage(channelId: string, content: string) {
await this.request("message.send", { channelId, content });
return this.request<{ messageId?: string }>("message.send", { channelId, content });
}
async reactionAdd(channelId: string, messageId: string, emoji: string) {
return this.request("reaction.add", { channelId, messageId, emoji });
}
async sendDm(userId: string, content: string) {
+3 -1
View File
@@ -61,7 +61,9 @@ function handle(action: string | undefined, params: any) {
]
};
case "message.send":
return { channelId: params?.channelId, sent: true };
return { channelId: params?.channelId, messageId: "msg-1", sent: true };
case "reaction.add":
return { channelId: params?.channelId, messageId: params?.messageId, emoji: params?.emoji, added: true };
case "message.read":
return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES };
case "message.fetch": {
+123 -1
View File
@@ -16,7 +16,9 @@ import {
hasTier,
MessageLog,
parseCommand,
parsePoll,
parseTier,
POLL_EMOJIS,
randomActionDelay,
stripMentions,
toCachedMessage,
@@ -47,6 +49,7 @@ export interface PuppetConfig {
contextChars: number;
fetchCooldownSeconds: number;
continuationSeconds: number;
pollSeconds: number;
responsePolicy: ResponsePolicy;
}
@@ -55,6 +58,7 @@ export interface PuppetConfigPatch {
contextChars?: number;
fetchCooldownSeconds?: number;
continuationSeconds?: number;
pollSeconds?: number;
responsePolicy?: {
defaultMode?: "channel" | "dm";
sensitiveCommands?: string[];
@@ -67,6 +71,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
contextChars: 4000,
fetchCooldownSeconds: 5,
continuationSeconds: 10,
pollSeconds: 120,
responsePolicy: {
defaultMode: "channel",
sensitiveCommands: ["whitelist", "config"],
@@ -83,13 +88,24 @@ interface PersistedState {
/** The transport methods that the puppet logic needs. */
export interface PuppetTransport {
getSelfId(): Promise<string | undefined>;
sendMessage(channelId: string, content: string): Promise<void>;
sendMessage(channelId: string, content: string): Promise<{ messageId?: string } | void>;
sendDm(userId: string, content: string): Promise<unknown>;
fetchMessages(
channelId: string,
limit: number,
options?: { before?: string; after?: string }
): Promise<{ messages?: unknown[] }>;
reactionAdd(channelId: string, messageId: string, emoji: string): Promise<unknown>;
}
interface PollState {
channelId: string;
authorId: string;
question: string;
options: string[];
votes: Map<string, number>;
endsAt: number;
timer: ReturnType<typeof setTimeout>;
}
function clamp(value: number, min: number, max: number): number {
@@ -108,6 +124,7 @@ export class Puppet {
private readonly seenOrder: string[] = [];
private readonly lastFetchAt = new Map<string, number>();
private readonly pending = new Map<string, { entryId: string; expiresAt: number }>();
private readonly polls = new Map<string, PollState>();
private ownerId = DEFAULT_OWNER_ID;
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
@@ -143,6 +160,7 @@ export class Puppet {
0,
300
),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
responsePolicy: {
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
sensitiveCommands:
@@ -189,6 +207,7 @@ export class Puppet {
300
),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
responsePolicy: {
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
@@ -307,6 +326,11 @@ export class Puppet {
}
async handleEvent(event: any) {
if (event?.type === "MESSAGE_REACTION_ADD" || event?.type === "MESSAGE_REACTION_REMOVE") {
await this.handleReactionEvent(event);
return;
}
if (event?.type !== "MESSAGE_CREATE") return;
const { author, channelId, guildId, channelType, content, mentions } = event;
@@ -406,6 +430,8 @@ export class Puppet {
if (command?.name === "whitelist") {
await this.handleWhitelistCommand(command, author.id, tier, channelId);
} else if (command?.name === "poll") {
await this.handlePollCommand(author.id, channelId, text);
}
}
@@ -494,6 +520,102 @@ export class Puppet {
}
}
/* Polls */
private async handlePollCommand(authorId: string, channelId: string, text: string) {
const body = text.replace(/^poll\s+/i, "");
const poll = parsePoll(body);
if (!poll) {
return this.respond(
authorId,
channelId,
"poll",
"Usage: poll <question> | <option 1> | <option 2> [| option 3 [| option 4]]"
);
}
const lines = [
`📊 ${poll.question}`,
...poll.options.map((option, index) => `${POLL_EMOJIS[index]} ${option}`),
"",
"React to vote!"
];
const sent = await this.core.sendMessage(channelId, lines.join("\n"));
const messageId = (sent as { messageId?: string } | void)?.messageId;
if (!messageId) {
this.actionLog.append({ type: "error", scope: "poll", channelId, message: "no message ID for the poll" });
return;
}
const state: PollState = {
channelId,
authorId,
question: poll.question,
options: poll.options,
votes: new Map(),
endsAt: Date.now() + this.config.pollSeconds * 1000,
timer: undefined as unknown as ReturnType<typeof setTimeout>
};
state.timer = setTimeout(() => void this.finishPoll(messageId), this.config.pollSeconds * 1000);
this.polls.set(messageId, state);
for (let index = 0; index < poll.options.length; index++) {
try {
await this.core.reactionAdd(channelId, messageId, POLL_EMOJIS[index]);
} catch (error) {
this.actionLog.append({ type: "error", scope: "poll-reaction", message: String(error) });
}
}
this.actionLog.append({
type: "poll",
messageId,
channelId,
question: poll.question,
options: poll.options.length,
endsAt: new Date(state.endsAt).toISOString()
});
}
private async handleReactionEvent(event: any) {
const poll = this.polls.get(event.messageId);
if (!poll) return;
this.selfId ??= await this.core.getSelfId();
if (!this.selfId || event.userId === this.selfId) return;
const emoji = event.emoji?.id ?? event.emoji?.name;
const index = POLL_EMOJIS.indexOf(emoji);
if (index < 0 || index >= poll.options.length) return;
if (event.type === "MESSAGE_REACTION_ADD") poll.votes.set(event.userId, index);
else if (poll.votes.get(event.userId) === index) poll.votes.delete(event.userId);
}
private async finishPoll(messageId: string) {
const poll = this.polls.get(messageId);
if (!poll) return;
this.polls.delete(messageId);
clearTimeout(poll.timer);
const counts = poll.options.map(() => 0);
for (const index of poll.votes.values()) counts[index]++;
const lines = [
`📊 Results: ${poll.question}`,
...poll.options.map((option, index) => `${POLL_EMOJIS[index]} ${option} — ${counts[index]}`)
];
await this.respond(poll.authorId, poll.channelId, "poll", lines.join("\n"));
this.actionLog.append({ type: "poll-result", messageId, channelId: poll.channelId, counts });
}
/* Replies */
private async respond(
+10
View File
@@ -17,6 +17,7 @@ import {
hasTier,
isTrusted,
parseCommand,
parsePoll,
parseTier,
randomActionDelay,
redactSecrets,
@@ -176,4 +177,13 @@ function entry(id: number, trusted: boolean): PuppetMessage {
assert.equal(log.find("nope"), undefined);
}
// poll parser
{
assert.deepEqual(parsePoll("Lunch? | Pizza | Sushi"), { question: "Lunch?", options: ["Pizza", "Sushi"] });
assert.deepEqual(parsePoll("Q | A | B | C | D"), { question: "Q", options: ["A", "B", "C", "D"] });
assert.equal(parsePoll("Q | A"), undefined);
assert.equal(parsePoll("Q | A | B | C | D | E"), undefined);
assert.equal(parsePoll(`Q | ${"x".repeat(100)} | B`), undefined);
}
console.log("all communication tests passed");
+13 -1
View File
@@ -17,8 +17,9 @@ const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
const guildId = process.env.TEST_TRIGGER_GUILD;
const isGuild = !!guildId;
const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
const isReaction = process.env.TEST_TRIGGER_REACTION === "1";
const event = {
const messageEvent = {
type: "MESSAGE_CREATE",
id: `30000000000000${Date.now() % 10000}`,
channelId: process.env.TEST_TRIGGER_CHANNEL ?? (isGuild ? "200000000000000010" : "805255536223715370"),
@@ -30,6 +31,17 @@ const event = {
timestamp: new Date().toISOString()
};
const reactionEvent = {
type: process.env.TEST_TRIGGER_REMOVE === "1" ? "MESSAGE_REACTION_REMOVE" : "MESSAGE_REACTION_ADD",
messageId: process.env.TEST_REACTION_MESSAGE_ID ?? "msg-1",
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000010",
guildId: guildId ?? "100000000000000001",
userId: authorId,
emoji: { name: process.env.TEST_REACTION_EMOJI ?? "1️⃣", id: null }
};
const event = isReaction ? reactionEvent : messageEvent;
await new Promise<void>((resolve, reject) => {
const socket = net.connect(coreSocketPath);
socket.setEncoding("utf-8");