diff --git a/extensions/discord-status/README.md b/extensions/discord-status/README.md deleted file mode 100644 index fe7577fcc..000000000 --- a/extensions/discord-status/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Discord Status Plugin - -Updates Discord bot presence/status with session information after each message. - -## Current Status: Partial Implementation - -This plugin demonstrates the `message_sent` hook usage. It currently **logs** what status it would set, rather than actually updating Discord presence. - -### What Works -- ✅ Hook triggers after each message is sent -- ✅ Session tracking via sessionKey -- ✅ Channel filtering (only fires for Discord) -- ✅ Custom status format strings - -### What's Pending -- ⏳ Usage statistics (tokens, model, etc.) - requires wiring usage data through the reply pipeline -- ⏳ Actual Discord presence updates - requires exposing `setPresence` via PluginRuntime - -## Configuration - -```json5 -{ - plugins: { - entries: { - "discord-status": { - enabled: true, - config: { - format: "📊 {tokens} tokens", - activityType: "Custom" // Playing, Watching, Listening, Competing, Custom - } - } - } - } -} -``` - -### Format Placeholders - -- `{tokens}` - Total tokens used in session -- `{input}` - Input tokens (when usage tracking is implemented) -- `{output}` - Output tokens (when usage tracking is implemented) -- `{model}` - Model name -- `{provider}` - Provider name -- `{session}` - Session key - -## Contributing - -To complete this plugin, the following changes are needed: - -### 1. Usage Statistics (in `src/auto-reply/reply/dispatch-from-config.ts`) - -The `getReplyFromConfig` return type needs to include usage metadata: - -```typescript -type ReplyWithMeta = { - payload: ReplyPayload | ReplyPayload[]; - meta?: { - usage?: { - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - model?: string; - provider?: string; - }; - durationMs?: number; - }; -}; -``` - -### 2. Discord Presence API (in Discord channel runtime) - -Expose `setPresence` method via `PluginRuntime.channel.discord`: - -```typescript -// In src/discord/monitor/provider.ts -const gateway = client.getPlugin("gateway"); -// Add: api.setPresence = (options) => gateway.setPresence(options); -``` - -## License - -MIT - Clawdbot Contributors diff --git a/extensions/discord-status/clawdbot.plugin.json b/extensions/discord-status/clawdbot.plugin.json deleted file mode 100644 index 65fe0f7be..000000000 --- a/extensions/discord-status/clawdbot.plugin.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "id": "discord-status", - "name": "Discord Status", - "version": "0.1.0", - "description": "Updates Discord bot status with session context usage after each message", - "author": "Clawdbot Contributors", - "homepage": "https://github.com/clawdbot/clawdbot", - "entrypoint": "./index.ts", - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable Discord status updates" - }, - "format": { - "type": "string", - "description": "Status format string. Use {tokens} for token count.", - "default": "📊 {tokens} tokens" - }, - "activityType": { - "type": "string", - "enum": ["Playing", "Watching", "Listening", "Competing", "Custom"], - "description": "Discord activity type", - "default": "Custom" - } - } - }, - "uiHints": { - "enabled": { - "label": "Enable Status Updates" - }, - "format": { - "label": "Status Format", - "placeholder": "📊 {tokens} tokens" - }, - "activityType": { - "label": "Activity Type" - } - } -} diff --git a/extensions/discord-status/index.ts b/extensions/discord-status/index.ts deleted file mode 100644 index bee0c5aaa..000000000 --- a/extensions/discord-status/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Discord Status Plugin - * - * Updates Discord bot presence/status with session information after each message. - * - * NOTE: This plugin requires Discord presence API support to be added to the - * Discord channel runtime. Currently it logs the status that would be set. - * - * To enable full functionality: - * 1. Add `setPresence` method to Discord runtime - * 2. Expose it via PluginRuntime.channel.discord.setPresence - */ - -import type { PluginApi } from "clawdbot/plugin-sdk"; - -type DiscordStatusConfig = { - enabled?: boolean; - format?: string; - activityType?: "Playing" | "Watching" | "Listening" | "Competing" | "Custom"; -}; - -export default function register(api: PluginApi) { - const logger = api.logger; - const config = (api.config ?? {}) as DiscordStatusConfig; - - if (config.enabled === false) { - logger.info("Discord status plugin is disabled"); - return; - } - - const format = config.format ?? "📊 {tokens} tokens"; - const activityType = config.activityType ?? "Custom"; - - // Track cumulative tokens per session - const sessionTokens = new Map(); - - // Register message_sent hook - api.registerHook({ - hookName: "message_sent", - priority: 10, - handler: async (event, ctx) => { - // Only update for Discord channel - if (ctx.channelId !== "discord") { - return; - } - - const sessionKey = ctx.sessionKey ?? "unknown"; - - // Accumulate token usage (if available in future) - const currentTokens = sessionTokens.get(sessionKey) ?? 0; - const newTokens = event.usage?.totalTokens ?? 0; - const totalTokens = currentTokens + newTokens; - sessionTokens.set(sessionKey, totalTokens); - - // Format the status string - const statusText = format - .replace("{tokens}", totalTokens.toLocaleString()) - .replace("{input}", (event.usage?.inputTokens ?? 0).toLocaleString()) - .replace("{output}", (event.usage?.outputTokens ?? 0).toLocaleString()) - .replace("{model}", event.usage?.model ?? "unknown") - .replace("{provider}", event.usage?.provider ?? "unknown") - .replace("{session}", sessionKey); - - // Log what we would set (until Discord presence API is exposed) - logger.info(`[discord-status] Would set presence: ${activityType} "${statusText}"`); - - // TODO: When Discord runtime exposes setPresence: - // await api.runtime.channel.discord.setPresence({ - // status: "online", - // activities: [{ - // name: statusText, - // type: activityTypeToNumber(activityType), - // }], - // }); - }, - }); - - logger.info("Discord status plugin loaded - listening for message_sent events"); -} diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 12834916a..d7ff56686 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,5 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; -import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema, getPresenceManager } from "clawdbot/plugin-sdk"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; @@ -12,6 +12,35 @@ const plugin = { register(api: ClawdbotPluginApi) { setDiscordRuntime(api.runtime); api.registerChannel({ plugin: discordPlugin }); + + // Register message_sent hook for presence updates + api.registerHook({ + hookName: "message_sent", + priority: 5, + handler: async (event, ctx) => { + // Only handle Discord channel + if (ctx.channelId !== "discord") { + return; + } + + // Find the presence manager for this account + const accountId = ctx.accountId ?? "default"; + const manager = getPresenceManager(accountId); + if (!manager) { + return; + } + + // Update presence with usage info + manager.updatePresence({ + sessionKey: ctx.sessionKey, + tokens: event.usage?.totalTokens, + inputTokens: event.usage?.inputTokens, + outputTokens: event.usage?.outputTokens, + model: event.usage?.model, + provider: event.usage?.provider, + }); + }, + }); }, }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 374e6e8aa..220200ca8 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -263,6 +263,17 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + presence: z + .object({ + enabled: z.boolean().optional(), + showTokenUsage: z.boolean().optional(), + format: z.string().optional(), + // Note: Bots cannot use "Custom" activity type - that's user accounts only + activityType: z.enum(["Playing", "Watching", "Listening", "Competing"]).optional(), + status: z.enum(["online", "idle", "dnd", "invisible"]).optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/discord/monitor/presence-manager.ts b/src/discord/monitor/presence-manager.ts new file mode 100644 index 000000000..1192999ed --- /dev/null +++ b/src/discord/monitor/presence-manager.ts @@ -0,0 +1,156 @@ +/** + * Discord Bot Presence Manager + * + * Manages the bot's presence/status display, including activity type and status text. + * Can optionally show token usage information after each message. + */ + +import type { GatewayPlugin } from "@buape/carbon/gateway"; +import type { UpdatePresenceData, Activity } from "@buape/carbon/gateway"; +import { logVerbose } from "../../globals.js"; + +export type PresenceConfig = { + enabled?: boolean; + showTokenUsage?: boolean; + format?: string; + // Note: Bots cannot use "Custom" activity type - Discord API limitation + activityType?: "Playing" | "Watching" | "Listening" | "Competing"; + status?: "online" | "idle" | "dnd" | "invisible"; +}; + +export type PresenceContext = { + sessionKey?: string; + tokens?: number; + inputTokens?: number; + outputTokens?: number; + model?: string; + provider?: string; +}; + +// Activity type mapping +// Note: Bots CANNOT use type 4 (Custom) - that's user-only +// Available for bots: Playing (0), Streaming (1), Listening (2), Watching (3), Competing (5) +const ACTIVITY_TYPE_MAP: Record = { + Playing: 0, // "Playing {name}" + Streaming: 1, // "Streaming {name}" + Listening: 2, // "Listening to {name}" + Watching: 3, // "Watching {name}" + Competing: 5, // "Competing in {name}" +}; + +// Track cumulative tokens per session +const sessionTokens = new Map(); + +/** + * Create a presence manager for a Discord gateway + */ +export function createPresenceManager(params: { + gateway: GatewayPlugin; + config: PresenceConfig; + accountId: string; +}) { + const { gateway, config, accountId } = params; + + const defaultFormat = config.showTokenUsage ? "📊 {tokens} tokens" : ""; + const format = config.format ?? defaultFormat; + // Default to "Watching" as it reads well: "Watching 📊 1,234 tokens" + const activityType = config.activityType ?? "Watching"; + const status = config.status ?? "online"; + + /** + * Format the presence text with context variables + */ + function formatPresenceText(ctx: PresenceContext): string { + const sessionKey = ctx.sessionKey ?? "unknown"; + const currentTokens = sessionTokens.get(sessionKey) ?? 0; + const newTokens = ctx.tokens ?? 0; + const totalTokens = currentTokens + newTokens; + + if (newTokens > 0) { + sessionTokens.set(sessionKey, totalTokens); + } + + return format + .replace("{tokens}", totalTokens.toLocaleString()) + .replace("{input}", (ctx.inputTokens ?? 0).toLocaleString()) + .replace("{output}", (ctx.outputTokens ?? 0).toLocaleString()) + .replace("{model}", ctx.model ?? "unknown") + .replace("{provider}", ctx.provider ?? "unknown") + .replace("{session}", sessionKey); + } + + /** + * Update the bot's presence + */ + function updatePresence(ctx?: PresenceContext): void { + if (!config.enabled) { + return; + } + + if (!gateway.isConnected) { + logVerbose(`[discord:${accountId}] Cannot update presence: gateway not connected`); + return; + } + + try { + const text = ctx ? formatPresenceText(ctx) : ""; + const activities: Activity[] = text + ? [ + { + name: text, + type: ACTIVITY_TYPE_MAP[activityType] ?? 3, // Default to Watching (3) + }, + ] + : []; + + const presenceData: UpdatePresenceData = { + since: null, + activities, + status: status as "online" | "dnd" | "idle" | "invisible" | "offline", + afk: false, + }; + + gateway.updatePresence(presenceData); + logVerbose(`[discord:${accountId}] Updated presence: ${text || "(cleared)"}`); + } catch (err) { + logVerbose(`[discord:${accountId}] Failed to update presence: ${String(err)}`); + } + } + + /** + * Clear the bot's presence (remove activity) + */ + function clearPresence(): void { + if (!gateway.isConnected) { + return; + } + + try { + gateway.updatePresence({ + since: null, + activities: [], + status: status as "online" | "dnd" | "idle" | "invisible" | "offline", + afk: false, + }); + logVerbose(`[discord:${accountId}] Cleared presence`); + } catch (err) { + logVerbose(`[discord:${accountId}] Failed to clear presence: ${String(err)}`); + } + } + + /** + * Reset token tracking for a session + */ + function resetSessionTokens(sessionKey: string): void { + sessionTokens.delete(sessionKey); + } + + return { + updatePresence, + clearPresence, + resetSessionTokens, + getSessionTokens: (sessionKey: string) => sessionTokens.get(sessionKey) ?? 0, + }; +} + +export type PresenceManager = ReturnType; diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index ed5299cf7..6bc5f56fa 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -39,6 +39,8 @@ import { createDiscordNativeCommand, } from "./native-command.js"; import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js"; +import { createPresenceManager, type PresenceManager } from "./presence-manager.js"; +import { registerPresenceManager, unregisterPresenceManager } from "../presence-registry.js"; export type MonitorDiscordOpts = { token?: string; @@ -562,6 +564,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { emitter: gatewayEmitter, runtime, }); + + // Set up presence manager if enabled + let presenceManager: PresenceManager | undefined; + if (discordCfg.presence?.enabled && gateway) { + presenceManager = createPresenceManager({ + gateway, + config: discordCfg.presence, + accountId: account.accountId, + }); + registerPresenceManager(account.accountId, presenceManager); + runtime.log?.( + `discord: presence updates enabled (format: ${discordCfg.presence.format ?? "📊 {tokens} tokens"})`, + ); + } const abortSignal = opts.abortSignal; const onAbort = () => { if (!gateway) return; @@ -624,6 +640,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (execApprovalsHandler) { await execApprovalsHandler.stop(); } + // Cleanup presence manager + if (presenceManager) { + unregisterPresenceManager(account.accountId); + } } } diff --git a/src/discord/presence-registry.ts b/src/discord/presence-registry.ts new file mode 100644 index 000000000..6fba4a138 --- /dev/null +++ b/src/discord/presence-registry.ts @@ -0,0 +1,39 @@ +/** + * Discord Presence Registry + * + * A simple registry for presence managers that can be accessed by both + * the Discord provider and the Discord extension plugin. + */ + +import type { PresenceManager } from "./monitor/presence-manager.js"; + +// Global registry for presence managers +const presenceManagers = new Map(); + +/** + * Register a presence manager for an account + */ +export function registerPresenceManager(accountId: string, manager: PresenceManager): void { + presenceManagers.set(accountId, manager); +} + +/** + * Unregister a presence manager for an account + */ +export function unregisterPresenceManager(accountId: string): void { + presenceManagers.delete(accountId); +} + +/** + * Get a presence manager for an account + */ +export function getPresenceManager(accountId: string): PresenceManager | undefined { + return presenceManagers.get(accountId); +} + +/** + * Get all registered presence managers + */ +export function getAllPresenceManagers(): Map { + return presenceManagers; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index c0c201ff0..ed7d297ff 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -1,4 +1,10 @@ export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js"; +export { + getPresenceManager, + registerPresenceManager, + unregisterPresenceManager, +} from "../discord/presence-registry.js"; +export type { PresenceManager } from "../discord/monitor/presence-manager.js"; export { BLUEBUBBLES_ACTIONS, BLUEBUBBLES_ACTION_NAMES,