Add poll chat command with reaction voting
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user