mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
fix(providers): copilot-m365-web fails loudly on empty turns + tier-aware enterprise invocation (#7858, #7870) (#7958)
#7858 — accumulateBotContent() silently returned an empty delta for any unrecognized frame shape, and finish() only had a fallback for the type:2 finalResultMessage case; a turn with no content in ANY known shape closed with a bare `stop` + `[DONE]`, indistinguishable from a genuine empty answer. finish() now emits a sanitized error (Hard Rule #12) naming the resolved tier and the likely causes, and unrecognized update-frame shapes are logged by argument KEY only (never content, tokens, or cookies). #7870 — the enterprise tier only changed buildWsUrl() query params; buildChatInvocation() always fell back to the consumer M365_DEFAULT_OPTION_SETS (which declares the MSA-only enable_msa_user flag) and tone:"". resolveConnectionParams()/resolveTierOverrides() now also resolve and surface the tier itself, threaded through wsChat() -> sendChat() -> buildChatInvocation() via a new resolveChatInvocationOverrides() helper, so an enterprise-tier invocation declares the enterprise_*/bizchat_* option sets, the wider allowedMessageTypes captured from the real enterprise HAR (Discussion #7850), and tone:"Magic" — while individual and EDU payloads stay byte-identical to today. Regression tests: tests/unit/copilot-m365-web-silent-empty-7858.test.ts, tests/unit/copilot-m365-enterprise-invocation-7870.test.ts.
This commit is contained in:
committed by
GitHub
parent
5996acdcc7
commit
ec5b24b986
1
changelog.d/fixes/7858-copilot-m365-web-empty-frame.md
Normal file
1
changelog.d/fixes/7858-copilot-m365-web-empty-frame.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): copilot-m365-web now fails loudly on a fully-empty turn instead of a silent `stop`, and the enterprise tier threads its own `optionsSets`/`tone`/`allowedMessageTypes` into the chat invocation instead of only changing the WS URL (#7858, #7870)
|
||||
@@ -108,6 +108,8 @@ export interface M365ConnectionParams {
|
||||
isEdu?: string;
|
||||
licenseType?: string;
|
||||
agent?: string;
|
||||
/** Resolved tier name (#7870) — threads into the chat invocation payload, not just the URL. */
|
||||
tier?: "edu" | "enterprise";
|
||||
}
|
||||
|
||||
/** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */
|
||||
@@ -196,7 +198,7 @@ export function resolveConnectionParams(
|
||||
*/
|
||||
function resolveTierOverrides(
|
||||
psd: JsonRecord
|
||||
): Pick<M365ConnectionParams, "scenario" | "isEdu" | "licenseType" | "agent"> {
|
||||
): Pick<M365ConnectionParams, "scenario" | "isEdu" | "licenseType" | "agent" | "tier"> {
|
||||
const tier = typeof psd.tier === "string" ? psd.tier.toLowerCase() : "";
|
||||
const isEduTier = tier === "edu" || tier === "included";
|
||||
const isEnterpriseTier = tier === "enterprise" || tier === "work";
|
||||
@@ -217,6 +219,7 @@ function resolveTierOverrides(
|
||||
agent:
|
||||
(typeof psd.agent === "string" && psd.agent) ||
|
||||
(isEnterpriseTier ? M365_ENTERPRISE_OVERRIDES.agent : undefined),
|
||||
tier: isEduTier ? "edu" : isEnterpriseTier ? "enterprise" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,40 @@ export const ALLOWED_MESSAGE_TYPES = [
|
||||
"GenerateContentQuery",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Enterprise / "work" tier option sets (#7870), captured from @OfflinePing's HAR of the
|
||||
* real Microsoft 365 Copilot for work web UI (Discussion #7850). Unlike
|
||||
* {@link M365_DEFAULT_OPTION_SETS} (a consumer/MSA set), this omits `enable_msa_user` and
|
||||
* the `cwc_*` consumer entries and declares the `enterprise_*`/`bizchat_*` work-surface
|
||||
* flags the capture showed — the individual/consumer set never produces a turn on an AAD
|
||||
* enterprise tenant because it advertises the wrong account surface.
|
||||
*/
|
||||
export const M365_ENTERPRISE_OPTION_SETS = [
|
||||
"enterprise_flux_image",
|
||||
"enterprise_flux_web",
|
||||
"enterprise_flux_work",
|
||||
"enterprise_toolbox_with_skdsstore",
|
||||
"enterprise_pagination_support",
|
||||
"enterprise_flux_work_code_interpreter",
|
||||
"enterprise_code_interpreter_citation_fix",
|
||||
"bizchat_enable_federated_connectors",
|
||||
"at_mention_plugins_enable",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Additional SignalR message types observed on the enterprise capture beyond
|
||||
* {@link ALLOWED_MESSAGE_TYPES} (#7870) — the server actively emits `ReferencesListComplete`
|
||||
* on that tenant, a type we did not previously declare as allowed.
|
||||
*/
|
||||
export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
|
||||
"ReferencesListComplete",
|
||||
"EndOfRequest",
|
||||
"MemoryUpdate",
|
||||
"TriggerPlugin",
|
||||
"AuthError",
|
||||
"SwitchRespondingEndpoint",
|
||||
] as const;
|
||||
|
||||
export const M365_DEFAULT_OPTION_SETS = [
|
||||
"search_result_progress_messages_with_search_queries",
|
||||
"update_textdoc_response_after_streaming",
|
||||
@@ -130,6 +164,33 @@ export interface ChatInvocationOptions {
|
||||
/** Tier-specific option flags; left empty by default (tuned during live validation). */
|
||||
optionsSets?: string[];
|
||||
tone?: string;
|
||||
/** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */
|
||||
allowedMessageTypes?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tier-specific `optionsSets` / `tone` / `allowedMessageTypes` overrides for
|
||||
* the `type:4` chat invocation (#7870). Mirrors how `resolveConnectionParams`/`buildWsUrl`
|
||||
* already branch on tier for the WS URL — this is the request-payload counterpart so an
|
||||
* enterprise tier actually changes what is sent, not just where it is sent.
|
||||
*/
|
||||
export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
optionsSets: string[];
|
||||
tone: string;
|
||||
allowedMessageTypes: readonly string[];
|
||||
} {
|
||||
if (tier === "enterprise") {
|
||||
return {
|
||||
optionsSets: [...M365_ENTERPRISE_OPTION_SETS],
|
||||
tone: "Magic",
|
||||
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES, ...M365_ENTERPRISE_EXTRA_MESSAGE_TYPES],
|
||||
};
|
||||
}
|
||||
return {
|
||||
optionsSets: [...M365_DEFAULT_OPTION_SETS],
|
||||
tone: "",
|
||||
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +212,9 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
spokenTextMode: "None",
|
||||
options: {},
|
||||
extraExtensionParameters: {},
|
||||
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES],
|
||||
allowedMessageTypes: opts.allowedMessageTypes
|
||||
? [...opts.allowedMessageTypes]
|
||||
: [...ALLOWED_MESSAGE_TYPES],
|
||||
sliceIds: [],
|
||||
threadLevelGptId: {},
|
||||
traceId: opts.traceId,
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
handshakeError,
|
||||
handshakeFrame,
|
||||
isCompletionFrame,
|
||||
isUpdateFrame,
|
||||
keepaliveFrame,
|
||||
parseFrame,
|
||||
resolveChatInvocationOverrides,
|
||||
splitFrames,
|
||||
} from "./copilot-m365-frames.ts";
|
||||
|
||||
@@ -42,6 +44,19 @@ function sseChunk(model: string, delta: JsonRecord, finishReason: string | null
|
||||
})}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe an unrecognized `type:1 target:update` frame by its top-level argument keys
|
||||
* only (#7858 AC: log shape, never content/tokens/cookies) so the next unrecognized-shape
|
||||
* report arrives with the data needed to add a handler.
|
||||
*/
|
||||
function describeUpdateFrameShape(frame: Record<string, unknown> | null): string | null {
|
||||
if (!isUpdateFrame(frame) || !frame) return null;
|
||||
const args = frame.arguments;
|
||||
const first = Array.isArray(args) ? (args[0] as Record<string, unknown> | undefined) : undefined;
|
||||
if (!first) return null;
|
||||
return Object.keys(first).join(",");
|
||||
}
|
||||
|
||||
function errorResponse(message: string, status = 502): Response {
|
||||
return new Response(JSON.stringify({ error: { message } }), {
|
||||
status,
|
||||
@@ -58,6 +73,7 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
wsUrl: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
tier?: string;
|
||||
signal?: AbortSignal;
|
||||
log?: ExecutorLog | null;
|
||||
}): Promise<ReadableStream<Uint8Array>> {
|
||||
@@ -96,6 +112,21 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
controller.enqueue(
|
||||
encoder.encode(sseChunk(input.model, { content: finalResultMessage }))
|
||||
);
|
||||
} else if (!previousText && !finalResultMessage) {
|
||||
// #7858 — a turn that completed with no content in ANY known shape is
|
||||
// indistinguishable, from the outside, from a genuine successful-but-empty
|
||||
// reply. Fail loudly instead of a silent `stop`, per Hard Rule #12.
|
||||
const tierNote = input.tier ? `resolved tier: ${input.tier}` : "resolved tier: individual (default)";
|
||||
const message = sanitizeErrorMessage(
|
||||
`Microsoft 365 Copilot turn completed with no content in any known frame ` +
|
||||
`shape (${tierNote}). Possible causes: an unrecognized frame shape for ` +
|
||||
`this tenant, or a misconfigured tier.`
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ error: { message } })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(sseChunk(input.model, {}, "stop")));
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
@@ -135,6 +166,7 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
|
||||
const sendChat = () => {
|
||||
ws?.send(keepaliveFrame());
|
||||
const overrides = resolveChatInvocationOverrides(input.tier);
|
||||
ws?.send(
|
||||
encodeFrame(
|
||||
buildChatInvocation({
|
||||
@@ -142,6 +174,7 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
traceId,
|
||||
sessionId,
|
||||
isStartOfSession: true,
|
||||
...overrides,
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -179,6 +212,13 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
const { delta, next } = accumulateBotContent(previousText, frame);
|
||||
if (!delta && next === previousText) {
|
||||
// #7858 AC2/AC3 — log unrecognized-shape update frames by KEY only, so
|
||||
// the next report ships the data needed to add a handler without a
|
||||
// manual capture round-trip. Never log message content, tokens, cookies.
|
||||
const shape = describeUpdateFrameShape(frame);
|
||||
if (shape) log?.debug?.("M365_WS", `unrecognized update frame keys: ${shape}`);
|
||||
}
|
||||
previousText = next;
|
||||
if (delta) {
|
||||
controller.enqueue(encoder.encode(sseChunk(input.model, { content: delta })));
|
||||
@@ -265,6 +305,7 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
wsUrl,
|
||||
prompt,
|
||||
model,
|
||||
tier: connectionParams.tier,
|
||||
signal: input.signal ?? undefined,
|
||||
log: input.log,
|
||||
});
|
||||
|
||||
160
tests/unit/copilot-m365-enterprise-invocation-7870.test.ts
Normal file
160
tests/unit/copilot-m365-enterprise-invocation-7870.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
CopilotM365WebExecutor,
|
||||
__setCopilotM365WebSocketForTesting,
|
||||
} from "../../open-sse/executors/copilot-m365-web.ts";
|
||||
import { encodeFrame } from "../../open-sse/executors/copilot-m365-frames.ts";
|
||||
|
||||
type Listener = (...args: unknown[]) => void;
|
||||
|
||||
class MockM365WebSocket {
|
||||
static instances: MockM365WebSocket[] = [];
|
||||
sent: string[] = [];
|
||||
closed = false;
|
||||
listeners = new Map<string, Listener[]>();
|
||||
|
||||
constructor(public url: string, public options: unknown) {
|
||||
MockM365WebSocket.instances.push(this);
|
||||
queueMicrotask(() => this.emit("open"));
|
||||
}
|
||||
|
||||
on(event: string, listener: Listener): this {
|
||||
const listeners = this.listeners.get(event) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(event, listeners);
|
||||
return this;
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(String(data));
|
||||
const parsed = JSON.parse(String(data).replace(/\x1e$/, ""));
|
||||
if (parsed.protocol === "json") {
|
||||
queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({}))));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === 4 && parsed.target === "chat") {
|
||||
queueMicrotask(() => {
|
||||
this.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
encodeFrame({
|
||||
type: 1,
|
||||
target: "update",
|
||||
arguments: [{ messages: [{ text: "hi", author: "bot" }], isLastUpdate: true }],
|
||||
}) +
|
||||
encodeFrame({ type: 2, invocationId: "0", item: { messages: [] } }) +
|
||||
encodeFrame({ type: 3, invocationId: "0" })
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) listener(...args);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChatInvocation(tier: string | undefined) {
|
||||
MockM365WebSocket.instances = [];
|
||||
const restore = __setCopilotM365WebSocketForTesting(
|
||||
MockM365WebSocket as unknown as typeof import("ws").default
|
||||
);
|
||||
try {
|
||||
const executor = new CopilotM365WebExecutor();
|
||||
await executor.execute({
|
||||
model: "copilot-m365",
|
||||
stream: true,
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
credentials: {
|
||||
apiKey: "redacted-token",
|
||||
providerSpecificData: {
|
||||
chathubPath: "redacted-user@redacted-tenant",
|
||||
...(tier ? { tier } : {}),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(MockM365WebSocket.instances.length, 1);
|
||||
const sentFrames = MockM365WebSocket.instances[0].sent;
|
||||
const chatFrameRaw = sentFrames
|
||||
.map((f) => f.replace(/\x1e$/, ""))
|
||||
.map((f) => {
|
||||
try {
|
||||
return JSON.parse(f);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.find((f) => f && f.type === 4 && f.target === "chat");
|
||||
|
||||
assert.ok(chatFrameRaw, "expected a type:4 chat invocation frame to be sent");
|
||||
return chatFrameRaw.arguments[0] as Record<string, unknown>;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
test("#7870: enterprise-tier chat invocation must not carry the consumer enable_msa_user optionsSet", async () => {
|
||||
const invocationArgs = await sendChatInvocation("enterprise");
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(
|
||||
!optionsSets.includes("enable_msa_user"),
|
||||
`enterprise-tier invocation must not declare enable_msa_user (MSA/consumer flag); got optionsSets=${JSON.stringify(optionsSets)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#7870: enterprise-tier chat invocation declares the enterprise_flux_work option set", async () => {
|
||||
const invocationArgs = await sendChatInvocation("enterprise");
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(
|
||||
optionsSets.includes("enterprise_flux_work"),
|
||||
`expected enterprise_flux_work in optionsSets, got=${JSON.stringify(optionsSets)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#7870: enterprise-tier chat invocation widens allowedMessageTypes to include ReferencesListComplete", async () => {
|
||||
const invocationArgs = await sendChatInvocation("enterprise");
|
||||
const allowedMessageTypes = invocationArgs.allowedMessageTypes as string[];
|
||||
assert.ok(
|
||||
allowedMessageTypes.includes("ReferencesListComplete"),
|
||||
`expected ReferencesListComplete in allowedMessageTypes, got=${JSON.stringify(allowedMessageTypes)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () => {
|
||||
const invocationArgs = await sendChatInvocation("enterprise");
|
||||
assert.equal(invocationArgs.tone, "Magic");
|
||||
});
|
||||
|
||||
test("#7870: individual (no tier) chat invocation payload stays byte-identical to today", async () => {
|
||||
const invocationArgs = await sendChatInvocation(undefined);
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(optionsSets.includes("enable_msa_user"));
|
||||
assert.equal(invocationArgs.tone, "");
|
||||
assert.deepEqual(invocationArgs.allowedMessageTypes, [
|
||||
"Chat",
|
||||
"Suggestion",
|
||||
"InternalSearchQuery",
|
||||
"Disengaged",
|
||||
"InternalLoaderMessage",
|
||||
"Progress",
|
||||
"GeneratedCode",
|
||||
"RenderCardRequest",
|
||||
"AdsQuery",
|
||||
"SemanticSerp",
|
||||
"GenerateContentQuery",
|
||||
]);
|
||||
});
|
||||
|
||||
test("#7870: EDU-tier chat invocation payload stays byte-identical to today (unaffected by enterprise change)", async () => {
|
||||
const invocationArgs = await sendChatInvocation("edu");
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(optionsSets.includes("enable_msa_user"));
|
||||
assert.equal(invocationArgs.tone, "");
|
||||
});
|
||||
92
tests/unit/copilot-m365-web-silent-empty-7858.test.ts
Normal file
92
tests/unit/copilot-m365-web-silent-empty-7858.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
CopilotM365WebExecutor,
|
||||
__setCopilotM365WebSocketForTesting,
|
||||
} from "../../open-sse/executors/copilot-m365-web.ts";
|
||||
import { encodeFrame } from "../../open-sse/executors/copilot-m365-frames.ts";
|
||||
|
||||
function makeFakeWsCtor(frames: Array<Record<string, unknown>>) {
|
||||
return class FakeWS {
|
||||
private handlers: Record<string, (arg?: unknown) => void> = {};
|
||||
constructor(_url: string) {
|
||||
setImmediate(() => this.handlers.open?.());
|
||||
}
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
this.handlers[event] = cb;
|
||||
return this;
|
||||
}
|
||||
send(data: unknown) {
|
||||
const str = typeof data === "string" ? data : String(data);
|
||||
if (str.includes('"protocol":"json"')) {
|
||||
setImmediate(() => this.handlers.message?.(Buffer.from(encodeFrame({}))));
|
||||
} else if (str.includes('"target":"chat"')) {
|
||||
setImmediate(() => {
|
||||
for (const frame of frames) this.handlers.message?.(Buffer.from(encodeFrame(frame)));
|
||||
});
|
||||
}
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
}
|
||||
|
||||
test("copilot-m365-web: unrecognized frame shape must surface an error, not a silent stop [#7858]", async () => {
|
||||
const frames = [
|
||||
{ type: 1, target: "update", arguments: [{ someUnknownField: "not a known shape" }] },
|
||||
{ type: 3 },
|
||||
];
|
||||
const restore = __setCopilotM365WebSocketForTesting(makeFakeWsCtor(frames) as never);
|
||||
let body: string;
|
||||
try {
|
||||
const { response } = await new CopilotM365WebExecutor().execute({
|
||||
model: "copilot-m365",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "access_token=test-token",
|
||||
providerSpecificData: { chathubPath: "user-oid@tenant-id" },
|
||||
},
|
||||
log: null,
|
||||
} as never);
|
||||
body = await response.text();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
body.includes('"error"'),
|
||||
`expected the stream to carry an explicit error for a fully-empty turn, got: ${body}`
|
||||
);
|
||||
});
|
||||
|
||||
test("copilot-m365-web: a legitimate content-bearing turn is unaffected [#7858 regression guard]", async () => {
|
||||
const frames = [
|
||||
{
|
||||
type: 1,
|
||||
target: "update",
|
||||
arguments: [{ messages: [{ author: "bot", text: "hello there" }] }],
|
||||
},
|
||||
{ type: 3 },
|
||||
];
|
||||
const restore = __setCopilotM365WebSocketForTesting(makeFakeWsCtor(frames) as never);
|
||||
let body: string;
|
||||
try {
|
||||
const { response } = await new CopilotM365WebExecutor().execute({
|
||||
model: "copilot-m365",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "access_token=test-token",
|
||||
providerSpecificData: { chathubPath: "user-oid@tenant-id" },
|
||||
},
|
||||
log: null,
|
||||
} as never);
|
||||
body = await response.text();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.ok(!body.includes('"error"'), `expected no error for a content-bearing turn, got: ${body}`);
|
||||
assert.ok(body.includes("hello there"), `expected the streamed content, got: ${body}`);
|
||||
assert.ok(body.includes('"finish_reason":"stop"'), `expected a stop chunk, got: ${body}`);
|
||||
});
|
||||
Reference in New Issue
Block a user