Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 20 additions & 4 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ interface Env {
ZENDESK_EMAIL?: string;
ZENDESK_FIELD_ACTION_STATUS?: string;
ZENDESK_FIELD_ACTION_REQUESTED?: string;
ZENDESK_FIELD_CATEGORY?: string; // For auto-solve required fields
ZENDESK_FIELD_ISSUE?: string; // For auto-solve required fields
KV?: KVNamespace;
DB?: D1Database;
// Relay management configuration
Expand Down Expand Up @@ -1665,7 +1667,7 @@ async function addZendeskInternalNote(
const auth = btoa(`${env.ZENDESK_EMAIL}/token:${env.ZENDESK_API_TOKEN}`);
const url = `https://${env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/${ticketId}`;

const payload: { ticket: { comment: { body: string; public: boolean }; status?: string } } = {
const payload: { ticket: { comment: { body: string; public: boolean }; status?: string; assignee_email?: string; custom_fields?: Array<{ id: number; value: string }> } } = {
ticket: {
comment: {
body: note,
Expand All @@ -1676,6 +1678,19 @@ async function addZendeskInternalNote(

if (solve) {
payload.ticket.status = 'solved';
// Zendesk requires an assignee to solve a ticket (system rule, not enforced via API error).
// Without this, the comment is added but the status change is silently ignored.
payload.ticket.assignee_email = env.ZENDESK_EMAIL;
// Category and Issue are required fields. These values are set unconditionally. Safe
// because auto-categorize triggers fire at ticket creation, and this solve call runs
// later only for report types where triggers didn't set specific values (e.g., "other"
// reports with the previously misconfigured trigger).
if (env.ZENDESK_FIELD_CATEGORY && env.ZENDESK_FIELD_ISSUE) {
payload.ticket.custom_fields = [
{ id: parseInt(env.ZENDESK_FIELD_CATEGORY, 10), value: 'trust___safety' },
{ id: parseInt(env.ZENDESK_FIELD_ISSUE, 10), value: 'other_content_report' },
];
}
}

const response = await fetch(url, {
Expand Down Expand Up @@ -2238,9 +2253,10 @@ async function handleParseReport(
}

// Parse description with regex
const eventMatch = description.match(/Event ID:\s*([a-f0-9]{64})/i);
const pubkeyMatch = description.match(/Author Pubkey:\s*([a-f0-9]{64})/i);
const violationMatch = description.match(/Violation Type:\s*(\w+)/i);
// Tolerates markdown bold (**Event ID:**) and alternate field names (Reported Pubkey vs Author Pubkey)
const eventMatch = description.match(/\*{0,2}Event ID:?\*{0,2}\s*([a-f0-9]{64})/i);
const pubkeyMatch = description.match(/\*{0,2}(?:Author|Reported) Pubkey:?\*{0,2}\s*([a-f0-9]{64})/i);
const violationMatch = description.match(/\*{0,2}(?:Violation Type|Reason):?\*{0,2}\s*(\w[^\n]*\w|\w)/i);

const event_id = eventMatch?.[1] || null;
const author_pubkey = pubkeyMatch?.[1] || null;
Expand Down
286 changes: 286 additions & 0 deletions worker/src/zendesk-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
// ABOUTME: Tests for Zendesk sync reliability fixes
// ABOUTME: Covers parse-report regex variants and solved-ticket Zendesk payload behavior

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import worker from './index';

const WEBHOOK_SECRET = 'test-parse-report-secret';
const TEST_NSEC = 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5';
const LINKED_TICKET_ID = 926;

const ctx = {} as ExecutionContext;

class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;

readyState = MockWebSocket.OPEN;
private listeners: Map<string, Array<(event: unknown) => void>> = new Map();

constructor(_url: string) {
setTimeout(() => this.emit('open', {}), 0);
}

addEventListener(type: string, listener: (event: unknown) => void): void {
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type)!.push(listener);
}

send(data: string): void {
const parsed = JSON.parse(data);
if (parsed[0] === 'EVENT') {
setTimeout(() => {
this.emit('message', { data: JSON.stringify(['OK', parsed[1]?.id || 'test', true, '']) });
}, 0);
}
}

close(): void {
this.readyState = MockWebSocket.CLOSED;
}

private emit(type: string, event: unknown): void {
for (const handler of this.listeners.get(type) || []) handler(event);
}
}

function makeEnv(overrides: Record<string, unknown> = {}) {
return {
NOSTR_NSEC: TEST_NSEC,
ALLOWED_ORIGINS: 'https://relay.admin.divine.video',
RELAY_URL: 'wss://relay.divine.video',
ZENDESK_PARSE_REPORT_SECRET: WEBHOOK_SECRET,
ZENDESK_SUBDOMAIN: 'rabblelabs',
ZENDESK_API_TOKEN: 'test-token',
ZENDESK_EMAIL: 'test@divine.video',
ZENDESK_FIELD_CATEGORY: '14559549220879',
ZENDESK_FIELD_ISSUE: '14560383908879',
DB: {
prepare: () => ({
bind: (..._args: unknown[]) => ({
first: async () => null,
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
first: async () => null,
}),
exec: async () => ({}),
batch: async () => [],
dump: async () => new ArrayBuffer(0),
},
...overrides,
} as never;
}

function createMockDB() {
const sqlLog: { sql: string; bindings: unknown[] }[] = [];

const db = {
prepare: vi.fn().mockImplementation((sql: string) => ({
bind: vi.fn().mockImplementation((...args: unknown[]) => {
sqlLog.push({ sql, bindings: args });

if (sql.includes("SELECT ticket_id FROM zendesk_tickets WHERE event_id = ? AND status = 'open'")) {
return {
first: vi.fn().mockResolvedValue({ ticket_id: LINKED_TICKET_ID }),
run: vi.fn().mockResolvedValue({ success: true, meta: { changes: 0 } }),
all: vi.fn().mockResolvedValue({ results: [] }),
};
}

return {
first: vi.fn().mockResolvedValue(null),
run: vi.fn().mockResolvedValue({ success: true, meta: { changes: 1 } }),
all: vi.fn().mockResolvedValue({ results: [] }),
};
}),
run: vi.fn().mockResolvedValue({ success: true, meta: { changes: 0 } }),
first: vi.fn().mockResolvedValue(null),
all: vi.fn().mockResolvedValue({ results: [] }),
})),
exec: vi.fn().mockResolvedValue({}),
batch: vi.fn().mockResolvedValue([]),
dump: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};

return { db, sqlLog };
}

function makeParseReportRequest(description: string, ticketId = 12345) {
return new Request('https://api-relay-prod.divine.video/api/zendesk/parse-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Key': WEBHOOK_SECRET,
},
body: JSON.stringify({ ticket_id: ticketId, description }),
});
}

function makeResolutionPublishRequest(targetEventId: string) {
return new Request('https://api-relay-prod.divine.video/api/publish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cf-Access-Jwt-Assertion': 'test',
},
body: JSON.stringify({
kind: 1985,
content: '',
tags: [
['L', 'moderation/resolution'],
['l', 'reviewed', 'moderation/resolution'],
['e', targetEventId],
],
}),
});
}

describe('handleParseReport regex', () => {
const EVENT_ID = 'ab13eb2c66bea4cd8f538798054d23a02d5dca879401be5045b8482590e2482c';
const PUBKEY = '92aad7891d89ec67d3527ad2d25205a342cb2c121817dde5b0e2f5af2fb37101';

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ result: true }),
text: async () => '',
}));
});

afterEach(() => {
vi.restoreAllMocks();
});

it('parses divine-mobile plain text format', async () => {
const description = [
'Content Report - NIP-56',
'',
`Event ID: ${EVENT_ID}`,
`Author Pubkey: ${PUBKEY}`,
'',
'Violation Type: other',
].join('\n');

const response = await worker.fetch(makeParseReportRequest(description), makeEnv(), ctx);
const data = await response.json() as Record<string, unknown>;

expect(response.status).toBe(200);
expect(data.event_id).toBe(EVENT_ID);
expect(data.author_pubkey).toBe(PUBKEY);
expect(data.violation_type).toBe('other');
});

it('parses divine-web markdown bold format', async () => {
const description = [
`**Content Type:** video`,
`**Reason:** violence`,
`**Event ID:** ${EVENT_ID}`,
`**Reported Pubkey:** ${PUBKEY}`,
`**Content URL:** https://media.divine.video/abc`,
].join('\n');

const response = await worker.fetch(makeParseReportRequest(description), makeEnv(), ctx);
const data = await response.json() as Record<string, unknown>;

expect(response.status).toBe(200);
expect(data.event_id).toBe(EVENT_ID);
expect(data.author_pubkey).toBe(PUBKEY);
});

it('parses Reported Pubkey (web) same as Author Pubkey (mobile)', async () => {
const description = `Reported Pubkey: ${PUBKEY}\nEvent ID: ${EVENT_ID}`;

const response = await worker.fetch(makeParseReportRequest(description), makeEnv(), ctx);
const data = await response.json() as Record<string, unknown>;

expect(response.status).toBe(200);
expect(data.author_pubkey).toBe(PUBKEY);
});

it('parses multi-word violation types without crossing lines', async () => {
const description = [
`Event ID: ${EVENT_ID}`,
`Violation Type: Sexual Content`,
`Author Pubkey: ${PUBKEY}`,
].join('\n');

const response = await worker.fetch(makeParseReportRequest(description), makeEnv(), ctx);
const data = await response.json() as Record<string, unknown>;

expect(response.status).toBe(200);
expect(data.violation_type).toBe('Sexual Content');
});

it('returns 400 when no event_id or pubkey can be parsed', async () => {
const description = 'This is a report with no identifiers';

const response = await worker.fetch(makeParseReportRequest(description), makeEnv(), ctx);
expect(response.status).toBe(400);
});

it('rejects requests without valid webhook key', async () => {
const request = new Request('https://api-relay-prod.divine.video/api/zendesk/parse-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Key': 'wrong-secret',
},
body: JSON.stringify({ ticket_id: 999, description: `Event ID: ${EVENT_ID}` }),
});

const response = await worker.fetch(request, makeEnv(), ctx);
expect(response.status).toBe(401);
});
});

describe('addZendeskInternalNote solve payload', () => {
beforeEach(() => {
vi.stubGlobal('WebSocket', MockWebSocket);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('sends solved status, assignee, and required custom fields for resolution actions', async () => {
const { db, sqlLog } = createMockDB();
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ result: true }),
text: async () => '',
});
vi.stubGlobal('fetch', mockFetch);

const targetEventId = 'ab13eb2c66bea4cd8f538798054d23a02d5dca879401be5045b8482590e2482c';
const response = await worker.fetch(
makeResolutionPublishRequest(targetEventId),
makeEnv({ DB: db }),
ctx,
);

expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(1);

const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe(`https://rabblelabs.zendesk.com/api/v2/tickets/${LINKED_TICKET_ID}`);
expect(options.method).toBe('PUT');

const payload = JSON.parse(options.body as string);
expect(payload.ticket.status).toBe('solved');
expect(payload.ticket.assignee_email).toBe('test@divine.video');
expect(payload.ticket.custom_fields).toEqual([
{ id: 14559549220879, value: 'trust___safety' },
{ id: 14560383908879, value: 'other_content_report' },
]);
expect(payload.ticket.comment.public).toBe(false);

const resolvedUpdate = sqlLog.find(entry => entry.sql.includes('UPDATE zendesk_tickets'));
expect(resolvedUpdate).toBeDefined();
expect(resolvedUpdate?.bindings).toEqual(['reviewed', expect.any(String), LINKED_TICKET_ID]);
});
});
3 changes: 3 additions & 0 deletions worker/wrangler.local.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ ALLOWED_ORIGINS = "https://relay.admin.divine.video,https://app.divine.video,htt
# Zendesk custom field IDs for webhook callback
ZENDESK_FIELD_ACTION_STATUS = "14545793289743"
ZENDESK_FIELD_ACTION_REQUESTED = "14545826900367"
# Zendesk custom field IDs for auto-solve required fields
ZENDESK_FIELD_CATEGORY = "14559549220879"
ZENDESK_FIELD_ISSUE = "14560383908879"

# D1 Database for moderation decision log
[[d1_databases]]
Expand Down
3 changes: 3 additions & 0 deletions worker/wrangler.prod.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ CDN_DOMAIN = "media.divine.video"
# Zendesk custom field IDs for webhook callback
ZENDESK_FIELD_ACTION_STATUS = "14545793289743"
ZENDESK_FIELD_ACTION_REQUESTED = "14545826900367"
# Zendesk custom field IDs for auto-solve required fields
ZENDESK_FIELD_CATEGORY = "14559549220879"
ZENDESK_FIELD_ISSUE = "14560383908879"

# D1 Database for moderation decision log (production)
[[d1_databases]]
Expand Down
3 changes: 3 additions & 0 deletions worker/wrangler.staging.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ CDN_DOMAIN = "media.divine.video"
# Zendesk custom field IDs for webhook callback
ZENDESK_FIELD_ACTION_STATUS = "14545793289743"
ZENDESK_FIELD_ACTION_REQUESTED = "14545826900367"
# Zendesk custom field IDs for auto-solve required fields
ZENDESK_FIELD_CATEGORY = "14559549220879"
ZENDESK_FIELD_ISSUE = "14560383908879"

# D1 Database for moderation decision log (staging)
[[d1_databases]]
Expand Down
Loading