Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
85bdbe1
feat(comment): route comment spam scoring to dedicated comment model
Jun 4, 2026
c890bfd
Merge pull request #4838 from thematters/feat/comment-spam-detection
mashbean Jun 4, 2026
96637ee
feat(comment): add campaignDiscussion comment type for campaign discu…
yingshinlee Jun 11, 2026
d4a467c
feat(quote): quote wall data layer for campaigns
yingshinlee Jun 11, 2026
b812830
style: blank line between import groups (eslint import/order)
yingshinlee Jun 12, 2026
3360f4d
style: blank line between import groups (eslint import/order)
yingshinlee Jun 12, 2026
a1f92f4
chore: regenerate GraphQL schema types for campaignDiscussion
yingshinlee Jun 12, 2026
db28d97
chore: regenerate schema types for quote wall + fix lint/types
yingshinlee Jun 12, 2026
a118ea1
fix(quote): address review findings on dedupe, campaign attribution a…
mashbean Jun 13, 2026
ec9564e
fix(comment): handle campaignDiscussion in vote/delete/pin and guard …
mashbean Jun 13, 2026
1b954ab
feat(quote): paginate quotes with after cursor and idempotent deleteQ…
mashbean Jun 13, 2026
64a1490
feat(comment): auto-collapse spam comments behind env flag
Jun 13, 2026
409a9d5
test(comment): cover campaignDiscussion permissions and behavior
mashbean Jun 13, 2026
adce664
test(quote): add integration tests for putQuote/deleteQuote/quotes
mashbean Jun 13, 2026
cc29ecf
test(comment): cover campaignDiscussion error paths
mashbean Jun 13, 2026
ac0b481
Merge pull request #4841 from yingshinlee/feat/campaign-discussion
mashbean Jun 13, 2026
103bdb4
Merge pull request #4842 from yingshinlee/feat/quote-wall
mashbean Jun 13, 2026
fd62323
fix(report): allow community_watch_* reasons in DB check constraint
Jun 13, 2026
562fea5
chore: redeploy develop to staging for seven-day-book features
mashbean Jun 13, 2026
9297f35
Merge pull request #4844 from thematters/fix/report-reason-db-constraint
mashbean Jun 13, 2026
3827605
Merge pull request #4845 from thematters/redeploy/seven-day-book-staging
mashbean Jun 13, 2026
af4081c
feat(spam): capture moderated comments as training samples (axis-2 L2)
Jun 13, 2026
347ac3f
style: fix import/order in spamSample (eslint required check)
Jun 14, 2026
5ece166
test: mock findCommunityWatchActionByUUID for clear mutation (L2 snap…
Jun 14, 2026
3a8bb63
test(spam): cover enqueueSpamSample (codecov)
Jun 14, 2026
3200027
test(comment): cover _autoCollapseIfSpam (codecov)
Jun 14, 2026
086cd37
test(spam): cover enqueueSpamSample via the mutation path (codecov)
Jun 14, 2026
7e6f3bd
Merge pull request #4843 from thematters/feat/comment-spam-auto-collapse
mashbean Jun 14, 2026
2611f38
Merge remote-tracking branch 'origin/develop' into feat/spam-training…
Jun 14, 2026
6fec178
test(spam): cover enqueueSpamSample catch + blank-text guard (codecov…
Jun 14, 2026
9870c0a
ci: codecov project threshold 1% (patch stays strict)
Jun 14, 2026
571c047
Merge pull request #4846 from thematters/feat/spam-training-sample-ca…
mashbean Jun 15, 2026
70ae6d8
Merge remote-tracking branch 'origin/master' into develop
Jun 15, 2026
7067f3f
feat(moments): change the decay factor of the moments feed
zeckli Jun 15, 2026
7437886
Merge pull request #4850 from thematters/chore/backmerge-master-to-de…
mashbean Jun 16, 2026
f231f92
feat(comment-spam): 3-tier moderation alerting to admin Telegram (not…
Jun 16, 2026
b12c8b7
fix(comment-spam): mask rotated contact IDs in ring normalization
Jun 16, 2026
ff85c5a
Merge pull request #4851 from thematters/feat/comment-spam-telegram-t…
mashbean Jun 16, 2026
b64c77e
Merge pull request #4853 from thematters/feat/moments
zeckli Jun 16, 2026
97e235d
Merge remote-tracking branch 'origin/master' into chore/backmerge-mas…
Jun 16, 2026
897e157
feat(auth): support OSS Google SSO via allowlisted redirect_uri
mashbean Jun 17, 2026
ff3d700
feat(oss): rank content lists by spam score (last-N-days triage)
mashbean Jun 17, 2026
19d147d
test(auth): cover OSS Google SSO login paths
mashbean Jun 17, 2026
286abcc
test(oss): update moments query variable type to OSSMomentsInput
mashbean Jun 17, 2026
154c4c2
test(auth): cover fetchGoogleUserInfo redirect_uri exchange
mashbean Jun 17, 2026
915098c
test(oss): cover spam-score sort + datetime filter for comments/moments
mashbean Jun 17, 2026
c27a1b4
feat(campaign): enableQuoteWall flag to gate the quote wall (#4857)
yingshinlee Jun 17, 2026
1d9cd62
Merge pull request #4855 from thematters/feat/oss-google-sso
mashbean Jun 17, 2026
d8c9a60
Merge pull request #4856 from thematters/feat/oss-spam-ranking
mashbean Jun 17, 2026
f9a4c17
feat(campaign): open the discussion to any logged-in user (#4859)
yingshinlee Jun 17, 2026
54e496a
feat(campaign): default the quote wall on for every campaign (#4858)
yingshinlee Jun 18, 2026
463e728
feat(moment): route moment spam scoring to dedicated model (fallback …
Jun 19, 2026
7855073
Merge pull request #4854 from thematters/chore/backmerge-master-after…
mashbean Jun 19, 2026
e77d8a2
Merge pull request #4860 from thematters/feat/moment-spam-dedicated-e…
mashbean Jun 19, 2026
15cacc5
feat(comment-spam): add OSS deep-link to the 3-tier Telegram alerts
Jun 19, 2026
c818d27
feat(agent): add the basic rules for different models
zeckli Jun 19, 2026
f424c35
Merge pull request #4862 from thematters/feat/agent
zeckli Jun 19, 2026
f072eb5
Merge pull request #4861 from thematters/feat/comment-spam-alert-oss-…
mashbean Jun 20, 2026
20a98e2
feat(spam-ring): 軸一 D 後端——ring 資料模型 + 一鍵凍結(可逆)+ 偵測 job 匯入
mashbean Jun 19, 2026
de667ac
test(spam-ring): freeze/unfreeze/dismiss + migration + resolver 測試;us…
mashbean Jun 19, 2026
2887825
fix(spam-ring): satisfy service import order
mashbean Jun 20, 2026
68e4a91
test(spam-ring): cover OSS ring resolvers and import paths
mashbean Jun 20, 2026
89f67f5
Merge pull request #4863 from thematters/feat/spam-ring-detection
mashbean Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ MATTERS_PASSPHRASES_SECRET=
MATTERS_SPAM_DETECTION_API_URL=
MATTERS_SHORT_CONTENT_SPAM_DETECTION_API_URL=
MATTERS_COMMENT_SPAM_DETECTION_API_URL=
MATTERS_MOMENT_SPAM_DETECTION_API_URL=
MATTERS_COMMENT_SPAM_AUTO_COLLAPSE=false
MATTERS_AWS_SPAM_SAMPLE_QUEUE_URL=
MATTERS_SPAM_SAMPLE_HASH_SALT=
Expand Down
70 changes: 59 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,59 @@
---
description:
globs:
alwaysApply: true
---
You are an expect in Typescript, Graphql and Web development using express.js, and use best practices to write clean and maintainable code.

- Obey documentations mentioned by [README.md](mdc:README.md).
- *Must* read [Database-Modification.md](mdc:docs/Database-Modification.md) first for any database schema migration (create or alter tables).
- Prefer using methods in services over adding new code.
- Add or update tests according to [Unittest.md](mdc:docs/Unittest.md) for each changes.
## Basic
a. Never speculate. Investigate and confirm the facts before writing any code.
b. Related Matters repositories for reference are at https://github.com/orgs/thematters/repositories
c. If a requirement is ambiguous, or the existing architecture cannot solve the problem elegantly, ask the
developer first instead of modifying core low-level modules on your own.


## Development
a. TypeScript is the primary language.
b. Prefer arrow functions. Avoid TypeScript generic type parameters where a concrete type works.
c. Do not write an if-else on a single line.
d. Write comments in English and only on the important parts. Keep them short and clear; avoid over-commenting.
e. After every change, re-run lint and format to keep quality and style consistent.
f. After every change, add or update the relevant test cases and run the tests to make sure they pass.
g. Install non-essential packages as devDependencies, such as lint, format, build, test and tooling
packages like eslint and codecov; keep runtime dependencies minimal.


## Error handling
a. Always throw the named error classes defined in common/errors, such as ForbiddenError, UserNotFoundError,
EmailInvalidError.
b. Do not throw strings or a generic Error. Add a new class only when none fits, to keep error codes consistent.
c. When an error occurs, besides throwing from common/errors, log it with the logger.


## Data access
a. Do not write knex or raw SQL directly in a resolver; access data through the services in context.dataSources.
b. Load list fields via the matching DataLoader to avoid N+1; never query row by row inside a loop.
c. Wrap multi-write operations that need atomicity in an atomService transaction.


## Pagination
a. Return connection fields with connectionFromArray, connectionFromPromisedArray and cursorToIndex,
following the Relay cursor spec.
b. Annotate connection fields with @complexity(multipliers input.first) to bound query complexity and
avoid over-fetching.


## Constants and async
a. Use the constants in common/enums for roles, states and types, such as USER_ROLE, USER_STATE and
NODE_TYPES; do not use magic strings.
b. Offload heavy tasks such as sending email, on-chain calls and notifications to the queue; do not await
them synchronously inside a resolver.


## Security
a. Never hardcode passwords, API keys or any sensitive information in the code.


## Git
a. General feature development and fixes
a1. Branch off the develop branch and make changes on that branch.
a2. Before git push, sync with develop so conflicts are found and resolved first.
a3. When conflicts appear, do not revert on your own even if you are sure the revert is safe; ask the
developer first.
b. Hotfix
b1. If the change is based on the master branch, cherry-pick it onto the develop branch.
c. Commit message
c1. Follow Conventional Commits, such as feat:, fix: and docs:.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
5 changes: 5 additions & 0 deletions codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@
"Appreciation": "./appreciation.js#Appreciation",
"OAuthClient": "./user.js#OAuthClientDB",
"Report": "./report.js#Report",
"SpamRing": "./spamRing.js#SpamRing",
"SpamRingMember": "./spamRing.js#SpamRingMember",
"SpamRingEvent": "./spamRing.js#SpamRingEvent",
"SpamRingSignals": "./spamRing.js#SpamRingSignals",
"IcymiTopic": "./misc.js#MattersChoiceTopic",
"Moment": "./moment.js#Moment",
"MomentFeedApplication": "./moment.js#MomentFeedUser",
"Quote": "./quote.js#Quote",
"Writing": "./index.js#Writing",
"Campaign": "./campaign.js#Campaign",
"CampaignOSS": "./campaign.js#Campaign",
Expand Down
34 changes: 34 additions & 0 deletions db/migrations/20260611100000_create_quote_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { baseDown } from '../utils.js'

const table = 'quote'

const indexName = `${table}_user_id_article_id_content_unique`

export const up = async (knex) => {
await knex('entity_type').insert({ table })
await knex.schema.createTable(table, (t) => {
t.bigIncrements('id').primary()
t.text('content').notNullable()
t.bigInteger('article_id').unsigned().notNullable()
t.bigInteger('campaign_id').unsigned().notNullable()
t.bigInteger('user_id').unsigned().notNullable()
t.enu('state', ['active', 'archived', 'banned']).notNullable()
t.timestamp('created_at').defaultTo(knex.fn.now())
t.timestamp('updated_at').defaultTo(knex.fn.now())

t.foreign('article_id').references('id').inTable('article')
t.foreign('campaign_id').references('id').inTable('campaign')
t.foreign('user_id').references('id').inTable('user')

t.index('campaign_id')
t.index('article_id')
t.index(['user_id', 'created_at'])
})

// create a partial unique index to dedupe active quotes
await knex.raw(
`CREATE UNIQUE INDEX ${indexName} ON ${table} ("user_id", "article_id", "content") WHERE state = 'active'`
)
}

export const down = baseDown(table)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { alterEnumString } from '../utils.js'

const table = 'comment'

export const up = async (knex) => {
await knex.raw(
alterEnumString(table, 'type', [
'article',
'circle_discussion',
'circle_broadcast',
'moment',
'campaign_discussion',
])
)
}

export const down = async (knex) => {
await knex.raw(
alterEnumString(table, 'type', [
'article',
'circle_discussion',
'circle_broadcast',
'moment',
])
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const table = 'campaign'

export const up = async (knex) => {
await knex.schema.alterTable(table, (t) => {
// whether this campaign exposes a quote wall (post-to-wall affordance).
// opt-in: defaults to false; 七日書 campaigns are enabled via data backfill
// and, going forward, via the OSS campaign editor toggle.
t.boolean('enable_quote_wall').notNullable().defaultTo(false)
})
}

export const down = async (knex) => {
await knex.schema.alterTable(table, (t) => {
t.dropColumn('enable_quote_wall')
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const table = 'campaign'

// One-time backfill: enable the quote wall for existing 七日書 campaigns.
// The name match is used here ONCE (at migration time) only; from now on the
// flag is data-driven (toggled in the OSS campaign editor), so renaming a
// campaign no longer affects quote-wall eligibility.
export const up = async (knex) => {
await knex(table).where('name', 'like', '%七日書%').update({
enable_quote_wall: true,
})
}

export const down = async (knex) => {
await knex(table).where('name', 'like', '%七日書%').update({
enable_quote_wall: false,
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const table = 'campaign'

// Interim decision: open the quote wall for every campaign by default, instead
// of the original 七日書-only opt-in. The per-campaign `enable_quote_wall` flag
// is kept (as a future hook for the OSS toggle / 七日書-only restriction); we
// only flip its default to true and enable it on all existing campaigns.
export const up = async (knex) => {
await knex.schema.alterTable(table, (t) => {
t.boolean('enable_quote_wall').notNullable().defaultTo(true).alter()
})
await knex(table).update({ enable_quote_wall: true })
}

export const down = async (knex) => {
// revert the default; existing per-campaign values are left as-is (the prior
// mixed state cannot be reconstructed)
await knex.schema.alterTable(table, (t) => {
t.boolean('enable_quote_wall').notNullable().defaultTo(false).alter()
})
}
41 changes: 41 additions & 0 deletions db/migrations/20260620000000_create_spam_ring_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const table = 'spam_ring'

export const up = async (knex) => {
await knex('entity_type').insert({ table })

await knex.schema.createTable(table, (t) => {
t.bigIncrements('id').primary()
t.uuid('uuid').notNullable().unique()
// 模板/家族指紋:偵測 job 端的歸群 key,同一 ring 跨次匯入用它做 idempotent upsert
t.text('fingerprint').notNullable().unique()
t.enu('status', ['pending', 'frozen', 'dismissed', 'restored'])
.notNullable()
.defaultTo('pending')
// app 層訊號摘要(nearDupRingSize/entityRingSize/botUsernameRatio/topEntity/sampleCodes/sampleBrands/contentModelMax)
t.jsonb('signals').notNullable().defaultTo('{}')
t.integer('n_articles').notNullable().defaultTo(0)
t.integer('n_authors').notNullable().defaultTo(0)
t.decimal('new_account_ratio', 5, 4)
t.decimal('score', 8, 4)
t.enu('severity', ['low', 'medium', 'high', 'critical'])
t.timestamp('detected_at').notNullable().defaultTo(knex.fn.now())
t.timestamp('first_seen_at')
t.timestamp('last_seen_at')
t.timestamp('frozen_at')
t.bigInteger('frozen_by').unsigned()
t.text('note')
t.timestamp('created_at').defaultTo(knex.fn.now())
t.timestamp('updated_at').defaultTo(knex.fn.now())

t.foreign('frozen_by').references('id').inTable('user')
t.index(['status', 'score'])
t.index(['status', 'detected_at'])
t.index(['status', 'n_authors'])
t.index(['detected_at'])
})
}

export const down = async (knex) => {
await knex('entity_type').where({ table }).del()
await knex.schema.dropTable(table)
}
34 changes: 34 additions & 0 deletions db/migrations/20260620001000_create_spam_ring_member_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const table = 'spam_ring_member'

export const up = async (knex) => {
await knex('entity_type').insert({ table })

await knex.schema.createTable(table, (t) => {
t.bigIncrements('id').primary()
t.uuid('uuid').notNullable().unique()
t.bigInteger('ring_id').unsigned().notNullable()
t.bigInteger('user_id').unsigned().notNullable()
t.enu('status', ['pending', 'frozen', 'skipped', 'restored'])
.notNullable()
.defaultTo('pending')
// 可逆性關鍵:unfreeze 只解除「本 ring 凍結造成的」封禁,不動其他原因已封的帳號
t.boolean('banned_by_this_ring').notNullable().defaultTo(false)
t.text('skip_reason')
// 凍結當下捕捉的帳號狀態(稽核/還原參考)
t.enu('pre_freeze_state', ['active', 'banned', 'archived', 'frozen'])
t.jsonb('evidence').notNullable().defaultTo('{}')
t.timestamp('created_at').defaultTo(knex.fn.now())
t.timestamp('updated_at').defaultTo(knex.fn.now())

t.foreign('ring_id').references('id').inTable('spam_ring')
t.foreign('user_id').references('id').inTable('user')
t.unique(['ring_id', 'user_id'])
t.index(['ring_id', 'status'])
t.index(['user_id'])
})
}

export const down = async (knex) => {
await knex('entity_type').where({ table }).del()
await knex.schema.dropTable(table)
}
37 changes: 37 additions & 0 deletions db/migrations/20260620002000_create_spam_ring_event_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const table = 'spam_ring_event'

export const up = async (knex) => {
await knex('entity_type').insert({ table })

await knex.schema.createTable(table, (t) => {
t.bigIncrements('id').primary()
t.uuid('uuid').notNullable().unique()
t.bigInteger('ring_id').unsigned().notNullable()
t.bigInteger('member_id').unsigned()
// nullable:機器偵測(detected)事件無管理員,actor_id 留空
t.bigInteger('actor_id').unsigned()
t.enu('action', [
'detected',
'frozen',
'unfrozen',
'dismissed',
'member_banned',
'member_skipped',
'member_restored',
]).notNullable()
t.jsonb('detail').notNullable().defaultTo('{}')
t.timestamp('created_at').defaultTo(knex.fn.now())

t.foreign('ring_id').references('id').inTable('spam_ring')
t.foreign('member_id').references('id').inTable('spam_ring_member')
t.foreign('actor_id').references('id').inTable('user')
t.index(['ring_id', 'created_at'])
t.index(['actor_id', 'created_at'])
t.index(['action', 'created_at'])
})
}

export const down = async (knex) => {
await knex('entity_type').where({ table }).del()
await knex.schema.dropTable(table)
}
Loading
Loading