Skip to content

Commit b229087

Browse files
committed
[app-platform] add anti spam filter
1 parent eba896f commit b229087

File tree

1 file changed

+185
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)