Push labels to PDS (#1423)
* add lexicon for unspecced applyLabels procedure * implement label push to pds via unspecced.applyLabels * add hive retry to bsky appview * build * update applyLabels to work with raw label data * update bsky hive labeler * remove build
This commit is contained in:
parent
450dff7fa3
commit
e5e24d510e
.github/workflows
lexicons/app/bsky/unspecced
packages
api/src/client
bsky/src
pds
@ -3,7 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- indexer-redis-setup
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||
|
23
lexicons/app/bsky/unspecced/applyLabels.json
Normal file
23
lexicons/app/bsky/unspecced/applyLabels.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "app.bsky.unspecced.applyLabels",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "procedure",
|
||||
"description": "Allow a labeler to apply labels directly.",
|
||||
"input": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["labels"],
|
||||
"properties": {
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -122,6 +122,7 @@ import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notificatio
|
||||
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
|
||||
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
|
||||
import * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet'
|
||||
import * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
|
||||
import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
|
||||
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
|
||||
import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
|
||||
@ -241,6 +242,7 @@ export * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notificatio
|
||||
export * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
|
||||
export * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
|
||||
export * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet'
|
||||
export * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
|
||||
export * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
|
||||
export * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
|
||||
export * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
|
||||
@ -2036,6 +2038,17 @@ export class UnspeccedNS {
|
||||
this._service = service
|
||||
}
|
||||
|
||||
applyLabels(
|
||||
data?: AppBskyUnspeccedApplyLabels.InputSchema,
|
||||
opts?: AppBskyUnspeccedApplyLabels.CallOptions,
|
||||
): Promise<AppBskyUnspeccedApplyLabels.Response> {
|
||||
return this._service.xrpc
|
||||
.call('app.bsky.unspecced.applyLabels', opts?.qp, data, opts)
|
||||
.catch((e) => {
|
||||
throw AppBskyUnspeccedApplyLabels.toKnownErr(e)
|
||||
})
|
||||
}
|
||||
|
||||
getPopular(
|
||||
params?: AppBskyUnspeccedGetPopular.QueryParams,
|
||||
opts?: AppBskyUnspeccedGetPopular.CallOptions,
|
||||
|
@ -536,7 +536,6 @@ export const schemaDict = {
|
||||
},
|
||||
moderation: {
|
||||
type: 'object',
|
||||
required: [],
|
||||
properties: {
|
||||
currentAction: {
|
||||
type: 'ref',
|
||||
@ -708,7 +707,7 @@ export const schemaDict = {
|
||||
note: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Additionally add a note describing why the invites were disabled',
|
||||
'Additionally add a note describing why the invites were enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -1668,7 +1667,7 @@ export const schemaDict = {
|
||||
},
|
||||
reasonSexual: {
|
||||
type: 'token',
|
||||
description: 'Unwanted or mis-labeled sexual content',
|
||||
description: 'Unwanted or mislabeled sexual content',
|
||||
},
|
||||
reasonRude: {
|
||||
type: 'token',
|
||||
@ -6294,6 +6293,32 @@ export const schemaDict = {
|
||||
},
|
||||
},
|
||||
},
|
||||
AppBskyUnspeccedApplyLabels: {
|
||||
lexicon: 1,
|
||||
id: 'app.bsky.unspecced.applyLabels',
|
||||
defs: {
|
||||
main: {
|
||||
type: 'procedure',
|
||||
description: 'Allow a labeler to apply labels directly.',
|
||||
input: {
|
||||
encoding: 'application/json',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['labels'],
|
||||
properties: {
|
||||
labels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'ref',
|
||||
ref: 'lex:com.atproto.label.defs#label',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AppBskyUnspeccedGetPopular: {
|
||||
lexicon: 1,
|
||||
id: 'app.bsky.unspecced.getPopular',
|
||||
@ -6561,6 +6586,7 @@ export const ids = {
|
||||
'app.bsky.notification.listNotifications',
|
||||
AppBskyNotificationUpdateSeen: 'app.bsky.notification.updateSeen',
|
||||
AppBskyRichtextFacet: 'app.bsky.richtext.facet',
|
||||
AppBskyUnspeccedApplyLabels: 'app.bsky.unspecced.applyLabels',
|
||||
AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular',
|
||||
AppBskyUnspeccedGetPopularFeedGenerators:
|
||||
'app.bsky.unspecced.getPopularFeedGenerators',
|
||||
|
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* GENERATED CODE - DO NOT MODIFY
|
||||
*/
|
||||
import { Headers, XRPCError } from '@atproto/xrpc'
|
||||
import { ValidationResult, BlobRef } from '@atproto/lexicon'
|
||||
import { isObj, hasProp } from '../../../../util'
|
||||
import { lexicons } from '../../../../lexicons'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
|
||||
|
||||
export interface QueryParams {}
|
||||
|
||||
export interface InputSchema {
|
||||
labels: ComAtprotoLabelDefs.Label[]
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface CallOptions {
|
||||
headers?: Headers
|
||||
qp?: QueryParams
|
||||
encoding: 'application/json'
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
success: boolean
|
||||
headers: Headers
|
||||
}
|
||||
|
||||
export function toKnownErr(e: any) {
|
||||
if (e instanceof XRPCError) {
|
||||
}
|
||||
return e
|
||||
}
|
@ -11,7 +11,7 @@ export interface QueryParams {}
|
||||
|
||||
export interface InputSchema {
|
||||
account: string
|
||||
/** Additionally add a note describing why the invites were disabled */
|
||||
/** Additionally add a note describing why the invites were enabled */
|
||||
note?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam'
|
||||
export const REASONVIOLATION = 'com.atproto.moderation.defs#reasonViolation'
|
||||
/** Misleading identity, affiliation, or content */
|
||||
export const REASONMISLEADING = 'com.atproto.moderation.defs#reasonMisleading'
|
||||
/** Unwanted or mis-labeled sexual content */
|
||||
/** Unwanted or mislabeled sexual content */
|
||||
export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
|
||||
/** Rude, harassing, explicit, or otherwise unwelcoming behavior */
|
||||
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
|
||||
|
@ -73,6 +73,16 @@ export const parseBasicAuth = (
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
export const buildBasicAuth = (username: string, password: string): string => {
|
||||
return (
|
||||
BASIC +
|
||||
uint8arrays.toString(
|
||||
uint8arrays.fromString(`${username}:${password}`, 'utf8'),
|
||||
'base64pad',
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const getJwtStrFromReq = (req: express.Request): string | null => {
|
||||
const { authorization = '' } = req.headers
|
||||
if (!authorization.startsWith(BEARER)) {
|
||||
|
@ -15,6 +15,7 @@ export interface IndexerConfigValues {
|
||||
labelerDid: string
|
||||
hiveApiKey?: string
|
||||
labelerKeywords: Record<string, string>
|
||||
labelerPushUrl?: string
|
||||
indexerConcurrency?: number
|
||||
indexerPartitionIds: number[]
|
||||
indexerPartitionBatchSize?: number
|
||||
@ -54,6 +55,8 @@ export class IndexerConfig {
|
||||
DAY,
|
||||
)
|
||||
const labelerDid = process.env.LABELER_DID || 'did:example:labeler'
|
||||
const labelerPushUrl =
|
||||
overrides?.labelerPushUrl || process.env.LABELER_PUSH_URL || undefined
|
||||
const hiveApiKey = process.env.HIVE_API_KEY || undefined
|
||||
const indexerPartitionIds =
|
||||
overrides?.indexerPartitionIds ||
|
||||
@ -84,6 +87,7 @@ export class IndexerConfig {
|
||||
didCacheStaleTTL,
|
||||
didCacheMaxTTL,
|
||||
labelerDid,
|
||||
labelerPushUrl,
|
||||
hiveApiKey,
|
||||
indexerPartitionIds,
|
||||
indexerConcurrency,
|
||||
@ -139,6 +143,10 @@ export class IndexerConfig {
|
||||
return this.cfg.labelerDid
|
||||
}
|
||||
|
||||
get labelerPushUrl() {
|
||||
return this.cfg.labelerPushUrl
|
||||
}
|
||||
|
||||
get hiveApiKey() {
|
||||
return this.cfg.hiveApiKey
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import stream from 'stream'
|
||||
import { AtUri } from '@atproto/uri'
|
||||
import { AtpAgent } from '@atproto/api'
|
||||
import { cidForRecord } from '@atproto/repo'
|
||||
import { dedupe, getFieldsFromRecord } from './util'
|
||||
import { labelerLogger as log } from '../logger'
|
||||
@ -8,9 +9,11 @@ import Database from '../db'
|
||||
import { IdResolver } from '@atproto/identity'
|
||||
import { BackgroundQueue } from '../background'
|
||||
import { IndexerConfig } from '../indexer/config'
|
||||
import { buildBasicAuth } from '../auth'
|
||||
|
||||
export abstract class Labeler {
|
||||
public backgroundQueue: BackgroundQueue
|
||||
public pushAgent?: AtpAgent
|
||||
constructor(
|
||||
protected ctx: {
|
||||
db: Database
|
||||
@ -20,6 +23,14 @@ export abstract class Labeler {
|
||||
},
|
||||
) {
|
||||
this.backgroundQueue = ctx.backgroundQueue
|
||||
if (ctx.cfg.labelerPushUrl) {
|
||||
const url = new URL(ctx.cfg.labelerPushUrl)
|
||||
this.pushAgent = new AtpAgent({ service: url.origin })
|
||||
this.pushAgent.api.setHeader(
|
||||
'authorization',
|
||||
buildBasicAuth(url.username, url.password),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
processRecord(uri: AtUri, obj: unknown) {
|
||||
@ -51,6 +62,23 @@ export abstract class Labeler {
|
||||
.values(rows)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute()
|
||||
|
||||
if (this.pushAgent) {
|
||||
const agent = this.pushAgent
|
||||
try {
|
||||
await agent.api.app.bsky.unspecced.applyLabels({ labels: rows })
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
uri: uri.toString(),
|
||||
labels,
|
||||
receiver: agent.service.toString(),
|
||||
},
|
||||
'failed to push labels',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async labelRecord(uri: AtUri, obj: unknown): Promise<string[]> {
|
||||
|
@ -7,6 +7,7 @@ import { IdResolver } from '@atproto/identity'
|
||||
import Database from '../db'
|
||||
import { BackgroundQueue } from '../background'
|
||||
import { IndexerConfig } from '../indexer/config'
|
||||
import { retryHttp } from '../util/retry'
|
||||
|
||||
const HIVE_ENDPOINT = 'https://api.thehive.ai/api/v2/task/sync'
|
||||
|
||||
@ -51,13 +52,16 @@ export const makeHiveReq = async (
|
||||
): Promise<HiveRespClass[]> => {
|
||||
const form = new FormData()
|
||||
form.append('media', blob)
|
||||
const res = await axios.post(HIVE_ENDPOINT, form, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
authorization: `token ${hiveApiKey}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await retryHttp(() =>
|
||||
axios.post(HIVE_ENDPOINT, form, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
authorization: `token ${hiveApiKey}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
}),
|
||||
)
|
||||
return respToClasses(res.data)
|
||||
}
|
||||
|
||||
@ -73,30 +77,101 @@ export const respToClasses = (res: HiveResp): HiveRespClass[] => {
|
||||
return classes
|
||||
}
|
||||
|
||||
// sexual: https://docs.thehive.ai/docs/sexual-content
|
||||
// Matches only one (or none) of: porn, sexual, nudity
|
||||
//
|
||||
// porn: sexual and nudity. including both explicit activity or full-frontal and suggestive/intent
|
||||
// sexual: sexually suggestive, not explicit; may include some forms of nudity
|
||||
// nudity: non-sexual nudity (eg, artistic, possibly some photographic)
|
||||
//
|
||||
// hive docs/definitions: https://docs.thehive.ai/docs/sexual-content
|
||||
export const sexualLabels = (classes: HiveRespClass[]): string[] => {
|
||||
const scores = {}
|
||||
|
||||
for (const cls of classes) {
|
||||
scores[cls.class] = cls.score
|
||||
}
|
||||
|
||||
// first check if porn...
|
||||
for (const pornClass of [
|
||||
'yes_sexual_activity',
|
||||
'animal_genitalia_and_human',
|
||||
'yes_realistic_nsfw',
|
||||
]) {
|
||||
if (scores[pornClass] >= 0.9) {
|
||||
return ['porn']
|
||||
}
|
||||
}
|
||||
if (scores['general_nsfw'] >= 0.9) {
|
||||
// special case for some anime examples
|
||||
if (scores['animated_animal_genitalia'] >= 0.5) {
|
||||
return ['porn']
|
||||
}
|
||||
// special case for some pornographic/explicit classic drawings
|
||||
if (scores['yes_undressed'] >= 0.9 && scores['yes_sexual_activity'] > 0.9) {
|
||||
return ['porn']
|
||||
}
|
||||
}
|
||||
|
||||
// then check for sexual suggestive (which may include nudity)...
|
||||
for (const sexualClass of ['yes_sexual_intent', 'yes_sex_toy']) {
|
||||
if (scores[sexualClass] >= 0.9) {
|
||||
return ['sexual']
|
||||
}
|
||||
}
|
||||
if (scores['yes_undressed'] >= 0.9) {
|
||||
// special case for bondage examples
|
||||
if (scores['yes_sex_toy'] > 0.75) {
|
||||
return ['sexual']
|
||||
}
|
||||
}
|
||||
|
||||
// then non-sexual nudity...
|
||||
for (const nudityClass of [
|
||||
'yes_male_nudity',
|
||||
'yes_female_nudity',
|
||||
'yes_undressed',
|
||||
]) {
|
||||
if (scores[nudityClass] >= 0.9) {
|
||||
return ['nudity']
|
||||
}
|
||||
}
|
||||
|
||||
// then finally flag remaining "underwear" images in to sexually suggestive
|
||||
// (after non-sexual content already labeled above)
|
||||
for (const nudityClass of ['yes_male_underwear', 'yes_female_underwear']) {
|
||||
if (scores[nudityClass] >= 0.9) {
|
||||
// TODO: retaining 'underwear' label for a short time to help understand
|
||||
// the impact of labeling all "underwear" as "sexual". This *will* be
|
||||
// pulling in somewhat non-sexual content in to "sexual" label.
|
||||
return ['sexual', 'underwear']
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// gore and violence: https://docs.thehive.ai/docs/class-descriptions-violence-gore
|
||||
// iconography: https://docs.thehive.ai/docs/class-descriptions-hate-bullying
|
||||
const labelForClass = {
|
||||
yes_sexual_activity: 'porn',
|
||||
animal_genitalia_and_human: 'porn', // for some reason not included in 'yes_sexual_activity'
|
||||
yes_male_nudity: 'nudity',
|
||||
yes_female_nudity: 'nudity',
|
||||
general_suggestive: 'sexual',
|
||||
very_bloody: 'gore',
|
||||
human_corpse: 'corpse',
|
||||
hanging: 'corpse',
|
||||
}
|
||||
const labelForClassLessSensitive = {
|
||||
yes_self_harm: 'self-harm',
|
||||
yes_nazi: 'icon-nazi',
|
||||
yes_kkk: 'icon-kkk',
|
||||
yes_confederate: 'icon-confederate',
|
||||
}
|
||||
|
||||
export const summarizeLabels = (classes: HiveRespClass[]): string[] => {
|
||||
const labels: string[] = []
|
||||
const labels: string[] = sexualLabels(classes)
|
||||
for (const cls of classes) {
|
||||
if (labelForClass[cls.class] && cls.score >= 0.9) {
|
||||
labels.push(labelForClass[cls.class])
|
||||
}
|
||||
}
|
||||
for (const cls of classes) {
|
||||
if (labelForClassLessSensitive[cls.class] && cls.score >= 0.96) {
|
||||
labels.push(labelForClassLessSensitive[cls.class])
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
|
@ -102,6 +102,7 @@ import * as AppBskyGraphUnmuteActorList from './types/app/bsky/graph/unmuteActor
|
||||
import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notification/getUnreadCount'
|
||||
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
|
||||
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
|
||||
import * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
|
||||
import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
|
||||
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
|
||||
import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
|
||||
@ -1041,6 +1042,13 @@ export class UnspeccedNS {
|
||||
this._server = server
|
||||
}
|
||||
|
||||
applyLabels<AV extends AuthVerifier>(
|
||||
cfg: ConfigOf<AV, AppBskyUnspeccedApplyLabels.Handler<ExtractAuth<AV>>>,
|
||||
) {
|
||||
const nsid = 'app.bsky.unspecced.applyLabels' // @ts-ignore
|
||||
return this._server.xrpc.method(nsid, cfg)
|
||||
}
|
||||
|
||||
getPopular<AV extends AuthVerifier>(
|
||||
cfg: ConfigOf<AV, AppBskyUnspeccedGetPopular.Handler<ExtractAuth<AV>>>,
|
||||
) {
|
||||
|
@ -536,7 +536,6 @@ export const schemaDict = {
|
||||
},
|
||||
moderation: {
|
||||
type: 'object',
|
||||
required: [],
|
||||
properties: {
|
||||
currentAction: {
|
||||
type: 'ref',
|
||||
@ -708,7 +707,7 @@ export const schemaDict = {
|
||||
note: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Additionally add a note describing why the invites were disabled',
|
||||
'Additionally add a note describing why the invites were enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -1668,7 +1667,7 @@ export const schemaDict = {
|
||||
},
|
||||
reasonSexual: {
|
||||
type: 'token',
|
||||
description: 'Unwanted or mis-labeled sexual content',
|
||||
description: 'Unwanted or mislabeled sexual content',
|
||||
},
|
||||
reasonRude: {
|
||||
type: 'token',
|
||||
@ -6294,6 +6293,32 @@ export const schemaDict = {
|
||||
},
|
||||
},
|
||||
},
|
||||
AppBskyUnspeccedApplyLabels: {
|
||||
lexicon: 1,
|
||||
id: 'app.bsky.unspecced.applyLabels',
|
||||
defs: {
|
||||
main: {
|
||||
type: 'procedure',
|
||||
description: 'Allow a labeler to apply labels directly.',
|
||||
input: {
|
||||
encoding: 'application/json',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['labels'],
|
||||
properties: {
|
||||
labels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'ref',
|
||||
ref: 'lex:com.atproto.label.defs#label',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AppBskyUnspeccedGetPopular: {
|
||||
lexicon: 1,
|
||||
id: 'app.bsky.unspecced.getPopular',
|
||||
@ -6561,6 +6586,7 @@ export const ids = {
|
||||
'app.bsky.notification.listNotifications',
|
||||
AppBskyNotificationUpdateSeen: 'app.bsky.notification.updateSeen',
|
||||
AppBskyRichtextFacet: 'app.bsky.richtext.facet',
|
||||
AppBskyUnspeccedApplyLabels: 'app.bsky.unspecced.applyLabels',
|
||||
AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular',
|
||||
AppBskyUnspeccedGetPopularFeedGenerators:
|
||||
'app.bsky.unspecced.getPopularFeedGenerators',
|
||||
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* GENERATED CODE - DO NOT MODIFY
|
||||
*/
|
||||
import express from 'express'
|
||||
import { ValidationResult, BlobRef } from '@atproto/lexicon'
|
||||
import { lexicons } from '../../../../lexicons'
|
||||
import { isObj, hasProp } from '../../../../util'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import { HandlerAuth } from '@atproto/xrpc-server'
|
||||
import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
|
||||
|
||||
export interface QueryParams {}
|
||||
|
||||
export interface InputSchema {
|
||||
labels: ComAtprotoLabelDefs.Label[]
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface HandlerInput {
|
||||
encoding: 'application/json'
|
||||
body: InputSchema
|
||||
}
|
||||
|
||||
export interface HandlerError {
|
||||
status: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type HandlerOutput = HandlerError | void
|
||||
export type Handler<HA extends HandlerAuth = never> = (ctx: {
|
||||
auth: HA
|
||||
params: QueryParams
|
||||
input: HandlerInput
|
||||
req: express.Request
|
||||
res: express.Response
|
||||
}) => Promise<HandlerOutput> | HandlerOutput
|
@ -12,7 +12,7 @@ export interface QueryParams {}
|
||||
|
||||
export interface InputSchema {
|
||||
account: string
|
||||
/** Additionally add a note describing why the invites were disabled */
|
||||
/** Additionally add a note describing why the invites were enabled */
|
||||
note?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam'
|
||||
export const REASONVIOLATION = 'com.atproto.moderation.defs#reasonViolation'
|
||||
/** Misleading identity, affiliation, or content */
|
||||
export const REASONMISLEADING = 'com.atproto.moderation.defs#reasonMisleading'
|
||||
/** Unwanted or mis-labeled sexual content */
|
||||
/** Unwanted or mislabeled sexual content */
|
||||
export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
|
||||
/** Rude, harassing, explicit, or otherwise unwelcoming behavior */
|
||||
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { NotEmptyArray } from '@atproto/common'
|
||||
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
|
||||
import { Server } from '../../../../lexicon'
|
||||
import { FeedKeyset } from './util/feed'
|
||||
import { GenericKeyset, paginate } from '../../../../db/pagination'
|
||||
import AppContext from '../../../../context'
|
||||
import { FeedRow } from '../../../services/feed'
|
||||
import { isPostView } from '../../../../lexicon/types/app/bsky/feed/defs'
|
||||
import { NotEmptyArray } from '@atproto/common'
|
||||
import {
|
||||
isPostView,
|
||||
GeneratorView,
|
||||
} from '../../../../lexicon/types/app/bsky/feed/defs'
|
||||
import { isViewRecord } from '../../../../lexicon/types/app/bsky/embed/record'
|
||||
import { countAll, valuesList } from '../../../../db/util'
|
||||
import { InvalidRequestError } from '@atproto/xrpc-server'
|
||||
import { GeneratorView } from '@atproto/api/src/client/types/app/bsky/feed/defs'
|
||||
import { FeedKeyset } from './util/feed'
|
||||
|
||||
const NO_WHATS_HOT_LABELS: NotEmptyArray<string> = [
|
||||
'!no-promote',
|
||||
@ -189,6 +191,18 @@ export default function (server: Server, ctx: AppContext) {
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
server.app.bsky.unspecced.applyLabels({
|
||||
auth: ctx.roleVerifier,
|
||||
handler: async ({ auth, input }) => {
|
||||
if (!auth.credentials.admin) {
|
||||
throw new AuthRequiredError('Insufficient privileges')
|
||||
}
|
||||
const { services, db } = ctx
|
||||
const { labels } = input.body
|
||||
await services.appView.label(db).createLabels(labels)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type Result = { likeCount: number; cid: string }
|
||||
|
@ -102,6 +102,7 @@ import * as AppBskyGraphUnmuteActorList from './types/app/bsky/graph/unmuteActor
|
||||
import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notification/getUnreadCount'
|
||||
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
|
||||
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
|
||||
import * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
|
||||
import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
|
||||
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
|
||||
import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
|
||||
@ -1041,6 +1042,13 @@ export class UnspeccedNS {
|
||||
this._server = server
|
||||
}
|
||||
|
||||
applyLabels<AV extends AuthVerifier>(
|
||||
cfg: ConfigOf<AV, AppBskyUnspeccedApplyLabels.Handler<ExtractAuth<AV>>>,
|
||||
) {
|
||||
const nsid = 'app.bsky.unspecced.applyLabels' // @ts-ignore
|
||||
return this._server.xrpc.method(nsid, cfg)
|
||||
}
|
||||
|
||||
getPopular<AV extends AuthVerifier>(
|
||||
cfg: ConfigOf<AV, AppBskyUnspeccedGetPopular.Handler<ExtractAuth<AV>>>,
|
||||
) {
|
||||
|
@ -536,7 +536,6 @@ export const schemaDict = {
|
||||
},
|
||||
moderation: {
|
||||
type: 'object',
|
||||
required: [],
|
||||
properties: {
|
||||
currentAction: {
|
||||
type: 'ref',
|
||||
@ -708,7 +707,7 @@ export const schemaDict = {
|
||||
note: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Additionally add a note describing why the invites were disabled',
|
||||
'Additionally add a note describing why the invites were enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -1668,7 +1667,7 @@ export const schemaDict = {
|
||||
},
|
||||
reasonSexual: {
|
||||
type: 'token',
|
||||
description: 'Unwanted or mis-labeled sexual content',
|
||||
description: 'Unwanted or mislabeled sexual content',
|
||||
},
|
||||
reasonRude: {
|
||||
type: 'token',
|
||||
@ -6294,6 +6293,32 @@ export const schemaDict = {
|
||||
},
|
||||
},
|
||||
},
|
||||
AppBskyUnspeccedApplyLabels: {
|
||||
lexicon: 1,
|
||||
id: 'app.bsky.unspecced.applyLabels',
|
||||
defs: {
|
||||
main: {
|
||||
type: 'procedure',
|
||||
description: 'Allow a labeler to apply labels directly.',
|
||||
input: {
|
||||
encoding: 'application/json',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['labels'],
|
||||
properties: {
|
||||
labels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'ref',
|
||||
ref: 'lex:com.atproto.label.defs#label',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AppBskyUnspeccedGetPopular: {
|
||||
lexicon: 1,
|
||||
id: 'app.bsky.unspecced.getPopular',
|
||||
@ -6561,6 +6586,7 @@ export const ids = {
|
||||
'app.bsky.notification.listNotifications',
|
||||
AppBskyNotificationUpdateSeen: 'app.bsky.notification.updateSeen',
|
||||
AppBskyRichtextFacet: 'app.bsky.richtext.facet',
|
||||
AppBskyUnspeccedApplyLabels: 'app.bsky.unspecced.applyLabels',
|
||||
AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular',
|
||||
AppBskyUnspeccedGetPopularFeedGenerators:
|
||||
'app.bsky.unspecced.getPopularFeedGenerators',
|
||||
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* GENERATED CODE - DO NOT MODIFY
|
||||
*/
|
||||
import express from 'express'
|
||||
import { ValidationResult, BlobRef } from '@atproto/lexicon'
|
||||
import { lexicons } from '../../../../lexicons'
|
||||
import { isObj, hasProp } from '../../../../util'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import { HandlerAuth } from '@atproto/xrpc-server'
|
||||
import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
|
||||
|
||||
export interface QueryParams {}
|
||||
|
||||
export interface InputSchema {
|
||||
labels: ComAtprotoLabelDefs.Label[]
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface HandlerInput {
|
||||
encoding: 'application/json'
|
||||
body: InputSchema
|
||||
}
|
||||
|
||||
export interface HandlerError {
|
||||
status: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type HandlerOutput = HandlerError | void
|
||||
export type Handler<HA extends HandlerAuth = never> = (ctx: {
|
||||
auth: HA
|
||||
params: QueryParams
|
||||
input: HandlerInput
|
||||
req: express.Request
|
||||
res: express.Response
|
||||
}) => Promise<HandlerOutput> | HandlerOutput
|
@ -12,7 +12,7 @@ export interface QueryParams {}
|
||||
|
||||
export interface InputSchema {
|
||||
account: string
|
||||
/** Additionally add a note describing why the invites were disabled */
|
||||
/** Additionally add a note describing why the invites were enabled */
|
||||
note?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam'
|
||||
export const REASONVIOLATION = 'com.atproto.moderation.defs#reasonViolation'
|
||||
/** Misleading identity, affiliation, or content */
|
||||
export const REASONMISLEADING = 'com.atproto.moderation.defs#reasonMisleading'
|
||||
/** Unwanted or mis-labeled sexual content */
|
||||
/** Unwanted or mislabeled sexual content */
|
||||
export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
|
||||
/** Rude, harassing, explicit, or otherwise unwelcoming behavior */
|
||||
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
|
||||
|
187
packages/pds/tests/labeler/apply-labels.test.ts
Normal file
187
packages/pds/tests/labeler/apply-labels.test.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import AtpAgent from '@atproto/api'
|
||||
import {
|
||||
adminAuth,
|
||||
CloseFn,
|
||||
moderatorAuth,
|
||||
runTestServer,
|
||||
TestServerInfo,
|
||||
} from '../_util'
|
||||
import { SeedClient } from '../seeds/client'
|
||||
import basicSeed from '../seeds/basic'
|
||||
|
||||
describe('unspecced.applyLabels', () => {
|
||||
let server: TestServerInfo
|
||||
let close: CloseFn
|
||||
let agent: AtpAgent
|
||||
let sc: SeedClient
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await runTestServer({
|
||||
dbPostgresSchema: 'moderation',
|
||||
})
|
||||
close = server.close
|
||||
agent = new AtpAgent({ service: server.url })
|
||||
sc = new SeedClient(agent)
|
||||
await basicSeed(sc)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await close()
|
||||
})
|
||||
|
||||
it('requires admin auth.', async () => {
|
||||
const tryToLabel = agent.api.app.bsky.unspecced.applyLabels(
|
||||
{
|
||||
labels: [
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: sc.dids.carol,
|
||||
val: 'cats',
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: { authorization: moderatorAuth() },
|
||||
},
|
||||
)
|
||||
await expect(tryToLabel).rejects.toThrow('Insufficient privileges')
|
||||
})
|
||||
|
||||
it('adds and removes labels on record as though applied by the labeler.', async () => {
|
||||
const post = sc.posts[sc.dids.bob][1].ref
|
||||
await agent.api.app.bsky.unspecced.applyLabels(
|
||||
{
|
||||
labels: [
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: post.uriStr,
|
||||
cid: post.cidStr,
|
||||
val: 'birds',
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: post.uriStr,
|
||||
cid: post.cidStr,
|
||||
val: 'bats',
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: { authorization: adminAuth() },
|
||||
},
|
||||
)
|
||||
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([
|
||||
'birds',
|
||||
'bats',
|
||||
])
|
||||
await agent.api.app.bsky.unspecced.applyLabels(
|
||||
{
|
||||
labels: [
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: post.uriStr,
|
||||
cid: post.cidStr,
|
||||
val: 'birds',
|
||||
neg: true,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: post.uriStr,
|
||||
cid: post.cidStr,
|
||||
val: 'bats',
|
||||
neg: true,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: { authorization: adminAuth() },
|
||||
},
|
||||
)
|
||||
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('adds and removes labels on repo as though applied by the labeler.', async () => {
|
||||
await agent.api.app.bsky.unspecced.applyLabels(
|
||||
{
|
||||
labels: [
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: sc.dids.carol,
|
||||
val: 'birds',
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: sc.dids.carol,
|
||||
val: 'bats',
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: { authorization: adminAuth() },
|
||||
},
|
||||
)
|
||||
await expect(getRepoLabels(sc.dids.carol)).resolves.toEqual([
|
||||
'birds',
|
||||
'bats',
|
||||
])
|
||||
await agent.api.app.bsky.unspecced.applyLabels(
|
||||
{
|
||||
labels: [
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: sc.dids.carol,
|
||||
val: 'birds',
|
||||
neg: true,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
src: server.ctx.cfg.labelerDid,
|
||||
uri: sc.dids.carol,
|
||||
val: 'bats',
|
||||
neg: true,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: { authorization: adminAuth() },
|
||||
},
|
||||
)
|
||||
await expect(getRepoLabels(sc.dids.carol)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
async function getRecordLabels(uri: string) {
|
||||
const result = await agent.api.com.atproto.admin.getRecord(
|
||||
{ uri },
|
||||
{ headers: { authorization: adminAuth() } },
|
||||
)
|
||||
const labels = result.data.labels ?? []
|
||||
return labels.map((l) => l.val)
|
||||
}
|
||||
|
||||
async function getRepoLabels(did: string) {
|
||||
const result = await agent.api.com.atproto.admin.getRepo(
|
||||
{ did },
|
||||
{ headers: { authorization: adminAuth() } },
|
||||
)
|
||||
const labels = result.data.labels ?? []
|
||||
return labels.map((l) => l.val)
|
||||
}
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user