This commit is contained in:
CruecialCode 2026-01-30 11:55:31 +00:00 committed by GitHub
commit da885ef8b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 276 additions and 13 deletions

View File

@ -15,7 +15,7 @@ Use `discord` to manage messages, reactions, threads, polls, and moderation. You
- For reactions: `channelId`, `messageId`, and an `emoji`.
- 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.
- Polls also need a `question` plus 210 `answers`.
- Polls also need a `question` plus 2-10 `answers`.
- 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 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.
- Send celebratory stickers after successful deploys.
- Upload new emojis/stickers for release moments.
- Run weekly “priority check” polls in team channels.
- DM stickers as acknowledgements when a users request is completed.
- Run weekly "priority check" polls in team channels.
- 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
```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
**Keep it conversational!** Discord is a chat platform, not documentation.

View 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}`);
}
}

View File

@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { createActionGate, readStringParam } from "./common.js";
import { handleDiscordBotAction } from "./discord-actions-bot.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
@ -51,6 +52,8 @@ const guildActions = new Set([
const moderationActions = new Set(["timeout", "kick", "ban"]);
const botActions = new Set(["setPresence", "setNickname"]);
export async function handleDiscordAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
@ -67,5 +70,8 @@ export async function handleDiscordAction(
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled);
}
if (botActions.has(action)) {
return await handleDiscordBotAction(action, params, isActionEnabled);
}
throw new Error(`Unknown action: ${action}`);
}

View File

@ -48,6 +48,8 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"timeout",
"kick",
"ban",
"presence",
"nickname",
] as const;
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];

View File

@ -72,6 +72,8 @@ export type DiscordActionConfig = {
emojiUploads?: boolean;
stickerUploads?: boolean;
channels?: boolean;
presence?: boolean;
nickname?: boolean;
};
export type DiscordIntentsConfig = {

View File

@ -252,6 +252,8 @@ export const DiscordAccountSchema = z
events: z.boolean().optional(),
moderation: z.boolean().optional(),
channels: z.boolean().optional(),
presence: z.boolean().optional(),
nickname: z.boolean().optional(),
})
.strict()
.optional(),

81
src/discord/send.bot.ts Normal file
View 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 };
}

View File

@ -6,6 +6,13 @@ export {
removeChannelPermissionDiscord,
setChannelPermissionDiscord,
} from "./send.channels.js";
export {
setNicknameDiscord,
setPresenceDiscord,
type DiscordActivityType,
type DiscordPresenceStatus,
type DiscordPresenceUpdate,
} from "./send.bot.js";
export {
listGuildEmojisDiscord,
uploadEmojiDiscord,

View File

@ -53,6 +53,8 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
timeout: "none",
kick: "none",
ban: "none",
presence: "none",
nickname: "none",
};
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {