@atproto/ozone
Version:
Backend service for moderating the Bluesky network.
253 lines • 10.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScheduledActionProcessor = void 0;
const common_1 = require("@atproto/common");
const util_1 = require("../api/moderation/util");
const logger_1 = require("../logger");
const subject_1 = require("../mod-service/subject");
const util_2 = require("../util");
class ScheduledActionProcessor {
constructor(db, serviceDid, settingService, modService, scheduledActionService) {
Object.defineProperty(this, "db", {
enumerable: true,
configurable: true,
writable: true,
value: db
});
Object.defineProperty(this, "serviceDid", {
enumerable: true,
configurable: true,
writable: true,
value: serviceDid
});
Object.defineProperty(this, "settingService", {
enumerable: true,
configurable: true,
writable: true,
value: settingService
});
Object.defineProperty(this, "modService", {
enumerable: true,
configurable: true,
writable: true,
value: modService
});
Object.defineProperty(this, "scheduledActionService", {
enumerable: true,
configurable: true,
writable: true,
value: scheduledActionService
});
Object.defineProperty(this, "destroyed", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "processingPromise", {
enumerable: true,
configurable: true,
writable: true,
value: Promise.resolve()
});
Object.defineProperty(this, "timer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
}
start() {
this.poll();
}
poll() {
if (this.destroyed)
return;
this.processingPromise = this.findAndExecuteScheduledActions()
.catch((err) => logger_1.dbLogger.error({ err }, 'scheduled action processing errored'))
.finally(() => {
this.timer = setTimeout(() => this.poll(), getInterval());
});
}
async destroy() {
this.destroyed = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
await this.processingPromise;
}
async executeScheduledAction(actionId) {
await this.db.transaction(async (dbTxn) => {
const settingService = this.settingService(dbTxn);
const moderationTxn = this.modService(dbTxn);
const scheduledActionTxn = this.scheduledActionService(dbTxn);
try {
// maybe overfetching here to get the action again within the transaction to ensure it's still pending
const action = await dbTxn.db
.selectFrom('scheduled_action')
.selectAll()
.where('id', '=', actionId)
.where('status', '=', 'pending')
.executeTakeFirst();
if (!action) {
// already processed or cancelled
return;
}
let event;
const email = {
subject: '',
content: '',
};
let modTool;
// Create the appropriate moderation action based on the scheduled action type
switch (action.action) {
case 'takedown':
{
const eventData = action.eventData;
modTool = eventData.modTool;
event = {
$type: 'tools.ozone.moderation.defs#modEventTakedown',
comment: `[SCHEDULED_ACTION] ${eventData.comment || 'Scheduled takedown executed'}`,
durationInHours: eventData.durationInHours,
acknowledgeAccountSubjects: eventData.acknowledgeAccountSubjects,
policies: eventData.policies,
severityLevel: eventData.severityLevel,
strikeCount: eventData.strikeCount,
};
if (eventData.emailSubject && eventData.emailContent) {
email.subject = eventData.emailSubject;
email.content = eventData.emailContent;
}
}
break;
default:
throw new Error(`Unsupported scheduled action type: ${action.action}`);
}
const moderationEvent = await this.performTakedown({
action,
event,
modTool,
moderationTxn,
settingService,
email,
});
// Mark the scheduled action as executed
await scheduledActionTxn.markActionAsExecuted(actionId, moderationEvent.event.id);
logger_1.dbLogger.info({
did: action.did,
scheduledActionId: actionId,
moderationEventId: moderationEvent.event.id,
}, 'executed scheduled action');
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// mark as failed
await scheduledActionTxn.markActionAsFailed(actionId, errorMessage);
logger_1.dbLogger.error({
scheduledActionId: actionId,
error: errorMessage,
}, 'failed to execute scheduled action');
}
});
}
async performTakedown({ email, action, event, modTool, moderationTxn, settingService, }) {
const subject = new subject_1.RepoSubject(action.did);
const status = await moderationTxn.getStatus(subject);
if (status?.takendown) {
throw new Error(`Account is already taken down`);
}
if (status?.tags?.length) {
const protectedTags = await (0, util_1.getProtectedTags)(settingService, this.serviceDid);
if (protectedTags) {
(0, util_1.assertProtectedTagAction)({
protectedTags,
subjectTags: status.tags,
actionAuthor: action.createdBy,
isAdmin: true,
isModerator: false,
isTriage: false,
});
}
}
// log the event which also applies the necessary state changes to moderation subject
const moderationEvent = await moderationTxn.logEvent({
event,
subject,
modTool,
createdBy: action.createdBy,
});
// register the takedown in event pusher
await moderationTxn.takedownRepo(subject, moderationEvent.event.id, new Set(moderationEvent.event.meta?.targetServices
? `${moderationEvent.event.meta.targetServices}`.split(',')
: undefined));
if (email.content && email.subject) {
let isDelivered = false;
try {
await (0, util_2.retryHttp)(() => moderationTxn.sendEmail({
...email,
recipientDid: action.did,
}));
isDelivered = true;
}
catch (err) {
logger_1.dbLogger.error({ err, did: action.did }, 'failed to send takedown email');
}
await moderationTxn.logEvent({
event: {
content: email.content,
subjectLine: email.subject,
$type: 'tools.ozone.moderation.defs#modEventEmail',
comment: [
'Communication attached to scheduled action',
isDelivered ? '' : 'Email delivery failed',
].join('.'),
isDelivered,
},
subject,
modTool,
createdBy: action.createdBy,
});
}
return moderationEvent;
}
async findAndExecuteScheduledActions() {
const scheduledActionService = this.scheduledActionService(this.db);
const now = new Date();
const actionsToExecute = await scheduledActionService.getPendingActionsToExecute(now);
for (const action of actionsToExecute) {
// For randomized execution, check if we should execute now or wait
if (action.randomizeExecution && action.executeAfter) {
const executeAfter = new Date(action.executeAfter);
// Default to a 30 second window for execution
const executeUntil = action.executeUntil
? new Date(action.executeUntil)
: new Date(executeAfter.getTime() + 30 * common_1.SECOND);
// Only execute if we're past the earliest time
if (now < executeAfter) {
continue;
}
// For randomized scheduling, randomly decide whether to execute now
// The probability increases as we get closer to the deadline
const timeRange = executeUntil.getTime() - executeAfter.getTime();
const timeElapsed = now.getTime() - executeAfter.getTime();
const executeProb = Math.min(timeElapsed / timeRange, 1);
// Execute with increasing probability as we approach the deadline
// Always execute if we're at or past the deadline
if (now < executeUntil && Math.random() > executeProb * 0.1) {
continue;
}
}
await this.executeScheduledAction(action.id);
}
}
}
exports.ScheduledActionProcessor = ScheduledActionProcessor;
const getInterval = () => {
// Process scheduled actions every minute
const now = Date.now();
const intervalMs = common_1.MINUTE;
const nextIteration = Math.ceil(now / intervalMs);
return nextIteration * intervalMs - now;
};
//# sourceMappingURL=scheduled-action-processor.js.map