import { fetch } from "undici" import type { LoggerFriendly, LoggerFriendlyOptions } from "#Source/log/index.ts" import { Logger } from "#Source/log/index.ts" import { Ark } from "#Source/validation/index.ts" type TextEventType = | "nickname" // 昵称 | "title" // 标题 | "article" // 帖子 | "comment" // 评论 | "barrage" // 弹幕 | "search" // 搜索栏 | "profile" // 个人简介 type ImageEventType = | "head_image" // 头像 | "album" // 相册 | "dynamic" // 动态 | "article" // 帖子 | "comment" // 评论 | "room_cover" // 房间封面 | "group_message" // 群聊图片 | "message" // 私聊图片 | "product" // 商品图片 type ImageCategories = | "terrorism" // 暴恐元素的检测 | "porn" // 涉黄元素的检测 | "image_text" // 广告图文的检测 const SUGGESTION_TYPE = { block: "block", // 包含敏感信息,不通过 pass: "pass", // 不包含敏感信息,通过 review: "review", // 需要人工复检 } as const type SuggestionType = (typeof SUGGESTION_TYPE)[keyof typeof SUGGESTION_TYPE] const suggestionTypeSchema = Ark.type.or("'block'", "'pass'", "'review'") const textModerationResponseSchema = Ark.type({ result: Ark.type({ suggestion: suggestionTypeSchema, }), }) export interface ModerationTextOptions { input: string eventType: TextEventType } export interface ModerationTextResult { suggestion: SuggestionType } const imageModerationResponseSchema = Ark.type({ result: Ark.type({ suggestion: suggestionTypeSchema, }), }) export interface ModerationImageOptions { mode: "base64" | "url" input: string eventType: ImageEventType categories: ImageCategories[] } export interface ModerationImageResult { suggestion: SuggestionType } export interface ModerationOptions extends LoggerFriendlyOptions { userName: string userPwd: string domainName: string projectName: string projectId: string endpoint: string /** * @description Token 有效期,单位:毫秒,默认 11 小时 */ tokenValidTime?: number | undefined } interface ResolvedModerationOptions { userName: string userPwd: string domainName: string projectName: string projectId: string endpoint: string tokenValidTime: number } export class Moderation implements LoggerFriendly { readonly options: ResolvedModerationOptions readonly logger: Logger protected tokenGetTime: number | null = null protected token: string | null = null constructor(options: ModerationOptions) { this.options = { ...options, // token 有效期 12 小时,保险起见,我们超过 11 小时就重写获取一次,以免调用到一半忽然失效 // 请求新 token 并不会造成旧 token 失效,所以想象中的问题不会发生 tokenValidTime: options.tokenValidTime ?? 11 * 60 * 60 * 1_000, } this.logger = Logger.fromOptions(options).setDefaultName("Moderation") } /** * @description {@link https://support.huaweicloud.com/api-moderation/moderation_03_0003.html} * {@link https://support.huaweicloud.com/api-iam/iam_30_0001.html} */ protected async getNewToken(): Promise { const { userName, userPwd, domainName, projectName } = this.options const logger = this.logger try { logger.log("准备获取 token...") const response = await fetch(`https://iam.${projectName}.myhuaweicloud.com/v3/auth/tokens`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ auth: { identity: { methods: ["password"], password: { user: { name: userName, password: userPwd, domain: { name: domainName, }, }, }, }, scope: { project: { name: projectName, }, }, }, }), }) const responseText = await response.text() const responseBody = responseText === "" ? null : (() => { try { return JSON.parse(responseText) as unknown } catch { return responseText } })() const errorMessage = typeof responseBody === "string" ? responseBody : (() => { try { return JSON.stringify(responseBody) } catch { return String(responseBody) } })() if (response.ok === false) { throw new Error(errorMessage) } const token = response.headers.get("x-subject-token") if (token === null || token === undefined) { throw new Error(errorMessage) } logger.log("获取 token 成功") this.token = token this.tokenGetTime = Date.now() return token } catch (exception) { logger.error(`获取 toekn 失败:${String(exception)}`) throw exception } } protected async getToken(): Promise { const token = this.token const tokenGetTime = this.tokenGetTime const { tokenValidTime } = this.options if (token === null || tokenGetTime === null || tokenGetTime + tokenValidTime < Date.now()) { return await this.getNewToken() } else { return await Promise.resolve(token) } } /** * @see {@link https://support.huaweicloud.com/api-moderation/moderation_03_0069.html} */ async text(options: ModerationTextOptions): Promise { const { input, eventType } = options const { endpoint, projectId } = this.options const logger = this.logger // empty string is always safe if (input === "") { return { suggestion: "pass" } } try { logger.log(`开始调用 text 检查,输入:${input},类型:${eventType}`) const token = await this.getToken() const responseRaw = await fetch(`https://${endpoint}/v3/${projectId}/moderation/text`, { method: "POST", headers: { "Content-Type": "application/json", "X-Auth-Token": token, }, body: JSON.stringify({ event_type: eventType, data: { language: "zh", text: input, }, glossary_names: [], white_glossary_names: [], categories: ["terrorism", "porn", "ban", "abuse"], }), }) const responseText = await responseRaw.text() const response = responseText === "" ? null : (() => { try { return JSON.parse(responseText) as unknown } catch { return responseText } })() if (responseRaw.ok === false) { throw new Error(typeof response === "string" ? response : JSON.stringify(response)) } const validateResult = await textModerationResponseSchema["~standard"].validate(response) if (validateResult.issues !== undefined) { logger.error(`非预期的返回结果:${JSON.stringify(response)}`) throw new Error("非预期的返回结果") } logger.log(`调用成功:${JSON.stringify(validateResult)}`) return validateResult.value.result } catch (exception) { logger.error(`调用失败:${String(exception)}`) throw exception } } /** * @see {@link https://support.huaweicloud.com/api-moderation/moderation_03_0086.html} */ async image(options: ModerationImageOptions): Promise { const { mode, input, eventType, categories } = options const { endpoint, projectId } = this.options const logger = this.logger try { logger.log(`开始调用 image 检查,输入:${input},类型:${eventType}`) const token = await this.getToken() interface argType { event_type: ImageEventType categories: ImageCategories[] image?: string | undefined url?: string | undefined } const arg: argType = { event_type: eventType, categories, image: mode === "base64" ? input : undefined, url: mode === "url" ? input : undefined, } if (mode === "base64") { arg.image = input } else if (mode === "url") { arg.url = input } else { throw new Error("意外的 mode") } const responseRaw = await fetch(`https://${endpoint}/v3/${projectId}/moderation/image`, { method: "POST", headers: { "Content-Type": "application/json", "X-Auth-Token": token, }, body: JSON.stringify(arg), }) const responseText = await responseRaw.text() const response = responseText === "" ? null : (() => { try { return JSON.parse(responseText) as unknown } catch { return responseText } })() if (responseRaw.ok === false) { throw new Error(typeof response === "string" ? response : JSON.stringify(response)) } const validateResult = await imageModerationResponseSchema["~standard"].validate(response) if (validateResult.issues !== undefined) { logger.error(`非预期的返回结果:${JSON.stringify(response)}`) throw new Error("非预期的返回结果") } logger.log(`调用成功:${JSON.stringify(validateResult.value)}`) return validateResult.value.result } catch (exception) { logger.error(`调用失败:${String(exception)}`) throw exception } } }