mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 10:00:48 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b03692aef | |||
| 8e657c7db5 |
@@ -0,0 +1,30 @@
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Cause, Effect } from "effect"
|
||||
|
||||
const RED_BOLD = "\x1b[91m\x1b[1m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const RESET = "\x1b[0m"
|
||||
|
||||
export function handle(cause: Cause.Cause<unknown>, command: string) {
|
||||
const error = Cause.squash(cause)
|
||||
if (!(error instanceof Service.StartError)) return Effect.failCause(cause)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logError("background service startup failed", { cause: Cause.pretty(cause) })
|
||||
yield* Effect.sync(() => {
|
||||
process.stderr.write(render(error, command))
|
||||
process.exitCode = 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function render(error: Service.StartError, command: string) {
|
||||
const detail =
|
||||
error.stage === "spawn"
|
||||
? "The service process could not be started."
|
||||
: error.stage === "registration"
|
||||
? "The service exited or never became ready.\nThe expected registration file was not created."
|
||||
: "The service started but did not become ready."
|
||||
return `\n${RED_BOLD}OpenCode could not start its background service${RESET}\n\n${detail}\n\n${BOLD}Try:${RESET}\n ${command} service restart\n OPENCODE_LOG_LEVEL=DEBUG ${command}\n`
|
||||
}
|
||||
|
||||
export * as StartupError from "./startup-error"
|
||||
@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { StartupError } from "./framework/startup-error"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.catchCause((cause) => StartupError.handle(cause, Commands.name)),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
|
||||
@@ -11,11 +11,72 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schedule, Schema } from "effect"
|
||||
import { Cause, Effect, Exit, Schedule, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { StartupError } from "../src/framework/startup-error"
|
||||
|
||||
const RED_BOLD = "\x1b[91m\x1b[1m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const RESET = "\x1b[0m"
|
||||
|
||||
test("renders a missing registration as an actionable startup failure", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-start-"))
|
||||
const registration = path.join(root, "server.json")
|
||||
const script = path.join(root, "exit.ts")
|
||||
await Bun.write(script, "process.exit(1)\n")
|
||||
|
||||
try {
|
||||
const exit = await Service.start({ file: registration, command: [process.execPath, script] }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.exit,
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error).toBeInstanceOf(Service.StartError)
|
||||
if (error instanceof Service.StartError) {
|
||||
expect(StartupError.render(error, "opencode2")).toBe(
|
||||
`\n${RED_BOLD}OpenCode could not start its background service${RESET}\n\nThe service exited or never became ready.\nThe expected registration file was not created.\n\n${BOLD}Try:${RESET}\n opencode2 service restart\n OPENCODE_LOG_LEVEL=DEBUG opencode2\n`,
|
||||
)
|
||||
}
|
||||
expect(Cause.pretty(exit.cause)).toContain(
|
||||
`[cause]: PlatformError: NotFound: FileSystem.readFile (${registration})`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a service spawn failure without losing its cause", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-spawn-"))
|
||||
const command = path.join(os.tmpdir(), "opencode-command-that-does-not-exist")
|
||||
try {
|
||||
const exit = await Service.start({ file: path.join(root, "server.json"), command: [command] }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.exit,
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error).toBeInstanceOf(Service.StartError)
|
||||
if (error instanceof Service.StartError) {
|
||||
expect(error.stage).toBe("spawn")
|
||||
expect(StartupError.render(error, "opencode2")).toContain("The service process could not be started.")
|
||||
}
|
||||
expect(Cause.pretty(exit.cause)).toContain(command)
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { spawn } from "node:child_process"
|
||||
import { once } from "node:events"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
@@ -34,6 +35,11 @@ export type Options = {
|
||||
|
||||
export type StartReason = "missing" | "version-mismatch"
|
||||
|
||||
export class StartError extends Schema.TaggedErrorClass<StartError>()("ServiceStartError", {
|
||||
stage: Schema.Literals(["spawn", "registration", "readiness"]),
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type StartOptions = Options & {
|
||||
// Called once when start() decides it must spawn: either no service was
|
||||
// found, or a healthy service with a different version is being replaced.
|
||||
@@ -66,19 +72,23 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
const child = yield* Effect.try({
|
||||
try: () => {
|
||||
if (command === undefined)
|
||||
return yield* Effect.fail(new StartError({ stage: "spawn", cause: new Error("Missing service command") }))
|
||||
const child = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
await once(child, "spawn")
|
||||
child.unref()
|
||||
return child
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
catch: (cause) => new StartError({ stage: "spawn", cause }),
|
||||
})
|
||||
|
||||
return yield* discoverLocal(options).pipe(
|
||||
return yield* awaitReady(options).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
|
||||
found === undefined
|
||||
? Effect.fail(new StartError({ stage: "readiness", cause: new Error("Server is not ready") }))
|
||||
: Effect.succeed(found),
|
||||
),
|
||||
Effect.retry(poll),
|
||||
Effect.tap((found) =>
|
||||
@@ -90,7 +100,6 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
||||
),
|
||||
Effect.map((found) => found.endpoint),
|
||||
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
|
||||
Effect.mapError(() => new Error("Failed to start server")),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -135,6 +144,18 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
const awaitReady = Effect.fnUntraced(function* (options: Options) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const info = yield* fs.readFileString(options.file ?? fallback()).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new StartError({ stage: cause.reason._tag === "NotFound" ? "registration" : "readiness", cause }),
|
||||
),
|
||||
Effect.flatMap(decode),
|
||||
Effect.mapError((cause) => (cause instanceof StartError ? cause : new StartError({ stage: "readiness", cause }))),
|
||||
)
|
||||
return yield* probe(info, options.version)
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
|
||||
@@ -220,8 +220,31 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (
|
||||
tool.settled ||
|
||||
(mode === "hosted" && !tool.providerExecuted) ||
|
||||
(mode === "uncalled" && tool.called)
|
||||
)
|
||||
continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
}
|
||||
return failed
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
||||
yield* flush()
|
||||
yield* failTools(error, "uncalled")
|
||||
yield* startAssistant()
|
||||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
@@ -245,20 +268,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
error: SessionError.Error,
|
||||
hostedOnly = false,
|
||||
) {
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
}
|
||||
return failed
|
||||
return yield* failTools(error, hostedOnly ? "hosted" : "all")
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Model,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
@@ -716,7 +717,18 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
content: [fixture.expectedContent],
|
||||
content: [
|
||||
kind === "tool input"
|
||||
? {
|
||||
type: "tool",
|
||||
id: fragmentID(kind, "partial"),
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
},
|
||||
}
|
||||
: fixture.expectedContent,
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3876,6 +3888,45 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles malformed streamed tool input before the provider failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call a malformed tool")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
|
||||
response = reply.stop()
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
{ type: "session.step.started.1" },
|
||||
{
|
||||
type: "session.tool.failed.1",
|
||||
data: {
|
||||
callID: "call-malformed",
|
||||
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.step.failed.1",
|
||||
data: { error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -8,15 +8,12 @@ import { useTheme, selectedForeground } from "../../../context/theme"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { useBindings, useCommandShortcut } from "../../../keymap"
|
||||
import { useComposerTab } from "./index"
|
||||
import { Spinner } from "../../../component/spinner"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||
|
||||
interface SubagentEntry {
|
||||
sessionID: string
|
||||
agent: string
|
||||
title: string
|
||||
status: string
|
||||
background: boolean
|
||||
current: boolean
|
||||
}
|
||||
|
||||
@@ -29,7 +26,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const navigate = useRoute().navigate
|
||||
const composer = useComposerTab()
|
||||
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
|
||||
const backgroundHint = useCommandShortcut("composer.subagent.background")
|
||||
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
|
||||
@@ -43,18 +39,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
||||
for (const sibling of siblings) {
|
||||
const agentMatch = sibling.title.match(/@(\w+) subagent/)
|
||||
const agent = sibling.agent
|
||||
? Locale.titlecase(sibling.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent"
|
||||
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
|
||||
result.push({
|
||||
sessionID: sibling.id,
|
||||
agent,
|
||||
title: name,
|
||||
status: data.session.status(sibling.id),
|
||||
background: isBackgroundSubagent(data.session.message.list(current.parentID), sibling.id, sibling.title),
|
||||
current: sibling.id === route.sessionID,
|
||||
})
|
||||
}
|
||||
@@ -62,18 +53,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
||||
for (const child of children) {
|
||||
const agentMatch = child.title.match(/@(\w+) subagent/)
|
||||
const agent = child.agent
|
||||
? Locale.titlecase(child.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent"
|
||||
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
|
||||
result.push({
|
||||
sessionID: child.id,
|
||||
agent,
|
||||
title: name,
|
||||
status: data.session.status(child.id),
|
||||
background: isBackgroundSubagent(data.session.message.list(props.sessionID), child.id, child.title),
|
||||
current: child.id === route.sessionID,
|
||||
})
|
||||
}
|
||||
@@ -147,10 +133,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
hints: () => {
|
||||
const entry = selectedEntry()
|
||||
if (!entry || entry.status !== "running") return []
|
||||
return [
|
||||
...(!entry.background && backgroundHint() ? [{ label: "background", shortcut: backgroundHint() }] : []),
|
||||
{ label: "interrupt", shortcut: interruptHint() },
|
||||
]
|
||||
return [{ label: "interrupt", shortcut: interruptHint() }]
|
||||
},
|
||||
onClose: () => {
|
||||
const parentID = session()?.parentID
|
||||
@@ -193,16 +176,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "composer.subagent.background",
|
||||
title: "Background blocking tools",
|
||||
category: "Session",
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry || entry.status !== "running" || entry.background) return
|
||||
void sdk.api.session.background({ sessionID: session()?.parentID ?? props.sessionID })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "composer.subagent.interrupt",
|
||||
title: "Interrupt subagent",
|
||||
@@ -218,18 +191,25 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
|
||||
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
|
||||
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
|
||||
{ key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.subagent.background" },
|
||||
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Show when={composer.active("subagents")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<scrollbox
|
||||
scrollbarOptions={{ visible: false }}
|
||||
maxHeight={5}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
>
|
||||
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => {
|
||||
const active = createMemo(() => index() === selected())
|
||||
const status = createMemo(() => {
|
||||
if (entry.status === "running") return "Running"
|
||||
return ""
|
||||
})
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -242,30 +222,18 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
navigate({ type: "session", sessionID: entry.sessionID })
|
||||
}}
|
||||
>
|
||||
<box width={2} flexShrink={0}>
|
||||
<Show when={entry.status === "running" && !entry.background}>
|
||||
<Spinner color={active() ? fg : theme.textMuted} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="row">
|
||||
<text
|
||||
fg={active() ? fg : theme.textMuted}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{entry.agent}{" "}
|
||||
</text>
|
||||
<text
|
||||
fg={active() ? fg : entry.current ? theme.primary : theme.text}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{entry.title}
|
||||
{entry.agent}: {entry.title}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={entry.status === "running" && entry.background}>
|
||||
<Show when={status()}>
|
||||
<text fg={active() ? fg : theme.textMuted} wrapMode="none">
|
||||
background
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -277,16 +245,3 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function isBackgroundSubagent(messages: SessionMessageInfo[], sessionID: string, title: string) {
|
||||
return messages.some((message) =>
|
||||
message.type === "assistant"
|
||||
? message.content.some((part) => {
|
||||
if (part.type !== "tool" || part.name !== "subagent" || part.state.status === "streaming") return false
|
||||
if (part.state.input.background === true && part.state.input.description === title) return true
|
||||
if (part.state.status === "running") return false
|
||||
return part.state.structured.sessionID === sessionID && part.state.structured.status === "running"
|
||||
})
|
||||
: false,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user