Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add a policy property to takedown events #3271

Merged
merged 5 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/smooth-steaks-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@atproto/dev-env": patch
"@atproto/ozone": patch
"@atproto/api": patch
---

Allow setting policy names with takedown actions and when querying events
6 changes: 6 additions & 0 deletions lexicons/tools/ozone/moderation/defs.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@
"acknowledgeAccountSubjects": {
"type": "boolean",
"description": "If true, all other reports on content authored by this account will be resolved (acknowledged)."
},
"policies": {
"type": "array",
"maxLength": 5,
"items": { "type": "string" },
"description": "Names/Keywords of the policies that drove the decision."
}
}
},
Expand Down
7 changes: 7 additions & 0 deletions lexicons/tools/ozone/moderation/queryEvents.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@
"type": "string"
}
},
"policies": {
"type": "array",
"items": {
"type": "string",
"description": "If specified, only events where the policy matches the given policy are returned"
}
},
"cursor": {
"type": "string"
}
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11304,6 +11304,15 @@ export const schemaDict = {
description:
'If true, all other reports on content authored by this account will be resolved (acknowledged).',
},
policies: {
type: 'array',
maxLength: 5,
items: {
type: 'string',
},
description:
'Names/Keywords of the policies that drove the decision.',
},
},
},
modEventReverseTakedown: {
Expand Down Expand Up @@ -12340,6 +12349,14 @@ export const schemaDict = {
type: 'string',
},
},
policies: {
type: 'array',
items: {
type: 'string',
description:
'If specified, only events where the policy matches the given policy are returned',
},
},
cursor: {
type: 'string',
},
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/client/types/tools/ozone/moderation/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export interface ModEventTakedown {
durationInHours?: number
/** If true, all other reports on content authored by this account will be resolved (acknowledged). */
acknowledgeAccountSubjects?: boolean
/** Names/Keywords of the policies that drove the decision. */
policies?: string[]
[k: string]: unknown
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface QueryParams {
/** If specified, only events where all of these tags were removed are returned */
removedTags?: string[]
reportTypes?: string[]
policies?: string[]
cursor?: string
}

Expand Down
4 changes: 2 additions & 2 deletions packages/bsky/src/lexicon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2177,13 +2177,13 @@ export class ChatBskyModerationNS {

type SharedRateLimitOpts<T> = {
name: string
calcKey?: (ctx: T) => string
calcKey?: (ctx: T) => string | null
calcPoints?: (ctx: T) => number
}
type RouteRateLimitOpts<T> = {
durationMs: number
points: number
calcKey?: (ctx: T) => string
calcKey?: (ctx: T) => string | null
calcPoints?: (ctx: T) => number
}
type HandlerOpts = { blobLimit?: number }
Expand Down
5 changes: 4 additions & 1 deletion packages/dev-env/src/moderator-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,19 @@ export class ModeratorClient {
durationInHours?: number
acknowledgeAccountSubjects?: boolean
reason?: string
policies?: string[]
},
role?: ModLevel,
) {
const { durationInHours, acknowledgeAccountSubjects, ...rest } = opts
const { durationInHours, acknowledgeAccountSubjects, policies, ...rest } =
opts
return this.emitEvent(
{
event: {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
acknowledgeAccountSubjects,
durationInHours,
policies,
},
...rest,
},
Expand Down
2 changes: 2 additions & 0 deletions packages/ozone/src/api/moderation/queryEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default function (server: Server, ctx: AppContext) {
reportTypes,
collections = [],
subjectType,
policies,
} = params
const db = ctx.db
const modService = ctx.modService(db)
Expand All @@ -47,6 +48,7 @@ export default function (server: Server, ctx: AppContext) {
reportTypes,
collections,
subjectType,
policies,
})
return {
encoding: 'application/json',
Expand Down
4 changes: 2 additions & 2 deletions packages/ozone/src/lexicon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2638,13 +2638,13 @@ export class ToolsOzoneTeamNS {

type SharedRateLimitOpts<T> = {
name: string
calcKey?: (ctx: T) => string
calcKey?: (ctx: T) => string | null
calcPoints?: (ctx: T) => number
}
type RouteRateLimitOpts<T> = {
durationMs: number
points: number
calcKey?: (ctx: T) => string
calcKey?: (ctx: T) => string | null
calcPoints?: (ctx: T) => number
}
type HandlerOpts = { blobLimit?: number }
Expand Down
17 changes: 17 additions & 0 deletions packages/ozone/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11304,6 +11304,15 @@ export const schemaDict = {
description:
'If true, all other reports on content authored by this account will be resolved (acknowledged).',
},
policies: {
type: 'array',
maxLength: 5,
items: {
type: 'string',
},
description:
'Names/Keywords of the policies that drove the decision.',
},
},
},
modEventReverseTakedown: {
Expand Down Expand Up @@ -12340,6 +12349,14 @@ export const schemaDict = {
type: 'string',
},
},
policies: {
type: 'array',
items: {
type: 'string',
description:
'If specified, only events where the policy matches the given policy are returned',
},
},
cursor: {
type: 'string',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export interface ModEventTakedown {
durationInHours?: number
/** If true, all other reports on content authored by this account will be resolved (acknowledged). */
acknowledgeAccountSubjects?: boolean
/** Names/Keywords of the policies that drove the decision. */
policies?: string[]
[k: string]: unknown
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface QueryParams {
/** If specified, only events where all of these tags were removed are returned */
removedTags?: string[]
reportTypes?: string[]
policies?: string[]
cursor?: string
}

Expand Down
14 changes: 14 additions & 0 deletions packages/ozone/src/mod-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export class ModerationService {
reportTypes?: string[]
collections: string[]
subjectType?: string
policies?: string[]
}): Promise<{ cursor?: string; events: ModerationEventRow[] }> {
const {
subject,
Expand All @@ -172,6 +173,7 @@ export class ModerationService {
reportTypes,
collections,
subjectType,
policies,
} = opts
const { ref } = this.db.db.dynamic
let builder = this.db.db.selectFrom('moderation_event').selectAll()
Expand Down Expand Up @@ -264,6 +266,14 @@ export class ModerationService {
if (reportTypes?.length) {
builder = builder.where(sql`meta->>'reportType'`, 'in', reportTypes)
}
if (policies?.length) {
builder = builder.where((qb) => {
policies.forEach((policy) => {
qb = qb.orWhere(sql`meta->>'policies'`, 'ilike', `%${policy}%`)
})
return qb
})
}

const keyset = new TimeIdKeyset(
ref(`moderation_event.createdAt`),
Expand Down Expand Up @@ -435,6 +445,10 @@ export class ModerationService {
meta.acknowledgeAccountSubjects = true
}

if (isModEventTakedown(event) && event.policies) {
meta.policies = event.policies?.join(' ')
}

// Keep trace of reports that came in while the reporter was in muted stated
if (isModEventReport(event)) {
const isReportingMuted = await this.isReportingMutedForSubject(createdBy)
Expand Down
10 changes: 10 additions & 0 deletions packages/ozone/src/mod-service/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ export class ModerationViews {
}
}

if (
event.action === 'tools.ozone.moderation.defs#modEventTakedown' &&
typeof event.meta?.policies === 'string'
) {
eventView.event = {
...eventView.event,
policies: event.meta.policies.split(' '),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look out for the empty string, which likes to split into [''].

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other words:

const policies = []
const serialized = policies.join(' ')
const deserialized = serialized.split(' ')
// we want policies and deserialized to both be `[]`
// but policies is `[]` while deserialized is `['']`

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep yep! nice catch. realized the separator is an issue to start with and ended up finding this too.

}
}

if (event.action === 'tools.ozone.moderation.defs#modEventLabel') {
eventView.event = {
...eventView.event,
Expand Down
1 change: 1 addition & 0 deletions packages/ozone/src/setting/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const ProtectedTagSettingKey = 'tools.ozone.setting.protectedTags'
export const PolicyListSettingKey = 'tools.ozone.setting.policyList'
29 changes: 28 additions & 1 deletion packages/ozone/src/setting/validators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Selectable } from 'kysely'
import { Setting } from '../db/schema/setting'
import { ProtectedTagSettingKey } from './constants'
import { PolicyListSettingKey, ProtectedTagSettingKey } from './constants'
import { InvalidRequestError } from '@atproto/xrpc-server'

export const settingValidators = new Map<
Expand Down Expand Up @@ -58,4 +58,31 @@ export const settingValidators = new Map<
}
},
],
[
PolicyListSettingKey,
async (setting: Partial<Selectable<Setting>>) => {
if (setting.managerRole !== 'tools.ozone.team.defs#roleAdmin') {
throw new InvalidRequestError(
'Only admins should be able to manage policy list',
)
}

if (typeof setting.value !== 'object') {
throw new InvalidRequestError('Invalid value')
}
for (const [key, val] of Object.entries(setting.value)) {
if (!val || typeof val !== 'object') {
throw new InvalidRequestError(
`Invalid configuration for policy ${key}`,
)
}

if (!val['name'] || !val['description']) {
throw new InvalidRequestError(
`Must define a name and description for policy ${key}`,
)
}
}
},
],
])
64 changes: 64 additions & 0 deletions packages/ozone/tests/takedown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
TestNetwork,
TestOzone,
SeedClient,
basicSeed,
ModeratorClient,
} from '@atproto/dev-env'
import { AtpAgent } from '@atproto/api'

describe('moderation', () => {
let network: TestNetwork
let ozone: TestOzone
let agent: AtpAgent
let bskyAgent: AtpAgent
let pdsAgent: AtpAgent
let sc: SeedClient
let modClient: ModeratorClient

const repoSubject = (did: string) => ({
$type: 'com.atproto.admin.defs#repoRef',
did,
})

beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'ozone_takedown',
})
ozone = network.ozone
agent = network.ozone.getClient()
bskyAgent = network.bsky.getClient()
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
modClient = network.ozone.getModClient()
await basicSeed(sc)
await network.processAll()
})

afterAll(async () => {
await network.close()
})

it('allows specifying policy for takedown actions.', async () => {
await modClient.performTakedown({
subject: repoSubject(sc.dids.bob),
policies: ['trolling'],
})

// Verify that that the takedown even exposes the policy specified for it
const { events } = await modClient.queryEvents({
subject: sc.dids.bob,
types: ['tools.ozone.moderation.defs#modEventTakedown'],
})

expect(events[0].event.policies?.[0]).toEqual('trolling')

// Verify that event stream can be filtered by policy
const { events: filteredEvents } = await modClient.queryEvents({
subject: sc.dids.bob,
policies: ['trolling'],
})

expect(filteredEvents[0].subject.did).toEqual(sc.dids.bob)
})
})
4 changes: 2 additions & 2 deletions packages/pds/src/lexicon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2638,13 +2638,13 @@ export class ToolsOzoneTeamNS {

type SharedRateLimitOpts<T> = {
name: string
calcKey?: (ctx: T) => string
calcKey?: (ctx: T) => string | null
calcPoints?: (ctx: T) => number
}
type RouteRateLimitOpts<T> = {
durationMs: number
points: number
calcKey?: (ctx: T) => string
calcKey?: (ctx: T) => string | null
calcPoints?: (ctx: T) => number
}
type HandlerOpts = { blobLimit?: number }
Expand Down
Loading
Loading