|
| 1 | +name: Anti-Spam Comment Moderator |
| 2 | + |
| 3 | +on: |
| 4 | + issue_comment: |
| 5 | + types: [created, edited] |
| 6 | + pull_request_review_comment: |
| 7 | + types: [created, edited] |
| 8 | + |
| 9 | +permissions: |
| 10 | + issues: write # needed to delete issue comments |
| 11 | + pull-requests: write # needed to delete PR review comments |
| 12 | + contents: write # needed to delete commit comments |
| 13 | + # (discussions not handled here; API differs) |
| 14 | + |
| 15 | +jobs: |
| 16 | + moderate: |
| 17 | + if: ${{ github.event.action == 'created' || github.event.action == 'edited' }} |
| 18 | + runs-on: ubuntu-latest |
| 19 | + steps: |
| 20 | + - name: Run spam filter |
| 21 | + uses: actions/github-script@v7 |
| 22 | + with: |
| 23 | + script: | |
| 24 | + // 1) Collect event/comment info |
| 25 | + const ev = context.eventName; |
| 26 | + const comment = context.payload.comment || {}; |
| 27 | + const body = (comment.body || "").trim(); |
| 28 | + const bodyLower = body.toLowerCase(); |
| 29 | + const assoc = comment.author_association || "NONE"; |
| 30 | + const actor = comment.user?.login || "unknown"; |
| 31 | + const owner = context.repo.owner; |
| 32 | + const repo = context.repo.repo; |
| 33 | +
|
| 34 | + // Block specific user outright |
| 35 | + if ((actor || "").toLowerCase() === "phuole818") { |
| 36 | + try { |
| 37 | + if (ev === "issue_comment") { |
| 38 | + await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); |
| 39 | + core.notice(`Deleted comment from blocked user @${actor} (issue comment).`); |
| 40 | + } else if (ev === "pull_request_review_comment") { |
| 41 | + await github.rest.pulls.deleteReviewComment({ owner, repo, comment_id: comment.id }); |
| 42 | + core.notice(`Deleted comment from blocked user @${actor} (PR review comment).`); |
| 43 | + } else if (ev === "commit_comment") { |
| 44 | + await github.rest.repos.deleteCommitComment({ owner, repo, comment_id: comment.id }); |
| 45 | + core.notice(`Deleted comment from blocked user @${actor} (commit comment).`); |
| 46 | + } else { |
| 47 | + core.warning(`Unhandled event while blocking user: ${ev}`); |
| 48 | + } |
| 49 | + } catch (err) { |
| 50 | + core.setFailed(`Failed to delete blocked user's comment: ${err?.message || err}`); |
| 51 | + } |
| 52 | + return; |
| 53 | + } |
| 54 | +
|
| 55 | + // 2) Skip trusted roles or explicitly allowed text |
| 56 | + const trustedRoles = new Set(["OWNER","MEMBER","COLLABORATOR"]); |
| 57 | + if (trustedRoles.has(assoc)) { |
| 58 | + core.info(`Skipping trusted author (${assoc}) @${actor}`); |
| 59 | + return; |
| 60 | + } |
| 61 | + if (/#allow|#nospamfilter/i.test(body)) { |
| 62 | + core.info("Skipping due to explicit allow tag in comment."); |
| 63 | + return; |
| 64 | + } |
| 65 | +
|
| 66 | + // 3) Heuristic + sentiment-lite checks |
| 67 | + const linkCount = (body.match(/https?:\/\/|www\./gi) || []).length; |
| 68 | + const emailCount = (body.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || []).length; |
| 69 | + const phoneCount = (body.match(/(\+?\d[\d\s().-]{8,}\d)/g) || []).length; |
| 70 | + const mentions = (body.match(/@\w{1,39}/g) || []).length; |
| 71 | + const exclaimBlk = /!{3,}/.test(body); |
| 72 | + const repeatedChr = /(.)\1{6,}/.test(body); |
| 73 | + const shortened = /https?:\/\/(?:bit\.ly|t\.co|tinyurl\.com|goo\.gl|ow\.ly)\//i.test(body); |
| 74 | +
|
| 75 | + const lettersOnly = body.replace(/\s/g, ""); |
| 76 | + const uniqueRatio = lettersOnly.length ? (new Set(lettersOnly).size / lettersOnly.length) : 1; |
| 77 | + const lowUnique = lettersOnly.length > 80 && uniqueRatio < 0.30; |
| 78 | +
|
| 79 | + // English/ASCII spam terms (word-boundary safe) |
| 80 | + const blacklistAscii = [ |
| 81 | + "whatsapp","telegram","crypto","forex","investment","binary options","broker", |
| 82 | + "dm me","contact me","private message","girls","porn","xxx","nude","sex", |
| 83 | + "loan approval","free followers","click here","visit my profile","earn $","% off", |
| 84 | + "sugar daddy","promo code","join my group","passive income","weixin","vx","wx" |
| 85 | + ]; |
| 86 | + // Chinese/CJK spam phrases (substring match; \b doesn't work for CJK) |
| 87 | + const blacklistCJK = [ |
| 88 | + "微信","加我微信","添加微信","VX","V信","私信","联系我","电报","比特币","加密货币","外汇","投资","理财","二元期权", |
| 89 | + "裸聊","色情","黄片","成人网站","约炮","兼职","推广","优惠","促销","关注我","点击这里","访问我的主页","我的主页", |
| 90 | + "加入群","交流群","被动收入","糖爹","金主","优惠码","贷款","快速贷款","网贷","免费粉丝","粉丝增长", |
| 91 | + "赚快钱","快速赚钱","轻松赚钱","保证收益","零风险","无风险","稳赚","返利","优惠券" |
| 92 | + ]; |
| 93 | + const asciiHit = blacklistAscii.some(k => new RegExp(`\\b${k.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\b`, "i").test(body)); |
| 94 | + const cjkHit = blacklistCJK.some(k => body.includes(k)); |
| 95 | + const keywordHit = asciiHit || cjkHit; |
| 96 | + const hype = /(100%|guarantee|risk[- ]?free|no (fees|risk)|quick money|make money)/i.test(body) || |
| 97 | + /(保证|无风险|零风险|快速赚钱|轻松赚钱|立即联系|添加微信|加我微信|稳赚|包赚)/.test(body); |
| 98 | +
|
| 99 | + // Attack/Insult/Tech-context term lists (EN + CJK) |
| 100 | + const attackTermsAscii = [ |
| 101 | + "fake stars","astroturf","bot accounts","paid stars","star farming","star boosting","shill", |
| 102 | + "manipulated stars","kpi","kpi boosting","no maintainer","ignore issues","ignore prs", |
| 103 | + "close pr","close issue","no response","waste of time","trash project","scam project", |
| 104 | + "archive this project","unmaintained","low quality docs","unreadable docs","pitfall","avoid this project" |
| 105 | + ]; |
| 106 | + const attackTermsCJK = [ |
| 107 | + "刷星","水军","kpi刷单","假号","买粉","造假","刷榜", |
| 108 | + "别踩坑","大坑","浪费时间","赶紧换","不靠谱","建议归档","建议archive", |
| 109 | + "没人理你","没人管","装没看见","秒关","石沉大海", |
| 110 | + "问题一大堆","一塌糊涂","堪忧","离谱","看不懂","入不了门", |
| 111 | + "警告","大踩雷","失望透顶","全靠刷星","社区大踩雷" |
| 112 | + ]; |
| 113 | + const insultTermsAscii = [ |
| 114 | + "trash","garbage","bullshit","idiot","moron","stupid","dumb","shameful","useless" |
| 115 | + ]; |
| 116 | + const insultTermsCJK = [ |
| 117 | + "垃圾","辣鸡","废物","弱智","傻逼","脑残","狗屎","丢人" |
| 118 | + ]; |
| 119 | + const techContextAscii = [ |
| 120 | + "bug","repro","reproduce","steps to reproduce","minimal repro","expected","actual", |
| 121 | + "stack trace","traceback","stacktrace","log","logs","error","panic","poc","cve", |
| 122 | + "version","v1","v2","v3","config","configuration","file","line","code snippet" |
| 123 | + ]; |
| 124 | + const techContextCJK = [ |
| 125 | + "复现","复现步骤","最小复现","期望行为","实际行为","堆栈","栈追踪","日志","报错", |
| 126 | + "版本","配置","文件","行号","代码片段","poc","cve" |
| 127 | + ]; |
| 128 | +
|
| 129 | + const escapeRe = (s) => s.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); |
| 130 | + const countMatchesAscii = (terms) => |
| 131 | + terms.reduce((n, k) => n + (new RegExp(`\\b${escapeRe(k)}\\b`, "i").test(body) ? 1 : 0), 0); |
| 132 | + const countMatchesCJK = (terms) => |
| 133 | + terms.reduce((n, k) => n + (body.includes(k) ? 1 : 0), 0); |
| 134 | +
|
| 135 | + const attackHits = countMatchesAscii(attackTermsAscii) + countMatchesCJK(attackTermsCJK); |
| 136 | + const insultHit = (countMatchesAscii(insultTermsAscii) + countMatchesCJK(insultTermsCJK)) > 0; |
| 137 | + const techCtxHit = (countMatchesAscii(techContextAscii) + countMatchesCJK(techContextCJK)) > 0; |
| 138 | + const strongCJK = /(失望透顶|离谱|警告|大踩雷)/.test(body); |
| 139 | +
|
| 140 | + // Sentiment-lite (AFINN-style mini-lexicon) |
| 141 | + const afinn = { |
| 142 | + "amazing": 2, "great": 2, "free": 1, "guaranteed": -1, |
| 143 | + "scam": -3, "profit": 1, "winner": 1, "urgent": -1, "risk-free": -2 |
| 144 | + }; |
| 145 | + const tokens = body.toLowerCase().split(/[^a-z0-9+\-]+/); |
| 146 | + let sentiment = 0; |
| 147 | + for (const t of tokens) if (afinn[t] != null) sentiment += afinn[t]; |
| 148 | +
|
| 149 | + // Score |
| 150 | + let points = 0; |
| 151 | + if (linkCount >= 2) points += 2; |
| 152 | + if (emailCount > 0 || phoneCount > 0) points += 2; |
| 153 | + if (mentions >= 5) points += 1; |
| 154 | + if (exclaimBlk) points += 1; |
| 155 | + if (repeatedChr) points += 1; |
| 156 | + if (shortened) points += 1; |
| 157 | + if (lowUnique) points += 1; |
| 158 | + if (keywordHit) points += 3; |
| 159 | + if (hype) points += 2; |
| 160 | + if (sentiment >= 4 && linkCount >= 1) points += 1; // overly positive + links |
| 161 | + if (sentiment <= -2 && (hype || keywordHit)) points += 1; |
| 162 | +
|
| 163 | + // Attack/insult scoring with guardrails for technical context |
| 164 | + let attackContribution = 0; |
| 165 | + if (insultHit) attackContribution += 2; |
| 166 | + if (attackHits >= 3) attackContribution += 2; |
| 167 | + else if (attackHits >= 1) attackContribution += 1; |
| 168 | + if ((exclaimBlk || strongCJK) && attackContribution > 0) attackContribution += 1; |
| 169 | + if (techCtxHit) attackContribution = Math.min(1, attackContribution); // cap if technical context detected |
| 170 | + points += attackContribution; |
| 171 | +
|
| 172 | + core.info(`Spam score for @${actor} = ${points} (links:${linkCount}, emails:${emailCount}, phones:${phoneCount}, mentions:${mentions}, sentiment:${sentiment}, attackHits:${attackHits}, insult:${insultHit}, techCtx:${techCtxHit})`); |
| 173 | +
|
| 174 | + const isSpam = points >= 3; // adjust threshold to tune sensitivity |
| 175 | + if (!isSpam) { |
| 176 | + core.info("Comment not flagged as spam."); |
| 177 | + return; |
| 178 | + } |
| 179 | +
|
| 180 | + // 4) Delete the comment using the appropriate endpoint |
| 181 | + try { |
| 182 | + if (ev === "issue_comment") { |
| 183 | + await github.rest.issues.deleteComment({ |
| 184 | + owner, repo, comment_id: comment.id |
| 185 | + }); |
| 186 | + core.notice(`Deleted spam issue comment from @${actor}.`); |
| 187 | + } else if (ev === "pull_request_review_comment") { |
| 188 | + await github.rest.pulls.deleteReviewComment({ |
| 189 | + owner, repo, comment_id: comment.id |
| 190 | + }); |
| 191 | + core.notice(`Deleted spam PR review comment from @${actor}.`); |
| 192 | + } else if (ev === "commit_comment") { |
| 193 | + await github.rest.repos.deleteCommitComment({ |
| 194 | + owner, repo, comment_id: comment.id |
| 195 | + }); |
| 196 | + core.notice(`Deleted spam commit comment from @${actor}.`); |
| 197 | + } else { |
| 198 | + core.warning(`Unhandled event: ${ev}`); |
| 199 | + } |
| 200 | + } catch (err) { |
| 201 | + core.setFailed(`Failed to delete comment: ${err?.message || err}`); |
| 202 | + } |
| 203 | +
|
| 204 | +
|
0 commit comments