feat(discord): Add bot presence/status updates with token usage tracking

Integrates presence updates directly into the Discord channel integration:

- Added presence config schema (channels.discord.presence):
  - enabled: Toggle presence updates
  - showTokenUsage: Show cumulative token count
  - format: Custom format string with placeholders ({tokens}, {model}, etc.)
  - activityType: Playing, Watching, Listening, Competing (NOT Custom - bots can't use it)
  - status: online, idle, dnd, invisible

- Created PresenceManager in src/discord/monitor/presence-manager.ts:
  - Tracks cumulative token usage per session
  - Formats status text with configurable placeholders
  - Interfaces with Carbon GatewayPlugin.updatePresence()

- Created presence registry (src/discord/presence-registry.ts):
  - Shared registry for presence managers across accounts
  - Exported via plugin-sdk for extension access

- Wired up message_sent hook in Discord extension:
  - Hook triggers after each reply is delivered
  - Updates bot presence with session token counts

Config example:
```json5
channels: {
  discord: {
    presence: {
      enabled: true,
      showTokenUsage: true,
      format: "📊 {tokens} tokens",
      activityType: "Watching",  // Shows: "Watching 📊 1,234 tokens"
      status: "online"
    }
  }
}
```

Note: Discord API limitation - bots cannot use Custom activity type (type 4).
Available types: Playing, Watching, Listening, Competing.
This commit is contained in:
Johnathon Selstad 2026-01-26 14:30:27 -08:00
parent 93757f92e3
commit c624ff3098
9 changed files with 262 additions and 204 deletions

View File

@ -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<GatewayPlugin>("gateway");
// Add: api.setPresence = (options) => gateway.setPresence(options);
```
## License
MIT - Clawdbot Contributors

View File

@ -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"
}
}
}

View File

@ -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<string, number>();
// 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");
}

View File

@ -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,
});
},
});
},
};

View File

@ -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();

View File

@ -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<string, number> = {
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<string, number>();
/**
* 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<typeof createPresenceManager>;

View File

@ -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);
}
}
}

View File

@ -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<string, PresenceManager>();
/**
* 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<string, PresenceManager> {
return presenceManagers;
}

View File

@ -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,