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
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). El paso se ejecuta en modo avanzado y solicita una lista explícita de scopes — data:read, data:write, messaging:send, workflows:run, org.channels:admin — para que el token quede acotado exactamente a las llamadas listadas abajo, en lugar de llevar autoridad total sobre la empresa. La regla 2 solo se ejecuta para mensajes Discord text e image creados por contactos cuando ese JWT está presente: lanza actions/code/execute (language: "javascript") 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.customAttributesregistran ú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. El scope que exige cada llamada es exactamente lo que pide el paso del JWT:
| Llamada | Propósito | Scope |
|---|---|---|
GET /v1/dynadata/type/contacts/item/{authorContactId} | Obtener el registro de contacto del infractor | data:read |
POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/getDiscordMember | Omitir dueños/admins/moderadores/miembros protegidos | data:read |
POST /v1/dynadata/type/workflows/item/{workflowId}/executeFunction/executeManual | Clasificar imágenes sospechosas | workflows:run |
POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/deleteDiscordMessage | Eliminar la publicación infractora | org.channels:admin |
POST /v1/dynadata/type/contacts/update | Actualizar baselines de comportamiento e incrementar customAttributes.discordSpamWarnings | data:write |
POST /v1/dynadata/type/contacts/item/{conversationContactId}/executeFunction/sendMessage | Advertir al infractor | messaging:send |
POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/kickDiscordUser | Expulsar en la tercera infracción | org.channels:admin |
org.channels:admin está en la lista únicamente por deleteDiscordMessage y kickDiscordUser. Si adaptas este ejemplo a algo que solo detecta y advierte, elimina esas dos llamadas y elimina org.channels:admin con ellas.
log_entries es de solo lectura a través de la APIEl script también llama a POST /v1/dynadata/type/log_entries/create para registrar decisiones de moderación y motivos de omisión. Esa llamada es rechazada. Las escrituras en log_entries se deniegan en /v1 y en el servidor MCP para cualquier llamador — token con scopes, clave heredada sin scopes o JWT — con 403 y code: "log_entry_writes_not_allowed". Ningún scope lo concede; las lecturas no se ven afectadas.
El ejemplo está escrito para que esto sea inofensivo: createLog() nunca lanza error, su resultado solo se contabiliza en logsAttempted / logsSucceeded, y ninguna decisión de moderación depende de él. La moderación funciona igual aunque todas las llamadas de log fallen. Si quieres tu propio rastro duradero, escribe en un tipo de Dynadata propio (un custom type, por ejemplo) en lugar de log_entries, o consulta los logs del propio agente en el panel.
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. The JWT step requests an EXPLICIT scope list (advanced mode) instead of the historical full-tenant token: data:read + data:write for the contact read/update calls, messaging for contacts.sendMessage, workflows:run for workflows.executeManual, and admin for channels.deleteDiscordMessage/kickDiscordUser — those two are the only reason admin appears, so drop it if you remove the delete/kick calls. Its agent_log writes to log_entries are refused on /v1 by design (log_entries is read-only through the API); the code already treats a failed log as non-fatal.",
"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",
"agentType": "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": true,
"duration": 300,
"ips": [],
"scopes": [
"data:read",
"data:write",
"messaging:send",
"workflows:run",
"org.channels:admin"
]
}
}
},
{
"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/execute",
"actions/code/execute": {
"language": "javascript",
"code": "const rawText = String(input?.text || '');\nconst text = rawText.toLowerCase().replace(/\\s+/g, ' ').trim();\nconst agentId = String(input?.agentId || '');\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: 'agent_log', message, agentId, 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('Agent 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 \"agentId\": \"<agent-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": []
}
}
}