Initial project bootstrap
这个提交包含在:
114
server/_core/notification.ts
普通文件
114
server/_core/notification.ts
普通文件
@@ -0,0 +1,114 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ENV } from "./env";
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const TITLE_MAX_LENGTH = 1200;
|
||||
const CONTENT_MAX_LENGTH = 20000;
|
||||
|
||||
const trimValue = (value: string): string => value.trim();
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string): string => {
|
||||
const normalizedBase = baseUrl.endsWith("/")
|
||||
? baseUrl
|
||||
: `${baseUrl}/`;
|
||||
return new URL(
|
||||
"webdevtoken.v1.WebDevService/SendNotification",
|
||||
normalizedBase
|
||||
).toString();
|
||||
};
|
||||
|
||||
const validatePayload = (input: NotificationPayload): NotificationPayload => {
|
||||
if (!isNonEmptyString(input.title)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Notification title is required.",
|
||||
});
|
||||
}
|
||||
if (!isNonEmptyString(input.content)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Notification content is required.",
|
||||
});
|
||||
}
|
||||
|
||||
const title = trimValue(input.title);
|
||||
const content = trimValue(input.content);
|
||||
|
||||
if (title.length > TITLE_MAX_LENGTH) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (content.length > CONTENT_MAX_LENGTH) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { title, content };
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches a project-owner notification through the Manus Notification Service.
|
||||
* Returns `true` if the request was accepted, `false` when the upstream service
|
||||
* cannot be reached (callers can fall back to email/slack). Validation errors
|
||||
* bubble up as TRPC errors so callers can fix the payload.
|
||||
*/
|
||||
export async function notifyOwner(
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const { title, content } = validatePayload(payload);
|
||||
|
||||
if (!ENV.forgeApiUrl) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Notification service URL is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ENV.forgeApiKey) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Notification service API key is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = buildEndpointUrl(ENV.forgeApiUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
"content-type": "application/json",
|
||||
"connect-protocol-version": "1",
|
||||
},
|
||||
body: JSON.stringify({ title, content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
console.warn(
|
||||
`[Notification] Failed to notify owner (${response.status} ${response.statusText})${
|
||||
detail ? `: ${detail}` : ""
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[Notification] Error calling notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户