Skip to content

Commit 7cb6fc9

Browse files
Add Atlos integration, incident export, and Docker full stack.
Stop tracking llm-wiki-vault and secrets; expand gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 23d39cf commit 7cb6fc9

23 files changed

Lines changed: 880 additions & 7 deletions

.env.example

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
APP_NAME=groupint
2+
3+
# Neo4j (docker-compose.desktop.yml)
4+
NEO4J_URI=bolt://groupint-neo4j:7687
5+
NEO4J_USERNAME=neo4j
6+
NEO4J_PASSWORD=difficulties-pushup-gaps
7+
8+
# Telegram (main app + incident worker)
9+
TELEGRAM_PHONE=
10+
TELEGRAM_API_ID=
11+
TELEGRAM_API_HASH=
12+
13+
# Incident LLM pipeline
14+
INCIDENT_LLM_PROVIDER=anthropic
15+
ANTHROPIC_API_KEY=
16+
OPENAI_API_KEY=
17+
GOOGLE_MAPS_API_KEY=
18+
INCIDENT_POLL_INTERVAL_SEC=300
19+
20+
# Atlos export (defaults: local Docker stack from scripts/up-full.sh)
21+
ATLOS_BASE_URL=http://atlos:4000
22+
ATLOS_API_TOKEN=
23+
24+
# Atlos database (docker-compose.atlos.yml only)
25+
ATLOS_DB_USER=atlos
26+
ATLOS_DB_PASSWORD=atlos
27+
ATLOS_DB_NAME=atlos

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,16 @@ venv
6161
.idea
6262
*.session
6363
*.session-journal
64+
65+
# Local agent knowledge base (not for the public repo)
66+
llm-wiki-vault/
67+
68+
# Secrets and local environment
69+
.env
70+
.streamlit/secrets.toml
71+
72+
# Local lecture drafts
73+
lectures/
74+
75+
# IDE / Cursor
76+
.cursor/

.gitmodules

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[submodule "vendor/atlos"]
2+
path = vendor/atlos
3+
url = https://github.com/atlosdotorg/atlos.git
4+
shallow = true

core/incidents/atlos_export.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Export Groupint incidents to Atlos API v2."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import time
7+
from typing import Any
8+
from urllib.parse import urljoin
9+
10+
import httpx
11+
12+
from core.incidents.config import (
13+
apply_atlos_secrets,
14+
default_atlos_api_token,
15+
default_atlos_base_url,
16+
)
17+
18+
logger = logging.getLogger(__name__)
19+
20+
DEFAULT_SENSITIVE = ["Not Sensitive"]
21+
DEFAULT_STATUS = "To Do"
22+
REQUEST_TIMEOUT = 60.0
23+
24+
25+
def normalize_base_url(url: str) -> str:
26+
return (url or "").strip().rstrip("/")
27+
28+
29+
def atlos_config() -> dict[str, str]:
30+
"""Neo4j saved settings override env defaults (local Docker by default)."""
31+
from db.dal import GraphManager
32+
33+
apply_atlos_secrets()
34+
row = GraphManager.get_incident_monitor_config()
35+
base = normalize_base_url(row.get("atlos_base_url") or "") or default_atlos_base_url()
36+
token = (row.get("atlos_api_token") or "").strip() or default_atlos_api_token()
37+
return {"base_url": base, "api_token": token}
38+
39+
40+
def incident_to_atlos_payload(
41+
inc: dict,
42+
*,
43+
sensitive: list[str] | None = None,
44+
status: str = DEFAULT_STATUS,
45+
) -> dict[str, Any]:
46+
category = (inc.get("category") or "other").strip()
47+
location = (inc.get("location_text") or "").strip()
48+
summary = (inc.get("summary") or "").strip()
49+
occurred = inc.get("occurred_at") or ""
50+
parts = [f"[{category}]"]
51+
if location:
52+
parts.append(location)
53+
if summary:
54+
parts.append(summary)
55+
if occurred:
56+
parts.append(f"Occurred: {occurred}")
57+
description = "\n\n".join(parts).strip()
58+
if len(description) < 8:
59+
description = (description + " — Groupint OSINT incident export").strip()
60+
if len(description) < 8:
61+
description = "Groupint OSINT incident export."
62+
63+
payload: dict[str, Any] = {
64+
"description": description,
65+
"sensitive": sensitive or DEFAULT_SENSITIVE,
66+
"status": status,
67+
"tags": [category],
68+
}
69+
lat, lon = inc.get("lat"), inc.get("lon")
70+
if lat is not None and lon is not None:
71+
payload["geolocation"] = f"{float(lat)},{float(lon)}"
72+
urls = []
73+
for u in inc.get("source_urls") or []:
74+
s = str(u).strip()
75+
if s.startswith("http://") or s.startswith("https://"):
76+
urls.append(s)
77+
if urls:
78+
payload["urls"] = urls
79+
return payload
80+
81+
82+
def _extract_slug(data: dict | list | None) -> str | None:
83+
if not data:
84+
return None
85+
if isinstance(data, dict):
86+
for key in ("slug", "incident_slug"):
87+
if data.get(key):
88+
return str(data[key])
89+
for nested in ("incident", "data", "result"):
90+
inner = data.get(nested)
91+
if isinstance(inner, dict):
92+
found = _extract_slug(inner)
93+
if found:
94+
return found
95+
return None
96+
97+
98+
def create_atlos_incident(
99+
client: httpx.Client,
100+
base_url: str,
101+
token: str,
102+
payload: dict[str, Any],
103+
) -> str:
104+
url = urljoin(base_url + "/", "api/v2/incidents/new")
105+
resp = client.post(
106+
url,
107+
json=payload,
108+
headers={"Authorization": f"Bearer {token}"},
109+
timeout=REQUEST_TIMEOUT,
110+
)
111+
resp.raise_for_status()
112+
try:
113+
body = resp.json()
114+
except Exception:
115+
body = {}
116+
slug = _extract_slug(body if isinstance(body, dict) else {})
117+
if slug:
118+
return slug
119+
raise ValueError(f"Atlos response missing slug: {body!r}")
120+
121+
122+
def test_atlos_connection(base_url: str, token: str) -> tuple[bool, str]:
123+
if not token:
124+
return False, "API token is empty."
125+
base_url = normalize_base_url(base_url)
126+
url = urljoin(base_url + "/", "api/v2/incidents")
127+
try:
128+
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
129+
resp = client.get(
130+
url,
131+
headers={"Authorization": f"Bearer {token}"},
132+
params={"limit": 1},
133+
)
134+
if resp.status_code in (200, 401, 403):
135+
if resp.status_code == 200:
136+
return True, "Connected to Atlos API."
137+
return False, f"Atlos returned HTTP {resp.status_code}: check API token."
138+
return False, f"Unexpected HTTP {resp.status_code}: {resp.text[:200]}"
139+
except httpx.RequestError as exc:
140+
return False, f"Could not reach Atlos: {exc}"
141+
142+
143+
def export_incidents_batch(
144+
incidents: list[dict],
145+
*,
146+
base_url: str,
147+
api_token: str,
148+
sensitive: list[str] | None = None,
149+
skip_exported: bool = True,
150+
delay_sec: float = 0.3,
151+
) -> dict[str, Any]:
152+
base_url = normalize_base_url(base_url)
153+
if not api_token:
154+
return {
155+
"created": 0,
156+
"skipped": len(incidents),
157+
"failed": 0,
158+
"errors": ["ATLOS_API_TOKEN is not set."],
159+
}
160+
from db.dal import GraphManager
161+
162+
created = 0
163+
skipped = 0
164+
failed = 0
165+
errors: list[str] = []
166+
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
167+
for inc in incidents:
168+
if skip_exported and inc.get("atlos_slug"):
169+
skipped += 1
170+
continue
171+
try:
172+
payload = incident_to_atlos_payload(inc, sensitive=sensitive)
173+
slug = create_atlos_incident(client, base_url, api_token, payload)
174+
GraphManager.set_incident_atlos_export(str(inc["id"]), slug)
175+
created += 1
176+
if delay_sec > 0:
177+
time.sleep(delay_sec)
178+
except Exception as exc:
179+
failed += 1
180+
msg = f"{inc.get('id')}: {exc}"
181+
errors.append(msg)
182+
logger.warning("Atlos export failed: %s", msg)
183+
return {
184+
"created": created,
185+
"skipped": skipped,
186+
"failed": failed,
187+
"errors": errors,
188+
}

core/incidents/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,35 @@ def apply_incidents_secrets() -> None:
115115
except Exception:
116116
pass
117117
break
118+
119+
120+
def default_atlos_base_url() -> str:
121+
return (os.environ.get("ATLOS_BASE_URL") or "http://atlos:4000").strip().rstrip("/")
122+
123+
124+
def default_atlos_api_token() -> str:
125+
return (os.environ.get("ATLOS_API_TOKEN") or "").strip()
126+
127+
128+
def apply_atlos_secrets() -> None:
129+
"""Load [atlos] from secrets.toml into env when not already set."""
130+
for path in ("/app/.streamlit/secrets.toml", ".streamlit/secrets.toml"):
131+
if not os.path.isfile(path):
132+
continue
133+
try:
134+
import tomllib
135+
136+
with open(path, "rb") as fh:
137+
atlos = tomllib.load(fh).get("atlos") or {}
138+
for env_key, secret_key in (
139+
("ATLOS_BASE_URL", "base_url"),
140+
("ATLOS_API_TOKEN", "api_token"),
141+
):
142+
if os.environ.get(env_key):
143+
continue
144+
val = atlos.get(secret_key)
145+
if val is not None and str(val).strip():
146+
os.environ[env_key] = str(val).strip()
147+
except Exception:
148+
pass
149+
break
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Parse channel lists for bulk watchlist import."""
2+
3+
from __future__ import annotations
4+
5+
import csv
6+
import io
7+
import json
8+
import re
9+
10+
_TME_LINK = re.compile(
11+
r"^(?:https?://)?(?:www\.)?t\.me/(?P<ref>\+?[\w-]+)/?$",
12+
re.IGNORECASE,
13+
)
14+
15+
16+
def _normalize_channel(raw: str) -> str | None:
17+
"""Same rules as tg_api_connector.normalize_telegram_group_ref (no Telethon import)."""
18+
value = (raw or "").strip()
19+
if not value or value.startswith("#"):
20+
return None
21+
match = _TME_LINK.match(value)
22+
if match:
23+
ref = match.group("ref")
24+
elif value.startswith("@"):
25+
ref = value[1:]
26+
else:
27+
ref = value
28+
if not ref or len(ref) < 2:
29+
return None
30+
return ref
31+
32+
33+
def parse_channel_lines(text: str) -> list[str]:
34+
"""Split pasted text by newlines, commas, or semicolons; dedupe in order."""
35+
if not text or not str(text).strip():
36+
return []
37+
seen: set[str] = set()
38+
out: list[str] = []
39+
for line in str(text).splitlines():
40+
for chunk in re.split(r"[,;]", line):
41+
ref = _normalize_channel(chunk)
42+
if ref and ref not in seen:
43+
seen.add(ref)
44+
out.append(ref)
45+
return out
46+
47+
48+
def _channels_from_csv(raw: bytes) -> list[str]:
49+
text = raw.decode("utf-8-sig", errors="replace")
50+
reader = csv.reader(io.StringIO(text))
51+
rows = list(reader)
52+
if not rows:
53+
return []
54+
header = [c.strip().lower() for c in rows[0]]
55+
channel_cols = {"channel_ref", "channel", "username", "url", "link", "t.me"}
56+
start = 0
57+
col_idx = 0
58+
if header and any(h in channel_cols for h in header):
59+
for i, h in enumerate(header):
60+
if h in channel_cols:
61+
col_idx = i
62+
break
63+
start = 1
64+
seen: set[str] = set()
65+
out: list[str] = []
66+
for row in rows[start:]:
67+
if not row or col_idx >= len(row):
68+
continue
69+
ref = _normalize_channel(row[col_idx])
70+
if ref and ref not in seen:
71+
seen.add(ref)
72+
out.append(ref)
73+
return out
74+
75+
76+
def _channels_from_json(raw: bytes) -> list[str]:
77+
data = json.loads(raw.decode("utf-8-sig", errors="replace"))
78+
items: list = []
79+
if isinstance(data, list):
80+
items = data
81+
elif isinstance(data, dict):
82+
for key in ("channels", "watchlist", "items"):
83+
if key in data and isinstance(data[key], list):
84+
items = data[key]
85+
break
86+
seen: set[str] = set()
87+
out: list[str] = []
88+
for item in items:
89+
if isinstance(item, str):
90+
raw_val = item
91+
elif isinstance(item, dict):
92+
raw_val = (
93+
item.get("channel_ref")
94+
or item.get("channel")
95+
or item.get("url")
96+
or item.get("link")
97+
or ""
98+
)
99+
else:
100+
continue
101+
ref = _normalize_channel(str(raw_val))
102+
if ref and ref not in seen:
103+
seen.add(ref)
104+
out.append(ref)
105+
return out
106+
107+
108+
def parse_channels_from_upload(uploaded) -> list[str]:
109+
"""Parse .txt, .csv, or .json channel list from Streamlit upload."""
110+
raw: bytes = uploaded.getvalue()
111+
name = (uploaded.name or "").lower()
112+
if name.endswith(".json"):
113+
return _channels_from_json(raw)
114+
if name.endswith(".csv"):
115+
return _channels_from_csv(raw)
116+
return parse_channel_lines(raw.decode("utf-8-sig", errors="replace"))

0 commit comments

Comments
 (0)