Add poll chat command with reaction voting
This commit is contained in:
@@ -53,6 +53,7 @@ before you stop.
|
||||
| Trusted-only triggers, trust tiers | Implemented |
|
||||
| Context cache, fetch cooldown, continuation | Implemented |
|
||||
| Response policy (channel or DM) | Implemented |
|
||||
| Chat commands (`poll`) | Implemented |
|
||||
| Persistent action log | Implemented |
|
||||
| Voice state read | Implemented |
|
||||
| Voice actions, notification parsing | Not started |
|
||||
|
||||
@@ -24,6 +24,7 @@ persistent logging. Voice, audio, and video stay deferred.
|
||||
- Continuation window: a rushed follow-up joins the request context.
|
||||
- Response policy: origin channel or DM. Sensitive commands default to DM.
|
||||
- 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.
|
||||
- Event processing is serialized. Replayed events are dropped.
|
||||
- Unit tests for the pure logic and the cache. Mock end-to-end tests pass.
|
||||
|
||||
@@ -34,6 +34,8 @@ Done:
|
||||
- Response policy: origin channel or DM. Sensitive commands default to DM.
|
||||
- Persistent action log with rotation and secret redaction.
|
||||
- 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:
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ mention the puppet first.
|
||||
| `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 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:
|
||||
|
||||
@@ -45,6 +46,21 @@ Notes:
|
||||
- `whitelist` commands need the admin tier.
|
||||
- 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
|
||||
|
||||
The policy decides where a reply goes. Sensitive replies go by DM.
|
||||
|
||||
+5
-1
@@ -58,9 +58,10 @@ Event line:
|
||||
| `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. |
|
||||
| `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` |
|
||||
| `message.send` | `channelId`, `content` | `{ channelId, sent: true }` |
|
||||
| `message.send` | `channelId`, `content` | `{ channelId, messageId, sent: true }` |
|
||||
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. |
|
||||
| `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 }` |
|
||||
|
||||
## 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
|
||||
to walk history.
|
||||
- `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.
|
||||
|
||||
## 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. |
|
||||
| `NOT_SENDABLE` | The channel type cannot receive messages. |
|
||||
| `SEND_FAILED` | Discord rejected the message. |
|
||||
| `REACTION_FAILED` | Discord rejected the reaction. |
|
||||
| `DM_UNAVAILABLE` | The client cannot create a DM channel. |
|
||||
| `NOT_SUPPORTED` | The client does not offer the internal action. |
|
||||
| `RATE_LIMITED` | The core rate limit rejected the action. |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -38,6 +38,7 @@ const KNOWN_ACTIONS = new Set<string>([
|
||||
PuppetAction.MessageSend,
|
||||
PuppetAction.MessageRead,
|
||||
PuppetAction.MessageFetch,
|
||||
PuppetAction.ReactionAdd,
|
||||
PuppetAction.DmSend
|
||||
]);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
const MessageActions = findByPropsLazy("fetchMessages");
|
||||
|
||||
/** Discord's reaction actions, located lazily at runtime. */
|
||||
const ReactionActions = findByPropsLazy("addReaction", "removeReaction");
|
||||
|
||||
function compareIds(a: string, b: string): number {
|
||||
try {
|
||||
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 {
|
||||
await sendMessage(channelId, { content }, false);
|
||||
return await sendMessage(channelId, { content }, false);
|
||||
} catch (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> {
|
||||
let channelId = ChannelStore.getDMFromUserId(userId);
|
||||
if (!channelId) {
|
||||
@@ -204,8 +223,38 @@ export async function handlePuppetAction(
|
||||
getSendableChannel(channelId);
|
||||
enforceOutboundRateLimit();
|
||||
|
||||
await send(channelId, content);
|
||||
return ok({ channelId, sent: true });
|
||||
const messageId = await resolveMessageId(await send(channelId, content), channelId, content);
|
||||
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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const enum PuppetAction {
|
||||
MessageSend = "message.send",
|
||||
MessageRead = "message.read",
|
||||
MessageFetch = "message.fetch",
|
||||
ReactionAdd = "reaction.add",
|
||||
DmSend = "dm.send"
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export const PuppetActions: readonly PuppetAction[] = [
|
||||
PuppetAction.MessageSend,
|
||||
PuppetAction.MessageRead,
|
||||
PuppetAction.MessageFetch,
|
||||
PuppetAction.ReactionAdd,
|
||||
PuppetAction.DmSend
|
||||
];
|
||||
|
||||
@@ -41,6 +43,7 @@ export const enum PuppetErrorCode {
|
||||
ChannelNotFound = "CHANNEL_NOT_FOUND",
|
||||
NotSendable = "NOT_SENDABLE",
|
||||
SendFailed = "SEND_FAILED",
|
||||
ReactionFailed = "REACTION_FAILED",
|
||||
DmUnavailable = "DM_UNAVAILABLE",
|
||||
NotSupported = "NOT_SUPPORTED",
|
||||
RateLimited = "RATE_LIMITED",
|
||||
|
||||
Reference in New Issue
Block a user