feat(hooks): Wire up message_sent hook with usage statistics

- Extended PluginHookMessageSentEvent to include usage stats (tokens, model, provider, duration)
- Extended PluginHookMessageContext to include sessionKey and agentId
- Wired up runMessageSent hook call in dispatch-from-config.ts after final replies
- Added discord-status example plugin demonstrating how to use the hook

The message_sent hook now fires after each reply is delivered, enabling plugins to:
- Track token usage across sessions
- Update external status indicators (e.g., Discord bot presence)
- Log message delivery for analytics/monitoring

Note: Full Discord presence support requires exposing setPresence via PluginRuntime.
The example plugin currently logs the status it would set.
This commit is contained in:
Johnathon Selstad 2026-01-26 14:20:08 -08:00
parent 9c0c5866db
commit 93757f92e3
5 changed files with 253 additions and 0 deletions

View File

@ -0,0 +1,82 @@
# 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

@ -0,0 +1,42 @@
{
"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

@ -0,0 +1,79 @@
/**
* 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

@ -331,6 +331,40 @@ export async function dispatchReplyFromConfig(params: {
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
// Trigger message_sent hook for final replies
if (hookRunner?.hasHooks("message_sent") && replies.length > 0) {
const targetSessionKey =
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
const agentSessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
const agentId = agentSessionKey
? resolveSessionAgentId({ sessionKey: agentSessionKey, config: cfg })
: undefined;
const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase();
const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined;
for (const reply of replies) {
void hookRunner
.runMessageSent(
{
to: conversationId ?? "",
content: reply.text ?? "",
success: queuedFinal,
},
{
channelId,
accountId: ctx.AccountId,
conversationId,
sessionKey: agentSessionKey,
agentId,
},
)
.catch((err) => {
logVerbose(`dispatch-from-config: message_sent hook failed: ${String(err)}`);
});
}
}
recordProcessed("completed");
markIdle("message_completed");
return { queuedFinal, counts };

View File

@ -346,6 +346,8 @@ export type PluginHookMessageContext = {
channelId: string;
accountId?: string;
conversationId?: string;
sessionKey?: string;
agentId?: string;
};
// message_received hook
@ -374,6 +376,20 @@ export type PluginHookMessageSentEvent = {
content: string;
success: boolean;
error?: string;
/** Usage statistics from the agent run (if available) */
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
/** Model used for this response */
model?: string;
/** Provider used (e.g., "anthropic", "openai") */
provider?: string;
};
/** Duration of the agent run in milliseconds */
durationMs?: number;
};
// Tool context