Pular para o conteúdo principal
ExemploAvançado4 min de leituraAtualizado em Jul 2, 2026
agentactordiscordmoderationspamimagellmjavascriptjwtx-jwt-token

Discord Spam Moderator

Agente actor que modera spam de texto e imagem no Discord com heurísticas, um workflow LLM para imagens, sinais comportamentais de contas antigas comprometidas, avisos e expulsão de reincidentes.

Baixar ZIP · 11.2 KB

Dependências incluídas

informação

O ZIP para download inclui o workflow complementar abaixo. Como o agente guarda o ID do workflow dentro de uma string JavaScript/CEL, defina imageLlmWorkflowId com o ID do workflow importado depois da importação.

Workflows: Discord Image Spam LLM Classifier

Importe esse workflow e defina imageLlmWorkflowId no input do agente com o ID do workflow importado.

Como funciona

A regra 1 gera um JWT de curta duração via actions/security/auth/jwt/generate (tempo de vida padrão de 300 s). A regra 2 roda apenas para mensagens Discord text e image criadas por contatos quando esse JWT está presente: ela executa actions/code/javascript/execute com o token passado pelo input CEL.

O classificador JavaScript combina heurísticas de texto, sinais comportamentais de imagem, checagens de proteção do membro no Discord e o workflow LLM de imagem vinculado:

  • Heurísticas de texto detectam palavras de golpe, convites, encurtadores, menções em massa, trechos repetidos, proporção de caixa alta e termos promocionais/NSFW.
  • A moderação de imagem usa referências privadas de storage do AutoTalk ou URLs de imagem do Discord e chama o workflow vinculado quando existem sinais leves de risco.
  • Baselines em contact.customAttributes acompanham última atividade recebida, recência de imagem/link, janelas de cinco minutos para mensagens/imagens, hashes de imagem repetidos e avisos de spam.
  • Padrões de conta antiga ou comprometida, como imagem após período dormente, primeira imagem após 30 dias, primeiro link conhecido, rajadas de mensagens e hashes repetidos, podem acionar revisão por LLM.
  • Spam de imagem com alta confiança pelo LLM é apagado mesmo quando o membro do Discord não é novo; vereditos benignos do LLM vetam exclusão baseada só em comportamento.
  • Donos, admins, moderadores, membros protegidos e usuários que não estão mais no servidor são ignorados antes de qualquer ação destrutiva.

Quando a categoria ultrapassa o limiar de exclusão, o script chama a API do AutoTalk com o JWT no cabeçalho x-jwt-token:

  • GET /v1/dynadata/type/contacts/item/{authorContactId} para buscar o cadastro do contato infrator.
  • POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/getDiscordMember para pular donos/admins/moderadores/membros protegidos.
  • POST /v1/dynadata/type/workflows/item/{workflowId}/executeFunction/executeManual para classificar imagens suspeitas.
  • POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/deleteDiscordMessage para remover a mensagem infratora.
  • POST /v1/dynadata/type/contacts/update para atualizar baselines comportamentais e incrementar customAttributes.discordSpamWarnings.
  • POST /v1/dynadata/type/contacts/item/{conversationContactId}/executeFunction/sendMessage para avisar o infrator.
  • POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/kickDiscordUser na terceira ocorrência.
  • POST /v1/dynadata/type/log_entries/create para decisões de moderação e motivos de skip.

Para a especificação do cabeçalho x-jwt-token em si (e o fluxo alternativo x-api-key usado por integrações externas), veja a Referência da API.

JSON Completo

Clique 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
Regras2
Canaldiscord
Versão1.0.0
Baixar ZIP
discord-spam-moderator11.2 KB
Nesta página