feat(features): add TaskToastManager for consolidated task notifications
- Create task-toast-manager feature with singleton pattern - Show running task list (newest first) when new task starts - Track queued tasks status from ConcurrencyManager - Integrate with BackgroundManager and sisyphus-task tool 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
This commit is contained in:
@@ -23,7 +23,25 @@ export const PROMETHEUS_SYSTEM_PROMPT = `<system-reminder>
|
||||
|
||||
**YOU ARE A PLANNER. YOU ARE NOT AN IMPLEMENTER. YOU DO NOT WRITE CODE. YOU DO NOT EXECUTE TASKS.**
|
||||
|
||||
This is not a suggestion. This is your fundamental identity constraint:
|
||||
This is not a suggestion. This is your fundamental identity constraint.
|
||||
|
||||
### REQUEST INTERPRETATION (CRITICAL)
|
||||
|
||||
**When user says "do X", "implement X", "build X", "fix X", "create X":**
|
||||
- **NEVER** interpret this as a request to perform the work
|
||||
- **ALWAYS** interpret this as "create a work plan for X"
|
||||
|
||||
| User Says | You Interpret As |
|
||||
|-----------|------------------|
|
||||
| "Fix the login bug" | "Create a work plan to fix the login bug" |
|
||||
| "Add dark mode" | "Create a work plan to add dark mode" |
|
||||
| "Refactor the auth module" | "Create a work plan to refactor the auth module" |
|
||||
| "Build a REST API" | "Create a work plan for building a REST API" |
|
||||
| "Implement user registration" | "Create a work plan for user registration" |
|
||||
|
||||
**NO EXCEPTIONS. EVER. Under ANY circumstances.**
|
||||
|
||||
### Identity Constraints
|
||||
|
||||
| What You ARE | What You ARE NOT |
|
||||
|--------------|------------------|
|
||||
@@ -45,8 +63,24 @@ This is not a suggestion. This is your fundamental identity constraint:
|
||||
- Work plans saved to \`.sisyphus/plans/*.md\`
|
||||
- Drafts saved to \`.sisyphus/drafts/*.md\`
|
||||
|
||||
If user asks you to implement something: **REFUSE AND REDIRECT.**
|
||||
Say: "I'm a planner, not an implementer. Let me create a work plan for this. Run \`/start-work\` after I'm done to execute."
|
||||
### When User Seems to Want Direct Work
|
||||
|
||||
If user says things like "just do it", "don't plan, just implement", "skip the planning":
|
||||
|
||||
**STILL REFUSE. Explain why:**
|
||||
\`\`\`
|
||||
I understand you want quick results, but I'm Prometheus - a dedicated planner.
|
||||
|
||||
Here's why planning matters:
|
||||
1. Reduces bugs and rework by catching issues upfront
|
||||
2. Creates a clear audit trail of what was done
|
||||
3. Enables parallel work and delegation
|
||||
4. Ensures nothing is forgotten
|
||||
|
||||
Let me quickly interview you to create a focused plan. Then run \`/start-work\` and Sisyphus will execute it immediately.
|
||||
|
||||
This takes 2-3 minutes but saves hours of debugging.
|
||||
\`\`\`
|
||||
|
||||
**REMEMBER: PLANNING ≠ DOING. YOU PLAN. SOMEONE ELSE DOES.**
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MESSAGE_STORAGE,
|
||||
} from "../hook-message-injector"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
@@ -122,17 +123,14 @@ export class BackgroundManager {
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (tuiClient.tui?.showToast) {
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title: "Background Task Started",
|
||||
message: `"${input.description}" running with ${input.agent}`,
|
||||
variant: "info",
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
isBackground: true,
|
||||
})
|
||||
}
|
||||
|
||||
this.client.session.promptAsync({
|
||||
@@ -378,17 +376,13 @@ export class BackgroundManager {
|
||||
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (tuiClient.tui?.showToast) {
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title: "Background Task Completed",
|
||||
message: `Task "${task.description}" finished in ${duration}.`,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.showCompletionToast({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
|
||||
|
||||
2
src/features/task-toast-manager/index.ts
Normal file
2
src/features/task-toast-manager/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TaskToastManager, getTaskToastManager, initTaskToastManager } from "./manager"
|
||||
export type { TrackedTask, TaskStatus, TaskToastOptions } from "./types"
|
||||
190
src/features/task-toast-manager/manager.ts
Normal file
190
src/features/task-toast-manager/manager.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TrackedTask, TaskStatus } from "./types"
|
||||
import type { ConcurrencyManager } from "../background-agent/concurrency"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export class TaskToastManager {
|
||||
private tasks: Map<string, TrackedTask> = new Map()
|
||||
private client: OpencodeClient
|
||||
private concurrencyManager?: ConcurrencyManager
|
||||
|
||||
constructor(client: OpencodeClient, concurrencyManager?: ConcurrencyManager) {
|
||||
this.client = client
|
||||
this.concurrencyManager = concurrencyManager
|
||||
}
|
||||
|
||||
setConcurrencyManager(manager: ConcurrencyManager): void {
|
||||
this.concurrencyManager = manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new task and show consolidated toast
|
||||
*/
|
||||
addTask(task: {
|
||||
id: string
|
||||
description: string
|
||||
agent: string
|
||||
isBackground: boolean
|
||||
status?: TaskStatus
|
||||
}): void {
|
||||
const trackedTask: TrackedTask = {
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
agent: task.agent,
|
||||
status: task.status ?? "running",
|
||||
startedAt: new Date(),
|
||||
isBackground: task.isBackground,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, trackedTask)
|
||||
this.showTaskListToast(trackedTask)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status
|
||||
*/
|
||||
updateTask(id: string, status: TaskStatus): void {
|
||||
const task = this.tasks.get(id)
|
||||
if (task) {
|
||||
task.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove completed/error task
|
||||
*/
|
||||
removeTask(id: string): void {
|
||||
this.tasks.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running tasks (newest first)
|
||||
*/
|
||||
getRunningTasks(): TrackedTask[] {
|
||||
const running = Array.from(this.tasks.values())
|
||||
.filter((t) => t.status === "running")
|
||||
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
|
||||
return running
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queued tasks
|
||||
*/
|
||||
getQueuedTasks(): TrackedTask[] {
|
||||
return Array.from(this.tasks.values())
|
||||
.filter((t) => t.status === "queued")
|
||||
.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration since task started
|
||||
*/
|
||||
private formatDuration(startedAt: Date): string {
|
||||
const seconds = Math.floor((Date.now() - startedAt.getTime()) / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build task list message
|
||||
*/
|
||||
private buildTaskListMessage(newTask: TrackedTask): string {
|
||||
const running = this.getRunningTasks()
|
||||
const queued = this.getQueuedTasks()
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (running.length > 0) {
|
||||
lines.push(`Running (${running.length}):`)
|
||||
for (const task of running) {
|
||||
const duration = this.formatDuration(task.startedAt)
|
||||
const bgIcon = task.isBackground ? "⚡" : "🔄"
|
||||
const isNew = task.id === newTask.id ? " ← NEW" : ""
|
||||
lines.push(`${bgIcon} ${task.description} (${task.agent}) - ${duration}${isNew}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (queued.length > 0) {
|
||||
if (lines.length > 0) lines.push("")
|
||||
lines.push(`Queued (${queued.length}):`)
|
||||
for (const task of queued) {
|
||||
const bgIcon = task.isBackground ? "⏳" : "⏸️"
|
||||
lines.push(`${bgIcon} ${task.description} (${task.agent})`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Show consolidated toast with all running/queued tasks
|
||||
*/
|
||||
private showTaskListToast(newTask: TrackedTask): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (!tuiClient.tui?.showToast) return
|
||||
|
||||
const message = this.buildTaskListMessage(newTask)
|
||||
const running = this.getRunningTasks()
|
||||
const queued = this.getQueuedTasks()
|
||||
|
||||
const title = newTask.isBackground
|
||||
? `⚡ New Background Task`
|
||||
: `🔄 New Task Executed`
|
||||
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title,
|
||||
message: message || `${newTask.description} (${newTask.agent})`,
|
||||
variant: "info",
|
||||
duration: running.length + queued.length > 2 ? 5000 : 3000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show task completion toast
|
||||
*/
|
||||
showCompletionToast(task: { id: string; description: string; duration: string }): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (!tuiClient.tui?.showToast) return
|
||||
|
||||
this.removeTask(task.id)
|
||||
|
||||
const remaining = this.getRunningTasks()
|
||||
const queued = this.getQueuedTasks()
|
||||
|
||||
let message = `✅ "${task.description}" finished in ${task.duration}`
|
||||
if (remaining.length > 0 || queued.length > 0) {
|
||||
message += `\n\nStill running: ${remaining.length} | Queued: ${queued.length}`
|
||||
}
|
||||
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title: "Task Completed",
|
||||
message,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
let instance: TaskToastManager | null = null
|
||||
|
||||
export function getTaskToastManager(): TaskToastManager | null {
|
||||
return instance
|
||||
}
|
||||
|
||||
export function initTaskToastManager(
|
||||
client: OpencodeClient,
|
||||
concurrencyManager?: ConcurrencyManager
|
||||
): TaskToastManager {
|
||||
instance = new TaskToastManager(client, concurrencyManager)
|
||||
return instance
|
||||
}
|
||||
17
src/features/task-toast-manager/types.ts
Normal file
17
src/features/task-toast-manager/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type TaskStatus = "running" | "queued" | "completed" | "error"
|
||||
|
||||
export interface TrackedTask {
|
||||
id: string
|
||||
description: string
|
||||
agent: string
|
||||
status: TaskStatus
|
||||
startedAt: Date
|
||||
isBackground: boolean
|
||||
}
|
||||
|
||||
export interface TaskToastOptions {
|
||||
title: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
duration?: number
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export * from "./constants"
|
||||
@@ -10,15 +13,35 @@ function isAllowedFile(filePath: string): boolean {
|
||||
return hasAllowedExtension && isInAllowedPath
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const TASK_TOOLS = ["sisyphus_task", "task", "call_omo_agent"]
|
||||
|
||||
function getAgentFromSession(sessionID: string): string | undefined {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return undefined
|
||||
return findNearestMessageWithFields(messageDir)?.agent
|
||||
}
|
||||
|
||||
export function createPrometheusMdOnlyHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string; agent?: string },
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
const agentName = input.agent
|
||||
const agentName = getAgentFromSession(input.sessionID)
|
||||
|
||||
if (!agentName || !PROMETHEUS_AGENTS.includes(agentName)) {
|
||||
return
|
||||
|
||||
@@ -14,6 +14,37 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
export const HOOK_NAME = "sisyphus-orchestrator"
|
||||
|
||||
const ALLOWED_PATH_PREFIX = ".sisyphus/"
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
const DIRECT_WORK_REMINDER = `
|
||||
|
||||
---
|
||||
|
||||
[SYSTEM REMINDER - DELEGATION REQUIRED]
|
||||
|
||||
You just performed direct file modifications outside \`.sisyphus/\`.
|
||||
|
||||
**Sisyphus is an ORCHESTRATOR, not an IMPLEMENTER.**
|
||||
|
||||
As an orchestrator, you should:
|
||||
- **DELEGATE** implementation work to subagents via \`sisyphus_task\`
|
||||
- **VERIFY** the work done by subagents
|
||||
- **COORDINATE** multiple tasks and ensure completion
|
||||
|
||||
You should NOT:
|
||||
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
|
||||
- Make direct file edits outside \`.sisyphus/\`
|
||||
- Implement features yourself
|
||||
|
||||
**If you need to make changes:**
|
||||
1. Use \`sisyphus_task\` to delegate to an appropriate subagent
|
||||
2. Provide clear instructions in the prompt
|
||||
3. Verify the subagent's work after completion
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
const BOULDER_CONTINUATION_PROMPT = `[SYSTEM REMINDER - BOULDER CONTINUATION]
|
||||
|
||||
You have an active work plan with incomplete tasks. Continue working.
|
||||
@@ -208,12 +239,20 @@ interface ToolExecuteInput {
|
||||
agent?: string
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
interface ToolExecuteAfterInput {
|
||||
tool: string
|
||||
sessionID?: string
|
||||
callID?: string
|
||||
}
|
||||
|
||||
interface ToolExecuteAfterOutput {
|
||||
title: string
|
||||
output: string
|
||||
metadata: unknown
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ToolExecuteOutput = ToolExecuteAfterOutput
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
@@ -427,9 +466,26 @@ export function createSisyphusOrchestratorHook(
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput
|
||||
input: ToolExecuteAfterInput,
|
||||
output: ToolExecuteAfterOutput
|
||||
): Promise<void> => {
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
|
||||
const filePath = output.metadata?.filePath as string | undefined
|
||||
if (filePath && !filePath.includes(ALLOWED_PATH_PREFIX)) {
|
||||
output.output = (output.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (input.tool !== "sisyphus_task") {
|
||||
return
|
||||
}
|
||||
@@ -440,10 +496,6 @@ export function createSisyphusOrchestratorHook(
|
||||
if (isBackgroundLaunch) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (output.output && typeof output.output === "string") {
|
||||
const gitStats = getGitDiffStats(ctx.directory)
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { initTaskToastManager } from "./features/task-toast-manager";
|
||||
import { type HookName } from "./config";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
@@ -207,6 +208,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
initTaskToastManager(ctx.client);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
? createTodoContinuationEnforcer(ctx, { backgroundManager })
|
||||
: null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SISYPHUS_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { resolveMultipleSkills } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { createBuiltinSkills } from "../../features/builtin-skills/skills"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -242,20 +243,10 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = client as any
|
||||
if (tuiClient.tui?.showToast) {
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title: "Task Started",
|
||||
message: `"${args.description}" running with ${agentToUse}`,
|
||||
variant: "info",
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
const toastManager = getTaskToastManager()
|
||||
let taskId: string | undefined
|
||||
|
||||
try {
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: ctx.sessionID,
|
||||
@@ -268,8 +259,18 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
taskId = `sync_${sessionID.slice(0, 8)}`
|
||||
const startTime = new Date()
|
||||
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: taskId,
|
||||
description: args.description,
|
||||
agent: agentToUse,
|
||||
isBackground: false,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID, category: args.category, sync: true },
|
||||
@@ -308,6 +309,10 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
|
||||
const duration = formatDuration(startTime)
|
||||
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
|
||||
return `Task completed in ${duration}.
|
||||
|
||||
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
|
||||
@@ -317,6 +322,9 @@ Session ID: ${sessionID}
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
} catch (error) {
|
||||
if (toastManager && taskId !== undefined) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `❌ Task failed: ${message}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user