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
+1
View File
@@ -53,6 +53,7 @@ before you stop.
| Trusted-only triggers, trust tiers | Implemented | | Trusted-only triggers, trust tiers | Implemented |
| Context cache, fetch cooldown, continuation | Implemented | | Context cache, fetch cooldown, continuation | Implemented |
| Response policy (channel or DM) | Implemented | | Response policy (channel or DM) | Implemented |
| Chat commands (`poll`) | Implemented |
| Persistent action log | Implemented | | Persistent action log | Implemented |
| Voice state read | Implemented | | Voice state read | Implemented |
| Voice actions, notification parsing | Not started | | Voice actions, notification parsing | Not started |
+1
View File
@@ -24,6 +24,7 @@ persistent logging. Voice, audio, and video stay deferred.
- Continuation window: a rushed follow-up joins the request context. - Continuation window: a rushed follow-up joins the request context.
- Response policy: origin channel or DM. Sensitive commands default to DM. - Response policy: origin channel or DM. Sensitive commands default to DM.
- Persistent action log: triggers, commands, replies, errors, rotation, and secret redaction. - Persistent action log: triggers, commands, replies, errors, rotation, and secret redaction.
- Chat command: `poll` with number-emoji reactions, one vote per user, and auto results.
- MCP tools: context, config, voice state, action log, and a read-only batch tool. - MCP tools: context, config, voice state, action log, and a read-only batch tool.
- Event processing is serialized. Replayed events are dropped. - Event processing is serialized. Replayed events are dropped.
- Unit tests for the pure logic and the cache. Mock end-to-end tests pass. - Unit tests for the pure logic and the cache. Mock end-to-end tests pass.
+2
View File
@@ -34,6 +34,8 @@ Done:
- Response policy: origin channel or DM. Sensitive commands default to DM. - Response policy: origin channel or DM. Sensitive commands default to DM.
- Persistent action log with rotation and secret redaction. - Persistent action log with rotation and secret redaction.
- MCP batch tool for read-only commands, plus a voice state tool. - MCP batch tool for read-only commands, plus a voice state tool.
- Reaction poll command: `poll <question> | opt 1 | opt 2 [| opt 3 [| opt 4]]`. Number emojis,
one vote per user, auto results after `pollSeconds`.
Next: Next:
+16
View File
@@ -37,6 +37,7 @@ mention the puppet first.
| `whitelist list` | Show the owner and the trusted users with their tiers. | | `whitelist list` | Show the owner and the trusted users with their tiers. |
| `whitelist add @user [user\|trusted\|admin]` | Add one or more users. The default tier is `user`. | | `whitelist add @user [user\|trusted\|admin]` | Add one or more users. The default tier is `user`. |
| `whitelist remove @user` | Remove one or more users from the trust list. | | `whitelist remove @user` | Remove one or more users from the trust list. |
| `poll <question> \| <opt 1> \| <opt 2> [\| opt 3 [\| opt 4]]` | Start a reaction poll. |
Notes: Notes:
@@ -45,6 +46,21 @@ Notes:
- `whitelist` commands need the admin tier. - `whitelist` commands need the admin tier.
- The reply follows the response policy. - The reply follows the response policy.
## Polls
The `poll` command posts a message and adds one number emoji per option. Discord users vote by
reacting. The poll closes after `pollSeconds` (default 120) and the daemon posts the result.
- Options: 2 to 4. Each option is at most 80 characters.
- Emojis: `1️⃣`, `2️⃣`, `3️⃣`, `4️⃣`.
- One vote per user. A new vote replaces the old vote.
- Removing the reaction removes the vote.
- The puppet's own reactions do not count.
- Votes do not need a trust tier. Friends can vote.
- The result message goes to the poll channel.
Example: `@puppet poll Lunch? | Pizza | Sushi`
## Response policy ## Response policy
The policy decides where a reply goes. Sensitive replies go by DM. The policy decides where a reply goes. Sensitive replies go by DM.
+5 -1
View File
@@ -58,9 +58,10 @@ Event line:
| `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. | | `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. |
| `state.get` | none | The current user, the guilds, and the channels. Cache-only. | | `state.get` | none | The current user, the guilds, and the channels. Cache-only. |
| `voice.get` | optional `userId`, optional `guildId` | The voice channel and its members, or `channelId: null` | | `voice.get` | optional `userId`, optional `guildId` | The voice channel and its members, or `channelId: null` |
| `message.send` | `channelId`, `content` | `{ channelId, sent: true }` | | `message.send` | `channelId`, `content` | `{ channelId, messageId, sent: true }` |
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. | | `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. |
| `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. | | `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. |
| `reaction.add` | `channelId`, `messageId`, `emoji` | `{ added: true }` |
| `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` | | `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` |
## Validation rules ## Validation rules
@@ -78,6 +79,8 @@ The core validates all input. The daemon cannot bypass these rules.
- `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them - `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them
to walk history. to walk history.
- `voice.get` accepts snowflakes only. It reads the client cache. It never joins a call. - `voice.get` accepts snowflakes only. It reads the client cache. It never joins a call.
- `reaction.add` accepts a short emoji string without line breaks. It counts as an outbound action
for the rate limit.
- A request line must not be larger than 64 KiB. - A request line must not be larger than 64 KiB.
## Core rate limit ## Core rate limit
@@ -104,6 +107,7 @@ The core rate limit is a safety net. It does not replace the daemon pacing.
| `CHANNEL_NOT_FOUND` | The channel is not in the client cache. | | `CHANNEL_NOT_FOUND` | The channel is not in the client cache. |
| `NOT_SENDABLE` | The channel type cannot receive messages. | | `NOT_SENDABLE` | The channel type cannot receive messages. |
| `SEND_FAILED` | Discord rejected the message. | | `SEND_FAILED` | Discord rejected the message. |
| `REACTION_FAILED` | Discord rejected the reaction. |
| `DM_UNAVAILABLE` | The client cannot create a DM channel. | | `DM_UNAVAILABLE` | The client cannot create a DM channel. |
| `NOT_SUPPORTED` | The client does not offer the internal action. | | `NOT_SUPPORTED` | The client does not offer the internal action. |
| `RATE_LIMITED` | The core rate limit rejected the action. | | `RATE_LIMITED` | The core rate limit rejected the action. |
+20
View File
@@ -187,6 +187,26 @@ export function parseTier(value: string): TrustTier | undefined {
return value === "user" || value === "trusted" || value === "admin" ? value : 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. */ /* Secret redaction for persistent logs. Keep it minimal. */
const SECRET_PATTERNS = [ const SECRET_PATTERNS = [
+1
View File
@@ -179,6 +179,7 @@ function buildServer(): McpServer {
contextChars: z.number().int().min(200).max(20000).optional(), contextChars: z.number().int().min(200).max(20000).optional(),
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(), fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
continuationSeconds: 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 responsePolicy: z
.object({ .object({
defaultMode: z.enum(["channel", "dm"]).optional(), defaultMode: z.enum(["channel", "dm"]).optional(),
+5 -1
View File
@@ -120,7 +120,11 @@ export class CoreClient {
} }
async sendMessage(channelId: string, content: string) { 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) { async sendDm(userId: string, content: string) {
+3 -1
View File
@@ -61,7 +61,9 @@ function handle(action: string | undefined, params: any) {
] ]
}; };
case "message.send": 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": case "message.read":
return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES }; return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES };
case "message.fetch": { case "message.fetch": {
+123 -1
View File
@@ -16,7 +16,9 @@ import {
hasTier, hasTier,
MessageLog, MessageLog,
parseCommand, parseCommand,
parsePoll,
parseTier, parseTier,
POLL_EMOJIS,
randomActionDelay, randomActionDelay,
stripMentions, stripMentions,
toCachedMessage, toCachedMessage,
@@ -47,6 +49,7 @@ export interface PuppetConfig {
contextChars: number; contextChars: number;
fetchCooldownSeconds: number; fetchCooldownSeconds: number;
continuationSeconds: number; continuationSeconds: number;
pollSeconds: number;
responsePolicy: ResponsePolicy; responsePolicy: ResponsePolicy;
} }
@@ -55,6 +58,7 @@ export interface PuppetConfigPatch {
contextChars?: number; contextChars?: number;
fetchCooldownSeconds?: number; fetchCooldownSeconds?: number;
continuationSeconds?: number; continuationSeconds?: number;
pollSeconds?: number;
responsePolicy?: { responsePolicy?: {
defaultMode?: "channel" | "dm"; defaultMode?: "channel" | "dm";
sensitiveCommands?: string[]; sensitiveCommands?: string[];
@@ -67,6 +71,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
contextChars: 4000, contextChars: 4000,
fetchCooldownSeconds: 5, fetchCooldownSeconds: 5,
continuationSeconds: 10, continuationSeconds: 10,
pollSeconds: 120,
responsePolicy: { responsePolicy: {
defaultMode: "channel", defaultMode: "channel",
sensitiveCommands: ["whitelist", "config"], sensitiveCommands: ["whitelist", "config"],
@@ -83,13 +88,24 @@ interface PersistedState {
/** The transport methods that the puppet logic needs. */ /** The transport methods that the puppet logic needs. */
export interface PuppetTransport { export interface PuppetTransport {
getSelfId(): Promise<string | undefined>; 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>; sendDm(userId: string, content: string): Promise<unknown>;
fetchMessages( fetchMessages(
channelId: string, channelId: string,
limit: number, limit: number,
options?: { before?: string; after?: string } options?: { before?: string; after?: string }
): Promise<{ messages?: unknown[] }>; ): 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 { function clamp(value: number, min: number, max: number): number {
@@ -108,6 +124,7 @@ export class Puppet {
private readonly seenOrder: string[] = []; private readonly seenOrder: string[] = [];
private readonly lastFetchAt = new Map<string, number>(); private readonly lastFetchAt = new Map<string, number>();
private readonly pending = new Map<string, { entryId: string; expiresAt: number }>(); private readonly pending = new Map<string, { entryId: string; expiresAt: number }>();
private readonly polls = new Map<string, PollState>();
private ownerId = DEFAULT_OWNER_ID; private ownerId = DEFAULT_OWNER_ID;
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG); private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
@@ -143,6 +160,7 @@ export class Puppet {
0, 0,
300 300
), ),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
responsePolicy: { responsePolicy: {
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode, defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
sensitiveCommands: sensitiveCommands:
@@ -189,6 +207,7 @@ export class Puppet {
300 300
), ),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300), continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
responsePolicy: { responsePolicy: {
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode, defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands, sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
@@ -307,6 +326,11 @@ export class Puppet {
} }
async handleEvent(event: any) { 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; if (event?.type !== "MESSAGE_CREATE") return;
const { author, channelId, guildId, channelType, content, mentions } = event; const { author, channelId, guildId, channelType, content, mentions } = event;
@@ -406,6 +430,8 @@ export class Puppet {
if (command?.name === "whitelist") { if (command?.name === "whitelist") {
await this.handleWhitelistCommand(command, author.id, tier, channelId); 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 */ /* Replies */
private async respond( private async respond(
+10
View File
@@ -17,6 +17,7 @@ import {
hasTier, hasTier,
isTrusted, isTrusted,
parseCommand, parseCommand,
parsePoll,
parseTier, parseTier,
randomActionDelay, randomActionDelay,
redactSecrets, redactSecrets,
@@ -176,4 +177,13 @@ function entry(id: number, trusted: boolean): PuppetMessage {
assert.equal(log.find("nope"), undefined); 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"); 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 guildId = process.env.TEST_TRIGGER_GUILD;
const isGuild = !!guildId; const isGuild = !!guildId;
const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0"; const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
const isReaction = process.env.TEST_TRIGGER_REACTION === "1";
const event = { const messageEvent = {
type: "MESSAGE_CREATE", type: "MESSAGE_CREATE",
id: `30000000000000${Date.now() % 10000}`, id: `30000000000000${Date.now() % 10000}`,
channelId: process.env.TEST_TRIGGER_CHANNEL ?? (isGuild ? "200000000000000010" : "805255536223715370"), channelId: process.env.TEST_TRIGGER_CHANNEL ?? (isGuild ? "200000000000000010" : "805255536223715370"),
@@ -30,6 +31,17 @@ const event = {
timestamp: new Date().toISOString() 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) => { await new Promise<void>((resolve, reject) => {
const socket = net.connect(coreSocketPath); const socket = net.connect(coreSocketPath);
socket.setEncoding("utf-8"); socket.setEncoding("utf-8");
+1
View File
@@ -38,6 +38,7 @@ const KNOWN_ACTIONS = new Set<string>([
PuppetAction.MessageSend, PuppetAction.MessageSend,
PuppetAction.MessageRead, PuppetAction.MessageRead,
PuppetAction.MessageFetch, PuppetAction.MessageFetch,
PuppetAction.ReactionAdd,
PuppetAction.DmSend PuppetAction.DmSend
]); ]);
+53 -4
View File
@@ -24,6 +24,9 @@ const SENDABLE_CHANNEL_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
/** Discord's message fetch action, located lazily at runtime. */ /** Discord's message fetch action, located lazily at runtime. */
const MessageActions = findByPropsLazy("fetchMessages"); const MessageActions = findByPropsLazy("fetchMessages");
/** Discord's reaction actions, located lazily at runtime. */
const ReactionActions = findByPropsLazy("addReaction", "removeReaction");
function compareIds(a: string, b: string): number { function compareIds(a: string, b: string): number {
try { try {
const left = BigInt(a); const left = BigInt(a);
@@ -122,14 +125,30 @@ function buildState() {
}; };
} }
async function send(channelId: string, content: string) { async function send(channelId: string, content: string): Promise<unknown> {
try { try {
await sendMessage(channelId, { content }, false); return await sendMessage(channelId, { content }, false);
} catch (error) { } catch (error) {
throw new ActionError(PuppetErrorCode.SendFailed, `Discord rejected the message: ${String(error)}`); throw new ActionError(PuppetErrorCode.SendFailed, `Discord rejected the message: ${String(error)}`);
} }
} }
/** Find the ID of a just-sent message. Use the send result first, then the store. */
async function resolveMessageId(raw: unknown, channelId: string, content: string): Promise<string | undefined> {
if (raw && typeof raw === "object" && typeof (raw as any).id === "string") return (raw as any).id;
const selfId = UserStore.getCurrentUser()?.id;
for (let attempt = 0; attempt < 10; attempt++) {
const messages: any[] = MessageStore.getMessages(channelId)?._array ?? [];
const match = [...messages].reverse().find(message => message.author?.id === selfId && message.content === content);
if (match) return match.id;
await new Promise(resolve => setTimeout(resolve, 100));
}
return undefined;
}
async function resolveDmChannel(userId: string): Promise<string> { async function resolveDmChannel(userId: string): Promise<string> {
let channelId = ChannelStore.getDMFromUserId(userId); let channelId = ChannelStore.getDMFromUserId(userId);
if (!channelId) { if (!channelId) {
@@ -204,8 +223,38 @@ export async function handlePuppetAction(
getSendableChannel(channelId); getSendableChannel(channelId);
enforceOutboundRateLimit(); enforceOutboundRateLimit();
await send(channelId, content); const messageId = await resolveMessageId(await send(channelId, content), channelId, content);
return ok({ channelId, sent: true }); return ok({ channelId, messageId, sent: true });
}
case PuppetAction.ReactionAdd: {
const { channelId, messageId, emoji } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
assert(isSnowflake(messageId), PuppetErrorCode.InvalidSnowflake, "messageId must be a snowflake");
assert(
typeof emoji === "string" && emoji.length > 0 && emoji.length <= 32 && !/[\r\n]/.test(emoji),
PuppetErrorCode.InvalidParam,
"emoji must be a short string"
);
enforceOutboundRateLimit();
const addReaction = (ReactionActions as any)?.addReaction;
assert(
typeof addReaction === "function",
PuppetErrorCode.NotSupported,
"reaction action is not available in this client"
);
try {
await addReaction(channelId, messageId, emoji);
} catch (error) {
throw new ActionError(
PuppetErrorCode.ReactionFailed,
`Discord rejected the reaction: ${String(error)}`
);
}
return ok({ channelId, messageId, emoji, added: true });
} }
case PuppetAction.MessageRead: { case PuppetAction.MessageRead: {
+34
View File
@@ -34,6 +34,22 @@ function normalizeMessage(event: any): PuppetEvent | undefined {
}; };
} }
function normalizeReaction(event: any, type: "MESSAGE_REACTION_ADD" | "MESSAGE_REACTION_REMOVE"): PuppetEvent | undefined {
if (!event?.messageId || !event?.userId) return undefined;
return {
type,
messageId: event.messageId,
channelId: event.channelId,
guildId: event.guildId ?? null,
userId: event.userId,
emoji: {
name: event.emoji?.name ?? null,
id: event.emoji?.id ?? null
}
};
}
let installed = false; let installed = false;
export function initPuppetEvents() { export function initPuppetEvents() {
@@ -50,6 +66,24 @@ export function initPuppetEvents() {
} }
}); });
dispatcher.subscribe("MESSAGE_REACTION_ADD", (event: any) => {
try {
const normalized = normalizeReaction(event, "MESSAGE_REACTION_ADD");
if (normalized) VesktopNative.puppet.emit(normalized);
} catch (error) {
console.error("[puppet] reaction normalization failed:", error);
}
});
dispatcher.subscribe("MESSAGE_REACTION_REMOVE", (event: any) => {
try {
const normalized = normalizeReaction(event, "MESSAGE_REACTION_REMOVE");
if (normalized) VesktopNative.puppet.emit(normalized);
} catch (error) {
console.error("[puppet] reaction normalization failed:", error);
}
});
console.log("[veskslop] puppet events active"); console.log("[veskslop] puppet events active");
}); });
} }
+3
View File
@@ -14,6 +14,7 @@ export const enum PuppetAction {
MessageSend = "message.send", MessageSend = "message.send",
MessageRead = "message.read", MessageRead = "message.read",
MessageFetch = "message.fetch", MessageFetch = "message.fetch",
ReactionAdd = "reaction.add",
DmSend = "dm.send" DmSend = "dm.send"
} }
@@ -25,6 +26,7 @@ export const PuppetActions: readonly PuppetAction[] = [
PuppetAction.MessageSend, PuppetAction.MessageSend,
PuppetAction.MessageRead, PuppetAction.MessageRead,
PuppetAction.MessageFetch, PuppetAction.MessageFetch,
PuppetAction.ReactionAdd,
PuppetAction.DmSend PuppetAction.DmSend
]; ];
@@ -41,6 +43,7 @@ export const enum PuppetErrorCode {
ChannelNotFound = "CHANNEL_NOT_FOUND", ChannelNotFound = "CHANNEL_NOT_FOUND",
NotSendable = "NOT_SENDABLE", NotSendable = "NOT_SENDABLE",
SendFailed = "SEND_FAILED", SendFailed = "SEND_FAILED",
ReactionFailed = "REACTION_FAILED",
DmUnavailable = "DM_UNAVAILABLE", DmUnavailable = "DM_UNAVAILABLE",
NotSupported = "NOT_SUPPORTED", NotSupported = "NOT_SUPPORTED",
RateLimited = "RATE_LIMITED", RateLimited = "RATE_LIMITED",