mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 11:02:26 +00:00
fix(usage): tighten usage footer template handling
This commit is contained in:
@@ -30,6 +30,23 @@ title: "Usage tracking"
|
||||
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: "Usage" section under Context (only if available).
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
Set `messages.usageTemplate` to customize the per-response `/usage full`
|
||||
footer. The value can be an inline template object or a JSON file path:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": {
|
||||
"usageTemplate": "~/.openclaw/usage-footer.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
|
||||
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
|
||||
unreadable, invalid, or empty templates fall back to the built-in usage line.
|
||||
|
||||
## Providers + credentials
|
||||
|
||||
- **Anthropic (Claude)**: OAuth tokens in auth profiles.
|
||||
|
||||
@@ -70,6 +70,9 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { buildUsageContract } from "../usage-bar/contract.js";
|
||||
import { loadUsageBarTemplate } from "../usage-bar/template.js";
|
||||
import { renderUsageBar } from "../usage-bar/translator.js";
|
||||
import {
|
||||
buildKnownAgentRunFailureReplyPayload,
|
||||
runAgentTurnWithFallback,
|
||||
@@ -90,9 +93,6 @@ import {
|
||||
import { resetReplyRunSession } from "./agent-runner-session-reset.js";
|
||||
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js";
|
||||
import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js";
|
||||
import { buildUsageContract } from "../usage-bar/contract.js";
|
||||
import { loadUsageBarTemplate } from "../usage-bar/template.js";
|
||||
import { renderUsageBar } from "../usage-bar/translator.js";
|
||||
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// Build the `openclaw.usageLine.v1` contract that the translator consumes from
|
||||
// the per-turn `reply_payload_sending` usage snapshot. This is the in-core port
|
||||
// of the usage-footer plugin's `buildContract`, so the same template renders
|
||||
// identically whether driven by the plugin or by the native /usage full path.
|
||||
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
|
||||
import type { UsageContract } from "./translator.js";
|
||||
|
||||
@@ -16,15 +12,10 @@ export function buildUsageContract(
|
||||
const cacheWrite = usage.cacheWrite;
|
||||
const total = usage.total;
|
||||
|
||||
// cache_hit_pct: cacheRead only (writes are misses being cached). Matches
|
||||
// core status-message.ts.
|
||||
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
|
||||
const cacheHitPct =
|
||||
promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined;
|
||||
|
||||
// Last-call usage (final model call only) so templates can render the last
|
||||
// exchange instead of the turn aggregate. Its cache_hit_pct is computed over
|
||||
// the last call's prompt total, same formula as the turn-level one.
|
||||
const last = state.lastUsage;
|
||||
const lastPromptTotal = last
|
||||
? (last.cacheRead ?? 0) + (last.cacheWrite ?? 0) + (last.input ?? 0)
|
||||
@@ -35,11 +26,6 @@ export function buildUsageContract(
|
||||
: undefined;
|
||||
|
||||
const maxTokens = state.contextTokenBudget;
|
||||
// Context occupancy is a point-in-time STATE, never an aggregate. Prefer the
|
||||
// turn's real end-of-turn context size (final call's prompt tokens); fall back
|
||||
// to the aggregate prompt total only for harnesses that don't report it
|
||||
// (single-call turns, where the two coincide). The aggregate over a multi-call
|
||||
// tool loop overstates occupancy — often past the window — pinning the gauge.
|
||||
const usedTokens =
|
||||
typeof state.contextUsedTokens === "number" && state.contextUsedTokens > 0
|
||||
? state.contextUsedTokens
|
||||
@@ -58,7 +44,6 @@ export function buildUsageContract(
|
||||
return {
|
||||
schema: "openclaw.usageLine.v1",
|
||||
surface: surface ?? null,
|
||||
// agentId is exposed flat so templates can key per-agent (e.g. emoji map).
|
||||
agentId: state.agentId ?? null,
|
||||
chat_type: state.chatType ?? null,
|
||||
model: {
|
||||
@@ -79,15 +64,12 @@ export function buildUsageContract(
|
||||
compactions: typeof state.compactionCount === "number" ? state.compactionCount : null,
|
||||
},
|
||||
usage: {
|
||||
// Turn aggregate: summed across every model call in the turn's tool loop.
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
cache_read_tokens: cacheRead,
|
||||
cache_write_tokens: cacheWrite,
|
||||
total_tokens: total,
|
||||
cache_hit_pct: cacheHitPct,
|
||||
// Final model call only. Templates choose `{usage.input_tokens}` (turn)
|
||||
// vs `{usage.last.input_tokens}` (last exchange). Absent → segment drops.
|
||||
last: last
|
||||
? {
|
||||
input_tokens: last.input,
|
||||
@@ -117,6 +99,5 @@ export function buildUsageContract(
|
||||
avatar: state.identity?.avatar ?? null,
|
||||
},
|
||||
session: { id: state.sessionId ?? null },
|
||||
...(state.limits ? { limits: state.limits } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
|
||||
|
||||
// Two structurally-valid templates (isUsableTemplate accepts either an `output`
|
||||
// object or a `segments` array) so we can tell which one resolved.
|
||||
const tplA = { segments: [{ text: "A" }] };
|
||||
const tplB = { output: { lines: [] } };
|
||||
|
||||
@@ -46,25 +44,23 @@ describe("loadUsageBarTemplate", () => {
|
||||
expect(loadUsageBarTemplate(path)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache a missing file, so a later-created template is picked up", () => {
|
||||
it("caches a missing path as no template", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
|
||||
const missing = join(dir, "missing.json");
|
||||
expect(loadUsageBarTemplate(missing)).toBeUndefined();
|
||||
writeFileSync(missing, JSON.stringify(tplB));
|
||||
expect(loadUsageBarTemplate(missing)).toBeUndefined();
|
||||
clearUsageBarTemplateCacheForTest();
|
||||
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
|
||||
});
|
||||
|
||||
it("serves the cached template on the hot path without re-reading the file", () => {
|
||||
it("serves the cached template without re-reading the file", () => {
|
||||
const path = tmpFile("t.json", JSON.stringify(tplA));
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplA); // first load caches
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
|
||||
|
||||
// Change the file on disk. The reply path must NOT re-read synchronously, so
|
||||
// the very next call still returns the cached value (the watcher refresh is
|
||||
// async and has not fired within this synchronous sequence).
|
||||
writeFileSync(path, JSON.stringify(tplB));
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
|
||||
|
||||
// After an explicit cache reset the fresh content loads.
|
||||
clearUsageBarTemplateCacheForTest();
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
// Resolve the usage-bar template from config (`messages.usageTemplate`): either
|
||||
// an inline template object, or a path to a JSON file. For a path, the template
|
||||
// is read ONCE into memory and then kept fresh by a filesystem watcher, so the
|
||||
// per-reply render path never touches disk — no synchronous stat/read in the
|
||||
// latency-sensitive reply-delivery path. When no usable template resolves, the
|
||||
// caller falls back to the built-in (boring) usage line.
|
||||
import { type FSWatcher, readFileSync, watch } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
@@ -12,9 +6,6 @@ import type { UsageBarTemplate } from "./translator.js";
|
||||
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
|
||||
|
||||
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
|
||||
// Keyed by resolved path. A present entry means the file was read at least once;
|
||||
// the reply path then serves `template` synchronously with zero filesystem
|
||||
// access, and a watcher refreshes it off the hot path on change.
|
||||
const fileCache = new Map<string, CacheEntry>();
|
||||
|
||||
function expandPath(p: string): string {
|
||||
@@ -27,7 +18,6 @@ function expandPath(p: string): string {
|
||||
return isAbsolute(p) ? p : resolve(p);
|
||||
}
|
||||
|
||||
// A usable template must carry a layout the engine understands.
|
||||
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
@@ -37,24 +27,40 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate {
|
||||
return hasOutput || Array.isArray(obj.segments);
|
||||
}
|
||||
|
||||
// Read + parse a template file into a usable template, or undefined for
|
||||
// unreadable/invalid contents. Only called off the reply path: at first load and
|
||||
// from the watcher callback.
|
||||
function readTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return undefined; // removed/unreadable -> boring fallback
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isUsableTemplate(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined; // invalid JSON -> boring fallback
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
const entry: CacheEntry = { template: readTemplateFile(path) };
|
||||
if (entry.template) {
|
||||
try {
|
||||
const watcher = watch(path, { persistent: false }, () => {
|
||||
entry.template = readTemplateFile(path);
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
watcher.close();
|
||||
});
|
||||
entry.watcher = watcher;
|
||||
} catch {
|
||||
// Cache remains valid without live refresh.
|
||||
}
|
||||
}
|
||||
fileCache.set(path, entry);
|
||||
return entry.template;
|
||||
}
|
||||
|
||||
export function loadUsageBarTemplate(
|
||||
configured: UsageTemplateConfig,
|
||||
): UsageBarTemplate | undefined {
|
||||
@@ -67,44 +73,9 @@ export function loadUsageBarTemplate(
|
||||
const path = expandPath(configured);
|
||||
const cached = fileCache.get(path);
|
||||
if (cached) {
|
||||
return cached.template; // hot path: in-memory, no filesystem access
|
||||
return cached.template;
|
||||
}
|
||||
// First resolution for this path. Probe once; if the file is missing/unreadable
|
||||
// we do NOT cache, so a later-created template is still picked up on a
|
||||
// subsequent call (the only path that stats per reply is the misconfigured
|
||||
// "configured but absent" one, never the normal one).
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
let template: UsageBarTemplate | undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
template = isUsableTemplate(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
template = undefined;
|
||||
}
|
||||
// The file exists and was read once; from here the reply path is filesystem
|
||||
// free. Keep the in-memory copy fresh via a watcher (off the hot path). A watch
|
||||
// failure (unsupported FS, race) just leaves the one-time load with no live
|
||||
// refresh — still strictly better than a stat on every reply.
|
||||
const entry: CacheEntry = { template };
|
||||
try {
|
||||
const watcher = watch(path, { persistent: false }, () => {
|
||||
entry.template = readTemplateFile(path);
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
// Best-effort: keep the last-known template rather than throwing on a
|
||||
// watch error (e.g. the file being removed).
|
||||
});
|
||||
entry.watcher = watcher;
|
||||
} catch {
|
||||
// Unwatchable path: cache the one-time load anyway (no refresh until restart).
|
||||
}
|
||||
fileCache.set(path, entry);
|
||||
return template;
|
||||
return cacheTemplateFile(path);
|
||||
}
|
||||
|
||||
export function clearUsageBarTemplateCacheForTest(): void {
|
||||
|
||||
@@ -90,25 +90,23 @@ describe("usage-bar segment forms", () => {
|
||||
it("each with item_scales picks a scale per window by position", () => {
|
||||
const seg = [
|
||||
{
|
||||
text: "📊",
|
||||
each: "limits.windows",
|
||||
text: "W",
|
||||
each: "windows",
|
||||
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
|
||||
item_scales: ["weather", "plants"],
|
||||
},
|
||||
];
|
||||
const out = render(seg, {
|
||||
limits: {
|
||||
windows: [
|
||||
{ pct_left: 92, resets_in_s: 17100 },
|
||||
{ pct_left: 70, resets_in_s: 570240 },
|
||||
],
|
||||
},
|
||||
windows: [
|
||||
{ pct_left: 92, resets_in_s: 17100 },
|
||||
{ pct_left: 70, resets_in_s: 570240 },
|
||||
],
|
||||
});
|
||||
expect(out).toBe("📊 ☀️4h45m 🍀6.6d");
|
||||
expect(out).toBe("W ☀️4h45m 🍀6.6d");
|
||||
});
|
||||
|
||||
it("each drops the whole segment when the array is empty", () => {
|
||||
expect(render([{ text: "📊", each: "limits.windows", item: "{x}" }], { limits: {} })).toBe("");
|
||||
expect(render([{ text: "W", each: "windows", item: "{x}" }], {})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,15 +120,9 @@ describe("usage-bar end-to-end with buildUsageContract", () => {
|
||||
fastMode: false,
|
||||
fallbackUsed: false,
|
||||
contextTokenBudget: 272000,
|
||||
contextUsedTokens: 204000,
|
||||
usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 },
|
||||
limits: {
|
||||
available: true,
|
||||
source: "core",
|
||||
windows: [
|
||||
{ label: "5h", used_pct: 8, pct_left: 92, resets_in_s: 17100 },
|
||||
{ label: "week", used_pct: 30, pct_left: 70, resets_in_s: 570240 },
|
||||
],
|
||||
},
|
||||
turnUsd: 0.03771985,
|
||||
},
|
||||
"discord",
|
||||
);
|
||||
@@ -141,15 +133,8 @@ describe("usage-bar end-to-end with buildUsageContract", () => {
|
||||
{ when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" },
|
||||
{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } },
|
||||
{ text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" },
|
||||
{
|
||||
text: " | 📊",
|
||||
each: "limits.windows",
|
||||
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
|
||||
item_scales: ["weather", "plants"],
|
||||
},
|
||||
{ text: " | ${cost.turn_usd|fixed:4}" },
|
||||
];
|
||||
expect(renderUsageBar(tpl(pieces), contract)).toBe(
|
||||
"opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | 📊 ☀️4h45m 🍀6.6d",
|
||||
);
|
||||
expect(renderUsageBar(tpl(pieces), contract)).toBe("opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | $0.0377");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
// Declarative usage-bar translator — the in-core port of the reference
|
||||
// `usage_bar.py` engine. It is a TRANSLATOR, not a dictionary: it contains no
|
||||
// glyphs, no layout, and no default footer — only mechanisms. All *content*
|
||||
// (which glyphs make a meter, the segment order, the framing) is DATA in the
|
||||
// template (`scales` / `aliases` / `output`). See usage-bar/template.ts.
|
||||
//
|
||||
// Verbs, used as {path|verb:args|fallback}:
|
||||
// num 3000 -> "3.0k" (compact count)
|
||||
// fixed:N 0.03771985 -> "0.0377" (fixed-decimal; N digits, default 2)
|
||||
// dur 14820 -> "4h07m" (seconds -> reset)
|
||||
// pct 96 -> "96%"
|
||||
// inv 100-value complement (88 -> 12); pipe before another verb
|
||||
// alias:TABLE look value up in aliases[TABLE]; echo raw value if unlisted
|
||||
// meter:WIDTH:SCALE 0-100 value -> WIDTH cells from scales[SCALE] (graded
|
||||
// boundary cell); meter:1 = a single glyph
|
||||
// Segment forms: text / when / map+cases / each(+item, item_scales).
|
||||
|
||||
export type UsageBarTemplate = Record<string, unknown>;
|
||||
export type UsageContract = Record<string, unknown>;
|
||||
type Vocab = Record<string, unknown>;
|
||||
@@ -22,23 +5,16 @@ type Vocab = Record<string, unknown>;
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
// A "scale" is an ordered glyph vocabulary. Strings must be split by CODE POINT
|
||||
// (not UTF-16 unit) so astral glyphs like 🌑 stay intact — JS string indexing
|
||||
// would slice a surrogate pair in half.
|
||||
function toGlyphs(scale: unknown): string[] {
|
||||
if (Array.isArray(scale)) {
|
||||
return scale.filter((g): g is string => typeof g === "string");
|
||||
}
|
||||
if (typeof scale === "string") {
|
||||
// Array.from iterates by code point (like spread) so astral glyphs survive,
|
||||
// without tripping no-misused-spread. Scales are single-code-point glyphs;
|
||||
// multi-code-point emoji (e.g. ☀️) are supplied as array scales instead.
|
||||
return Array.from(scale);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- number formatters (algorithms, not content) ----------------------------
|
||||
function num(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "";
|
||||
@@ -111,7 +87,6 @@ function norm(value: unknown): number {
|
||||
return Math.max(0, Math.min(100, n)) / 100;
|
||||
}
|
||||
|
||||
// --- meter mechanism (glyphs supplied by the template, not here) --------------
|
||||
function meter(value: unknown, width: number, scale: unknown): string {
|
||||
const glyphs = toGlyphs(scale);
|
||||
if (glyphs.length < 2 || width < 1) {
|
||||
@@ -171,7 +146,6 @@ function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab):
|
||||
}
|
||||
}
|
||||
|
||||
// --- template walker ----------------------------------------------------------
|
||||
function getPath(ctx: unknown, path: string): unknown {
|
||||
let cur: unknown = ctx;
|
||||
for (const part of path.split(".")) {
|
||||
@@ -273,7 +247,6 @@ function resolveLayout(
|
||||
const sep = typeof output.sep === "string" ? output.sep : "";
|
||||
return { sep, pieces: Array.isArray(pieces) ? (pieces as Segment[]) : [] };
|
||||
}
|
||||
// legacy: top-level surfaces.<surface>.{sep,segments} over top-level sep/segments
|
||||
const ov =
|
||||
typeof surface === "string" &&
|
||||
isObject(template.surfaces) &&
|
||||
@@ -290,11 +263,6 @@ function resolveLayout(
|
||||
return { sep, pieces: segments as Segment[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a usage footer from a template + contract. Returns "" when the template
|
||||
* produces nothing (caller falls back to the boring built-in footer). Never
|
||||
* throws for malformed templates — best-effort, fail-open.
|
||||
*/
|
||||
export function renderUsageBar(template: UsageBarTemplate, contract: UsageContract): string {
|
||||
try {
|
||||
const { sep, pieces } = resolveLayout(template, contract.surface);
|
||||
|
||||
@@ -1873,6 +1873,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Controls visible source replies across direct, group, and channel conversations. "message_tool" requires message(action=send) for visible output and keeps normal final text private. "automatic" posts normal replies as before.',
|
||||
"messages.responsePrefix":
|
||||
"Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.",
|
||||
"messages.usageTemplate":
|
||||
"Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.",
|
||||
"messages.groupChat":
|
||||
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
|
||||
"messages.groupChat.mentionPatterns":
|
||||
|
||||
@@ -967,6 +967,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"messages.messagePrefix": "Inbound Message Prefix",
|
||||
"messages.visibleReplies": "Visible Replies",
|
||||
"messages.responsePrefix": "Outbound Response Prefix",
|
||||
"messages.usageTemplate": "Usage Footer Template",
|
||||
"messages.groupChat": "Group Chat Rules",
|
||||
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
|
||||
"messages.groupChat.historyLimit": "Group History Limit",
|
||||
|
||||
@@ -139,15 +139,7 @@ export type MessagesConfig = {
|
||||
* Default: none
|
||||
*/
|
||||
responsePrefix?: string;
|
||||
/**
|
||||
* Custom `/usage full` footer template (the declarative `openclaw.usageBar.v1`
|
||||
* format: `scales` / `aliases` / `output.surfaces`). When set and usable, the
|
||||
* per-reply footer is rendered from it instead of the built-in line; an absent,
|
||||
* unreadable, or invalid template falls back to the built-in (fail-open).
|
||||
*
|
||||
* - string: path to a JSON template file (supports a leading `~`).
|
||||
* - object: an inline template.
|
||||
*/
|
||||
/** Custom `/usage full` footer template, inline or JSON file path. */
|
||||
usageTemplate?: string | Record<string, unknown>;
|
||||
groupChat?: GroupChatConfig;
|
||||
queue?: QueueConfig;
|
||||
|
||||
Reference in New Issue
Block a user