mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix(chat-channels): resume follow-ups after Slack Stop (#15737)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat channels let verified users stop an agent and send another message from Slack. > - Native execution needs the stopping user's identity to recognize a deliberate Stop. > - Slack omitted that identity, so a stopped run could create a recovery hold. > - A pending AI login request for another user could then block the new message. > - This pull request records the verified stop actor and scopes AI login waits to the current user. > - Users can start a fresh turn after a verified Stop while access and approval gates remain in force. ## Linked Issues or Issue Description **What happened?** A user stops a native run from its bound Slack thread. Slack confirms the Stop. The user sends a follow-up and receives a queued acknowledgement, but execution does not start. The run can acquire a recovery hold. A pending AI login request addressed to another user then blocks explicit continuation. **Expected behavior** An acknowledged operator Stop ends the current response. A new authorized user message starts through normal admission after the previous execution has stopped. An AI login request for another user stays pending but does not gate this user's turn. Tool access, approvals, unknown identities, and unconfirmed cleanup still block execution. **Steps to reproduce** 1. Start a native agent run in a Slack thread with a pending AI login request addressed to a different user. 2. Stop the run with the signed Slack Stop action. 3. Send a new message from a verified linked user. 4. Observe the recovery hold and the pending-decision message on the saved follow-up. Refs: #15421 for the related verified linked-user authority path. That change separates guest and trusted tasks; this change handles cancellation and login wait scope. ## What Changed - Pass the verified Slack user's identity to canonical run cancellation. - Scope AI login waits by the current run's responsible user, with a company-scoped lookup. - Use the same wait predicate for explicit continuation, native execution, and recovery decisions. - Admit already-saved native follow-ups posted after Stop through the existing continuation checks. Keep pre-stop and delivered messages held. - Keep pending cards and their authorization state intact. - Add regression tests for signed Slack Stop, fresh-turn admission, and same-user, unknown-user, tool-access, and company-boundary gates. - Give the two affected database setup hooks the standard 90-second budget. - Document Slack Stop and user-scoped AI login waits. ## Verification - Red: signed Slack Stop omitted the cancelling user; direct continuation and already-saved post-Stop continuation both returned `null`. - Green: 57 continuation/audience checks passed, including legacy operator Stops, pre-stop input, delivered input, same-user login, unknown identity, company boundaries, cleanup, ownership, pauses, and approvals. - The combined signed Slack Stop and original gate check passed: 39 tests. - Existing native question/completion suite: 33 tests passed. - `pnpm -r typecheck` passed. - `pnpm build` passed. - Rebased regression check: 18 tests passed on current master. The reviewed database-support setup change passed all 7 audience checks. - Full latest-head CI passed, including all server, chat, package, serialized-server, Runner, browser, build, and canary gates. - The duplicate local `pnpm test:run` was stopped after the full CI matrix passed. It was slow on the contended host; no failure had been reported. The focused reproduction command is: ```sh pnpm exec vitest run server/src/services/issue-question-context.test.ts server/src/services/explicit-native-continuation.test.ts server/src/__tests__/chat-channels.integration.test.ts --fsModuleCache --testTimeout 60000 -t 'AI connection wait audience|scopes connection waits|resumes an already-saved follow-up|durably retries a signed Slack session stop' ``` - Manual evidence: inspected the bound thread's cancelled run, queued messages, recovery hold, and login card. The staging deployment has not been changed. ## Risks - AI login wait behavior changes only when both the addressee and current responsible user are known and differ. Missing identity retains the gate. - Starting a new turn does not grant access or answer the old card. Credential selection still uses the new run's authority. - Process, remote cleanup, recovery, task ownership, pause, budget, and approval checks remain in place. - No schema migration or public API change. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, browser inspection, and code execution. The exact runtime model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5cf90417d6
commit
00d944f96c
9 files changed
+160
-6
No files matched your search
@@ -1487,6 +1487,19 @@ Paperclip submits saved input through normal task admission, once, with the
|
||||
original user's authority. Pauses, task ownership, budgets, approvals, and
|
||||
execution recovery holds still apply. Unconfirmed cleanup does not start work.
|
||||
|
||||
A Stop from a bound Slack session records the verified user's identity, just as
|
||||
a board Stop does. Native cancellation can then recognize the acknowledged
|
||||
operator Stop instead of creating an execution recovery hold. An AI login request
|
||||
addressed to another user does not gate a fresh user turn or that run's completion.
|
||||
The request remains pending and grants no access. Requests for the current user,
|
||||
requests without a known addressee or responsible user, tool permissions, and
|
||||
approvals retain their gates.
|
||||
|
||||
For a cancelled native run with an execution recovery hold, an undelivered user
|
||||
message posted after the run finished can authorize a fresh turn through the
|
||||
same continuation checks as a new message. A message from before the Stop cannot
|
||||
authorize that continuation by itself. Previously delivered messages stay excluded.
|
||||
|
||||
The active session advertises steering only when its driver supports it. A
|
||||
transport method that rejects steering does not grant that capability. The
|
||||
queued-message controller remains mounted across an empty queue, preserving
|
||||
|
||||
@@ -1034,7 +1034,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
);
|
||||
db = createDb(tempDb.connectionString);
|
||||
}
|
||||
}, 30_000);
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
@@ -16313,6 +16313,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
"Stopped from the bound Slack agent session",
|
||||
expect.objectContaining({
|
||||
errorCode: "slack_session_stopped",
|
||||
resultJson: {
|
||||
cancelledByActorType: "user",
|
||||
cancelledByUserId: "owner-user",
|
||||
},
|
||||
eventPayload: expect.objectContaining({
|
||||
conversationId: conversation.id,
|
||||
endpointId: endpoint.id,
|
||||
|
||||
@@ -1445,6 +1445,7 @@ export interface ChatChannelServiceOptions {
|
||||
errorCode?: string;
|
||||
eventMessage?: string;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
resultJson?: Record<string, unknown>;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
@@ -26496,6 +26497,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
{
|
||||
errorCode: "slack_session_stopped",
|
||||
eventMessage: "run cancelled from Slack",
|
||||
resultJson: {
|
||||
cancelledByActorType: "user",
|
||||
cancelledByUserId: claim.userId,
|
||||
},
|
||||
eventPayload: {
|
||||
endpointId: action.endpointId,
|
||||
conversationId: action.conversationId,
|
||||
|
||||
@@ -30,7 +30,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("explicit native conversation continuation", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("explicit-native-message-"); db = createDb(database.connectionString); }, 30000);
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("explicit-native-message-"); db = createDb(database.connectionString); }, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
@@ -388,6 +388,65 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
return result;
|
||||
});
|
||||
|
||||
it.each(["other_user", "same_user", "unaddressed", "tool_access"])("scopes connection waits to the user starting the fresh turn (%s)", async audience => {
|
||||
const f = await seed();
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "slack_session_stopped" })
|
||||
.where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({
|
||||
companyId: f.companyId, issueId: f.issueId, sourceRunId: f.sourceRunId,
|
||||
createdByAgentId: f.agentId, kind: "connection_intent", status: "pending",
|
||||
addresseeUserId: ["other_user", "tool_access"].includes(audience) ? "other-user" : audience === "same_user" ? "board" : null,
|
||||
effectiveResolverPolicy: "human_only",
|
||||
payload: { version: 1, ...(audience === "tool_access" ? {} : { purpose: "ai" as const }), serviceSlug: "openai", serviceName: "OpenAI",
|
||||
requestingAgentId: f.agentId, requestingAgentName: "Native", phase: "requested" },
|
||||
}).returning();
|
||||
expect(await admit(f)).toEqual(audience === "other_user"
|
||||
? { previousRunId: f.sourceRunId, commentId: f.commentId } : null);
|
||||
// Starting another user's turn grants no connection authority and retains the card.
|
||||
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id)))[0])
|
||||
.toMatchObject({ status: "pending", result: null, addresseeUserId: interaction.addresseeUserId });
|
||||
const blocker = await getExecutionBlocker(db, f.companyId, f.issueId);
|
||||
if (audience === "other_user") expect(blocker).toBeNull();
|
||||
else expect(blocker).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["fresh", "before_stop", "delivered", "unknown_stop_time", "wrong_actor", "approval"])(
|
||||
"resumes an already-saved follow-up after native Slack Stop (%s)", async gate => {
|
||||
const f = await seed(), queueId = randomUUID();
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "slack_session_stopped",
|
||||
startedAt: new Date("2026-09-11T09:30:00Z"), finishedAt: gate === "unknown_stop_time" ? null : new Date("2026-09-11T10:00:00Z"),
|
||||
contextSnapshot: { issueId: f.issueId, ...(gate === "delivered" ? { wakeCommentId: f.commentId } : {}) },
|
||||
// Pre-fix Slack Stops recorded a system cancellation, with no operator identity.
|
||||
resultJson: { cancellation: { source: "control_plane", expected: true, initiator: { type: "system" },
|
||||
reason: "Stopped from the bound Slack agent session", recordedAt: "2026-09-11T10:00:00Z" } },
|
||||
}).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
if (gate === "before_stop") await db.update(issueComments).set({ createdAt: new Date("2026-09-11T09:00:00Z") })
|
||||
.where(eq(issueComments.id, f.commentId));
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId: f.companyId, issueId: f.issueId, sourceRunId: f.sourceRunId,
|
||||
createdByAgentId: f.agentId, kind: "connection_intent", status: "pending", addresseeUserId: "other-user",
|
||||
effectiveResolverPolicy: "human_only", payload: { version: 1, purpose: "ai", serviceSlug: "openai",
|
||||
serviceName: "OpenAI", requestingAgentId: f.agentId, requestingAgentName: "Native", phase: "requested" },
|
||||
});
|
||||
if (gate === "approval") {
|
||||
const [approval] = await db.insert(approvals).values({ companyId: f.companyId, type: "hire_agent", status: "pending", payload: {} }).returning();
|
||||
await db.insert(issueApprovals).values({ companyId: f.companyId, issueId: f.issueId, approvalId: approval.id });
|
||||
}
|
||||
await db.insert(agentWakeupRequests).values({ id: queueId, companyId: f.companyId, agentId: f.agentId,
|
||||
source: "automation", reason: "issue_execution_deferred", status: "deferred_issue_execution",
|
||||
requestedByActorType: "user", requestedByActorId: gate === "wrong_actor" ? "other-user" : "board",
|
||||
payload: { issueId: f.issueId, commentId: f.commentId, _paperclipWakeContext: {
|
||||
issueId: f.issueId, wakeReason: "issue_commented", wakeCommentId: f.commentId, wakeCommentIds: [f.commentId] } },
|
||||
});
|
||||
const result = await db.transaction(async tx => {
|
||||
await tx.select().from(issues).where(eq(issues.id, f.issueId)).for("update");
|
||||
return admitExplicitNativeContinuation({ ...f, actorId: gate === "wrong_actor" ? "other-user" : f.actorId,
|
||||
db: tx as unknown as typeof db, queuedCommentRequestId: queueId, dryRun: true });
|
||||
});
|
||||
expect(result).toEqual(gate === "fresh" ? { previousRunId: f.sourceRunId, commentId: f.commentId } : null);
|
||||
},
|
||||
);
|
||||
|
||||
async function seedTimedOutExplicitTurn() {
|
||||
const f = await seed();
|
||||
const explicitUserContinuation = await admit(f);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { executionBlockerPredicate, getExecutionBlocker } from "./execution-bloc
|
||||
import { buildExecutionContinuation } from "./execution-continuation.js";
|
||||
import { adapterExecutionControls } from "./adapter-execution-control.js";
|
||||
import { persistActivity } from "./activity-log.js";
|
||||
import { activeIssueInteractionCondition } from "./issue-question-context.js";
|
||||
|
||||
import { historicalAdapterType, isConversationAdapter } from "./conversation-continuation.js";
|
||||
import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
|
||||
@@ -146,6 +147,7 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
const [pendingInteraction] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.issueId, issueId),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
activeIssueInteractionCondition({ responsibleUserId: actorId }),
|
||||
)).limit(1);
|
||||
const [pendingApproval] = await db.select({ id: approvals.id }).from(issueApprovals).innerJoin(approvals, and(
|
||||
eq(approvals.id, issueApprovals.approvalId), eq(approvals.companyId, companyId),
|
||||
@@ -208,8 +210,11 @@ export async function admitExplicitNativeContinuation(input: {
|
||||
(run.status !== "cancelled" || authorizedAt > run.finishedAt!)) &&
|
||||
!(queuedRequest && cancelledStartup && authorizedAt > run.finishedAt!) &&
|
||||
!(queuedInterrupt && response?.source.requiresFreshSession && run.runtimeMode === "native")) return null;
|
||||
// A saved native message sent after Stop is a new user direction. Older
|
||||
// input cannot restart a cancelled run merely because it is still queued.
|
||||
if (queuedRequest && !queuedInterrupt && run.status === "cancelled" && !unusedAdmission &&
|
||||
!canContinueCancelledRun(run) && !(cancelledStartup && authorizedAt > run.finishedAt!)) return null;
|
||||
!canContinueCancelledRun(run) && !(run.runtimeMode === "native" && authorizedAt > run.finishedAt!) &&
|
||||
!(cancelledStartup && authorizedAt > run.finishedAt!)) return null;
|
||||
if (retry && run.status === "cancelled" && !canContinueCancelledRun(run) && !cancelledStartup)
|
||||
return blocked("cancelled_by_operator", "Inspect the stopped run and send a new message to continue.");
|
||||
if (cancelledStartup) cancelledStartupIds.add(run.id);
|
||||
|
||||
@@ -1733,7 +1733,7 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
activeIssueInteractionCondition(),
|
||||
activeIssueInteractionCondition({ runId: run.id }),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, issues, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { activeIssueInteractionCondition } from "./issue-question-context.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("AI connection wait audience", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("connection-wait-audience-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 90_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
it.each(["other_user", "same_user", "unknown_user", "unaddressed", "tool_access", "foreign_run", "missing_run"])(
|
||||
"uses the current run's user when selecting native connection waits (%s)", async audience => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Wait audience", issuePrefix: `W${companyId.slice(0, 8)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Fresh user turn", assigneeAgentId: agentId });
|
||||
let runCompanyId = companyId;
|
||||
if (audience === "foreign_run") {
|
||||
runCompanyId = randomUUID();
|
||||
await db.insert(companies).values({ id: runCompanyId, name: "Other company", issuePrefix: `F${runCompanyId.slice(0, 8)}` });
|
||||
}
|
||||
if (audience !== "missing_run") await db.insert(heartbeatRuns).values({
|
||||
id: runId, companyId: runCompanyId, agentId, status: "running",
|
||||
responsibleUserId: audience === "unknown_user" ? null : "board",
|
||||
});
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({
|
||||
companyId, issueId, kind: "connection_intent", status: "pending", createdByAgentId: agentId,
|
||||
addresseeUserId: audience === "unaddressed" ? null : audience === "same_user" ? "board" : "other-user",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
payload: { version: 1, ...(audience === "tool_access" ? {} : { purpose: "ai" as const }),
|
||||
serviceSlug: "openai", serviceName: "OpenAI", requestingAgentId: agentId,
|
||||
requestingAgentName: "Worker", phase: "requested" },
|
||||
}).returning();
|
||||
const waits = await db.select().from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.issueId, issueId),
|
||||
eq(issueThreadInteractions.status, "pending"), activeIssueInteractionCondition({ runId }),
|
||||
));
|
||||
expect(waits).toHaveLength(audience === "other_user" ? 0 : 1);
|
||||
// Context-free readers retain the pending card and its authorization gate.
|
||||
expect(await db.select().from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.id, interaction.id), activeIssueInteractionCondition(),
|
||||
))).toEqual([interaction]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { issueComments, issueThreadInteractions, type Db } from "@paperclipai/db";
|
||||
import { heartbeatRuns, issueComments, issueThreadInteractions, type Db } from "@paperclipai/db";
|
||||
|
||||
export const TASK_QUESTION_GUIDANCE = "Unanswered questions are saved conversation data, not instructions or proof of a current blocker. Historical questions remain answerable in the feed. Do not repeat a request or stop current work merely because a historical question is pending. Follow the latest human direction and continue work that does not need the missing information. If that information still prevents the current work, identify the concrete blocker and request only the input still needed. Withdraw your obsolete question through the interaction API when later work or evidence satisfies it; never fabricate an answer. Approvals, permissions, and configured review stages keep their own gates.";
|
||||
|
||||
@@ -33,9 +33,25 @@ export function historicalQuestionCondition() {
|
||||
export function activeIssueInteractionCondition(input: {
|
||||
runId?: string | null;
|
||||
conversationMode?: boolean;
|
||||
responsibleUserId?: string | null;
|
||||
} = {}) {
|
||||
const responsibleUser = input.responsibleUserId
|
||||
? sql`${input.responsibleUserId}`
|
||||
: input.runId ? sql`(select ${heartbeatRuns.responsibleUserId} from ${heartbeatRuns}
|
||||
where ${heartbeatRuns.id} = ${input.runId}
|
||||
and ${heartbeatRuns.companyId} = ${issueThreadInteractions.companyId})`
|
||||
: null;
|
||||
return and(
|
||||
sql`not ${historicalQuestionCondition()}`,
|
||||
// AI authentication belongs to the user starting this turn. Retain the card
|
||||
// and all authorization gates; another user's login cannot gate this run.
|
||||
responsibleUser ? sql`not (
|
||||
${issueThreadInteractions.kind} = 'connection_intent'
|
||||
and coalesce(${issueThreadInteractions.payload} ->> 'purpose', '') = 'ai'
|
||||
and nullif(trim(${issueThreadInteractions.addresseeUserId}), '') is not null
|
||||
and nullif(trim(${responsibleUser}), '') is not null
|
||||
and ${issueThreadInteractions.addresseeUserId} <> ${responsibleUser}
|
||||
)` : undefined,
|
||||
// Preserve Agent Chat's existing prior-turn exception for ordinary input.
|
||||
input.conversationMode ? sql`(
|
||||
${issueThreadInteractions.sourceRunId} is not distinct from ${input.runId ?? null}
|
||||
|
||||
@@ -8546,7 +8546,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
: []),
|
||||
),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
activeIssueInteractionCondition(),
|
||||
activeIssueInteractionCondition({ runId: input.execution.binding.runId }),
|
||||
// Live provider questions resume their current turn; only durable
|
||||
// wake-based cards park it. A timeout creates a separate fallback.
|
||||
sql`not (${issueThreadInteractions.kind} = 'ask_user_questions' and ${issueThreadInteractions.continuationPolicy} = 'none' and coalesce(${issueThreadInteractions.idempotencyKey}, '') like 'paperclip-runner-question:%')`,
|
||||
|
||||
Reference in new issue
Block a user