Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
},
"homepage": "https://github.com/teekay/JamComments#readme",
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
"@azure/functions": "3.2.0",
"@azure/service-bus": "7.6.0",
"@nestjs/common": "10.*",
Expand Down
3 changes: 3 additions & 0 deletions sql/migrations/5-add-llm-spam-check-settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE account_settings ADD COLUMN use_llm_check BOOLEAN DEFAULT false;
ALTER TABLE account_settings ADD COLUMN llm_api_key VARCHAR(256);
ALTER TABLE account_settings ADD COLUMN llm_confidence_threshold DECIMAL DEFAULT 0.8;
5 changes: 4 additions & 1 deletion sql/sqlite/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ CREATE TABLE IF NOT EXISTS account_settings (
blog_url TEXT,
akismet_key TEXT,
use_akismet INTEGER DEFAULT 0,
require_moderation INTEGER NOT NULL DEFAULT 0
require_moderation INTEGER NOT NULL DEFAULT 0,
use_llm_check INTEGER DEFAULT 0,
llm_api_key TEXT,
llm_confidence_threshold REAL DEFAULT 0.8
);

-- Account email settings
Expand Down
5 changes: 3 additions & 2 deletions src/azure/accounts.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AccountService } from '../shared/accounts/account.service'
import { AkismetService } from '../shared/comments/akismet.service'
import { LlmSpamService } from '../shared/comments/llm-spam.service'
import { AzureCommentsModule } from './comments.module'
import { CryptoModule } from '../shared/crypto/crypto.module'
import { forwardRef, Module } from '@nestjs/common'
Expand All @@ -10,7 +11,7 @@ import { TokenService } from '../shared/accounts/token.service'
@Module({
imports: [forwardRef(() => AzureCommentsModule), CryptoModule, PersistenceModule, PassportModule],
controllers: [],
providers: [AccountService, TokenService, AkismetService],
exports: [AccountService, TokenService, AkismetService],
providers: [AccountService, TokenService, AkismetService, LlmSpamService],
exports: [AccountService, TokenService, AkismetService, LlmSpamService],
})
export class AzureAccountsModule {}
5 changes: 3 additions & 2 deletions src/shared/accounts/account.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AccountService } from './account.service'
import { AkismetService } from '../comments/akismet.service'
import { LlmSpamService } from '../comments/llm-spam.service'
import { CommentsModule } from '../comments/comments.module'
import { CryptoModule } from '../crypto/crypto.module'
import { forwardRef, Module } from '@nestjs/common'
Expand All @@ -9,7 +10,7 @@ import { TokenService } from './token.service'
@Module({
imports: [forwardRef(() => CommentsModule), CryptoModule, PassportModule],
controllers: [],
providers: [AccountService, TokenService, AkismetService],
exports: [AccountService, TokenService, AkismetService],
providers: [AccountService, TokenService, AkismetService, LlmSpamService],
exports: [AccountService, TokenService, AkismetService, LlmSpamService],
})
export class AccountsModule {}
10 changes: 8 additions & 2 deletions src/shared/accounts/accounts.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@ export interface IAccountSettingsResult {
id: string;
require_moderation: boolean;
use_akismet: boolean | null;
use_llm_check: boolean | null;
llm_api_key: string | null;
llm_confidence_threshold: string | null;
}

/** 'AccountSettings' query type */
Expand Down Expand Up @@ -424,6 +427,9 @@ export interface IUpdateSettingsParams {
blogUrl?: string | null | void;
requireModeration?: boolean | null | void;
useAkismet?: boolean | null | void;
useLlmCheck?: boolean | null | void;
llmApiKey?: string | null | void;
llmConfidenceThreshold?: number | null | void;
}

/** 'UpdateSettings' return type */
Expand All @@ -435,12 +441,12 @@ export interface IUpdateSettingsQuery {
result: IUpdateSettingsResult;
}

const updateSettingsIR: any = {"usedParamSet":{"requireModeration":true,"blogUrl":true,"useAkismet":true,"akismetKey":true,"accountId":true},"params":[{"name":"requireModeration","required":false,"transform":{"type":"scalar"},"locs":[{"a":47,"b":64}]},{"name":"blogUrl","required":false,"transform":{"type":"scalar"},"locs":[{"a":76,"b":83}]},{"name":"useAkismet","required":false,"transform":{"type":"scalar"},"locs":[{"a":98,"b":108}]},{"name":"akismetKey","required":false,"transform":{"type":"scalar"},"locs":[{"a":123,"b":133}]},{"name":"accountId","required":false,"transform":{"type":"scalar"},"locs":[{"a":152,"b":161}]}],"statement":"UPDATE account_settings SET require_moderation=:requireModeration, blog_url=:blogUrl, use_akismet=:useAkismet, akismet_key=:akismetKey WHERE account_id=:accountId"};
const updateSettingsIR: any = {"usedParamSet":{"requireModeration":true,"blogUrl":true,"useAkismet":true,"akismetKey":true,"useLlmCheck":true,"llmApiKey":true,"llmConfidenceThreshold":true,"accountId":true},"params":[{"name":"requireModeration","required":false,"transform":{"type":"scalar"},"locs":[{"a":47,"b":64}]},{"name":"blogUrl","required":false,"transform":{"type":"scalar"},"locs":[{"a":76,"b":83}]},{"name":"useAkismet","required":false,"transform":{"type":"scalar"},"locs":[{"a":98,"b":108}]},{"name":"akismetKey","required":false,"transform":{"type":"scalar"},"locs":[{"a":123,"b":133}]},{"name":"useLlmCheck","required":false,"transform":{"type":"scalar"},"locs":[{"a":150,"b":161}]},{"name":"llmApiKey","required":false,"transform":{"type":"scalar"},"locs":[{"a":177,"b":186}]},{"name":"llmConfidenceThreshold","required":false,"transform":{"type":"scalar"},"locs":[{"a":214,"b":236}]},{"name":"accountId","required":false,"transform":{"type":"scalar"},"locs":[{"a":255,"b":264}]}],"statement":"UPDATE account_settings SET require_moderation=:requireModeration, blog_url=:blogUrl, use_akismet=:useAkismet, akismet_key=:akismetKey, use_llm_check=:useLlmCheck, llm_api_key=:llmApiKey, llm_confidence_threshold=:llmConfidenceThreshold WHERE account_id=:accountId"};

/**
* Query generated from SQL:
* ```
* UPDATE account_settings SET require_moderation=:requireModeration, blog_url=:blogUrl, use_akismet=:useAkismet, akismet_key=:akismetKey WHERE account_id=:accountId
* UPDATE account_settings SET require_moderation=:requireModeration, blog_url=:blogUrl, use_akismet=:useAkismet, akismet_key=:akismetKey, use_llm_check=:useLlmCheck, llm_api_key=:llmApiKey, llm_confidence_threshold=:llmConfidenceThreshold WHERE account_id=:accountId
* ```
*/
export const updateSettings = new PreparedQuery<IUpdateSettingsParams,IUpdateSettingsResult>(updateSettingsIR);
Expand Down
2 changes: 1 addition & 1 deletion src/shared/accounts/accounts.sql
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ SELECT * FROM account_settings WHERE account_id=:accountId;
SELECT * FROM account_email_settings WHERE account_id=:accountId;

/* @name updateSettings */
UPDATE account_settings SET require_moderation=:requireModeration, blog_url=:blogUrl, use_akismet=:useAkismet, akismet_key=:akismetKey WHERE account_id=:accountId;
UPDATE account_settings SET require_moderation=:requireModeration, blog_url=:blogUrl, use_akismet=:useAkismet, akismet_key=:akismetKey, use_llm_check=:useLlmCheck, llm_api_key=:llmApiKey, llm_confidence_threshold=:llmConfidenceThreshold WHERE account_id=:accountId;

/* @name updateEmailSettings */
UPDATE account_email_settings SET notify_on_comments=:notifyOnComments, send_comments_digest=:sendCommentsDigest WHERE account_id=:accountId;
Expand Down
3 changes: 3 additions & 0 deletions src/shared/accounts/settings.param.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export class SettingsParam {
useAkismet = false
akismetKey = ''
blogUrl = ''
useLlmCheck = false
llmApiKey = ''
llmConfidenceThreshold = 0.8
}

export class EmailSettingsParam {
Expand Down
36 changes: 32 additions & 4 deletions src/shared/accounts/views/settings.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -155,22 +155,41 @@
</div>
<div id="anti-spam" class="card settings-tab">
<div>
<h2 class="admin-setting-heading heading-font">Anti-Spam <div class="tooltip icon"><span
<h2 class="admin-setting-heading heading-font">Anti-Spam (Akismet) <div class="tooltip icon"><span
class="tooltiptext">If you enable Akismet integration, we will check each comment to
determine if it's SPAM or not relying on the Akismet SPAM detection feature.</span>
</div>
</h2>
<span class="admin-setting-description">Use anti-SPAM protection (Akismet) <img
class="icon icon-small toggle" src="/assets/images/icons/setting-toggle-{{#if useAkismet}}on{{else}}off{{/if}}.svg"
alt=""/>
<input type="checkbox"
<input type="checkbox"
value="1"
class="d-none"
name="useAkismet"
class="d-none"
name="useAkismet"
id="useAkismet"{{#if useAkismet}} checked{{/if}}>
</span>
<input type="text" id="akismetKey" name="akismetKey" placeholder="Akismet API Key" value="{{ akismetKey }}">
<input type="text" id="blogUrl" name="blogUrl" placeholder="Blog URL (required by Akismet)" value="{{ blogUrl }}">
</div>
</div>
<div id="llm-spam" class="card settings-tab">
<div>
<h2 class="admin-setting-heading heading-font">Anti-Spam (LLM) <div class="tooltip icon"><span
class="tooltiptext">Use an LLM (Claude Haiku) as a second pass to detect spam. Comments flagged by the LLM with confidence above threshold will be sent to moderation.</span>
</div>
</h2>
<span class="admin-setting-description">Use LLM spam detection <img
class="icon icon-small toggle" src="/assets/images/icons/setting-toggle-{{#if useLlmCheck}}on{{else}}off{{/if}}.svg"
alt=""/>
<input type="checkbox"
value="1"
class="d-none"
name="useLlmCheck"
id="useLlmCheck"{{#if useLlmCheck}} checked{{/if}}>
</span>
<input type="text" id="llmApiKey" name="llmApiKey" placeholder="Anthropic API Key" value="{{ llmApiKey }}">
<input type="number" id="llmConfidenceThreshold" name="llmConfidenceThreshold" placeholder="Confidence threshold (0-1)" value="{{ llmConfidenceThreshold }}" min="0" max="1" step="0.1">
<button class="btn" type="submit">SAVE</button>
</div>
</div>
Expand Down Expand Up @@ -329,6 +348,15 @@
inputBlogUrl.removeAttribute('required');
}
});
document.getElementById('useLlmCheck').addEventListener('change', function(evt) {
let isOn = evt.target.checked;
let inputApiKey = document.getElementById('llmApiKey');
if (isOn) {
inputApiKey.setAttribute('required', 'required');
} else {
inputApiKey.removeAttribute('required');
}
});

// remember tabs
document.querySelectorAll('button.tablinks')
Expand Down
36 changes: 31 additions & 5 deletions src/shared/comments/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import moment from 'moment'
import { Account } from '../accounts/account.interface'
import { AccountService } from '../accounts/account.service'
import { AkismetService } from './akismet.service'
import { LlmSpamService } from './llm-spam.service'
import { Comment, CommentBase, CommentWithId } from './comment.interface'
import { Inject, Injectable } from '@nestjs/common'
import { Logger } from 'nestjs-pino'
Expand All @@ -20,23 +21,48 @@ export class CommentService {
@Inject(COMMENT_REPOSITORY) private commentRepo: ICommentRepository,
private readonly accountService: AccountService,
private readonly akismetService: AkismetService,
private readonly llmSpamService: LlmSpamService,
private readonly logger: Logger
) {}

async create(account: Account, comment: CommentBase, ip: string): Promise<CommentCreatedResult|string> {
const settings = await this.accountService.settingsFor(account)
const toModeration = settings?.requireModeration ?? false
const payload = this.commentToDbParam(account, comment)
if (toModeration || (settings?.akismetKey && settings.useAkismet)) {
const flagIt = toModeration || (settings && (await this.akismetService.isCommentSpam(settings, comment, ip)))
if (flagIt) {
this.logger.warn(`${toModeration ? 'Moderation enforced' : 'SPAM detected'}: ${JSON.stringify(comment)}`)

// Manual moderation takes precedence
if (toModeration) {
this.logger.warn(`Moderation enforced: ${JSON.stringify(comment)}`)
await this.commentRepo.createFlaggedComment(payload)
return CommentCreatedResult.Flagged
}

// First pass: Akismet (if configured)
if (settings?.akismetKey && settings.useAkismet) {
const isAkismetSpam = await this.akismetService.isCommentSpam(settings, comment, ip)
if (isAkismetSpam) {
this.logger.warn(`SPAM detected (Akismet): ${JSON.stringify(comment)}`)
await this.commentRepo.createFlaggedComment(payload)
return CommentCreatedResult.Flagged
}
}
await this.commentRepo.createComment(payload)

// Second pass: LLM check (if configured)
if (settings?.llmApiKey && settings.useLlmCheck) {
const llmResult = await this.llmSpamService.checkComment(settings, comment)
if (llmResult) {
const threshold = settings.llmConfidenceThreshold ?? 0.8
if (llmResult.is_spam && llmResult.confidence >= threshold) {
this.logger.warn(
`SPAM detected (LLM, confidence: ${llmResult.confidence}): ${JSON.stringify(comment)}`
)
await this.commentRepo.createFlaggedComment(payload)
return CommentCreatedResult.Flagged
}
}
}

await this.commentRepo.createComment(payload)
return payload.id
}

Expand Down
76 changes: 76 additions & 0 deletions src/shared/comments/llm-spam.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Anthropic from '@anthropic-ai/sdk'
import { CommentBase } from './comment.interface'
import { Injectable } from '@nestjs/common'
import { Logger } from 'nestjs-pino'
import { SettingsParam } from '../accounts/settings.param'

export interface LlmSpamResult {
is_spam: boolean
confidence: number
}

@Injectable()
export class LlmSpamService {
constructor(private readonly logger: Logger) {}

async checkComment(
accountSettings: SettingsParam,
comment: CommentBase
): Promise<LlmSpamResult | undefined> {
const apiKey = accountSettings.llmApiKey
if (!apiKey) return

const client = new Anthropic({ apiKey })

const prompt = `You are a spam detection system. Analyze the following comment and determine if it is spam.

Comment author: ${comment.author.name}
Comment author email: ${comment.author.email || 'not provided'}
Comment author website: ${comment.author.website || 'not provided'}
Comment text: ${comment.text}
Page URL: ${comment.postUrl}

Respond with ONLY a JSON object in this exact format (no markdown, no explanation):
{"is_spam": true or false, "confidence": 0.0 to 1.0}

Consider these spam indicators:
- Promotional content or advertisements
- Links to suspicious websites
- Generic or irrelevant content
- Excessive use of keywords
- Poor grammar typical of automated spam
- Mentions of money, gambling, adult content, or pharmaceuticals

Consider these legitimate comment indicators:
- Relevant to the page content
- Personal opinions or questions
- Natural language patterns
- Engagement with the topic`

try {
const response = await client.messages.create({
model: 'claude-haiku-4-20250514',
max_tokens: 100,
messages: [{ role: 'user', content: prompt }],
})

const textContent = response.content.find((c) => c.type === 'text')
if (!textContent || textContent.type !== 'text') {
this.logger.warn('LLM spam check returned no text content')
return
}

const result = JSON.parse(textContent.text) as LlmSpamResult
if (typeof result.is_spam !== 'boolean' || typeof result.confidence !== 'number') {
this.logger.warn(`LLM spam check returned invalid format: ${textContent.text}`)
return
}

this.logger.debug(`LLM spam check result: ${JSON.stringify(result)}`)
return result
} catch (error) {
this.logger.warn(`Could not reach LLM API: ${(error as Error)?.message}`)
return
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export class PostgresAccountRepository implements IAccountRepository {
useAkismet: s[0].use_akismet ?? false,
akismetKey: s[0].akismet_key ?? '',
blogUrl: s[0].blog_url ?? '',
useLlmCheck: s[0].use_llm_check ?? false,
llmApiKey: s[0].llm_api_key ?? '',
llmConfidenceThreshold: parseFloat(s[0].llm_confidence_threshold ?? '0.8'),
}
}

Expand All @@ -102,6 +105,9 @@ export class PostgresAccountRepository implements IAccountRepository {
useAkismet: settings.useAkismet ?? false,
akismetKey: settings.akismetKey,
blogUrl: settings.blogUrl,
useLlmCheck: settings.useLlmCheck ?? false,
llmApiKey: settings.llmApiKey,
llmConfidenceThreshold: settings.llmConfidenceThreshold ?? 0.8,
},
this.client
)
Expand Down
12 changes: 11 additions & 1 deletion src/shared/repositories/sqlite/sqlite-account.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export class SqliteAccountRepository implements IAccountRepository {
useAkismet: Boolean(row.use_akismet),
akismetKey: row.akismet_key ?? '',
blogUrl: row.blog_url ?? '',
useLlmCheck: Boolean(row.use_llm_check),
llmApiKey: row.llm_api_key ?? '',
llmConfidenceThreshold: row.llm_confidence_threshold ?? 0.8,
}
}

Expand All @@ -96,14 +99,18 @@ export class SqliteAccountRepository implements IAccountRepository {
async updateSettings(accountId: string, settings: SettingsParam): Promise<void> {
const stmt = this.db.prepare(`
UPDATE account_settings
SET require_moderation = ?, blog_url = ?, use_akismet = ?, akismet_key = ?
SET require_moderation = ?, blog_url = ?, use_akismet = ?, akismet_key = ?,
use_llm_check = ?, llm_api_key = ?, llm_confidence_threshold = ?
WHERE account_id = ?
`)
stmt.run(
settings.requireModeration ? 1 : 0,
settings.blogUrl,
settings.useAkismet ? 1 : 0,
settings.akismetKey,
settings.useLlmCheck ? 1 : 0,
settings.llmApiKey,
settings.llmConfidenceThreshold ?? 0.8,
accountId
)
}
Expand Down Expand Up @@ -157,6 +164,9 @@ interface SqliteSettingsRow {
akismet_key: string | null
use_akismet: number | null
require_moderation: number
use_llm_check: number | null
llm_api_key: string | null
llm_confidence_threshold: number | null
}

interface SqliteEmailSettingsRow {
Expand Down
Loading