| 1 | name: Spam comment guard |
| 2 | |
| 3 | # Activity-farming bots post short emoji-padded template comments on whatever |
| 4 | # issue is currently active. They are cheap to spot structurally: a recently |
| 5 | # registered account with no history here, no followers, and a pile of forked |
| 6 | # repos, posting a sub-50 character comment with emoji and no code/link/log. |
| 7 | # Every signal must fire — on 7604 replayed comments that caught all 83 known |
| 8 | # spam posts with no false positive. Loosening any one of them reintroduces |
| 9 | # real users ("点赞", "同样的问题", a 2020 account posting "感谢各位❤"). |
| 10 | |
| 11 | on: |
| 12 | issue_comment: |
| 13 | types: [created] |
| 14 | |
| 15 | permissions: |
| 16 | issues: write |
| 17 | pull-requests: write |
| 18 | |
| 19 | concurrency: |
| 20 | group: spam-guard-${{ github.event.comment.id }} |
| 21 | |
| 22 | jobs: |
| 23 | screen: |
| 24 | if: github.event.comment.user.type != 'Bot' && github.event.comment.author_association == 'NONE' |
| 25 | runs-on: ubuntu-latest |
| 26 | steps: |
| 27 | - uses: actions/github-script@v9 |
| 28 | with: |
| 29 | script: | |
| 30 | const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu; |
| 31 | const comment = context.payload.comment; |
| 32 | const body = comment.body || ''; |
| 33 | |
| 34 | const hasSubstance = /```|https?:\/\/|!\[|^>/m.test(body); |
| 35 | const emojiCount = (body.match(EMOJI) || []).length; |
| 36 | const bare = body.replace(EMOJI, '').trim(); |
| 37 | |
| 38 | const { data: author } = await github.rest.users.getByUsername({ |
| 39 | username: comment.user.login, |
| 40 | }); |
| 41 | |
| 42 | const ageDays = (Date.now() - Date.parse(author.created_at)) / 86400000; |
| 43 | |
| 44 | const signals = { |
| 45 | newAccount: ageDays < 365, |
| 46 | noFollowers: author.followers === 0, |
| 47 | forkPadded: author.public_repos >= 15, |
| 48 | tooShort: bare.length <= 45, |
| 49 | emojiPadded: emojiCount >= 1, |
| 50 | noSubstance: !hasSubstance, |
| 51 | }; |
| 52 | const failed = Object.entries(signals).filter(([, v]) => !v).map(([k]) => k); |
| 53 | core.info(`${comment.user.login}: ${failed.length ? `kept (${failed.join(',')})` : 'spam'}`); |
| 54 | if (failed.length) return; |
| 55 | |
| 56 | await github.graphql( |
| 57 | `mutation($id: ID!) { |
| 58 | minimizeComment(input: { subjectId: $id, classifier: SPAM }) { |
| 59 | minimizedComment { isMinimized } |
| 60 | } |
| 61 | }`, |
| 62 | { id: comment.node_id }, |
| 63 | ); |
| 64 | core.notice(`Minimized spam comment by ${comment.user.login}`); |
| 65 |