Merge branch 'codex/production-intelligence-terminal' into codex/issue-3-acled-diagnostics-auth-tests
This commit is contained in:
@@ -36,6 +36,8 @@ ACLED_EMAIL=
|
|||||||
ACLED_PASSWORD=
|
ACLED_PASSWORD=
|
||||||
CLOUDFLARE_API_TOKEN=
|
CLOUDFLARE_API_TOKEN=
|
||||||
BLS_API_KEY=
|
BLS_API_KEY=
|
||||||
|
REDDIT_CLIENT_ID=
|
||||||
|
REDDIT_CLIENT_SECRET=
|
||||||
|
|
||||||
# Telegram bot and alerts
|
# Telegram bot and alerts
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|||||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
|||||||
* @calesthio
|
* @MrSphay
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Security report
|
- name: Security report
|
||||||
url: mailto:celesthioailabs@gmail.com
|
url: https://git.wilkensxl.de/MrSphay/intelligence-terminal
|
||||||
about: Report security issues privately instead of opening a public issue.
|
about: Report security issues privately instead of opening a public issue.
|
||||||
|
|||||||
68
.github/workflows/docker-publish.yml
vendored
68
.github/workflows/docker-publish.yml
vendored
@@ -1,68 +0,0 @@
|
|||||||
name: Build & Publish Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
tags: ['v*']
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
if: github.event_name != 'pull_request' && vars.DOCKERHUB_ENABLED == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
${{ vars.DOCKERHUB_ENABLED == 'true' && format('{0}/{1}', secrets.DOCKERHUB_USERNAME, 'crucix') || '' }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=sha,prefix=
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to Crucix
|
# Contributing to Intelligence Terminal
|
||||||
|
|
||||||
Crucix moves quickly, but review bandwidth is limited. The easiest way to get a change merged is to keep it small, well-scoped, and aligned with the project's direction.
|
Intelligence Terminal moves quickly, but review bandwidth is limited. The easiest way to get a change merged is to keep it small, well-scoped, and aligned with the project's private home-server deployment direction.
|
||||||
|
|
||||||
## What Contributions Are Most Helpful
|
## What Contributions Are Most Helpful
|
||||||
|
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -4,22 +4,12 @@
|
|||||||
|
|
||||||
**Your own intelligence terminal. 27 sources. One command. Zero cloud.**
|
**Your own intelligence terminal. 27 sources. One command. Zero cloud.**
|
||||||
|
|
||||||
## [Visit The Live Site: crucix.live](https://www.crucix.live/)
|
|
||||||
|
|
||||||
[](https://www.crucix.live/)
|
|
||||||
[](https://www.crucix.live/)
|
|
||||||
|
|
||||||
[](#quick-start)
|
[](#quick-start)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[-orange)](#architecture)
|
[-orange)](#architecture)
|
||||||
[](#data-sources-27)
|
[](#data-sources-27)
|
||||||
[](#docker)
|
[](#docker)
|
||||||
|
|
||||||
**Enter The Signal Network**
|
|
||||||
|
|
||||||
[](https://x.com/crucixmonitor)
|
|
||||||
[](https://discord.gg/ChVy7SF4)
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -37,15 +27,13 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **Live website:** [https://www.crucix.live/](https://www.crucix.live/)
|
> **Supported deployment:** private home-server or lab deployment through Docker, Dockge, Pangolin, or local Node.js.
|
||||||
> Explore the public demo first, then clone the repo to run Crucix locally.
|
> Runtime data stays in your configured `runs/` volume and API keys are operator-owned.
|
||||||
|
|
||||||
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
|
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
|
||||||
|
|
||||||
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
|
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
|
||||||
|
|
||||||
Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack.
|
|
||||||
|
|
||||||
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
|
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
|
||||||
|
|
||||||
## Token / Asset Warning
|
## Token / Asset Warning
|
||||||
@@ -71,8 +59,8 @@ It was built for anyone who wants to understand what's actually happening in the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repo
|
# 1. Clone the repo
|
||||||
git clone https://github.com/calesthio/Crucix.git
|
git clone ssh://git@git.wilkensxl.de:2222/MrSphay/intelligence-terminal.git
|
||||||
cd Crucix
|
cd intelligence-terminal
|
||||||
|
|
||||||
# 2. Install dependencies (just Express)
|
# 2. Install dependencies (just Express)
|
||||||
npm install
|
npm install
|
||||||
@@ -190,6 +178,39 @@ For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-ter
|
|||||||
|
|
||||||
The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`.
|
The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`.
|
||||||
|
|
||||||
|
#### Scenario Watchlist
|
||||||
|
|
||||||
|
Crucix can track operator hypotheses across sweeps with a runtime scenario file at `runs/scenarios.json`. On first run, the server creates three disabled starter examples:
|
||||||
|
|
||||||
|
- Middle East energy shock
|
||||||
|
- Macro stress spillover
|
||||||
|
- Regional escalation risk
|
||||||
|
|
||||||
|
Enable or add scenarios by editing `runs/scenarios.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "middle-east-energy-shock",
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Middle East energy shock",
|
||||||
|
"description": "Energy supply risk building from regional conflict.",
|
||||||
|
"regions": ["Middle East", "Iran", "Strait of Hormuz"],
|
||||||
|
"categories": ["osint", "energy", "maritime"],
|
||||||
|
"keywords": ["missile", "strike", "hormuz", "oil"],
|
||||||
|
"thresholds": { "watching": 2, "building": 4, "confirmed": 7 },
|
||||||
|
"invalidation": "WTI normalizes and urgent regional signals fade."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Malformed scenario config degrades safely: sweeps continue and the dashboard shows the watchlist as a config issue. Scenario state is persisted in `runs/scenario-state.json`; delete that file to reset state transitions without deleting definitions.
|
||||||
|
|
||||||
|
Scenario states are `dormant`, `watching`, `building`, and `confirmed`. The dashboard shows active scenario state, confidence, score, and recent trigger time. Briefings include a `Scenario Watchlist` section when one or more scenarios change state.
|
||||||
|
|
||||||
#### Build And Publish Your Gitea Image
|
#### Build And Publish Your Gitea Image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -330,6 +351,9 @@ These three unlock the most valuable economic and satellite data. Each takes abo
|
|||||||
| `ACLED_EMAIL` + `ACLED_PASSWORD` | Armed conflict event data | [acleddata.com/register](https://acleddata.com/register/) — free, OAuth2. `ACLED_USER` / `ACLED_USERNAME` are accepted as email aliases |
|
| `ACLED_EMAIL` + `ACLED_PASSWORD` | Armed conflict event data | [acleddata.com/register](https://acleddata.com/register/) — free, OAuth2. `ACLED_USER` / `ACLED_USERNAME` are accepted as email aliases |
|
||||||
| `AISSTREAM_API_KEY` | Maritime AIS vessel tracking | [aisstream.io](https://aisstream.io/) — free |
|
| `AISSTREAM_API_KEY` | Maritime AIS vessel tracking | [aisstream.io](https://aisstream.io/) — free |
|
||||||
| `ADSB_API_KEY` | Unfiltered flight tracking | [RapidAPI](https://rapidapi.com/adsbexchange/api/adsbexchange-com1) — ~$10/mo |
|
| `ADSB_API_KEY` | Unfiltered flight tracking | [RapidAPI](https://rapidapi.com/adsbexchange/api/adsbexchange-com1) — ~$10/mo |
|
||||||
|
| `REDDIT_CLIENT_ID` + `REDDIT_CLIENT_SECRET` | Reddit social sentiment | [reddit.com/prefs/apps](https://www.reddit.com/prefs/apps/) — create a script app |
|
||||||
|
|
||||||
|
Reddit is OAuth-only in this fork. If the Reddit credentials are missing or rejected, the Reddit source is reported as degraded and no unauthenticated `reddit.com/.../hot.json` fallback is used.
|
||||||
|
|
||||||
### LLM Provider (optional, for AI-enhanced ideas)
|
### LLM Provider (optional, for AI-enhanced ideas)
|
||||||
|
|
||||||
@@ -643,27 +667,13 @@ To update them: run the dashboard, wait for a sweep to complete, then use your b
|
|||||||
|
|
||||||
Found a bug? Want to add a 28th source? PRs welcome. Each source is a standalone module in `apis/sources/` — just export a `briefing()` function that returns structured data and add it to the orchestrator in `apis/briefing.mjs`.
|
Found a bug? Want to add a 28th source? PRs welcome. Each source is a standalone module in `apis/sources/` — just export a `briefing()` function that returns structured data and add it to the orchestrator in `apis/briefing.mjs`.
|
||||||
|
|
||||||
If you find this useful, a star helps others find it too.
|
|
||||||
|
|
||||||
For contribution guidelines, review expectations, and source-add rules, see `CONTRIBUTING.md`. For security reports, see `SECURITY.md`.
|
For contribution guidelines, review expectations, and source-add rules, see `CONTRIBUTING.md`. For security reports, see `SECURITY.md`.
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
For partnerships, integrations, or other non-issue inquiries, you can reach me at `celesthioailabs@gmail.com`.
|
For bugs and feature requests, use the Gitea issues in this repository so discussion stays visible and actionable.
|
||||||
|
|
||||||
For bugs and feature requests, please use GitHub Issues so discussion stays visible and actionable.
|
This fork keeps upstream Crucix attribution through the AGPL-3.0 license and commit history while documenting the supported private Gitea/Docker deployment path here.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=calesthio%2FCrucix&type=date&legend=top-left">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&theme=dark&legend=top-left" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&legend=top-left" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=calesthio/Crucix&type=date&legend=top-left" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security issue in Crucix, please report it privately instead of opening a public GitHub issue.
|
If you discover a security issue in Intelligence Terminal, please report it privately instead of opening a public issue.
|
||||||
|
|
||||||
Email: `celesthioailabs@gmail.com`
|
Use the private security contact configured for this Gitea repository or contact the repository owner directly.
|
||||||
|
|
||||||
Use a subject line like:
|
Use a subject line like:
|
||||||
|
|
||||||
`[Crucix Security] short description`
|
`[Intelligence Terminal Security] short description`
|
||||||
|
|
||||||
Please include:
|
Please include:
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// Reddit — social sentiment intelligence
|
// Reddit social sentiment intelligence.
|
||||||
// Reddit now requires OAuth for API access (public JSON API returns 403).
|
// Reddit API access requires OAuth. Runtime sweeps intentionally do not use
|
||||||
// Gracefully degrades when not authenticated.
|
// unauthenticated reddit.com .json scraping because it is unreliable and not
|
||||||
// To enable: register an app at https://www.reddit.com/prefs/apps/ and set
|
// acceptable for production operation.
|
||||||
// REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env
|
|
||||||
|
|
||||||
import { safeFetch } from '../utils/fetch.mjs';
|
import { safeFetch } from '../utils/fetch.mjs';
|
||||||
import '../utils/env.mjs';
|
import '../utils/env.mjs';
|
||||||
|
|
||||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
const USER_AGENT = 'Crucix/2.0 intelligence-engine';
|
||||||
|
|
||||||
const SUBREDDITS = [
|
const SUBREDDITS = [
|
||||||
'worldnews',
|
'worldnews',
|
||||||
'geopolitics',
|
'geopolitics',
|
||||||
@@ -17,48 +18,95 @@ const SUBREDDITS = [
|
|||||||
'commodities',
|
'commodities',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get OAuth token using client credentials flow (application-only)
|
export function getRedditConfig(env = process.env) {
|
||||||
async function getToken() {
|
const clientId = env.REDDIT_CLIENT_ID || '';
|
||||||
const clientId = process.env.REDDIT_CLIENT_ID;
|
const clientSecret = env.REDDIT_CLIENT_SECRET || '';
|
||||||
const clientSecret = process.env.REDDIT_CLIENT_SECRET;
|
const missing = [];
|
||||||
if (!clientId || !clientSecret) return null;
|
if (!clientId) missing.push('REDDIT_CLIENT_ID');
|
||||||
|
if (!clientSecret) missing.push('REDDIT_CLIENT_SECRET');
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
configured: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialsMessage(missing) {
|
||||||
|
return `Reddit requires OAuth. Register a script app at https://www.reddit.com/prefs/apps/ and set ${missing.join(' and ')} in .env`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getToken({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
||||||
|
const config = getRedditConfig(env);
|
||||||
|
if (!config.configured) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'no_credentials',
|
||||||
|
missing: config.missing,
|
||||||
|
error: 'missing_reddit_oauth_credentials',
|
||||||
|
message: credentialsMessage(config.missing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
const auth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
|
||||||
const res = await fetch('https://www.reddit.com/api/v1/access_token', {
|
const res = await fetchImpl('https://www.reddit.com/api/v1/access_token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Basic ${auth}`,
|
'Authorization': `Basic ${auth}`,
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'User-Agent': 'Crucix/1.0 intelligence-engine',
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
body: 'grant_type=client_credentials',
|
body: 'grant_type=client_credentials',
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'auth_failed',
|
||||||
|
error: `reddit_oauth_http_${res.status}`,
|
||||||
|
message: `Reddit OAuth token request failed with HTTP ${res.status}`,
|
||||||
|
detail: body.slice(0, 200),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.access_token || null;
|
if (!data.access_token) {
|
||||||
} catch {
|
return {
|
||||||
return null;
|
ok: false,
|
||||||
|
status: 'auth_failed',
|
||||||
|
error: 'reddit_oauth_missing_access_token',
|
||||||
|
message: 'Reddit OAuth token response did not include an access token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, status: 'ok', token: data.access_token };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'auth_failed',
|
||||||
|
error: 'reddit_oauth_request_failed',
|
||||||
|
message: e.message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch hot posts — tries OAuth first, then falls back to public endpoint
|
|
||||||
export async function getHot(subreddit, opts = {}) {
|
export async function getHot(subreddit, opts = {}) {
|
||||||
const { limit = 10, token = null } = opts;
|
const { limit = 10, token = null } = opts;
|
||||||
|
|
||||||
if (token) {
|
if (!token) {
|
||||||
// Use OAuth endpoint
|
return {
|
||||||
return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
|
status: 'no_credentials',
|
||||||
headers: {
|
error: 'reddit_oauth_required',
|
||||||
'Authorization': `Bearer ${token}`,
|
message: 'Reddit source requires OAuth; unauthenticated reddit.com .json scraping is disabled',
|
||||||
'User-Agent': 'Crucix/1.0 intelligence-engine',
|
};
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try public endpoint (may 403)
|
return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
|
||||||
return safeFetch(`https://www.reddit.com/r/${subreddit}/hot.json?limit=${limit}&raw_json=1`, {
|
source: 'Reddit',
|
||||||
headers: { 'User-Agent': 'Crucix/1.0 intelligence-engine' },
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,29 +122,46 @@ function compactPost(child) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function briefing() {
|
export async function briefing(opts = {}) {
|
||||||
const token = await getToken();
|
const {
|
||||||
|
env = process.env,
|
||||||
|
subreddits = SUBREDDITS,
|
||||||
|
delayMs = 1000,
|
||||||
|
fetchImpl = globalThis.fetch,
|
||||||
|
} = opts;
|
||||||
|
const tokenResult = await getToken({ env, fetchImpl });
|
||||||
|
|
||||||
if (!token && !process.env.REDDIT_CLIENT_ID) {
|
if (!tokenResult.ok) {
|
||||||
return {
|
return {
|
||||||
source: 'Reddit',
|
source: 'Reddit',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'no_key',
|
status: tokenResult.status,
|
||||||
message: 'Reddit requires OAuth. Register at https://www.reddit.com/prefs/apps/ (script type), set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env',
|
error: tokenResult.error,
|
||||||
|
message: tokenResult.message,
|
||||||
|
missing: tokenResult.missing || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const subredditResults = {};
|
const subredditResults = {};
|
||||||
for (const sub of SUBREDDITS) {
|
const errors = [];
|
||||||
const result = await getHot(sub, { limit: 10, token });
|
for (const sub of subreddits) {
|
||||||
|
const result = await getHot(sub, { limit: 10, token: tokenResult.token });
|
||||||
|
if (result?.error) {
|
||||||
|
errors.push({ subreddit: sub, error: result.error });
|
||||||
|
subredditResults[sub] = [];
|
||||||
|
if (delayMs > 0) await delay(delayMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const children = result?.data?.children || [];
|
const children = result?.data?.children || [];
|
||||||
subredditResults[sub] = children.map(compactPost).filter(Boolean);
|
subredditResults[sub] = children.map(compactPost).filter(Boolean);
|
||||||
await delay(token ? 1000 : 2000);
|
if (delayMs > 0) await delay(delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'Reddit',
|
source: 'Reddit',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
status: errors.length > 0 ? 'degraded' : 'ok',
|
||||||
|
...(errors.length > 0 ? { error: 'reddit_subreddit_fetch_failed', errors } : {}),
|
||||||
subreddits: subredditResults,
|
subreddits: subredditResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1652,6 +1652,14 @@ function renderRight(){
|
|||||||
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">▼</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">▼</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
||||||
}
|
}
|
||||||
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
|
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
|
||||||
|
const scenarioItems = (D.scenarios?.items || []).filter(s => s.enabled || s.state !== 'dormant').slice(0,4);
|
||||||
|
const scenarioHtml = scenarioItems.length ? scenarioItems.map(s => `
|
||||||
|
<div class="signal-row">
|
||||||
|
<strong>${s.name} <span class="delta-badge ${s.changed?'new':''}">${(s.state||'dormant').toUpperCase()}</span></strong>
|
||||||
|
<p>${s.description || ''}</p>
|
||||||
|
<div class="layer-sub">${s.confidence || 0}% confidence · score ${s.score || 0}${s.lastTriggerTime ? ' · ' + getAge(s.lastTriggerTime) : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">No active scenario watchlist items</div>`;
|
||||||
|
|
||||||
document.getElementById('rightRail').innerHTML=`
|
document.getElementById('rightRail').innerHTML=`
|
||||||
<div class="g-panel right-actions">
|
<div class="g-panel right-actions">
|
||||||
@@ -1667,6 +1675,10 @@ function renderRight(){
|
|||||||
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
||||||
${signals}
|
${signals}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="g-panel right-scenarios">
|
||||||
|
<div class="sec-head"><h3>Scenario Watchlist</h3><span class="badge">${D.scenarios?.available===false?'CONFIG':'LIVE'}</span></div>
|
||||||
|
${scenarioHtml}
|
||||||
|
</div>
|
||||||
${mobile ? '' : buildOsintPanel('right-osint', 260)}
|
${mobile ? '' : buildOsintPanel('right-osint', 260)}
|
||||||
<div class="g-panel right-core">
|
<div class="g-panel right-core">
|
||||||
<div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
|
<div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ Source docs:
|
|||||||
- [Telegram](telegram.md)
|
- [Telegram](telegram.md)
|
||||||
- [FIRMS](firms.md)
|
- [FIRMS](firms.md)
|
||||||
- [Maritime](maritime.md)
|
- [Maritime](maritime.md)
|
||||||
|
- [Reddit](reddit.md)
|
||||||
|
|||||||
33
docs/sources/reddit.md
Normal file
33
docs/sources/reddit.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Reddit Source
|
||||||
|
|
||||||
|
Reddit is used as a social sentiment input for selected geopolitical and market subreddits.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a Reddit script app at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://www.reddit.com/prefs/apps/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REDDIT_CLIENT_ID=
|
||||||
|
REDDIT_CLIENT_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Behavior
|
||||||
|
|
||||||
|
- The source uses the OAuth client credentials flow and then reads `https://oauth.reddit.com`.
|
||||||
|
- Unauthenticated `reddit.com/.../hot.json` scraping is intentionally disabled.
|
||||||
|
- Missing credentials return `status: no_credentials` and are surfaced as source degradation.
|
||||||
|
- OAuth failures return `status: auth_failed` without logging or returning the client secret.
|
||||||
|
- Subreddit fetch failures return `status: degraded` with per-subreddit errors.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apis/sources/reddit.mjs
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
212
lib/scenarios.mjs
Normal file
212
lib/scenarios.mjs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const DEFAULT_SCENARIOS = [
|
||||||
|
{
|
||||||
|
id: 'middle-east-energy-shock',
|
||||||
|
enabled: false,
|
||||||
|
name: 'Middle East energy shock',
|
||||||
|
description: 'Energy supply risk building from Middle East conflict or chokepoint pressure.',
|
||||||
|
regions: ['Middle East', 'Iran', 'Israel', 'Strait of Hormuz'],
|
||||||
|
categories: ['osint', 'energy', 'maritime'],
|
||||||
|
keywords: ['missile', 'strike', 'hormuz', 'oil', 'energy', 'blockade'],
|
||||||
|
thresholds: { watching: 2, building: 4, confirmed: 7 },
|
||||||
|
invalidation: 'WTI normalizes and regional urgent signals fade for several sweeps.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macro-stress-spillover',
|
||||||
|
enabled: false,
|
||||||
|
name: 'Macro stress spillover',
|
||||||
|
description: 'Market stress spreads from volatility into credit, rates, or commodities.',
|
||||||
|
regions: ['US', 'Global'],
|
||||||
|
categories: ['macro', 'markets'],
|
||||||
|
keywords: ['vix', 'spread', 'credit', 'yield', 'inflation', 'gold'],
|
||||||
|
thresholds: { watching: 2, building: 4, confirmed: 6 },
|
||||||
|
invalidation: 'VIX and credit stress both normalize while source health remains stable.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'regional-escalation-risk',
|
||||||
|
enabled: false,
|
||||||
|
name: 'Regional escalation risk',
|
||||||
|
description: 'Local conflict signals broaden across adjacent regions or source categories.',
|
||||||
|
regions: ['Ukraine', 'Taiwan', 'Africa', 'Middle East'],
|
||||||
|
categories: ['conflict', 'thermal', 'osint', 'air'],
|
||||||
|
keywords: ['mobilization', 'intercept', 'drone', 'ballistic', 'fatalities', 'border'],
|
||||||
|
thresholds: { watching: 2, building: 5, confirmed: 8 },
|
||||||
|
invalidation: 'No fresh cross-source escalation signals appear inside the configured horizon.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function evaluateScenarios(data, delta, runsDir) {
|
||||||
|
const loaded = loadScenarioDefinitions(runsDir);
|
||||||
|
if (!loaded.ok) {
|
||||||
|
return { available: false, error: loaded.error, items: [], changed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statePath = join(runsDir, 'scenario-state.json');
|
||||||
|
const previous = readJson(statePath, {});
|
||||||
|
const evaluatedAt = data.meta?.timestamp || new Date().toISOString();
|
||||||
|
const corpus = buildCorpus(data, delta);
|
||||||
|
const items = loaded.scenarios.map(def => evaluateScenario(def, corpus, previous[def.id], evaluatedAt));
|
||||||
|
const changed = items.filter(item => item.changed);
|
||||||
|
|
||||||
|
writeJson(statePath, Object.fromEntries(items.map(item => [item.id, {
|
||||||
|
state: item.state,
|
||||||
|
score: item.score,
|
||||||
|
confidence: item.confidence,
|
||||||
|
lastTriggerTime: item.lastTriggerTime,
|
||||||
|
updatedAt: evaluatedAt,
|
||||||
|
}])));
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
path: loaded.path,
|
||||||
|
items,
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadScenarioDefinitions(runsDir) {
|
||||||
|
const path = join(runsDir, 'scenarios.json');
|
||||||
|
try {
|
||||||
|
if (!existsSync(runsDir)) mkdirSync(runsDir, { recursive: true });
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
writeJson(path, {
|
||||||
|
version: 1,
|
||||||
|
scenarios: DEFAULT_SCENARIOS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
||||||
|
if (!raw || !Array.isArray(raw.scenarios)) throw new Error('scenarios must be an array');
|
||||||
|
const scenarios = raw.scenarios
|
||||||
|
.map(normalizeScenario)
|
||||||
|
.filter(Boolean);
|
||||||
|
return { ok: true, path, scenarios };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, path, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScenario(input) {
|
||||||
|
if (!input || typeof input !== 'object') return null;
|
||||||
|
const id = String(input.id || input.name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
const name = String(input.name || input.id || '').trim();
|
||||||
|
if (!id || !name) return null;
|
||||||
|
const thresholds = input.thresholds || {};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
enabled: input.enabled === true,
|
||||||
|
name,
|
||||||
|
description: String(input.description || ''),
|
||||||
|
regions: arrayOfStrings(input.regions),
|
||||||
|
categories: arrayOfStrings(input.categories),
|
||||||
|
keywords: arrayOfStrings(input.keywords).map(s => s.toLowerCase()),
|
||||||
|
thresholds: {
|
||||||
|
watching: Number(thresholds.watching || 2),
|
||||||
|
building: Number(thresholds.building || 4),
|
||||||
|
confirmed: Number(thresholds.confirmed || 7),
|
||||||
|
},
|
||||||
|
invalidation: String(input.invalidation || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateScenario(def, corpus, previous, evaluatedAt) {
|
||||||
|
if (!def.enabled) {
|
||||||
|
return {
|
||||||
|
...publicScenario(def),
|
||||||
|
state: 'dormant',
|
||||||
|
score: 0,
|
||||||
|
confidence: 0,
|
||||||
|
evidence: [],
|
||||||
|
changed: previous?.state && previous.state !== 'dormant',
|
||||||
|
lastTriggerTime: previous?.lastTriggerTime || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = [];
|
||||||
|
let score = 0;
|
||||||
|
for (const keyword of def.keywords) {
|
||||||
|
const hit = corpus.entries.find(entry => entry.text.includes(keyword));
|
||||||
|
if (hit) {
|
||||||
|
score += 1;
|
||||||
|
evidence.push({ type: 'keyword', label: keyword, source: hit.source, text: hit.original.slice(0, 180) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const region of def.regions) {
|
||||||
|
const needle = region.toLowerCase();
|
||||||
|
const hit = corpus.entries.find(entry => entry.text.includes(needle));
|
||||||
|
if (hit) {
|
||||||
|
score += 1;
|
||||||
|
evidence.push({ type: 'region', label: region, source: hit.source, text: hit.original.slice(0, 180) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const category of def.categories) {
|
||||||
|
if (corpus.categories.has(category.toLowerCase())) {
|
||||||
|
score += 1;
|
||||||
|
evidence.push({ type: 'category', label: category, source: 'sweep', text: `${category} category active` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = score >= def.thresholds.confirmed ? 'confirmed'
|
||||||
|
: score >= def.thresholds.building ? 'building'
|
||||||
|
: score >= def.thresholds.watching ? 'watching'
|
||||||
|
: 'dormant';
|
||||||
|
const confidence = Math.min(100, Math.round((score / Math.max(1, def.thresholds.confirmed)) * 100));
|
||||||
|
const changed = previous?.state ? previous.state !== state : state !== 'dormant';
|
||||||
|
return {
|
||||||
|
...publicScenario(def),
|
||||||
|
state,
|
||||||
|
score,
|
||||||
|
confidence,
|
||||||
|
evidence: evidence.slice(0, 6),
|
||||||
|
changed,
|
||||||
|
lastTriggerTime: state === 'dormant' ? (previous?.lastTriggerTime || null) : evaluatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicScenario(def) {
|
||||||
|
return {
|
||||||
|
id: def.id,
|
||||||
|
name: def.name,
|
||||||
|
description: def.description,
|
||||||
|
enabled: def.enabled,
|
||||||
|
invalidation: def.invalidation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCorpus(data, delta) {
|
||||||
|
const entries = [];
|
||||||
|
const categories = new Set();
|
||||||
|
const push = (source, text, category) => {
|
||||||
|
if (!text) return;
|
||||||
|
entries.push({ source, original: String(text), text: String(text).toLowerCase() });
|
||||||
|
if (category) categories.add(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const signal of data.tSignals || []) push('thermal', signal, 'thermal');
|
||||||
|
for (const post of data.tg?.urgent || []) push(post.channel || 'telegram', post.text, 'osint');
|
||||||
|
for (const item of data.newsFeed || []) push(item.source || 'news', item.headline || item.title, 'news');
|
||||||
|
for (const item of data.news || []) push(item.source || 'news', item.headline || item.title, 'news');
|
||||||
|
for (const item of data.acled?.deadliestEvents || []) push('ACLED', `${item.country || ''} ${item.location || ''} ${item.event_type || ''} ${item.fatalities || ''}`, 'conflict');
|
||||||
|
for (const item of data.air || []) push('OpenSky', `${item.region} ${item.total} aircraft`, 'air');
|
||||||
|
for (const item of data.chokepoints || []) push('Maritime', `${item.label} ${item.note}`, 'maritime');
|
||||||
|
if (data.energy?.wti || data.energy?.brent) push('energy', `WTI ${data.energy.wti} Brent ${data.energy.brent}`, 'energy');
|
||||||
|
if (data.markets?.vix || data.fred?.some(f => f.id === 'VIXCLS')) push('markets', 'VIX volatility market stress', 'markets');
|
||||||
|
if (delta?.summary) push('delta', `${delta.summary.direction} ${delta.summary.totalChanges} changes ${delta.summary.criticalChanges} critical`, 'delta');
|
||||||
|
for (const signal of delta?.signals?.new || []) push('delta', signal.label || signal.reason || signal.key, 'delta');
|
||||||
|
for (const signal of delta?.signals?.escalated || []) push('delta', signal.label || signal.reason || signal.key, 'delta');
|
||||||
|
|
||||||
|
return { entries, categories };
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayOfStrings(value) {
|
||||||
|
return Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(path, fallback) {
|
||||||
|
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(path, value) {
|
||||||
|
writeFileSync(path, JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"brief:save": "node apis/save-briefing.mjs",
|
"brief:save": "node apis/save-briefing.mjs",
|
||||||
"diag": "node diag.mjs",
|
"diag": "node diag.mjs",
|
||||||
"test": "npm run test:unit",
|
"test": "npm run test:unit",
|
||||||
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs",
|
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs",
|
||||||
"compose:config": "docker compose config",
|
"compose:config": "docker compose config",
|
||||||
"clean": "node scripts/clean.mjs",
|
"clean": "node scripts/clean.mjs",
|
||||||
"fresh-start": "npm run clean && npm start"
|
"fresh-start": "npm run clean && npm start"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { TelegramAlerter } from './lib/alerts/telegram.mjs';
|
|||||||
import { DiscordAlerter } from './lib/alerts/discord.mjs';
|
import { DiscordAlerter } from './lib/alerts/discord.mjs';
|
||||||
import { getFetchMetrics } from './apis/utils/fetch.mjs';
|
import { getFetchMetrics } from './apis/utils/fetch.mjs';
|
||||||
import { IntelligenceStore } from './lib/intelligence-store.mjs';
|
import { IntelligenceStore } from './lib/intelligence-store.mjs';
|
||||||
|
import { evaluateScenarios } from './lib/scenarios.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
@@ -447,6 +448,13 @@ function buildBrief(data) {
|
|||||||
lines.push('', '*Why This Matters*');
|
lines.push('', '*Why This Matters*');
|
||||||
for (const idea of ideas) lines.push(`- ${idea.title}: ${(idea.rationale || idea.text || '').slice(0, 140)}`);
|
for (const idea of ideas) lines.push(`- ${idea.title}: ${(idea.rationale || idea.text || '').slice(0, 140)}`);
|
||||||
}
|
}
|
||||||
|
const scenarioChanges = data.scenarios?.changed || [];
|
||||||
|
if (scenarioChanges.length) {
|
||||||
|
lines.push('', '*Scenario Watchlist*');
|
||||||
|
for (const scenario of scenarioChanges.slice(0, 4)) {
|
||||||
|
lines.push(`- ${scenario.name}: ${scenario.state.toUpperCase()} (${scenario.confidence}% confidence)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
lines.push('', '*What To Do Next*', '- Open the dashboard, verify the evidence links, and compare source health before acting.');
|
lines.push('', '*What To Do Next*', '- Open the dashboard, verify the evidence links, and compare source health before acting.');
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
@@ -493,6 +501,7 @@ async function runSweepCycle() {
|
|||||||
// 4. Delta computation + memory
|
// 4. Delta computation + memory
|
||||||
const delta = memory.addRun(synthesized);
|
const delta = memory.addRun(synthesized);
|
||||||
synthesized.delta = delta;
|
synthesized.delta = delta;
|
||||||
|
synthesized.scenarios = evaluateScenarios(synthesized, delta, RUNS_DIR);
|
||||||
|
|
||||||
// 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep
|
// 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep
|
||||||
if (llmProvider?.isConfigured) {
|
if (llmProvider?.isConfigured) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
||||||
|
|
||||||
test('safeFetch reports HTML as degraded JSON response', async () => {
|
test('safeFetch reports HTML as degraded JSON response', async () => {
|
||||||
@@ -34,3 +35,18 @@ test('safeFetchText returns text and byte count', async () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scenario watchlist feature is wired into sweep, briefing, and dashboard', () => {
|
||||||
|
const scenarios = readFileSync(new URL('../lib/scenarios.mjs', import.meta.url), 'utf8');
|
||||||
|
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
|
||||||
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
|
const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
|
||||||
|
assert.match(scenarios, /DEFAULT_SCENARIOS/);
|
||||||
|
assert.match(scenarios, /runsDir, 'scenarios\.json'/);
|
||||||
|
assert.match(scenarios, /scenario-state\.json/);
|
||||||
|
assert.match(scenarios, /watching.*building.*confirmed/s);
|
||||||
|
assert.match(server, /evaluateScenarios\(synthesized, delta, RUNS_DIR\)/);
|
||||||
|
assert.match(server, /\*Scenario Watchlist\*/);
|
||||||
|
assert.match(html, /Scenario Watchlist/);
|
||||||
|
assert.match(readme, /runs\/scenarios\.json/);
|
||||||
|
});
|
||||||
|
|||||||
109
test/reddit-source.test.mjs
Normal file
109
test/reddit-source.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { briefing, getHot, getRedditConfig, getToken } from '../apis/sources/reddit.mjs';
|
||||||
|
|
||||||
|
test('Reddit reports missing OAuth credentials without network access', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const data = await briefing({
|
||||||
|
env: {},
|
||||||
|
delayMs: 0,
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls++;
|
||||||
|
throw new Error('unexpected network access');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(data.status, 'no_credentials');
|
||||||
|
assert.equal(data.error, 'missing_reddit_oauth_credentials');
|
||||||
|
assert.deepEqual(data.missing, ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reddit hot posts require OAuth token and never use public JSON fallback', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let calledUrl = null;
|
||||||
|
globalThis.fetch = async url => {
|
||||||
|
calledUrl = url;
|
||||||
|
throw new Error('unexpected public fallback');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getHot('worldnews');
|
||||||
|
assert.equal(calledUrl, null);
|
||||||
|
assert.equal(data.status, 'no_credentials');
|
||||||
|
assert.equal(data.error, 'reddit_oauth_required');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reddit classifies OAuth HTTP failure without exposing secrets', async () => {
|
||||||
|
const result = await getToken({
|
||||||
|
env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: async () => 'invalid client',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.status, 'auth_failed');
|
||||||
|
assert.equal(result.error, 'reddit_oauth_http_401');
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /client-secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reddit fetches hot posts through oauth.reddit.com when configured', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const urls = [];
|
||||||
|
globalThis.fetch = async url => {
|
||||||
|
urls.push(String(url));
|
||||||
|
if (String(url).includes('/api/v1/access_token')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: 'test-token' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
text: async () => JSON.stringify({
|
||||||
|
data: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: 'Market stress headline',
|
||||||
|
score: 42,
|
||||||
|
num_comments: 7,
|
||||||
|
url: 'https://example.test/post',
|
||||||
|
created_utc: 1700000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await briefing({
|
||||||
|
env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
|
||||||
|
subreddits: ['worldnews'],
|
||||||
|
delayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(data.status, 'ok');
|
||||||
|
assert.equal(data.subreddits.worldnews[0].title, 'Market stress headline');
|
||||||
|
assert.ok(urls.some(url => url === 'https://www.reddit.com/api/v1/access_token'));
|
||||||
|
assert.ok(urls.some(url => url.startsWith('https://oauth.reddit.com/r/worldnews/hot')));
|
||||||
|
assert.equal(urls.some(url => url.includes('hot.json')), false);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reddit config reports partial credential state', () => {
|
||||||
|
assert.deepEqual(getRedditConfig({ REDDIT_CLIENT_ID: 'id' }).missing, ['REDDIT_CLIENT_SECRET']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user