Saltar al contenido principal
EjemploAvanzado4 min de lecturaActualizado el Jul 2, 2026
agentactordiscordmoderationspamimagellmjavascriptjwtx-jwt-token

Discord Spam Moderator

Agente actor que modera spam de texto e imagen en Discord con heurísticas, un workflow LLM para imágenes, señales de comportamiento para cuentas antiguas comprometidas, advertencias y expulsión de reincidentes.

Descargar ZIP · 11.2 KB

Dependencias incluidas

info

El ZIP descargable incluye el workflow complementario de abajo. Como el agente guarda el ID del workflow dentro de una string JavaScript/CEL, define imageLlmWorkflowId con el ID del workflow importado después de la importación.

Workflows: Discord Image Spam LLM Classifier

Importa este workflow y define imageLlmWorkflowId en el input del agente con el ID del workflow importado.

Cómo funciona

La regla 1 genera un JWT de corta duración mediante actions/security/auth/jwt/generate (vida predeterminada de 300 s). La regla 2 solo se ejecuta para mensajes Discord text e image creados por contactos cuando ese JWT está presente: lanza actions/code/javascript/execute con el token pasado por el input CEL.

El clasificador JavaScript combina heurísticas de texto, señales de comportamiento de imagen, verificaciones de protección del miembro en Discord y el workflow LLM de imagen vinculado:

  • Las heurísticas de texto detectan palabras de estafa, invitaciones, acortadores, menciones masivas, fragmentos repetidos, proporción de mayúsculas y términos promocionales/NSFW.
  • La moderación de imagen usa referencias privadas de storage de AutoTalk o URLs de imagen de Discord y llama al workflow vinculado cuando hay señales ligeras de riesgo.
  • Los baselines en contact.customAttributes registran última actividad entrante, recencia de imagen/link, ventanas de cinco minutos para mensajes/imágenes, hashes de imagen repetidos y advertencias de spam.
  • Patrones de cuentas antiguas o comprometidas, como imagen después de inactividad, primera imagen después de 30 días, primer link conocido, ráfagas de mensajes y hashes repetidos, pueden activar revisión por LLM.
  • El spam de imagen con alta confianza del LLM se elimina aunque el miembro de Discord no sea nuevo; los veredictos benignos del LLM vetan eliminaciones basadas solo en comportamiento.
  • Dueños, admins, moderadores, miembros protegidos y usuarios que ya no están en el servidor se omiten antes de cualquier acción destructiva.

Cuando la categoría supera el umbral de eliminación, el script llama a la API de AutoTalk con el JWT en el encabezado x-jwt-token:

  • GET /v1/dynadata/type/contacts/item/{authorContactId} para obtener el registro de contacto del infractor.
  • POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/getDiscordMember para omitir dueños/admins/moderadores/miembros protegidos.
  • POST /v1/dynadata/type/workflows/item/{workflowId}/executeFunction/executeManual para clasificar imágenes sospechosas.
  • POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/deleteDiscordMessage para eliminar la publicación infractora.
  • POST /v1/dynadata/type/contacts/update para actualizar baselines de comportamiento e incrementar customAttributes.discordSpamWarnings.
  • POST /v1/dynadata/type/contacts/item/{conversationContactId}/executeFunction/sendMessage para advertir al infractor.
  • POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/kickDiscordUser en la tercera infracción.
  • POST /v1/dynadata/type/log_entries/create para decisiones de moderación y motivos de omisión.

Para la especificación del encabezado x-jwt-token en sí (y el flujo alternativo x-api-key utilizado por integraciones externas), consulta la Referencia de la API.

JSON Completo

Clic para expandir
{
"description": "Actor agent that moderates Discord text and image spam, calls an image LLM workflow for suspicious media, tracks behavioral signals for compromised older accounts, deletes high-confidence spam, warns offenders, and kicks repeat violators after three warnings.",
"tags": [
"agent",
"actor",
"discord",
"moderation",
"spam",
"image",
"llm",
"javascript",
"jwt",
"x-jwt-token"
],
"linkedExamples": [
{
"type": "workflows",
"variant": "discord-image-spam-llm-classifier",
"relationship": "Import this workflow and set imageLlmWorkflowId in the agent input to the imported workflow ID."
}
],
"document": {
"name": "Discord Spam Moderator",
"assistantType": "actor",
"platform": {
"actor": {
"rules": [
{
"condition": {
"expr": "(get(contactMessage, 'type', '') == 'text' || get(contactMessage, 'type', '') == 'image') && get(contactMessage, 'createdBy', '') == 'contact' && get(contactMessage, 'deletedAt', null) == null",
"onFailure": "throw",
"fallback": "",
"resultType": "any",
"strict": false,
"useSimpleCel": false
},
"action": {
"actionType": "actions/security/auth/jwt/generate",
"actions/security/auth/jwt/generate": {
"is_advanced": false,
"duration": 300,
"ips": []
}
}
},
{
"condition": {
"expr": "(get(contactMessage, 'type', '') == 'text' || get(contactMessage, 'type', '') == 'image') && get(contactMessage, 'createdBy', '') == 'contact' && get(contactMessage, 'deletedAt', null) == null && step_ok(0) && present(step(0).jwt)",
"onFailure": "throw",
"fallback": "",
"resultType": "any",
"strict": false,
"useSimpleCel": false
},
"action": {
"actionType": "actions/code/javascript/execute",
"actions/code/javascript/execute": {
"code": "const rawText = String(input?.text || '');\nconst text = rawText.toLowerCase().replace(/\\s+/g, ' ').trim();\nconst assistantId = String(input?.assistantId || '');\nconst conversationId = String(input?.conversationId || '');\nconst messageId = String(input?.messageId || '');\nconst platformMid = String(input?.platformMid || '');\nconst type = String(input?.type || '');\nconst createdBy = String(input?.createdBy || '');\nconst deletedAt = input?.deletedAt ?? null;\nconst file = input?.file || null;\nconst metadataDiscordChannelId = String(input?.metadataDiscordChannelId || '');\nconst dynadataChannelId = String(input?.dynadataChannelId || '');\nconst jwtToken = String(input?.jwt || '');\nconst apiUrl = String(input?.apiUrl || 'https://api.autotalk.io/v1');\nconst authorContactId = String(input?.authorContactId || '').trim();\nconst conversationContactId = String(input?.conversationContactId || '').trim();\nconst fallbackContactName = String(input?.contactName || '').trim();\nconst imageLlmWorkflowId = String(input?.imageLlmWorkflowId || '').trim();\nconst warningThreshold = 3;\nconst imageWindowMs = 5 * 60 * 1000;\nconst messageWindowMs = 5 * 60 * 1000;\nconst newMemberHours = 24;\nconst dormantAccountDays = 14;\nconst staleBehaviorDays = 30;\nconst llmHighConfidenceThreshold = 0.9;\nconst llmMediumConfidenceThreshold = 0.75;\nconst nowMs = Date.now();\nconst nowIso = new Date(nowMs).toISOString();\n\nconst warningTemplates = [\n '{mention} o detector de spam apitou bonito. Aviso {warning}/{limit}.',\n '{mention} tentou meter o golpe do nitro e tomou uma vassourada digital. Aviso {warning}/{limit}.',\n '{mention} sua mensagem foi promovida para adubo de lixeira. Aviso {warning}/{limit}.',\n '{mention} acabou de descobrir que aqui o spam vai de arrasta. Aviso {warning}/{limit}.',\n '{mention} essa mensagem suspeita ja foi apagada. Aviso {warning}/{limit}.'\n];\nconst kickTemplates = [\n '{mention} fechou a trilogia do spam. Aviso {warning}/{limit}. Hora de ir respirar do lado de fora.',\n '{mention} completou o combo do caos. Aviso {warning}/{limit}. Foi chutado com carinho administrativo.',\n '{mention} zerou o medidor de paciencia da moderacao. Aviso {warning}/{limit}. Tchau e bencao.'\n];\n\nconst result = {\n shouldDelete: false,\n deleteAttempted: false,\n deleteSucceeded: false,\n warningStored: false,\n warningCount: 0,\n warningMessageAttempted: false,\n warningMessageSucceeded: false,\n warningText: '',\n kickAttempted: false,\n kickSucceeded: false,\n category: 'not_spam',\n score: 0,\n textScore: 0,\n imageScore: 0,\n reason: 'No spam signals detected.',\n matchedRules: [],\n discordChannelId: '',\n discordMessageId: '',\n discordUserId: '',\n dynadataChannelId,\n authorContactId,\n conversationContactId,\n platformMid,\n memberLookupAttempted: false,\n memberLookupStatus: null,\n protectedSkip: false,\n logsAttempted: 0,\n logsSucceeded: 0,\n apiStatus: null,\n apiResponse: null,\n kickStatus: null,\n kickResponse: null,\n type,\n fileMd5: '',\n memberJoinedAt: null,\n memberAgeHours: null,\n isNewMember24h: false,\n recentMessageCount: 0,\n recentImageCount: 0,\n sameHashImageCount: 0,\n isDormantAccount: false,\n dormantDays: null,\n firstImageAfterDays: null,\n firstLinkAfterDays: null,\n isFirstKnownImage: false,\n isFirstKnownLink: false,\n behaviorSignals: [],\n behaviorStateStored: false,\n imageStateStored: false,\n imageLlmReviewSupport: false,\n imageLlmHighConfidenceDelete: false,\n imageLlmMediumConfidenceDelete: false,\n imageLlmAttempted: false,\n imageLlmSucceeded: false,\n imageLlmStatus: null,\n imageLlmSource: '',\n imageLlmUrlUsed: '',\n imageLlmFileUsed: null,\n imageLlmVerdict: null,\n imageLlmBenignVeto: false,\n};\n\nfunction getPath(obj, path, fallback = null) {\n try { return String(path).split('.').reduce((acc, key) => acc == null ? undefined : acc[key], obj) ?? fallback; } catch (_error) { return fallback; }\n}\nfunction toNonNegativeInt(value) {\n const n = Number(value);\n if (!Number.isFinite(n) || n < 0) return 0;\n return Math.floor(n);\n}\nfunction elapsedDaysSince(value) {\n const ms = Date.parse(String(value || ''));\n if (!Number.isFinite(ms)) return null;\n return Math.max(0, (nowMs - ms) / 864e5);\n}\nfunction isAtLeastDays(value, days) { return typeof value === 'number' && value >= days; }\nfunction pick(items) { return items[Math.floor(Math.random() * items.length)]; }\nfunction fillTemplate(template, values) { return template.replace(/\\{(\\w+)\\}/g, (_, key) => String(values[key] ?? '')); }\nfunction bodyToString(body) {\n if (typeof body === 'string') return body;\n try { return JSON.stringify(body); } catch (_error) { return String(body); }\n}\nfunction summarizeValue(value, limit = 600) {\n const textValue = bodyToString(value);\n return textValue.length > limit ? `${textValue.slice(0, limit)}...` : textValue;\n}\nfunction summarizeResponse(response) {\n return { ok: Boolean(response?.ok), status: response?.status ?? null, statusText: response?.statusText || '', body: summarizeValue(response?.body) };\n}\nfunction compactObject(value) {\n const out = {};\n for (const [key, item] of Object.entries(value || {})) {\n if (item === undefined || item === null || item === '') continue;\n out[key] = item;\n }\n return out;\n}\nfunction unique(items) { return [...new Set((items || []).filter(Boolean))]; }\nfunction summarizeMember(member) {\n if (!member || typeof member !== 'object') return null;\n return compactObject({\n discordUserId: member.discordUserId,\n guildId: member.guildId,\n isMember: member.isMember,\n isOwner: member.isOwner,\n isAdmin: member.isAdmin,\n isModerator: member.isModerator,\n isProtected: member.isProtected,\n joinedAt: member.joinedAt,\n roleNames: Array.isArray(member.roles) ? member.roles.map((role) => role?.name).filter(Boolean) : [],\n });\n}\nasync function apiRequest(method, url, body) {\n const request = { method, headers: { 'Content-Type': 'application/json', 'x-jwt-token': jwtToken } };\n if (body !== undefined) request.body = JSON.stringify(body);\n const response = await fetch(url, request);\n return { ok: response.ok, status: response.status, statusText: response.statusText, body: response.body };\n}\nfunction cleanUrl(value) {\n return String(value || '').trim().replace(/^<|>$/g, '').replace(/[)\\].,;]+$/g, '');\n}\nfunction isHttpUrl(value) { return /^https?:\\/\\//i.test(String(value || '')); }\nfunction looksLikeImageUrl(value) {\n const url = String(value || '');\n return /\\.(?:png|jpe?g|webp|gif)(?:[?#]|$)/i.test(url) || /cdn\\.discordapp\\.com\\/attachments\\//i.test(url) || /media\\.discordapp\\.net\\/attachments\\//i.test(url);\n}\nfunction getImageReviewUrl() {\n const candidates = [\n getPath(file, 'sourceUrl', ''),\n getPath(file, 'metadata.sourceUrl', ''),\n getPath(file, 'metadata.metadata.sourceUrl', ''),\n getPath(file, 'metadata.metadata.originalUrl', ''),\n getPath(file, 'metadata.metadata.remoteUrl', ''),\n getPath(file, 'metadata.metadata.url', ''),\n ];\n const urlsInText = rawText.match(/https?:\\/\\/\\S+/gi) || [];\n for (const candidate of [...candidates, ...urlsInText]) {\n const url = cleanUrl(candidate);\n if (isHttpUrl(url) && looksLikeImageUrl(url)) return url;\n }\n return '';\n}\nfunction getImageReviewFile() {\n const bucket = String(getPath(file, 'bucket', '') || '').trim();\n const fullPath = String(getPath(file, 'fullPath', '') || '').trim();\n if (bucket && fullPath) return { bucket, fullPath };\n return null;\n}\nfunction parseJsonish(value) {\n if (value && typeof value === 'object') return value;\n let raw = String(value || '').trim();\n raw = raw.replace(/^```(?:json)?\\s*/i, '').replace(/```$/i, '').trim();\n const first = raw.indexOf('{');\n const last = raw.lastIndexOf('}');\n if (first >= 0 && last > first) raw = raw.slice(first, last + 1);\n try { return JSON.parse(raw); } catch (_error) { return null; }\n}\nfunction extractWorkflowReturn(body) {\n const workflowReturn = getPath(body, 'data.returnValue', undefined);\n if (workflowReturn !== undefined) return workflowReturn;\n const directReturn = getPath(body, 'returnValue', undefined);\n if (directReturn !== undefined) return directReturn;\n return null;\n}\nfunction normalizeLlmVerdict(value) {\n const parsed = parseJsonish(value);\n if (!parsed || typeof parsed !== 'object') return null;\n return {\n is_spam: parsed.is_spam === true,\n confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0)),\n category: String(parsed.category || 'suspicious').slice(0, 80),\n reason: String(parsed.reason || '').slice(0, 240),\n signals: Array.isArray(parsed.signals) ? parsed.signals.map((item) => String(item).slice(0, 80)).filter(Boolean).slice(0, 8) : [],\n };\n}\nasync function classifyImageWithLlm(imageRef) {\n if (!imageLlmWorkflowId || !imageRef || !jwtToken) return null;\n const payload = {\n caption: rawText.slice(0, 1000),\n member_age_hours: result.memberAgeHours,\n is_new_member_24h: result.isNewMember24h,\n is_dormant_account: result.isDormantAccount,\n dormant_days: result.dormantDays,\n first_image_after_days: result.firstImageAfterDays,\n first_link_after_days: result.firstLinkAfterDays,\n recent_message_count: result.recentMessageCount,\n recent_image_count: result.recentImageCount,\n same_hash_image_count: result.sameHashImageCount,\n text_score: result.textScore,\n matched_rules: result.matchedRules.join(', '),\n behavioral_signals: result.behaviorSignals.join(', '),\n };\n if (typeof imageRef === 'string') {\n payload.image_url = imageRef;\n result.imageLlmSource = 'url';\n result.imageLlmUrlUsed = imageRef;\n } else if (typeof imageRef === 'object' && imageRef.bucket && imageRef.fullPath) {\n payload.image_bucket = imageRef.bucket;\n payload.image_full_path = imageRef.fullPath;\n result.imageLlmSource = 'storage';\n result.imageLlmFileUsed = { bucket: imageRef.bucket, fullPath: imageRef.fullPath };\n } else {\n return null;\n }\n result.imageLlmAttempted = true;\n const response = await apiRequest('POST', `${apiUrl}/dynadata/type/workflows/item/${encodeURIComponent(imageLlmWorkflowId)}/executeFunction/executeManual`, payload);\n result.imageLlmStatus = response.status;\n if (!response.ok || !response.body || response.body.success !== true) {\n await createLog('warn', 'Discord image LLM workflow call failed; continuing with heuristic moderation.', { workflowResponse: summarizeResponse(response) }, ['llm-image', 'workflow-failed']);\n return null;\n }\n const verdict = normalizeLlmVerdict(extractWorkflowReturn(response.body));\n if (!verdict) {\n await createLog('warn', 'Discord image LLM workflow returned an unreadable verdict; continuing with heuristic moderation.', { workflowBody: summarizeValue(response.body) }, ['llm-image', 'bad-verdict']);\n return null;\n }\n result.imageLlmSucceeded = true;\n result.imageLlmVerdict = verdict;\n return verdict;\n}\nfunction buildLogExtra(extraInfo = {}) {\n return compactObject({\n messageId,\n platformMid,\n type,\n discordChannelId: result.discordChannelId,\n discordMessageId: result.discordMessageId,\n discordUserId: result.discordUserId,\n dynadataChannelId,\n authorContactId,\n conversationContactId,\n category: result.category,\n score: result.score,\n textScore: result.textScore,\n imageScore: result.imageScore,\n matchedRules: result.matchedRules,\n memberJoinedAt: result.memberJoinedAt,\n memberAgeHours: result.memberAgeHours,\n isNewMember24h: result.isNewMember24h,\n recentMessageCount: result.recentMessageCount,\n recentImageCount: result.recentImageCount,\n sameHashImageCount: result.sameHashImageCount,\n isDormantAccount: result.isDormantAccount,\n dormantDays: result.dormantDays,\n firstImageAfterDays: result.firstImageAfterDays,\n firstLinkAfterDays: result.firstLinkAfterDays,\n isFirstKnownImage: result.isFirstKnownImage,\n isFirstKnownLink: result.isFirstKnownLink,\n behaviorSignals: result.behaviorSignals,\n behaviorStateStored: result.behaviorStateStored,\n imageLlmReviewSupport: result.imageLlmReviewSupport,\n imageLlmHighConfidenceDelete: result.imageLlmHighConfidenceDelete,\n imageLlmMediumConfidenceDelete: result.imageLlmMediumConfidenceDelete,\n imageLlmAttempted: result.imageLlmAttempted,\n imageLlmSucceeded: result.imageLlmSucceeded,\n imageLlmStatus: result.imageLlmStatus,\n imageLlmSource: result.imageLlmSource,\n imageLlmFileUsed: result.imageLlmFileUsed,\n imageLlmVerdict: result.imageLlmVerdict,\n imageLlmBenignVeto: result.imageLlmBenignVeto,\n textPreview: rawText.slice(0, 180),\n ...extraInfo,\n });\n}\nasync function createLog(logLevel, message, extraInfo = {}, tags = []) {\n if (!jwtToken || !apiUrl) return { ok: false, skipped: true };\n const payload = compactObject({ logLevel, logType: 'assistant_log', message, assistantId, conversationId, contactId: authorContactId || conversationContactId, channelId: dynadataChannelId });\n const fullExtraInfo = buildLogExtra(extraInfo);\n if (Object.keys(fullExtraInfo).length) payload.extraInfo = fullExtraInfo;\n const cleanTags = unique(['discord-spam-moderator', type, ...tags]);\n if (cleanTags.length) payload.tags = cleanTags;\n result.logsAttempted += 1;\n try {\n const response = await apiRequest('POST', `${apiUrl}/dynadata/type/log_entries/create`, { data: payload });\n if (response.ok && response.body && response.body.success === true) result.logsSucceeded += 1;\n return response;\n } catch (error) {\n console.warn('Assistant log creation threw an error.', String(error));\n return { ok: false, status: 0, statusText: 'log_create_error', body: String(error) };\n }\n}\nfunction addSignal(points, label, bucket = 'text') {\n if (bucket === 'image') result.imageScore += points;\n else result.textScore += points;\n result.score += points;\n result.matchedRules.push(label);\n}\nfunction addBehaviorSignal(points, label, bucket = 'text') {\n result.behaviorSignals.push(label);\n addSignal(points, label, bucket);\n}\nfunction includesAny(items) { return items.filter((item) => text.includes(item)); }\n\nif (!['text', 'image'].includes(type) || createdBy !== 'contact' || deletedAt) return result;\nif (type === 'text' && !rawText.trim()) return result;\n\nconst exactPlatformMatch = platformMid.match(/^discord_(\\d+)_(\\d+)(?:_\\d+)?$/);\nif (exactPlatformMatch) {\n result.discordChannelId = exactPlatformMatch[1];\n result.discordMessageId = exactPlatformMatch[2];\n}\nif (!result.discordChannelId && metadataDiscordChannelId) result.discordChannelId = metadataDiscordChannelId;\nresult.fileMd5 = String(getPath(file, 'metadata.md5Hash', '') || '');\n\nconst urlMatches = rawText.match(/https?:\\/\\/\\S+/gi) || [];\nconst hasUrl = urlMatches.length > 0;\nconst hasInvite = /(?:discord(?:app)?\\.com\\/invite|discord\\.gg)\\/[a-z0-9-]+/i.test(rawText);\nconst hasMassMention = /@everyone|@here/i.test(rawText);\nconst inlineMentionCount = (rawText.match(/<@!?\\d+>/g) || []).length;\nconst typedMentionCount = (rawText.match(/@\\w+/g) || []).filter((item) => !['@everyone', '@here'].includes(item.toLowerCase())).length;\nconst mentionCount = inlineMentionCount + typedMentionCount;\nconst manyMentions = hasMassMention || mentionCount >= 5;\nconst suspiciousShortener = /(bit\\.ly|tinyurl\\.com|t\\.co|cutt\\.ly|rebrand\\.ly|tiny\\.cc|goo\\.su)/i.test(rawText);\nconst repeatedCharacters = /(.)\\1{7,}/.test(text);\nconst repeatedWords = /\\b(\\w+)(?:\\s+\\1){4,}\\b/i.test(text);\nconst repeatedChunk = repeatedCharacters || repeatedWords;\nconst upperLetters = rawText.replace(/[^A-Z]/g, '').length;\nconst alphaLetters = rawText.replace(/[^A-Za-z]/g, '').length;\nconst capsRatio = alphaLetters ? upperLetters / alphaLetters : 0;\nconst exclamationCount = (rawText.match(/!/g) || []).length;\nconst scamHits = includesAny(['free nitro', 'nitro free', 'steam gift', 'airdrop', 'wallet connect', 'seed phrase', 'crypto profit', 'double your', 'claim now', 'verify account', 'support team', 'limited time reward']);\nconst adHits = includesAny(['buy now', 'discount', 'promo code', 'cheap', 'followers', 'subscribers', 'boost your server', 'join my server', 'dm me', 'earn money', 'work from home', 'marketplace']);\nconst nsfwHits = includesAny(['onlyfans', '18+', 'nsfw', 'nudes', 'leaks', 'xxx']);\nif (scamHits.length) addSignal(3, `scam:${scamHits[0]}`);\nif (hasInvite) addSignal(2, 'discord_invite');\nif (hasUrl && suspiciousShortener) addSignal(2, 'shortener_link');\nif (adHits.length) addSignal(2, `advertising:${adHits[0]}`);\nif (nsfwHits.length) addSignal(3, `nsfw:${nsfwHits[0]}`);\nif (manyMentions) addSignal(2, 'mass_mentions');\nif (urlMatches.length >= 2) addSignal(1, 'multiple_links');\nif (repeatedChunk) addSignal(2, 'repeated_text');\nif (capsRatio > 0.6 && rawText.length > 30) addSignal(1, 'mostly_caps');\nif (exclamationCount >= 5) addSignal(1, 'excessive_exclamations');\nif (rawText.length > 300 && (hasUrl || hasInvite)) addSignal(1, 'long_promo_message');\n\nlet textCategory = 'not_spam';\nif (nsfwHits.length && (hasUrl || hasInvite || manyMentions)) textCategory = 'nsfw_spam';\nelse if (scamHits.length && (hasUrl || hasInvite || suspiciousShortener)) {\n const phishingTerms = ['wallet connect', 'seed phrase', 'verify account', 'support team', 'crypto profit', 'airdrop'];\n textCategory = scamHits.some((item) => phishingTerms.includes(item)) ? 'phishing_scam' : 'malicious_link';\n} else if (manyMentions && (hasUrl || hasInvite || adHits.length)) textCategory = 'mass_mention_spam';\nelse if (hasInvite && (adHits.length || manyMentions || result.textScore >= 4)) textCategory = 'invite_spam';\nelse if (adHits.length && (hasUrl || hasInvite || rawText.length > 80)) textCategory = 'unsolicited_advertising';\nelse if (repeatedChunk && (hasUrl || manyMentions || rawText.length > 120)) textCategory = 'repeated_message';\nresult.category = textCategory;\n\nlet latestContact = null;\nif (authorContactId && jwtToken) {\n const contactResponse = await apiRequest('GET', `${apiUrl}/dynadata/type/contacts/item/${encodeURIComponent(authorContactId)}`);\n if (contactResponse.ok && contactResponse.body && contactResponse.body.success === true && contactResponse.body.item) {\n latestContact = contactResponse.body.item;\n result.discordUserId = String(latestContact.contactIdentification || '').trim();\n result.memberJoinedAt = getPath(latestContact, 'discordMemberInfo.joinedAt', null);\n }\n}\nlet member = null;\nif (dynadataChannelId && result.discordUserId && jwtToken) {\n const memberLookupResponse = await apiRequest('POST', `${apiUrl}/dynadata/type/channels/item/${encodeURIComponent(dynadataChannelId)}/executeFunction/getDiscordMember`, { discordUserId: result.discordUserId });\n result.memberLookupAttempted = true;\n result.memberLookupStatus = memberLookupResponse.status;\n const memberPayload = getPath(memberLookupResponse.body, 'data.data', null);\n if (memberLookupResponse.ok && memberLookupResponse.body && memberLookupResponse.body.success === true && memberPayload) {\n member = memberPayload;\n result.memberJoinedAt = getPath(member, 'joinedAt', result.memberJoinedAt);\n }\n}\nif (result.memberJoinedAt) {\n const joinedAtMs = Date.parse(result.memberJoinedAt);\n if (Number.isFinite(joinedAtMs)) {\n result.memberAgeHours = Math.max(0, (nowMs - joinedAtMs) / 36e5);\n result.isNewMember24h = result.memberAgeHours < newMemberHours;\n }\n}\n\nif (latestContact) {\n const ca = latestContact.customAttributes || {};\n const behaviorBucket = type === 'image' ? 'image' : 'text';\n const lastInboundDays = elapsedDaysSince(ca.discordLastInboundAt);\n if (lastInboundDays !== null) {\n result.dormantDays = lastInboundDays;\n result.isDormantAccount = lastInboundDays >= dormantAccountDays;\n }\n\n const lastMessageWindowAtMs = Date.parse(ca.discordMessageWindowAt || '');\n const isFreshMessageWindow = Number.isFinite(lastMessageWindowAtMs) && (nowMs - lastMessageWindowAtMs) <= messageWindowMs;\n const previousMessageCount = isFreshMessageWindow ? toNonNegativeInt(ca.discordMessageCount5m || 0) : 0;\n result.recentMessageCount = previousMessageCount + 1;\n if (result.recentMessageCount >= 5) addBehaviorSignal(2, 'message_burst_5m', behaviorBucket);\n\n const contactStateData = {\n 'customAttributes.discordLastInboundAt': nowIso,\n 'customAttributes.discordMessageWindowAt': nowIso,\n 'customAttributes.discordMessageCount5m': String(result.recentMessageCount),\n };\n\n if (hasUrl || hasInvite) {\n const lastLinkDays = elapsedDaysSince(ca.discordLastLinkAt);\n result.isFirstKnownLink = lastLinkDays === null;\n result.firstLinkAfterDays = lastLinkDays;\n if (result.isFirstKnownLink) addBehaviorSignal(1, 'first_known_link', behaviorBucket);\n else if (isAtLeastDays(lastLinkDays, staleBehaviorDays)) addBehaviorSignal(1, 'first_link_after_30d', behaviorBucket);\n if (result.isDormantAccount) addBehaviorSignal(1, 'dormant_account_link', behaviorBucket);\n contactStateData['customAttributes.discordLastLinkAt'] = nowIso;\n }\n\n if (type === 'image') {\n const lastImageAtMs = Date.parse(ca.discordImageWindowAt || '');\n const isFreshWindow = Number.isFinite(lastImageAtMs) && (nowMs - lastImageAtMs) <= imageWindowMs;\n const previousImageCount = isFreshWindow ? toNonNegativeInt(ca.discordImageCount5m || 0) : 0;\n const previousHash = isFreshWindow ? String(ca.discordLastImageHash || '') : '';\n const previousSameHashCount = isFreshWindow ? toNonNegativeInt(ca.discordSameImageHashCount5m || 0) : 0;\n result.recentImageCount = previousImageCount + 1;\n result.sameHashImageCount = result.fileMd5 && previousHash === result.fileMd5 ? previousSameHashCount + 1 : 1;\n\n const lastImageSeenDays = elapsedDaysSince(ca.discordLastImageAt);\n result.isFirstKnownImage = lastImageSeenDays === null;\n result.firstImageAfterDays = lastImageSeenDays;\n\n if (result.isNewMember24h) addSignal(2, 'new_member_lt_24h', 'image');\n if (result.isDormantAccount) addBehaviorSignal(1, 'dormant_account_image', 'image');\n if (result.isFirstKnownImage) addBehaviorSignal(1, 'first_known_image', 'image');\n else if (isAtLeastDays(lastImageSeenDays, staleBehaviorDays)) addBehaviorSignal(1, 'first_image_after_30d', 'image');\n if (result.recentImageCount >= 3) addSignal(3, 'image_burst_5m', 'image');\n if (result.sameHashImageCount >= 2) addSignal(3, 'repeated_image_hash_5m', 'image');\n\n contactStateData['customAttributes.discordImageWindowAt'] = nowIso;\n contactStateData['customAttributes.discordImageCount5m'] = String(result.recentImageCount);\n contactStateData['customAttributes.discordLastImageHash'] = result.fileMd5;\n contactStateData['customAttributes.discordSameImageHashCount5m'] = String(result.sameHashImageCount);\n contactStateData['customAttributes.discordLastImageAt'] = nowIso;\n }\n\n const behaviorStateResponse = await apiRequest('POST', `${apiUrl}/dynadata/type/contacts/update`, {\n filter: { _id: authorContactId },\n data: contactStateData,\n });\n result.behaviorStateStored = behaviorStateResponse.ok && behaviorStateResponse.body && behaviorStateResponse.body.success === true;\n if (type === 'image') result.imageStateStored = result.behaviorStateStored;\n}\n\nif (type === 'image') {\n const imageReviewUrl = getImageReviewUrl();\n const imageReviewFile = getImageReviewFile();\n const imageReviewTarget = imageReviewUrl || imageReviewFile;\n const behaviorReviewSignal = result.isDormantAccount || result.isFirstKnownImage || isAtLeastDays(result.firstImageAfterDays, staleBehaviorDays) || result.isFirstKnownLink || isAtLeastDays(result.firstLinkAfterDays, staleBehaviorDays) || result.recentMessageCount >= 4;\n const hasImageReviewSupport = result.score >= 2 || result.textScore >= 2 || result.isNewMember24h || result.recentImageCount >= 2 || result.sameHashImageCount >= 2 || behaviorReviewSignal;\n result.imageLlmReviewSupport = Boolean(hasImageReviewSupport);\n const shouldAskLlm = Boolean(imageReviewTarget) && hasImageReviewSupport;\n if (shouldAskLlm) {\n const verdict = await classifyImageWithLlm(imageReviewTarget);\n if (verdict && verdict.is_spam && verdict.confidence >= llmHighConfidenceThreshold) {\n result.imageLlmHighConfidenceDelete = true;\n addSignal(5, `llm_image_high_confidence_spam:${verdict.category}`, 'image');\n result.category = `llm_image_${verdict.category}_spam`;\n } else if (verdict && verdict.is_spam && verdict.confidence >= llmMediumConfidenceThreshold && hasImageReviewSupport) {\n result.imageLlmMediumConfidenceDelete = true;\n addSignal(4, `llm_image_spam:${verdict.category}`, 'image');\n result.category = `llm_image_${verdict.category}_spam`;\n } else if (verdict && verdict.is_spam && verdict.confidence >= 0.6 && result.score >= 2) {\n addSignal(2, `llm_image_suspicious:${verdict.category}`, 'image');\n result.category = `llm_image_${verdict.category}_suspected`;\n } else if (verdict && verdict.is_spam === false && verdict.confidence >= 0.7) {\n const strongBehaviorDelete = result.isNewMember24h && (result.sameHashImageCount >= 2 || result.recentImageCount >= 4);\n result.imageLlmBenignVeto = !strongBehaviorDelete && textCategory === 'not_spam';\n }\n } else if (result.score >= 2 || result.matchedRules.length >= 2) {\n await createLog('debug', 'Discord image was suspicious but had no usable image URL or storage ref for LLM review.', { imageReviewUrlFound: Boolean(imageReviewUrl), imageReviewFileFound: Boolean(imageReviewFile) }, ['llm-image', 'no-media']);\n }\n}\n\nif (type === 'image' && result.category.startsWith('llm_image_')) {\n // LLM category already selected above.\n} else if (type === 'image' && textCategory !== 'not_spam') result.category = `image_caption_${textCategory}`;\nelse if (type === 'image' && result.sameHashImageCount >= 2) result.category = 'repeated_image_spam';\nelse if (type === 'image' && result.recentImageCount >= 3) result.category = 'image_burst_spam';\nelse if (type === 'image' && result.isNewMember24h) result.category = 'new_member_image';\n\nconst textDelete = textCategory !== 'not_spam' && result.textScore >= 4;\nconst imageDelete = type === 'image' && !result.imageLlmBenignVeto && (textDelete || result.score >= 5 || result.imageLlmHighConfidenceDelete || result.imageLlmMediumConfidenceDelete);\nconst shouldDelete = type === 'text' ? textDelete : imageDelete;\nresult.shouldDelete = shouldDelete;\nresult.reason = shouldDelete\n ? `Matched ${result.category} with score ${result.score}: ${result.matchedRules.join(', ')}`\n : (result.matchedRules.length ? `Signals seen below delete threshold: ${result.matchedRules.join(', ')}` : 'No spam signals detected.');\nif (result.imageLlmBenignVeto) result.reason = `LLM image review marked the image benign; deletion vetoed. ${result.reason}`;\n\nif (!shouldDelete) {\n if (result.score >= 2 || result.matchedRules.length >= 2 || result.imageLlmAttempted) {\n await createLog('debug', 'Discord spam candidate below delete threshold.', { reason: result.reason }, ['candidate', 'below-threshold']);\n }\n return result;\n}\n\nif (!dynadataChannelId) {\n await createLog('error', 'Spam moderation skipped because the dynadata channel ID is missing.', {}, ['skip', 'missing-channel-id']);\n return result;\n}\nif (!jwtToken) return result;\nif (!authorContactId) {\n await createLog('error', 'Spam moderation skipped because the author contact ID is missing.', {}, ['skip', 'missing-author-contact']);\n return result;\n}\nif (!latestContact) {\n await createLog('error', 'Spam moderation skipped because author contact lookup failed.', {}, ['skip', 'contact-lookup-failed']);\n return result;\n}\nconst resolvedContactName = String(latestContact.name || fallbackContactName || 'amigo').trim();\nif (!result.discordUserId) {\n await createLog('warn', 'Spam moderation skipped because the author has no Discord user ID on contactIdentification.', { resolvedContactName }, ['skip', 'missing-discord-user-id']);\n return result;\n}\nif (!member) {\n await createLog('error', 'Spam moderation skipped because Discord member protection lookup failed.', { resolvedContactName }, ['skip', 'member-lookup-failed']);\n return result;\n}\nif (member.isMember !== true) {\n await createLog('warn', 'Spam moderation skipped because the Discord user is not an active guild member.', { member: summarizeMember(member), resolvedContactName }, ['skip', 'member-not-found']);\n return result;\n}\nif (member.isProtected === true || member.isAdmin === true || member.isModerator === true || member.isOwner === true) {\n result.protectedSkip = true;\n await createLog('info', 'Spam moderation skipped for a protected Discord member.', { member: summarizeMember(member), resolvedContactName }, ['skip', 'protected-member']);\n return result;\n}\nif (!result.discordChannelId || !result.discordMessageId) {\n await createLog('error', 'Spam candidate could not be moderated because Discord message identifiers are missing.', { member: summarizeMember(member) }, ['skip', 'missing-message-identifiers']);\n return result;\n}\n\nconst deleteResponse = await apiRequest('POST', `${apiUrl}/dynadata/type/channels/item/${encodeURIComponent(dynadataChannelId)}/executeFunction/deleteDiscordMessage`, {\n discordChannelId: result.discordChannelId,\n discordMessageId: result.discordMessageId,\n});\nresult.deleteAttempted = true;\nresult.apiStatus = deleteResponse.status;\nresult.apiResponse = deleteResponse.body;\nif (!deleteResponse.ok || !deleteResponse.body || deleteResponse.body.success !== true) {\n await createLog('error', 'Discord spam delete failed.', { deleteResponse: summarizeResponse(deleteResponse), member: summarizeMember(member) }, ['delete', 'error']);\n throw new Error(`Moderation API returned HTTP ${deleteResponse.status} ${deleteResponse.statusText}: ${bodyToString(deleteResponse.body)}`);\n}\nresult.deleteSucceeded = true;\n\nconst currentWarnings = toNonNegativeInt(latestContact?.customAttributes?.discordSpamWarnings ?? 0);\nconst newWarnings = Math.min(currentWarnings + 1, warningThreshold);\nresult.warningCount = newWarnings;\nconst updateResponse = await apiRequest('POST', `${apiUrl}/dynadata/type/contacts/update`, {\n filter: { _id: authorContactId },\n data: {\n 'customAttributes.discordSpamWarnings': String(newWarnings),\n 'customAttributes.discordLastSpamAt': nowIso,\n 'customAttributes.discordLastSpamCategory': result.category,\n 'customAttributes.discordLastDeletedMessageId': result.discordMessageId,\n 'customAttributes.discordLastDeletedChannelId': result.discordChannelId,\n 'customAttributes.discordLastModerationAction': newWarnings >= warningThreshold ? 'kick' : 'warn',\n },\n});\nif (!updateResponse.ok || !updateResponse.body || updateResponse.body.success !== true) {\n await createLog('error', 'Discord spam was deleted but warning persistence failed.', { updateResponse: summarizeResponse(updateResponse), member: summarizeMember(member), warningCount: newWarnings }, ['delete', 'warning-update-failed']);\n throw new Error(`Could not persist spam warning count: HTTP ${updateResponse.status} ${updateResponse.statusText} ${bodyToString(updateResponse.body)}`);\n}\nresult.warningStored = true;\n\nconst mention = result.discordUserId ? `<@${result.discordUserId}>` : resolvedContactName;\nconst selectedTemplate = newWarnings >= warningThreshold ? pick(kickTemplates) : pick(warningTemplates);\nconst warningText = fillTemplate(selectedTemplate, { mention, warning: newWarnings, limit: warningThreshold, nome: resolvedContactName });\nresult.warningText = warningText;\nif (conversationContactId) {\n const sendWarningResponse = await apiRequest('POST', `${apiUrl}/dynadata/type/contacts/item/${encodeURIComponent(conversationContactId)}/executeFunction/sendMessage`, {\n type: 'text',\n body: { text: warningText },\n });\n result.warningMessageAttempted = true;\n result.warningMessageSucceeded = sendWarningResponse.ok && sendWarningResponse.body && sendWarningResponse.body.success === true;\n}\nif (newWarnings >= warningThreshold) {\n const kickResponse = await apiRequest('POST', `${apiUrl}/dynadata/type/channels/item/${encodeURIComponent(dynadataChannelId)}/executeFunction/kickDiscordUser`, {\n discordUserId: result.discordUserId,\n reason: `AutoTalk anti-spam: ${newWarnings}/${warningThreshold} warnings (${result.category})`,\n });\n result.kickAttempted = true;\n result.kickStatus = kickResponse.status;\n result.kickResponse = kickResponse.body;\n if (!kickResponse.ok || !kickResponse.body || kickResponse.body.success !== true) {\n await createLog('error', 'Discord spam was deleted and warning stored, but the kick failed.', { kickResponse: summarizeResponse(kickResponse), member: summarizeMember(member), warningCount: newWarnings, warningMessageSucceeded: result.warningMessageSucceeded }, ['delete', 'kick-failed']);\n throw new Error(`Kick API returned HTTP ${kickResponse.status} ${kickResponse.statusText}: ${bodyToString(kickResponse.body)}`);\n }\n result.kickSucceeded = true;\n await createLog('warn', 'Deleted spam Discord message, incremented warnings, and kicked the member.', { member: summarizeMember(member), warningCount: newWarnings, warningMessageSucceeded: result.warningMessageSucceeded }, ['delete', 'warn', 'kick']);\n return result;\n}\nawait createLog('warn', 'Deleted spam Discord message and incremented the warning count.', { member: summarizeMember(member), warningCount: newWarnings, warningMessageSucceeded: result.warningMessageSucceeded }, ['delete', 'warn']);\nreturn result;",
"input": {
"expr": "{\n \"assistantId\": \"<assistant-id>\",\n \"conversationId\": conversation._id,\n \"messageId\": get(contactMessage, '_id', ''),\n \"type\": get(contactMessage, 'type', ''),\n \"text\": get(contactMessage, 'body.text', ''),\n \"file\": get(contactMessage, 'body.file', null),\n \"platformMid\": get(contactMessage, 'platformMid', ''),\n \"createdBy\": get(contactMessage, 'createdBy', ''),\n \"deletedAt\": get(contactMessage, 'deletedAt', null),\n \"metadataDiscordChannelId\": get(contactMessage, 'metadata.discord.channelId', ''),\n \"dynadataChannelId\": conversation.channelId,\n \"jwt\": step(0).jwt,\n \"apiUrl\": 'https://api.autotalk.io/v1',\n \"imageLlmWorkflowId\": \"<linked-workflow-id>\",\n \"authorContactId\": get(contactMessage, 'authorContactId', ''),\n \"conversationContactId\": get(contactMessage, 'contactId', ''),\n \"contactName\": get(contact, 'name', '')\n}",
"onFailure": "throw",
"fallback": "",
"resultType": "any",
"strict": false,
"useSimpleCel": false
},
"purpose": "Classify Discord text and image messages, call the image spam LLM workflow for suspicious images with usable URLs or private AutoTalk storage refs, use member age and image burst heuristics, delete high-risk spam through the AutoTalk public API, warn offenders and kick after three warnings",
"timeout": 30000,
"memoryLimit": 128
}
}
}
]
}
},
"options": {
"log_level": "info",
"listenMessagesContaining": []
}
}
}
TipoAgent · actor
Reglas2
Canaldiscord
Versión1.0.0
Descargar ZIP
discord-spam-moderator11.2 KB
En esta página