Discord Spam Moderator
Actor agent that moderates Discord text and image spam with heuristics, an image LLM workflow, behavioral signals for compromised older accounts, warnings, and repeat-offender kicks.
Download ZIP · 11.2 KB
Included Dependencies
The downloadable ZIP includes the companion workflow below. Because the agent stores the workflow ID inside a JavaScript/CEL input string, set imageLlmWorkflowId to the imported workflow ID after import.
Workflows: Discord Image Spam LLM Classifier
Import this workflow and set imageLlmWorkflowId in the agent input to the imported workflow ID.
How it works
Rule 1 mints a short-lived JWT via actions/security/auth/jwt/generate (default 300 s lifetime). Rule 2 only runs for contact-created Discord text and image messages when that JWT is present: it launches actions/code/javascript/execute with the token piped in through CEL input.
The JavaScript classifier combines text heuristics, image behavior signals, Discord member protection checks, and the linked image LLM workflow:
- Text heuristics detect scam keywords, invite links, shorteners, mass mentions, repeated chunks, caps ratio, and promotional/NSFW terms.
- Image moderation uses private AutoTalk storage refs or Discord image URLs and calls the linked workflow when there are lightweight risk signals.
- Behavioral baselines on
contact.customAttributestrack last inbound activity, image/link recency, five-minute message/image windows, repeated image hashes, and spam warnings. - Older or compromised-account patterns such as dormant-account image posts, first image after 30 days, first known link, message bursts, and repeated image hashes can trigger LLM review.
- High-confidence image spam from the LLM is deleted even when the Discord member is not new; benign LLM verdicts veto behavior-only image deletion.
- Owners, admins, moderators, protected members, and users no longer in the guild are skipped before any destructive moderation action.
When the category crosses the delete threshold, the script calls back into the AutoTalk API with the JWT in the x-jwt-token header:
GET /v1/dynadata/type/contacts/item/{authorContactId}to fetch the offender's contact record.POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/getDiscordMemberto skip owners/admins/moderators/protected members.POST /v1/dynadata/type/workflows/item/{workflowId}/executeFunction/executeManualto classify suspicious images.POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/deleteDiscordMessageto remove the offending post.POST /v1/dynadata/type/contacts/updateto update behavioral baselines and incrementcustomAttributes.discordSpamWarnings.POST /v1/dynadata/type/contacts/item/{conversationContactId}/executeFunction/sendMessageto warn the offender.POST /v1/dynadata/type/channels/item/{channelId}/executeFunction/kickDiscordUseron the third strike.POST /v1/dynadata/type/log_entries/createfor moderation decisions and skip reasons.
For the x-jwt-token header spec itself (and the alternative x-api-key flow used by external integrations), see the API Reference.
Full JSON
Click to expand
{
"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": []
}
}
}