Skip to content

Commit 73c0e84

Browse files
committed
chore(workflow): add Anti-Spam Comment Moderator; CJK spam; attack/insult/tech-context; block phuole818; remove unsupported commit_comment
1 parent 8cc27f0 commit 73c0e84

File tree

1 file changed

+204
-0
lines changed

1 file changed

+204
-0
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)