Add player bug reports and profile identity features

This commit is contained in:
2026-10-08 18:27:20 +05:00
parent 36e8d11eb4
commit 0b08672f4a
51 changed files with 3938 additions and 45 deletions

View File

@@ -0,0 +1,168 @@
import {
escapeSupportMarkdown,
type BugReportRecord,
} from "../shared/bug-reports";
type Issue = { id: string; identifier: string; team: { id: string } };
export class LinearSupportClient {
constructor(
private key: string,
private team: string,
private project?: string,
private request: typeof fetch = fetch,
) {}
async graphql<T>(
query: string,
variables: object,
missing = false,
): Promise<T | null> {
const response = await this.request("https://api.linear.app/graphql", {
method: "POST",
headers: { Authorization: this.key, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(15_000),
});
const result = await response.json();
if (
missing &&
response.ok &&
result.errors?.length &&
result.errors?.every(
(error: { message?: string; extensions?: { code?: string } }) =>
error.extensions?.code === "ENTITY_NOT_FOUND" ||
/^Entity not found/i.test(error.message ?? ""),
)
)
return null;
if (!response.ok || result.errors?.length || !result.data)
throw new Error("Support delivery is temporarily unavailable");
return result.data;
}
async issue(id: string): Promise<Issue | null> {
const data = await this.graphql<{ issue: Issue }>(
"query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } } }",
{ id },
true,
);
const issue = data?.issue ?? null;
if (issue && issue.team.id !== this.team)
throw new Error("Support issue team mismatch");
return issue;
}
async createIssue(report: BugReportRecord): Promise<Issue> {
const existing = await this.issue(report.id);
if (existing) return existing;
const description = [
"#user-report-message",
`Reporter: ${escapeSupportMarkdown(report.display_name)}`,
`Page: ${report.page_path}`,
"",
escapeSupportMarkdown(report.description),
"",
report.linear_asset_url
? `![Player screenshot](${report.linear_asset_url})`
: "",
"",
"Reply to the player with #player-report-send followed by your message.",
"Grant or remove the username reward with #player-report-glow grant or #player-report-glow revoke.",
`Report reference: ${report.id}`,
].join("\n");
try {
const data = await this.graphql<{
issueCreate: { success: boolean; issue: Issue };
}>(
"mutation SupportIssueCreate($input: IssueCreateInput!) { issueCreate(input:$input) { success issue { id identifier team { id } } } }",
{
input: {
id: report.id,
teamId: this.team,
...(this.project ? { projectId: this.project } : {}),
title: `[Player bug] ${report.title}`,
description,
priority: 3,
},
},
);
if (
!data?.issueCreate.success ||
data.issueCreate.issue?.id !== report.id
)
throw new Error("Support issue creation failed");
return data.issueCreate.issue;
} catch (error) {
// A timed-out creation may have succeeded. A stable UUID prevents a second issue.
const recovered = await this.issue(report.id);
if (recovered) return recovered;
throw error;
}
}
async comment(id: string, reportId: string, name: string, text: string) {
const lookup = () =>
this.graphql<{ comment: { id: string; issue: { id: string } } }>(
"query SupportComment($id:String!) { comment(id:$id) { id issue { id } } }",
{ id },
true,
);
const existing = (await lookup())?.comment;
if (existing) {
if (existing.issue.id !== reportId)
throw new Error("Support comment mismatch");
return;
}
try {
const data = await this.graphql<{
commentCreate: { success: boolean; comment: { id: string } };
}>(
"mutation SupportCommentCreate($input:CommentCreateInput!) { commentCreate(input:$input) { success comment { id } } }",
{
input: {
id,
issueId: reportId,
body: `#user-report-message\n${escapeSupportMarkdown(name)}\n\n${escapeSupportMarkdown(text)}`,
},
},
);
if (!data?.commentCreate.success || data.commentCreate.comment?.id !== id)
throw new Error("Support comment creation failed");
} catch (error) {
const recovered = (await lookup())?.comment;
if (!recovered || recovered.issue.id !== reportId) throw error;
}
}
async upload(bytes: Uint8Array, filename: string) {
const data = await this.graphql<{
fileUpload: {
success: boolean;
uploadFile: {
uploadUrl: string;
assetUrl: string;
headers: { key: string; value: string }[];
};
};
}>(
'mutation SupportFile($size:Int!,$filename:String!){ fileUpload(contentType:"image/webp",filename:$filename,size:$size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }',
{ filename, size: bytes.length },
);
const file = data?.fileUpload.uploadFile;
if (
!data?.fileUpload.success ||
!file ||
new URL(file.uploadUrl).protocol !== "https:" ||
new URL(file.assetUrl).protocol !== "https:"
)
throw new Error("Support screenshot upload failed");
const headers = new Headers({
"Content-Type": "image/webp",
"Cache-Control": "private, max-age=31536000",
});
file.headers.forEach(({ key, value }) => headers.set(key, value));
const result = await this.request(file.uploadUrl, {
method: "PUT",
headers,
body: Buffer.from(bytes),
signal: AbortSignal.timeout(15_000),
});
if (!result.ok) throw new Error("Support screenshot upload failed");
return file.assetUrl;
}
}