Skip to content
Open
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,30 @@ SUMMARIES_TIMEOUT_MS="15000"
SUMMARIES_RATE_WINDOW_MS="60000"
SUMMARIES_RATE_MAX="4"

# Summaries budget preflight (optional)
# If MODEL_MAX_TOKENS is set, we reserve RESPONSE_TOKENS and trim input accordingly.
# Otherwise we use TARGET_MAX_TOKENS as an overall budget.
SUMMARIES_MODEL_MAX_TOKENS=""
SUMMARIES_TARGET_MAX_TOKENS=""
SUMMARIES_RESPONSE_TOKENS="600"
# Approx chars per token used to bound input when estimating (default 4)
SUMMARIES_CHARS_PER_TOKEN="4"
# Optional stricter caps (defaults: posts=150, chars=12000)
SUMMARIES_MAX_POSTS=""
SUMMARIES_MAX_CHARS=""

# Resend (optional, for email notifications)
RESEND_API_KEY=""
RESEND_FROM="notifications@matchday-pulse.dev"

# Bluesky verification (optional)
BSKY_VERIFY_TTL_MS="600000"

# Twitter API (optional GA integration)
# Provide a Bearer token to enable real profile resolution and recent tweet fetching.
# Leave empty to use allowlist-only placeholder data.
TWITTER_BEARER_TOKEN=""

# Remote Slack MCP Server
# Slack bot token for posting messages via Slack Web API
SLACK_BOT_TOKEN=""
Expand Down
33 changes: 22 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,41 @@ on:
- chore/**
pull_request:
branches:
- "**"
- main

jobs:
test:
name: Install and Test
test-and-build:
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
matrix:
node-version: [20.x]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: 20
node-version: ${{ matrix.node-version }}
cache: npm

- name: Install dependencies
run: npm ci
env:
CI: true

- name: Run Vitest
run: npx vitest run --reporter=verbose
- name: Run unit tests
run: npm run test:run
env:
CI: true
# Provide minimal envs so tests that rely on server-only env don't crash
SUPABASE_URL: http://localhost
SUPABASE_SERVICE_ROLE_KEY: stub
OPENAI_API_KEY: stub

# Optional: run the simple test runner as a smoke check
- name: Run simple test suite
run: npm test
- name: Build (SvelteKit)
run: npm run build
env:
CI: true
15 changes: 13 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"wink-sentiment": "^5.0.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-vercel": "^5.10.3",
"@sveltejs/kit": "^2.46.5",
"@vitest/coverage-v8": "^1.6.1",
Expand Down
8 changes: 7 additions & 1 deletion src/lib/services/bskyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,13 @@ export function summarizeSentiment(posts: SimplePost[]) {
else neuCount++;
}

const total = posts.length || 1;
const total = posts.length;
if (total === 0) {
return {
ratios: { pos: 0, neg: 0, neu: 0 },
counts: { total: 0, pos: 0, neg: 0, neu: 0 }
};
}
const pos = posCount / total;
const neg = negCount / total;
const neu = neuCount / total;
Expand Down
158 changes: 146 additions & 12 deletions src/lib/services/twitterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,59 @@ export const DEFAULT_RECENCY_MINUTES = TWITTER_DEFAULT_RECENCY_MINUTES;
* @returns An array of profiles where each entry has `handle` and `displayName` set to the handle, `followersCount` and `postsCount` left undefined, and `createdAt` set to `null`
*/
export async function resolveAllowlistProfiles(handles: string[] = TWITTER_ALLOWLIST): Promise<TwitterProfileBasic[]> {
return handles.map((h) => ({
handle: h,
displayName: h,
followersCount: undefined,
postsCount: undefined,
createdAt: null
}));
const bearer = process.env.TWITTER_BEARER_TOKEN;
// If no bearer token configured, fall back to minimal profiles
if (!bearer) {
return handles.map((h) => ({
handle: h,
displayName: h,
followersCount: undefined,
postsCount: undefined,
createdAt: null
}));
}

// With bearer token, try to resolve basic profile info; gracefully fall back per-handle on errors
const resolved: TwitterProfileBasic[] = [];
for (const handle of handles) {
try {
const url = `https://api.twitter.com/2/users/by/username/${encodeURIComponent(handle)}?user.fields=created_at,public_metrics,name,username`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${bearer}` }
});
if (!res.ok) throw new Error(`twitter_user_lookup_failed ${res.status}`);
const data: any = await res.json();
const u = data?.data;
if (u?.id) {
resolved.push({
user_id: String(u.id),
handle: u.username || handle,
displayName: u.name || handle,
followersCount: u.public_metrics?.followers_count ?? undefined,
postsCount: u.public_metrics?.tweet_count ?? undefined,
createdAt: u.created_at ?? null
});
continue;
}
// Fallback if response incomplete
resolved.push({
handle,
displayName: handle,
followersCount: undefined,
postsCount: undefined,
createdAt: null
});
} catch {
resolved.push({
handle,
displayName: handle,
followersCount: undefined,
postsCount: undefined,
createdAt: null
});
}
}
return resolved;
}

/**
Expand Down Expand Up @@ -126,11 +172,28 @@ function keyOf(p: TwitterProfileBasic): string {
export async function selectEligibleAccounts(params?: { matchId?: string | null }): Promise<SelectedAccount[]> {
const matchId = params?.matchId ?? null;

// 1) Start from allowlist-based resolution
const baseProfiles = await resolveAllowlistProfiles();
// 1) Start from allowlist-based resolution (support vi.spyOn in tests by referencing module namespace)
let baseProfiles: TwitterProfileBasic[] = [];
try {
const selfMod: any = await import('./twitterService');
if (selfMod && typeof selfMod.resolveAllowlistProfiles === 'function') {
baseProfiles = await selfMod.resolveAllowlistProfiles();
} else {
baseProfiles = await resolveAllowlistProfiles();
}
} catch {
baseProfiles = await resolveAllowlistProfiles();
}

// 2) Load overrides (per-match takes precedence over global)
const { include: inc, exclude: exc } = await getOverrides({ platform: 'twitter', matchId });
let ov: any;
try {
ov = await getOverrides({ platform: 'twitter', matchId });
} catch {
ov = { include: [], exclude: [] };
}
const inc = (ov?.include ?? []) as any[];
const exc = (ov?.exclude ?? []) as any[];

// Build exclude set
const excludeKeys = new Set<string>();
Expand Down Expand Up @@ -206,7 +269,78 @@ export async function fetchRecentTweetsForAccounts(
_accounts: SelectedAccount[],
_sinceMinutes: number = DEFAULT_RECENCY_MINUTES
): Promise<SimpleTweet[]> {
return [];
const bearer = process.env.TWITTER_BEARER_TOKEN;
if (!_accounts?.length || !bearer) {
return [];
}

const startIso = new Date(Date.now() - Math.max(1, _sinceMinutes) * 60_000).toISOString();

// Helper to resolve user id if missing
async function ensureUserId(acc: SelectedAccount): Promise<{ user_id?: string; handle: string; displayName?: string }> {
const basic = {
user_id: acc.profile.user_id,
handle: acc.profile.handle,
displayName: acc.profile.displayName
};
if (basic.user_id) return basic;

try {
const url = `https://api.twitter.com/2/users/by/username/${encodeURIComponent(basic.handle)}?user.fields=name,username`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${bearer}` }
});
if (!res.ok) return basic;
const data: any = await res.json();
const u = data?.data;
if (u?.id) {
return {
user_id: String(u.id),
handle: u.username || basic.handle,
displayName: u.name || basic.displayName || basic.handle
};
}
return basic;
} catch {
return basic;
}
}

const tweets: SimpleTweet[] = [];
for (const acc of _accounts) {
try {
const auth = { Authorization: `Bearer ${bearer}` };
const user = await ensureUserId(acc);
if (!user.user_id) continue;

// Fetch recent tweets for this user since startIso
const url = new URL(`https://api.twitter.com/2/users/${encodeURIComponent(user.user_id)}/tweets`);
url.searchParams.set('max_results', '100');
url.searchParams.set('start_time', startIso);
url.searchParams.set('tweet.fields', 'created_at');

const res = await fetch(url.toString(), { headers: auth });
if (!res.ok) continue;
const data: any = await res.json();
const arr: any[] = Array.isArray(data?.data) ? data.data : [];
for (const t of arr) {
if (!t?.id || !t?.text || !t?.created_at) continue;
tweets.push({
id: String(t.id),
author: { user_id: user.user_id, handle: user.handle, displayName: user.displayName },
text: t.text,
createdAt: t.created_at
});
}
} catch {
// skip account on error
continue;
}
}

// Sort newest first
tweets.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
return tweets;
}

/**
Expand Down Expand Up @@ -242,4 +376,4 @@ export async function getAccountsSnapshot(): Promise<
createdAt: a.profile.createdAt ?? null,
eligibility: a.eligibility
}));
}
}
Loading
Loading