@respectify/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ import type { RespectifyConfig } from './config';
2
+ import { loadRespectifyConfig } from './config';
3
+ import { RESPECTIFY_API_KEY, RESPECTIFY_EMAIL } from 'astro:env/server';
4
+ import {
5
+ RespectifyClient,
6
+ AuthenticationError,
7
+ PaymentRequiredError,
8
+ RespectifyError,
9
+ } from '@respectify/client';
10
+ import { getIntegrationOptions } from '../runtime-config';
11
+ import { logger } from './logger';
12
+ import type { RespectifyAnalysisResult } from '../types';
13
+
14
+ let client: RespectifyClient | null = null;
15
+ const articleIdCache = new Map<string, string>();
16
+
17
+ function getRespectifyClient(): RespectifyClient {
18
+ if (!client) {
19
+ if (!RESPECTIFY_EMAIL || !RESPECTIFY_API_KEY) {
20
+ throw new Error('Respectify credentials not configured. Set RESPECTIFY_EMAIL and RESPECTIFY_API_KEY.');
21
+ }
22
+
23
+ client = new RespectifyClient({
24
+ email: RESPECTIFY_EMAIL,
25
+ apiKey: RESPECTIFY_API_KEY,
26
+ });
27
+ }
28
+
29
+ return client;
30
+ }
31
+
32
+ async function getOrInitializeArticleId(postSlug: string): Promise<string> {
33
+ const cached = articleIdCache.get(postSlug);
34
+ if (cached) return cached;
35
+
36
+ const { getPostUrl } = getIntegrationOptions();
37
+ const site = import.meta.env.SITE;
38
+ if (!site) {
39
+ throw new Error('import.meta.env.SITE must be set in astro.config.ts for Respectify topic initialization.');
40
+ }
41
+
42
+ const postUrl = getPostUrl!(postSlug, site);
43
+ const respectifyClient = getRespectifyClient();
44
+
45
+ const result = await respectifyClient.initTopicFromUrl(postUrl, `Post: ${postSlug}`);
46
+ articleIdCache.set(postSlug, result.article_id);
47
+ logger.info('Initialized Respectify topic', { postSlug });
48
+
49
+ return result.article_id;
50
+ }
51
+
52
+ export async function verifyRespectifyCredentials(): Promise<boolean> {
53
+ try {
54
+ const respectifyClient = getRespectifyClient();
55
+ const result = await respectifyClient.checkUserCredentials();
56
+ return result.active;
57
+ } catch (error) {
58
+ logger.error('Respectify credential verification failed', { error });
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export async function analyzeComment(text: string, postSlug?: string): Promise<RespectifyAnalysisResult> {
64
+ const config: RespectifyConfig = loadRespectifyConfig();
65
+
66
+ if (!config.enabled) {
67
+ return {
68
+ score: 1,
69
+ approved: true,
70
+ feedback: config.feedback.approved,
71
+ };
72
+ }
73
+
74
+ try {
75
+ const respectifyClient = getRespectifyClient();
76
+ let respectifyArticleId: string | undefined;
77
+
78
+ if (postSlug) {
79
+ try {
80
+ respectifyArticleId = await getOrInitializeArticleId(postSlug);
81
+ } catch (error) {
82
+ logger.warn('Could not initialize Respectify article ID; falling back to spam-only check', {
83
+ postSlug,
84
+ error,
85
+ });
86
+ }
87
+ }
88
+
89
+ const megacallOptions: {
90
+ comment: string;
91
+ articleId?: string;
92
+ includeSpam: boolean;
93
+ includeCommentScore?: boolean;
94
+ } = {
95
+ comment: text,
96
+ includeSpam: true,
97
+ };
98
+
99
+ if (respectifyArticleId) {
100
+ megacallOptions.articleId = respectifyArticleId;
101
+ megacallOptions.includeCommentScore = true;
102
+ }
103
+
104
+ const result = await respectifyClient.megacall(megacallOptions);
105
+
106
+ if (result.spam_check?.is_spam) {
107
+ return {
108
+ score: 0,
109
+ approved: false,
110
+ feedback: 'This comment appears to be spam.',
111
+ isSpam: true,
112
+ spamConfidence: result.spam_check.confidence,
113
+ };
114
+ }
115
+
116
+ let respectfulnessScore = config.thresholds.autoApprove;
117
+
118
+ if (result.comment_score) {
119
+ const toxicityScore = result.comment_score.toxicity_score ?? 0;
120
+ respectfulnessScore = 1 - toxicityScore;
121
+ }
122
+
123
+ let approved = false;
124
+ let feedback = '';
125
+
126
+ if (respectfulnessScore >= config.thresholds.autoApprove) {
127
+ approved = true;
128
+ feedback = config.feedback.approved;
129
+ } else if (respectfulnessScore < config.thresholds.block && config.moderation.blockDisrespectful) {
130
+ approved = false;
131
+ feedback = config.feedback.blocked;
132
+ } else {
133
+ approved = config.moderation.allowOverride;
134
+ feedback = config.feedback.warning;
135
+ }
136
+
137
+ let suggestion: string | undefined;
138
+ if (result.comment_score) {
139
+ const suggestions: string[] = [];
140
+
141
+ if (result.comment_score.toxicity_explanation) {
142
+ suggestions.push(result.comment_score.toxicity_explanation);
143
+ }
144
+
145
+ if (result.comment_score.logical_fallacies.length > 0) {
146
+ suggestions.push(
147
+ `Logical fallacies detected: ${result.comment_score.logical_fallacies.map((f) => f.fallacy_name).join(', ')}`,
148
+ );
149
+ }
150
+
151
+ if (result.comment_score.objectionable_phrases.length > 0) {
152
+ suggestions.push(`Objectionable phrases found: ${result.comment_score.objectionable_phrases.length} issue(s)`);
153
+ }
154
+
155
+ if (suggestions.length > 0) suggestion = suggestions.join(' ');
156
+ }
157
+
158
+ const analysisResult: RespectifyAnalysisResult = {
159
+ score: respectfulnessScore,
160
+ approved,
161
+ feedback,
162
+ };
163
+
164
+ if (suggestion) analysisResult.suggestion = suggestion;
165
+
166
+ return analysisResult;
167
+ } catch (error) {
168
+ if (error instanceof AuthenticationError) {
169
+ logger.error('Respectify authentication failed', { error });
170
+ } else if (error instanceof PaymentRequiredError) {
171
+ logger.error('Respectify subscription required', { error });
172
+ } else if (error instanceof RespectifyError) {
173
+ logger.error('Respectify API error', { statusCode: error.statusCode, message: error.message });
174
+ } else {
175
+ logger.error('Respectify analysis failed', { error });
176
+ }
177
+
178
+ return {
179
+ score: 0,
180
+ approved: false,
181
+ feedback: 'Comment analysis service is temporarily unavailable. Please try again later.',
182
+ };
183
+ }
184
+ }
@@ -0,0 +1,44 @@
1
+ import type { APIContext } from 'astro';
2
+ import { db, Comment as CommentTable, eq, and, asc } from 'astro:db';
3
+
4
+ export const prerender = false;
5
+
6
+ export async function GET(context: APIContext): Promise<Response> {
7
+ const slug = context.url.searchParams.get('slug');
8
+
9
+ if (!slug) {
10
+ return new Response(JSON.stringify({ error: 'Missing slug parameter' }), {
11
+ status: 400,
12
+ headers: { 'Content-Type': 'application/json' },
13
+ });
14
+ }
15
+
16
+ try {
17
+ const comments = await db
18
+ .select({
19
+ id: CommentTable.id,
20
+ author: CommentTable.author,
21
+ content: CommentTable.content,
22
+ createdAt: CommentTable.createdAt,
23
+ })
24
+ .from(CommentTable)
25
+ .where(and(eq(CommentTable.postSlug, slug), eq(CommentTable.approved, true)))
26
+ .orderBy(asc(CommentTable.createdAt));
27
+
28
+ return new Response(JSON.stringify({ comments }), {
29
+ status: 200,
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'Cache-Control': 'no-store',
33
+ },
34
+ });
35
+ } catch {
36
+ return new Response(JSON.stringify({ comments: [] }), {
37
+ status: 200,
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ 'Cache-Control': 'no-store',
41
+ },
42
+ });
43
+ }
44
+ }
@@ -0,0 +1,25 @@
1
+ import type { RespectifyIntegrationOptions } from './types';
2
+
3
+ const defaults: Required<Pick<RespectifyIntegrationOptions, 'configPath' | 'commentsApiPath' | 'showBranding'>> &
4
+ Pick<RespectifyIntegrationOptions, 'getPostUrl' | 'rateLimit'> = {
5
+ configPath: './respectify.config.json',
6
+ commentsApiPath: '/api/respectify/comments',
7
+ showBranding: true,
8
+ getPostUrl: (slug, site) => `${site.replace(/\/$/, '')}/posts/${slug}/`,
9
+ rateLimit: { windowMs: 5 * 60 * 1000, maxRequests: 10 },
10
+ };
11
+
12
+ let options: typeof defaults = { ...defaults };
13
+
14
+ export function setIntegrationOptions(userOptions: RespectifyIntegrationOptions = {}): void {
15
+ options = {
16
+ ...defaults,
17
+ ...userOptions,
18
+ getPostUrl: userOptions.getPostUrl ?? defaults.getPostUrl,
19
+ rateLimit: userOptions.rateLimit ?? defaults.rateLimit,
20
+ };
21
+ }
22
+
23
+ export function getIntegrationOptions(): typeof defaults {
24
+ return options;
25
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { defineTable, column, NOW } from 'astro:db';
2
+
3
+ /** Astro DB table definition for Respectify comments. Merge into your db/config.ts. */
4
+ export const RespectifyComment = defineTable({
5
+ columns: {
6
+ id: column.number({ primaryKey: true }),
7
+ postSlug: column.text(),
8
+ author: column.text(),
9
+ email: column.text({ optional: true }),
10
+ content: column.text(),
11
+ createdAt: column.date({ default: NOW }),
12
+ approved: column.boolean({ default: false }),
13
+ parentId: column.number({ optional: true }),
14
+ },
15
+ indexes: [
16
+ { on: ['postSlug'], unique: false },
17
+ { on: ['approved'], unique: false },
18
+ ],
19
+ });
20
+
21
+ /** Alias for backwards compatibility with existing schemas */
22
+ export const Comment = RespectifyComment;
package/src/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ export interface RespectifyIntegrationOptions {
2
+ /** Path to respectify.config.json (default: ./respectify.config.json) */
3
+ configPath?: string;
4
+
5
+ /** Build the canonical URL for a post — used to initialize Respectify topics */
6
+ getPostUrl?: (slug: string, site: string) => string;
7
+
8
+ /** API route path for fetching comments (default: /api/respectify/comments) */
9
+ commentsApiPath?: string;
10
+
11
+ /** Show Powered by Respectify branding (default: true) */
12
+ showBranding?: boolean;
13
+
14
+ /** Rate limit for comment submissions per IP */
15
+ rateLimit?: {
16
+ windowMs: number;
17
+ maxRequests: number;
18
+ };
19
+ }
20
+
21
+ export interface RespectifyAnalysisResult {
22
+ score: number;
23
+ approved: boolean;
24
+ feedback: string;
25
+ suggestion?: string;
26
+ isSpam?: boolean;
27
+ spamConfidence?: number;
28
+ }