mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 07:40:48 +00:00
fix(feishu): keep group_topic message-tool replies inside the topic (#77151)
Merged via squash.
Prepared head SHA: 3a47a09da1
Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu: auto-thread `message(action="send")` replies inside the topic when the active session is group_topic or group_topic_sender, and propagate `replyInThread` through text, card, and media outbound adapters so topic-scoped sessions no longer post at the group root. Fixes #74903. (#77151) Thanks @ai-hpc.
|
||||
|
||||
## 2026.5.9
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -578,6 +578,149 @@ describe("feishuPlugin actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-threads `send` text against the inbound trigger in group_topic sessions", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_topic", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", text: "topic reply" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
sessionKey: "feishu:group:oc_group_1:topic:om_inbound",
|
||||
toolContext: { currentMessageId: "om_inbound" },
|
||||
} as never);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: "topic reply",
|
||||
accountId: undefined,
|
||||
replyToMessageId: "om_inbound",
|
||||
replyInThread: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-threads `send` cards against the inbound trigger in group_topic sessions", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_topic_card", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
presentation: {
|
||||
title: "Topic update",
|
||||
blocks: [{ type: "text", text: "topic reply" }],
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
sessionKey: "feishu:group:oc_group_1:topic:om_inbound",
|
||||
toolContext: { currentMessageId: "om_inbound" },
|
||||
} as never);
|
||||
|
||||
expect(sendCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_inbound",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-threads `send` media against the inbound trigger in group_topic sessions", async () => {
|
||||
feishuOutboundSendMediaMock.mockResolvedValueOnce({
|
||||
channel: "feishu",
|
||||
messageId: "om_topic_media",
|
||||
details: { messageId: "om_topic_media", chatId: "oc_group_1" },
|
||||
});
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: "topic reply",
|
||||
media: "/tmp/image.png",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
sessionKey: "feishu:group:oc_group_1:topic:om_inbound",
|
||||
toolContext: { currentMessageId: "om_inbound" },
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
} as never);
|
||||
|
||||
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "om_inbound",
|
||||
}),
|
||||
);
|
||||
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ replyToId: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-threads `send` in group_topic_sender sessions too", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_topic", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", text: "topic reply" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
sessionKey: "feishu:group:oc_group_1:topic:om_inbound:sender:ou_user",
|
||||
toolContext: { currentMessageId: "om_inbound" },
|
||||
} as never);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_inbound",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not auto-thread `send` in plain group sessions (no topic)", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_plain", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", text: "plain group reply" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
sessionKey: "feishu:group:oc_group_1",
|
||||
toolContext: { currentMessageId: "om_inbound" },
|
||||
} as never);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: "plain group reply",
|
||||
accountId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-thread `send` in group_topic when no inbound currentMessageId is available", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_topic", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", text: "topic reply" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
sessionKey: "feishu:group:oc_group_1:topic:om_inbound",
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: "topic reply",
|
||||
accountId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates pins", async () => {
|
||||
createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" });
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageToolDiscovery,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
@@ -351,6 +352,49 @@ function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFeishuGroupTopicSessionKey(sessionKey: string | null | undefined): boolean {
|
||||
if (typeof sessionKey !== "string" || !sessionKey) {
|
||||
return false;
|
||||
}
|
||||
const parsed = parseFeishuConversationId({ conversationId: sessionKey });
|
||||
return parsed?.scope === "group_topic" || parsed?.scope === "group_topic_sender";
|
||||
}
|
||||
|
||||
type FeishuActionReplyAnchor = {
|
||||
replyToMessageId: string | undefined;
|
||||
replyInThread: boolean;
|
||||
};
|
||||
|
||||
type FeishuSendActionContext = Pick<
|
||||
ChannelMessageActionContext,
|
||||
"action" | "params" | "sessionKey" | "toolContext"
|
||||
>;
|
||||
|
||||
function resolveFeishuTopicAutoThreadAnchor(ctx: FeishuSendActionContext): string | undefined {
|
||||
if (ctx.action !== "send") {
|
||||
return undefined;
|
||||
}
|
||||
if (!isFeishuGroupTopicSessionKey(ctx.sessionKey)) {
|
||||
return undefined;
|
||||
}
|
||||
const inbound = ctx.toolContext?.currentMessageId;
|
||||
return typeof inbound === "string" && inbound.length > 0 ? inbound : undefined;
|
||||
}
|
||||
|
||||
function buildFeishuSendReplyAnchor(ctx: FeishuSendActionContext): FeishuActionReplyAnchor {
|
||||
if (ctx.action === "thread-reply") {
|
||||
return {
|
||||
replyToMessageId: resolveFeishuMessageId(ctx.params),
|
||||
replyInThread: true,
|
||||
};
|
||||
}
|
||||
const autoThreadId = resolveFeishuTopicAutoThreadAnchor(ctx);
|
||||
return {
|
||||
replyToMessageId: autoThreadId,
|
||||
replyInThread: autoThreadId !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
|
||||
const trimmed = conversationId.trim();
|
||||
if (!trimmed || trimmed.includes(":")) {
|
||||
@@ -754,8 +798,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
if (!to) {
|
||||
throw new Error(`Feishu ${ctx.action} requires a target (to).`);
|
||||
}
|
||||
const replyToMessageId =
|
||||
ctx.action === "thread-reply" ? resolveFeishuMessageId(ctx.params) : undefined;
|
||||
const { replyToMessageId, replyInThread } = buildFeishuSendReplyAnchor(ctx);
|
||||
if (ctx.action === "thread-reply" && !replyToMessageId) {
|
||||
throw new Error("Feishu thread-reply requires messageId.");
|
||||
}
|
||||
@@ -791,7 +834,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
card,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
replyInThread,
|
||||
});
|
||||
} else if (mediaUrl) {
|
||||
result = await sendMedia!({
|
||||
@@ -801,7 +844,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
mediaUrl,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
replyToId: replyToMessageId,
|
||||
...(replyInThread
|
||||
? { threadId: replyToMessageId }
|
||||
: { replyToId: replyToMessageId }),
|
||||
...(audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
});
|
||||
} else {
|
||||
@@ -811,7 +856,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
text: text!,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
return jsonActionResult({
|
||||
|
||||
@@ -381,6 +381,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
to: "chat_1",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_thread_2",
|
||||
replyInThread: true,
|
||||
accountId: "main",
|
||||
}),
|
||||
);
|
||||
@@ -958,6 +959,58 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
|
||||
);
|
||||
expect(sendMessageFeishuMock.mock.calls[0][0].replyToMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates threadId as replyInThread=true to sendMessageFeishu", async () => {
|
||||
await sendText({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text: "topic reply",
|
||||
threadId: "om_topic_root",
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_topic_root",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates threadId as replyInThread=true to sendStructuredCardFeishu when renderMode=card", async () => {
|
||||
await sendText({
|
||||
cfg: cardRenderConfig,
|
||||
to: "chat_1",
|
||||
text: "```code```",
|
||||
threadId: "om_topic_root",
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_topic_root",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers replyToId over threadId for plain text (inline reply, no auto-thread)", async () => {
|
||||
await sendText({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text: "inline reply",
|
||||
replyToId: "om_inline",
|
||||
threadId: "om_topic_root",
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_inline",
|
||||
replyInThread: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishuOutbound.sendMedia replyToId forwarding", () => {
|
||||
@@ -978,6 +1031,63 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_reply_target",
|
||||
replyInThread: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards threadId as replyInThread=true to sendMediaFeishu", async () => {
|
||||
await feishuOutbound.sendMedia?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
threadId: "om_topic_root",
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_topic_root",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers replyToId over threadId (inline reply) when both are set", async () => {
|
||||
await feishuOutbound.sendMedia?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
replyToId: "om_inline",
|
||||
threadId: "om_topic_root",
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_inline",
|
||||
replyInThread: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats whitespace-only replyToId as absent for replyInThread (falls back to threadId)", async () => {
|
||||
await feishuOutbound.sendMedia?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
replyToId: " ",
|
||||
threadId: "om_topic_root",
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_topic_root",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -408,6 +408,21 @@ function resolveReplyToMessageId(params: {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
type FeishuMediaReplyMode = {
|
||||
replyToMessageId: string | undefined;
|
||||
replyInThread: boolean;
|
||||
};
|
||||
|
||||
function resolveFeishuMediaReplyMode(params: {
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}): FeishuMediaReplyMode {
|
||||
const trimmedReplyToId = params.replyToId?.trim() || undefined;
|
||||
const replyToMessageId = resolveReplyToMessageId(params);
|
||||
const replyInThread = params.threadId != null && !trimmedReplyToId;
|
||||
return { replyToMessageId, replyInThread };
|
||||
}
|
||||
|
||||
async function sendCommentThreadReply(params: {
|
||||
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
|
||||
to: string;
|
||||
@@ -456,9 +471,10 @@ async function sendOutboundText(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
}) {
|
||||
const { cfg, to, text, accountId, replyToMessageId } = params;
|
||||
const { cfg, to, text, accountId, replyToMessageId, replyInThread } = params;
|
||||
const commentResult = await sendCommentThreadReply({
|
||||
cfg,
|
||||
to,
|
||||
@@ -474,10 +490,17 @@ async function sendOutboundText(params: {
|
||||
const renderMode = account.config?.renderMode ?? "auto";
|
||||
|
||||
if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
|
||||
return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId });
|
||||
return sendMarkdownCardFeishu({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId });
|
||||
return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId, replyInThread });
|
||||
}
|
||||
|
||||
export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
@@ -581,7 +604,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
mediaLocalRoots,
|
||||
identity,
|
||||
}) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
const { replyToMessageId, replyInThread } = resolveFeishuMediaReplyMode({
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
// Scheme A compatibility shim:
|
||||
// when upstream accidentally returns a local image path as plain text,
|
||||
// auto-upload and send as Feishu image message instead of leaking path text.
|
||||
@@ -594,6 +620,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl: localImagePath,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -609,6 +636,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,7 +657,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
to,
|
||||
text,
|
||||
replyToMessageId,
|
||||
replyInThread: threadId != null && !replyToId,
|
||||
replyInThread,
|
||||
accountId: accountId ?? undefined,
|
||||
header: header?.title ? header : undefined,
|
||||
});
|
||||
@@ -640,6 +668,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({
|
||||
@@ -653,7 +682,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
replyToId,
|
||||
threadId,
|
||||
}) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
const { replyToMessageId, replyInThread } = resolveFeishuMediaReplyMode({
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
const commentTarget = parseFeishuCommentTarget(to);
|
||||
if (commentTarget) {
|
||||
const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n");
|
||||
@@ -663,6 +695,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text: commentText || mediaUrl || text || "",
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -681,6 +714,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -694,6 +728,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
...(audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
});
|
||||
if (result.voiceIntentDegradedToFile && text?.trim()) {
|
||||
@@ -703,6 +738,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -717,6 +753,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text: fallbackText,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -728,6 +765,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
text: text ?? "",
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user