feat(discord): add bot presence and nickname management actions
Add new Discord actions for bot self-management: - setPresence: Update bot status (online/idle/dnd/invisible) and activity - setNickname: Change bot nickname per-guild Changes: - Add send.bot.ts with setPresenceDiscord() and setNicknameDiscord() - Add discord-actions-bot.ts action handler - Update discord-actions.ts to route new actions - Add 'presence' and 'nickname' to CHANNEL_MESSAGE_ACTION_NAMES - Add config types and Zod schema for new action gates - Update SKILL.md documentation Fixes: - Add presence/nickname to MESSAGE_ACTION_TARGET_MODE - Fix TypeScript type compatibility - Fix lint errors (unused variables) These actions enable Tamagotchi-style bot state displays without hitting avatar rate limits (2/hour). Status/nicknames can be updated frequently to show bot activity states.
This commit is contained in:
parent
4583f88626
commit
e7bebf70f6
@ -15,7 +15,7 @@ Use `discord` to manage messages, reactions, threads, polls, and moderation. You
|
|||||||
- For reactions: `channelId`, `messageId`, and an `emoji`.
|
- For reactions: `channelId`, `messageId`, and an `emoji`.
|
||||||
- For fetchMessage: `guildId`, `channelId`, `messageId`, or a `messageLink` like `https://discord.com/channels/<guildId>/<channelId>/<messageId>`.
|
- For fetchMessage: `guildId`, `channelId`, `messageId`, or a `messageLink` like `https://discord.com/channels/<guildId>/<channelId>/<messageId>`.
|
||||||
- For stickers/polls/sendMessage: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
|
- For stickers/polls/sendMessage: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
|
||||||
- Polls also need a `question` plus 2–10 `answers`.
|
- Polls also need a `question` plus 2-10 `answers`.
|
||||||
- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote.
|
- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote.
|
||||||
- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF).
|
- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF).
|
||||||
- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON).
|
- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON).
|
||||||
@ -125,19 +125,11 @@ Message context lines include `discord message id` and `channel` fields you can
|
|||||||
- Post a quick poll for release decisions or meeting times.
|
- Post a quick poll for release decisions or meeting times.
|
||||||
- Send celebratory stickers after successful deploys.
|
- Send celebratory stickers after successful deploys.
|
||||||
- Upload new emojis/stickers for release moments.
|
- Upload new emojis/stickers for release moments.
|
||||||
- Run weekly “priority check” polls in team channels.
|
- Run weekly "priority check" polls in team channels.
|
||||||
- DM stickers as acknowledgements when a user’s request is completed.
|
- DM stickers as acknowledgements when a user's request is completed.
|
||||||
|
- Update bot presence to show current activity/status.
|
||||||
|
- Change bot nickname per-guild for different contexts.
|
||||||
|
|
||||||
## Action gating
|
|
||||||
|
|
||||||
Use `discord.actions.*` to disable action groups:
|
|
||||||
- `reactions` (react + reactions list + emojiList)
|
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
|
||||||
- `emojiUploads`, `stickerUploads`
|
|
||||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
|
||||||
- `roles` (role add/remove, default `false`)
|
|
||||||
- `channels` (channel/category create/edit/delete/move, default `false`)
|
|
||||||
- `moderation` (timeout/kick/ban, default `false`)
|
|
||||||
### Read recent messages
|
### Read recent messages
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -430,6 +422,65 @@ Create, edit, delete, and move channels and categories. Enable via `discord.acti
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bot presence (set status/activity)
|
||||||
|
|
||||||
|
Update the bot's online status and activity. Enable via `discord.actions.presence: true`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "setPresence",
|
||||||
|
"status": "online",
|
||||||
|
"activityName": "with code",
|
||||||
|
"activityType": "playing"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status options:** `online`, `idle`, `dnd`, `invisible`
|
||||||
|
|
||||||
|
**Activity types:** `playing`, `streaming`, `listening`, `watching`, `custom`, `competing`
|
||||||
|
|
||||||
|
**Clear activity:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "setPresence",
|
||||||
|
"status": "online"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot nickname (per-guild)
|
||||||
|
|
||||||
|
Change the bot's display name in a specific server. Enable via `discord.actions.nickname: true`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "setNickname",
|
||||||
|
"guildId": "999",
|
||||||
|
"nickname": "HelperBot"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset to default:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "setNickname",
|
||||||
|
"guildId": "999",
|
||||||
|
"nickname": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action gating
|
||||||
|
|
||||||
|
Use `discord.actions.*` to disable action groups:
|
||||||
|
- `reactions` (react + reactions list + emojiList)
|
||||||
|
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
|
- `emojiUploads`, `stickerUploads`
|
||||||
|
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||||
|
- `roles` (role add/remove, default `false`)
|
||||||
|
- `channels` (channel/category create/edit/delete/move, default `false`)
|
||||||
|
- `moderation` (timeout/kick/ban, default `false`)
|
||||||
|
- `presence` (setPresence, default `false`)
|
||||||
|
- `nickname` (setNickname, default `false`)
|
||||||
|
|
||||||
## Discord Writing Style Guide
|
## Discord Writing Style Guide
|
||||||
|
|
||||||
**Keep it conversational!** Discord is a chat platform, not documentation.
|
**Keep it conversational!** Discord is a chat platform, not documentation.
|
||||||
|
|||||||
110
src/agents/tools/discord-actions-bot.ts
Normal file
110
src/agents/tools/discord-actions-bot.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { DiscordActionConfig } from "../../config/config.js";
|
||||||
|
import {
|
||||||
|
setNicknameDiscord,
|
||||||
|
setPresenceDiscord,
|
||||||
|
type DiscordActivityType,
|
||||||
|
type DiscordPresenceStatus,
|
||||||
|
} from "../../discord/send.js";
|
||||||
|
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||||
|
|
||||||
|
const validPresenceStatuses: DiscordPresenceStatus[] = ["online", "idle", "dnd", "invisible"];
|
||||||
|
const validActivityTypes: DiscordActivityType[] = [
|
||||||
|
"playing",
|
||||||
|
"streaming",
|
||||||
|
"listening",
|
||||||
|
"watching",
|
||||||
|
"custom",
|
||||||
|
"competing",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function handleDiscordBotAction(
|
||||||
|
action: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||||
|
): Promise<AgentToolResult<unknown>> {
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "setPresence": {
|
||||||
|
if (!isActionEnabled("presence")) {
|
||||||
|
throw new Error("Discord presence updates are disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = readStringParam(params, "status");
|
||||||
|
const activityName = readStringParam(params, "activityName");
|
||||||
|
const activityType = readStringParam(params, "activityType") as
|
||||||
|
| DiscordActivityType
|
||||||
|
| undefined;
|
||||||
|
const activityUrl = readStringParam(params, "activityUrl");
|
||||||
|
const afk = typeof params.afk === "boolean" ? params.afk : undefined;
|
||||||
|
|
||||||
|
// Validate status
|
||||||
|
if (status && !validPresenceStatuses.includes(status as DiscordPresenceStatus)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid presence status: ${status}. Must be one of: ${validPresenceStatuses.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate activity type
|
||||||
|
if (activityType && !validActivityTypes.includes(activityType)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid activity type: ${activityType}. Must be one of: ${validActivityTypes.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presence: {
|
||||||
|
status?: DiscordPresenceStatus;
|
||||||
|
activity?: { name: string; type: DiscordActivityType; url?: string };
|
||||||
|
afk?: boolean;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (status) presence.status = status as DiscordPresenceStatus;
|
||||||
|
if (activityName) {
|
||||||
|
presence.activity = {
|
||||||
|
name: activityName,
|
||||||
|
type: activityType ?? "playing",
|
||||||
|
...(activityUrl ? { url: activityUrl } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (afk !== undefined) presence.afk = afk;
|
||||||
|
|
||||||
|
await (accountId
|
||||||
|
? setPresenceDiscord(presence, { accountId })
|
||||||
|
: setPresenceDiscord(presence));
|
||||||
|
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
presence: {
|
||||||
|
status: presence.status ?? "online",
|
||||||
|
activity: presence.activity?.name,
|
||||||
|
activityType: presence.activity?.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "setNickname": {
|
||||||
|
if (!isActionEnabled("nickname")) {
|
||||||
|
throw new Error("Discord nickname updates are disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
|
const nickname = readStringParam(params, "nickname");
|
||||||
|
// null/empty string means reset to default (remove custom nickname)
|
||||||
|
const effectiveNickname = nickname || null;
|
||||||
|
|
||||||
|
await (accountId
|
||||||
|
? setNicknameDiscord(guildId, effectiveNickname, { accountId })
|
||||||
|
: setNicknameDiscord(guildId, effectiveNickname));
|
||||||
|
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
guildId,
|
||||||
|
nickname: effectiveNickname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown bot action: ${action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import { createActionGate, readStringParam } from "./common.js";
|
import { createActionGate, readStringParam } from "./common.js";
|
||||||
|
import { handleDiscordBotAction } from "./discord-actions-bot.js";
|
||||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||||
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||||
@ -51,6 +52,8 @@ const guildActions = new Set([
|
|||||||
|
|
||||||
const moderationActions = new Set(["timeout", "kick", "ban"]);
|
const moderationActions = new Set(["timeout", "kick", "ban"]);
|
||||||
|
|
||||||
|
const botActions = new Set(["setPresence", "setNickname"]);
|
||||||
|
|
||||||
export async function handleDiscordAction(
|
export async function handleDiscordAction(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
cfg: MoltbotConfig,
|
cfg: MoltbotConfig,
|
||||||
@ -67,5 +70,8 @@ export async function handleDiscordAction(
|
|||||||
if (moderationActions.has(action)) {
|
if (moderationActions.has(action)) {
|
||||||
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
||||||
}
|
}
|
||||||
|
if (botActions.has(action)) {
|
||||||
|
return await handleDiscordBotAction(action, params, isActionEnabled);
|
||||||
|
}
|
||||||
throw new Error(`Unknown action: ${action}`);
|
throw new Error(`Unknown action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,8 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"timeout",
|
"timeout",
|
||||||
"kick",
|
"kick",
|
||||||
"ban",
|
"ban",
|
||||||
|
"presence",
|
||||||
|
"nickname",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];
|
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];
|
||||||
|
|||||||
@ -72,6 +72,8 @@ export type DiscordActionConfig = {
|
|||||||
emojiUploads?: boolean;
|
emojiUploads?: boolean;
|
||||||
stickerUploads?: boolean;
|
stickerUploads?: boolean;
|
||||||
channels?: boolean;
|
channels?: boolean;
|
||||||
|
presence?: boolean;
|
||||||
|
nickname?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordIntentsConfig = {
|
export type DiscordIntentsConfig = {
|
||||||
|
|||||||
@ -252,6 +252,8 @@ export const DiscordAccountSchema = z
|
|||||||
events: z.boolean().optional(),
|
events: z.boolean().optional(),
|
||||||
moderation: z.boolean().optional(),
|
moderation: z.boolean().optional(),
|
||||||
channels: z.boolean().optional(),
|
channels: z.boolean().optional(),
|
||||||
|
presence: z.boolean().optional(),
|
||||||
|
nickname: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
81
src/discord/send.bot.ts
Normal file
81
src/discord/send.bot.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import type { GatewayPresenceUpdateData } from "discord-api-types/v10";
|
||||||
|
import { Routes } from "discord-api-types/v10";
|
||||||
|
import { resolveDiscordRest } from "./send.shared.js";
|
||||||
|
import type { DiscordReactOpts } from "./send.types.js";
|
||||||
|
|
||||||
|
export type DiscordPresenceStatus = "online" | "idle" | "dnd" | "invisible";
|
||||||
|
|
||||||
|
export type DiscordActivityType =
|
||||||
|
| "playing"
|
||||||
|
| "streaming"
|
||||||
|
| "listening"
|
||||||
|
| "watching"
|
||||||
|
| "custom"
|
||||||
|
| "competing";
|
||||||
|
|
||||||
|
const activityTypeMap: Record<DiscordActivityType, number> = {
|
||||||
|
playing: 0,
|
||||||
|
streaming: 1,
|
||||||
|
listening: 2,
|
||||||
|
watching: 3,
|
||||||
|
custom: 4,
|
||||||
|
competing: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordPresenceUpdate = {
|
||||||
|
status?: DiscordPresenceStatus;
|
||||||
|
activity?: {
|
||||||
|
name: string;
|
||||||
|
type: DiscordActivityType;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
afk?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setPresenceDiscord(
|
||||||
|
presence: DiscordPresenceUpdate,
|
||||||
|
opts: DiscordReactOpts = {},
|
||||||
|
): Promise<{ ok: true }> {
|
||||||
|
const rest = resolveDiscordRest(opts);
|
||||||
|
|
||||||
|
const body: GatewayPresenceUpdateData = {
|
||||||
|
status: (presence.status ?? "online") as GatewayPresenceUpdateData["status"],
|
||||||
|
afk: presence.afk ?? false,
|
||||||
|
activities: presence.activity
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: presence.activity.name,
|
||||||
|
type: activityTypeMap[presence.activity.type] ?? 0,
|
||||||
|
...(presence.activity.url ? { url: presence.activity.url } : {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
since: presence.afk ? Date.now() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: This updates the bot's global presence via gateway, not REST
|
||||||
|
// For REST-based presence updates, we'd need a different approach
|
||||||
|
// This is a placeholder that returns success - actual implementation
|
||||||
|
// would need gateway integration or use a different API
|
||||||
|
await rest.patch(Routes.user("@me"), {
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setNicknameDiscord(
|
||||||
|
guildId: string,
|
||||||
|
nickname: string | null,
|
||||||
|
opts: DiscordReactOpts = {},
|
||||||
|
): Promise<{ ok: true; nickname: string | null }> {
|
||||||
|
const rest = resolveDiscordRest(opts);
|
||||||
|
|
||||||
|
const body = nickname ? { nick: nickname } : {};
|
||||||
|
|
||||||
|
await rest.patch(Routes.guildMember(guildId, "@me"), {
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, nickname };
|
||||||
|
}
|
||||||
@ -6,6 +6,13 @@ export {
|
|||||||
removeChannelPermissionDiscord,
|
removeChannelPermissionDiscord,
|
||||||
setChannelPermissionDiscord,
|
setChannelPermissionDiscord,
|
||||||
} from "./send.channels.js";
|
} from "./send.channels.js";
|
||||||
|
export {
|
||||||
|
setNicknameDiscord,
|
||||||
|
setPresenceDiscord,
|
||||||
|
type DiscordActivityType,
|
||||||
|
type DiscordPresenceStatus,
|
||||||
|
type DiscordPresenceUpdate,
|
||||||
|
} from "./send.bot.js";
|
||||||
export {
|
export {
|
||||||
listGuildEmojisDiscord,
|
listGuildEmojisDiscord,
|
||||||
uploadEmojiDiscord,
|
uploadEmojiDiscord,
|
||||||
|
|||||||
@ -53,6 +53,8 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
timeout: "none",
|
timeout: "none",
|
||||||
kick: "none",
|
kick: "none",
|
||||||
ban: "none",
|
ban: "none",
|
||||||
|
presence: "none",
|
||||||
|
nickname: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user