truesignal-cli 0.1.0__py3-none-any.whl

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.
truesignal/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ Library entry point -- import truesignal's connectors and provenance layer programmatically.
3
+
4
+ from truesignal import ALL_CONNECTORS, get_connector
5
+
6
+ for connector in ALL_CONNECTORS:
7
+ for item in connector.fetch_items():
8
+ print(item.status, item.url)
9
+
10
+ This is the Python port of the truesignal-cli npm package
11
+ (https://www.npmjs.com/package/truesignal-cli). Both distributions ship the same five source
12
+ connectors (CISA-KEV, Cloudflare Radar, Reddit, Telegram, GDELT) and the same no-fabrication
13
+ provenance guarantee; see https://github.com/RudrenduPaul/truesignal for the canonical
14
+ documentation and the original TypeScript source.
15
+ """
16
+ from .connectors import ALL_CONNECTORS, get_connector
17
+ from .provenance import (
18
+ fetch_with_fallback,
19
+ get_cache_dir,
20
+ read_cache,
21
+ stamp_fallback,
22
+ stamp_live,
23
+ write_cache,
24
+ )
25
+ from .types import Connector, ConnectorNotConfiguredError, FeedItem, ItemStatus, UnstampedItem
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "Connector",
31
+ "FeedItem",
32
+ "ItemStatus",
33
+ "UnstampedItem",
34
+ "ConnectorNotConfiguredError",
35
+ "ALL_CONNECTORS",
36
+ "get_connector",
37
+ "fetch_with_fallback",
38
+ "stamp_live",
39
+ "stamp_fallback",
40
+ "read_cache",
41
+ "write_cache",
42
+ "get_cache_dir",
43
+ "__version__",
44
+ ]
truesignal/_util.py ADDED
@@ -0,0 +1,28 @@
1
+ """Small internal helpers shared across connectors and the provenance layer. Not part of the
2
+ public API (hence the leading underscore) -- see truesignal/__init__.py for the public surface."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def format_iso8601(dt: datetime) -> str:
9
+ """
10
+ Formats a datetime as a JavaScript-``Date.prototype.toISOString()``-equivalent string:
11
+ always UTC, always exactly 3 millisecond digits, always a trailing "Z". Naive datetimes are
12
+ assumed to already be UTC (every caller in this codebase constructs them that way).
13
+ """
14
+ if dt.tzinfo is None:
15
+ dt = dt.replace(tzinfo=timezone.utc)
16
+ dt = dt.astimezone(timezone.utc)
17
+ milliseconds = dt.microsecond // 1000
18
+ return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{milliseconds:03d}Z"
19
+
20
+
21
+ def parse_iso8601(value: str) -> datetime:
22
+ """Parses a real upstream ISO-8601 timestamp (with or without a trailing 'Z') into an aware
23
+ UTC datetime. Raises ValueError on anything that isn't a real, parseable instant."""
24
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
25
+ dt = datetime.fromisoformat(normalized)
26
+ if dt.tzinfo is None:
27
+ dt = dt.replace(tzinfo=timezone.utc)
28
+ return dt.astimezone(timezone.utc)
truesignal/cli.py ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ truesignal CLI entry point. This module is deliberately thin -- it wires argparse's argument
4
+ parsing to the testable logic in cli_helpers.py and returns process exit codes. See ExitCode in
5
+ cli_helpers.py for what each code means.
6
+
7
+ Ported from src/truesignal/cli.ts (which uses `commander`); this port uses the stdlib `argparse`
8
+ to avoid a CLI-framework dependency. Console entry point: `truesignal <command> [options]`,
9
+ installed via the `truesignal` console-script defined in python/pyproject.toml.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from typing import List, Optional
17
+
18
+ from .cli_helpers import (
19
+ ExitCode,
20
+ format_feed_item_human,
21
+ format_init_report,
22
+ get_connector_statuses,
23
+ run_feed,
24
+ run_verify,
25
+ )
26
+ from .connectors import ALL_CONNECTORS
27
+
28
+ _VERSION = "0.1.0"
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="truesignal",
34
+ description=(
35
+ "A provenance-first OSINT/security intelligence feed. Every item carries a real "
36
+ "source URL, a real timestamp, and an explicit live/fallback flag -- never a "
37
+ "fabricated or silently-replayed data point."
38
+ ),
39
+ )
40
+ parser.add_argument("--version", "-V", action="version", version=f"truesignal-cli {_VERSION}")
41
+
42
+ subparsers = parser.add_subparsers(dest="command")
43
+
44
+ init_parser = subparsers.add_parser(
45
+ "init",
46
+ help=(
47
+ "Check which connectors are ready to use right now and which environment variables "
48
+ "are still needed for the rest. CISA-KEV and GDELT need no configuration -- "
49
+ "truesignal works with zero setup for those two sources."
50
+ ),
51
+ )
52
+ init_parser.add_argument(
53
+ "--json", action="store_true", help="print machine-readable JSON instead of a human-readable report"
54
+ )
55
+
56
+ feed_parser = subparsers.add_parser(
57
+ "feed",
58
+ help=(
59
+ "Pull the current feed from every configured connector, or one connector with "
60
+ "--source. Prints human-readable output by default; use --json for a stable, "
61
+ "agent-parseable schema."
62
+ ),
63
+ )
64
+ feed_parser.add_argument("--source", default=None, help="only pull from this connector, e.g. cisa-kev, gdelt")
65
+ feed_parser.add_argument("--json", action="store_true", help="print machine-readable JSON instead of human-readable text")
66
+
67
+ verify_parser = subparsers.add_parser(
68
+ "verify",
69
+ help=(
70
+ "Re-fetch the source connector named in <item-id> and confirm whether that item "
71
+ "still resolves to real, live provenance, has fallen back to cached data, or can no "
72
+ "longer be found."
73
+ ),
74
+ )
75
+ verify_parser.add_argument("item_id", help="a feed item id from `truesignal feed`, e.g. cisa-kev:CVE-2026-12345")
76
+ verify_parser.add_argument("--json", action="store_true", help="print machine-readable JSON instead of human-readable text")
77
+
78
+ return parser
79
+
80
+
81
+ def run_cli(argv: List[str]) -> int:
82
+ """
83
+ `argv` follows the sys.argv convention: argv[0] is the program name, the real arguments start
84
+ at argv[1]. Returns the process exit code.
85
+ """
86
+ parser = build_parser()
87
+ args = parser.parse_args(argv[1:])
88
+
89
+ if args.command == "init":
90
+ return _run_init(json_output=args.json)
91
+ if args.command == "feed":
92
+ return _run_feed(source=args.source, json_output=args.json)
93
+ if args.command == "verify":
94
+ return _run_verify(item_id=args.item_id, json_output=args.json)
95
+
96
+ parser.print_help()
97
+ return ExitCode.SUCCESS
98
+
99
+
100
+ def _run_init(json_output: bool) -> int:
101
+ statuses = get_connector_statuses(ALL_CONNECTORS)
102
+ if json_output:
103
+ print(
104
+ json.dumps(
105
+ {
106
+ "connectors": [
107
+ {
108
+ "name": s.name,
109
+ "label": s.label,
110
+ "requires_config": s.requires_config,
111
+ "configured": s.configured,
112
+ "missing_env_vars": s.missing_env_vars,
113
+ }
114
+ for s in statuses
115
+ ]
116
+ },
117
+ indent=2,
118
+ )
119
+ )
120
+ else:
121
+ print(format_init_report(statuses))
122
+ ready_count = sum(1 for s in statuses if not s.requires_config or s.configured)
123
+ return ExitCode.SUCCESS if ready_count > 0 else ExitCode.NO_CONNECTORS_CONFIGURED
124
+
125
+
126
+ def _run_feed(source: Optional[str], json_output: bool) -> int:
127
+ result = run_feed(ALL_CONNECTORS, source)
128
+
129
+ if result.unknown_source:
130
+ known = ", ".join(c.name for c in ALL_CONNECTORS)
131
+ sys.stderr.write(f'Unknown source "{result.unknown_source}". Known sources: {known}\n')
132
+ return ExitCode.GENERAL_ERROR
133
+
134
+ if not result.items and result.skipped and not result.failures:
135
+ if json_output:
136
+ print(json.dumps(result.to_dict(), indent=2))
137
+ else:
138
+ sys.stderr.write(
139
+ f"No connectors are configured to run. Missing: {', '.join(result.skipped)}. "
140
+ f'Run "truesignal init" to see what\'s needed.\n'
141
+ )
142
+ return ExitCode.NO_CONNECTORS_CONFIGURED
143
+
144
+ if json_output:
145
+ print(json.dumps(result.to_dict(), indent=2))
146
+ else:
147
+ if result.skipped:
148
+ print(f"Skipped (not configured): {', '.join(result.skipped)}")
149
+ if not result.items:
150
+ print("No items returned.")
151
+ for item in result.items:
152
+ print(format_feed_item_human(item))
153
+ for failure in result.failures:
154
+ sys.stderr.write(f"{failure.source}: {failure.error}\n")
155
+
156
+ if not result.items and result.failures:
157
+ return ExitCode.NETWORK_ERROR
158
+ return ExitCode.SUCCESS
159
+
160
+
161
+ def _run_verify(item_id: str, json_output: bool) -> int:
162
+ result = run_verify(ALL_CONNECTORS, item_id)
163
+
164
+ if json_output:
165
+ print(json.dumps(result.to_dict(), indent=2))
166
+ elif result.error_message:
167
+ sys.stderr.write(f"{result.error_message}\n")
168
+ elif result.found:
169
+ if result.status == "live":
170
+ provenance = "LIVE"
171
+ else:
172
+ provenance = f"FALLBACK ({result.fallback_age_seconds or 0}s old cached data)"
173
+ print(f"{item_id}: {provenance} -- {result.url} -- {result.timestamp}")
174
+ else:
175
+ print(f"{item_id}: not found in the current feed (it may have rolled off, or never existed).")
176
+
177
+ if result.error_kind in ("invalid-id", "unknown-source"):
178
+ return ExitCode.INVALID_ITEM_ID
179
+ if result.error_kind == "not-configured":
180
+ return ExitCode.NO_CONNECTORS_CONFIGURED
181
+ if result.error_kind == "network-error":
182
+ return ExitCode.NETWORK_ERROR
183
+ if not result.found:
184
+ return ExitCode.GENERAL_ERROR
185
+ return ExitCode.SUCCESS
186
+
187
+
188
+ def main() -> None:
189
+ try:
190
+ code = run_cli(sys.argv)
191
+ except SystemExit:
192
+ raise
193
+ except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
194
+ sys.stderr.write(f"{error}\n")
195
+ sys.exit(ExitCode.GENERAL_ERROR)
196
+ else:
197
+ sys.exit(code)
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
@@ -0,0 +1,314 @@
1
+ """
2
+ Testable logic behind the CLI. cli.py wires this up to argparse and sys.exit(); every function
3
+ here is pure enough to unit test without spawning a real process.
4
+
5
+ Direct port of src/truesignal/cli-helpers.ts. Field/function names are snake_case here (Python
6
+ convention) where the TypeScript source uses camelCase.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import concurrent.futures
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from enum import IntEnum
15
+ from typing import Dict, List, Optional, Sequence, Tuple
16
+
17
+ from ._util import parse_iso8601
18
+ from .types import Connector, FeedItem, ItemStatus
19
+
20
+
21
+ class ExitCode(IntEnum):
22
+ """Exit codes this CLI uses. Documented here and in the per-subcommand --help text."""
23
+
24
+ #: Command completed successfully.
25
+ SUCCESS = 0
26
+ #: Unexpected/uncategorized error.
27
+ GENERAL_ERROR = 1
28
+ #: The requested connector(s) exist but none are configured -- nothing could run.
29
+ NO_CONNECTORS_CONFIGURED = 2
30
+ #: A configured connector's upstream fetch failed and produced no fallback data either.
31
+ NETWORK_ERROR = 3
32
+ #: `verify <item-id>` was given an id that doesn't parse or names an unknown source.
33
+ INVALID_ITEM_ID = 4
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ConnectorStatus:
38
+ name: str
39
+ label: str
40
+ requires_config: bool
41
+ configured: bool
42
+ missing_env_vars: List[str] = field(default_factory=list)
43
+
44
+
45
+ def get_connector_statuses(connectors: Sequence[Connector]) -> List[ConnectorStatus]:
46
+ """Computes the configuration status of every connector, for `truesignal init`."""
47
+ statuses = []
48
+ for connector in connectors:
49
+ configured = connector.is_configured()
50
+ missing = list(connector.config_env_vars) if connector.requires_config and not configured else []
51
+ statuses.append(
52
+ ConnectorStatus(
53
+ name=connector.name,
54
+ label=connector.label,
55
+ requires_config=connector.requires_config,
56
+ configured=configured,
57
+ missing_env_vars=missing,
58
+ )
59
+ )
60
+ return statuses
61
+
62
+
63
+ def format_init_report(statuses: Sequence[ConnectorStatus]) -> str:
64
+ """Human-readable report for `truesignal init`."""
65
+ lines = ["truesignal connector status:", ""]
66
+ for status in statuses:
67
+ if not status.requires_config:
68
+ lines.append(f" [ready] {status.label} ({status.name}) -- no configuration needed")
69
+ elif status.configured:
70
+ lines.append(f" [ready] {status.label} ({status.name}) -- configured")
71
+ else:
72
+ lines.append(
73
+ f" [not configured] {status.label} ({status.name}) -- set {', '.join(status.missing_env_vars)}"
74
+ )
75
+ ready_count = sum(1 for s in statuses if not s.requires_config or s.configured)
76
+ lines.append("")
77
+ lines.append(f"{ready_count}/{len(statuses)} connectors ready.")
78
+ if ready_count == 0:
79
+ lines.append(
80
+ "No connectors are usable. This should not happen -- CISA-KEV and GDELT need no configuration."
81
+ )
82
+ else:
83
+ if ready_count < len(statuses):
84
+ lines.append("Set the missing environment variables above to enable the rest. See .env.example.")
85
+ lines.append('Next: run "truesignal feed" to see your feed now.')
86
+ return "\n".join(lines)
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ConnectorSelection:
91
+ #: Connectors to actually query.
92
+ to_query: List[Connector]
93
+ #: Connectors that exist but are not configured, skipped rather than failed on.
94
+ skipped: List[Connector]
95
+ #: Set if --source named a connector that does not exist.
96
+ unknown_source: Optional[str] = None
97
+
98
+
99
+ def select_connectors(
100
+ all_connectors: Sequence[Connector],
101
+ source_filter: Optional[str] = None,
102
+ ) -> ConnectorSelection:
103
+ """
104
+ Resolves which connectors `truesignal feed` should query, given an optional --source filter.
105
+ Unconfigured connectors are skipped (reported, never crashed on); an unknown --source name is
106
+ reported back as an error rather than silently ignored.
107
+ """
108
+ candidates = list(all_connectors)
109
+ if source_filter:
110
+ match = next((c for c in all_connectors if c.name == source_filter), None)
111
+ if not match:
112
+ return ConnectorSelection(to_query=[], skipped=[], unknown_source=source_filter)
113
+ candidates = [match]
114
+ to_query = [c for c in candidates if not c.requires_config or c.is_configured()]
115
+ skipped = [c for c in candidates if c.requires_config and not c.is_configured()]
116
+ return ConnectorSelection(to_query=to_query, skipped=skipped)
117
+
118
+
119
+ def _relative_age(timestamp: str, now: datetime) -> str:
120
+ ms = (now - parse_iso8601(timestamp)).total_seconds() * 1000
121
+ seconds = max(0, round(ms / 1000))
122
+ if seconds < 60:
123
+ return f"{seconds}s ago"
124
+ minutes = round(seconds / 60)
125
+ if minutes < 60:
126
+ return f"{minutes}m ago"
127
+ hours = round(minutes / 60)
128
+ if hours < 24:
129
+ return f"{hours}h ago"
130
+ days = round(hours / 24)
131
+ return f"{days}d ago"
132
+
133
+
134
+ def _format_duration(seconds: int) -> str:
135
+ if seconds < 60:
136
+ return f"{seconds}s"
137
+ minutes = round(seconds / 60)
138
+ if minutes < 60:
139
+ return f"{minutes}m"
140
+ hours = round(minutes / 60)
141
+ if hours < 24:
142
+ return f"{hours}h"
143
+ days = round(hours / 24)
144
+ return f"{days}d"
145
+
146
+
147
+ _CONTROL_CHAR_PATTERN = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
148
+
149
+
150
+ def _sanitize_for_terminal(text: str) -> str:
151
+ """
152
+ Strips control characters (C0/C1, DEL) from untrusted upstream text before it reaches a
153
+ terminal. Reddit and Telegram content is attacker-controlled -- a crafted post title or
154
+ message could otherwise carry ANSI/OSC escape sequences (cursor manipulation, terminal-title
155
+ spoofing, an OSC-8 hyperlink escape that visually disguises the printed URL) straight into
156
+ the user's terminal via `print`.
157
+ """
158
+ return _CONTROL_CHAR_PATTERN.sub("", text)
159
+
160
+
161
+ def format_feed_item_human(item: FeedItem, now: Optional[datetime] = None) -> str:
162
+ """Formats one feed item as a single human-readable line."""
163
+ now = now or datetime.now(timezone.utc)
164
+ if item.status == "live":
165
+ tag = "[live]"
166
+ else:
167
+ tag = f"[fallback, {_format_duration(item.fallback_age_seconds or 0)} old]"
168
+ title = _sanitize_for_terminal(item.title)
169
+ url = _sanitize_for_terminal(item.url)
170
+ return f"{tag} {item.source}: {title} -- {url} -- {_relative_age(item.timestamp, now)}"
171
+
172
+
173
+ def parse_item_id(item_id: str) -> Optional[Tuple[str, str]]:
174
+ """Splits a feed item id (`{source}:{native_id}`) into its source and native-id parts."""
175
+ separator_index = item_id.find(":")
176
+ if separator_index <= 0 or separator_index == len(item_id) - 1:
177
+ return None
178
+ return item_id[:separator_index], item_id[separator_index + 1 :]
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class ConnectorFailure:
183
+ source: str
184
+ error: str
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class FeedRunResult:
189
+ items: List[FeedItem]
190
+ skipped: List[str]
191
+ failures: List[ConnectorFailure]
192
+ unknown_source: Optional[str] = None
193
+
194
+ def to_dict(self) -> Dict:
195
+ data: Dict = {
196
+ "items": [item.to_dict() for item in self.items],
197
+ "skipped": self.skipped,
198
+ "failures": [{"source": f.source, "error": f.error} for f in self.failures],
199
+ }
200
+ if self.unknown_source is not None:
201
+ data["unknown_source"] = self.unknown_source
202
+ return data
203
+
204
+
205
+ def run_feed(connectors: Sequence[Connector], source_filter: Optional[str] = None) -> FeedRunResult:
206
+ """
207
+ Runs `truesignal feed` end to end against real connectors: resolves which to query, fetches
208
+ them concurrently, and never lets one connector's failure take down the others.
209
+ """
210
+ selection = select_connectors(connectors, source_filter)
211
+ if selection.unknown_source:
212
+ return FeedRunResult(items=[], skipped=[], failures=[], unknown_source=selection.unknown_source)
213
+
214
+ items: List[FeedItem] = []
215
+ failures: List[ConnectorFailure] = []
216
+
217
+ if selection.to_query:
218
+ # Preserve connector order in the aggregated output, same as Promise.allSettled's
219
+ # index-ordered results in the TypeScript source.
220
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(selection.to_query)) as executor:
221
+ futures = [executor.submit(connector.fetch_items) for connector in selection.to_query]
222
+ for connector, future in zip(selection.to_query, futures):
223
+ try:
224
+ items.extend(future.result())
225
+ except Exception as error: # noqa: BLE001 -- one connector's failure must not sink the others
226
+ failures.append(ConnectorFailure(source=connector.name, error=str(error)))
227
+
228
+ return FeedRunResult(items=items, skipped=[c.name for c in selection.skipped], failures=failures)
229
+
230
+
231
+ VerifyErrorKind = str # "invalid-id" | "unknown-source" | "not-configured" | "network-error"
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class VerifyResult:
236
+ item_id: str
237
+ found: bool
238
+ status: Optional[ItemStatus] = None
239
+ url: Optional[str] = None
240
+ timestamp: Optional[str] = None
241
+ fallback_age_seconds: Optional[int] = None
242
+ error_kind: Optional[VerifyErrorKind] = None
243
+ error_message: Optional[str] = None
244
+
245
+ def to_dict(self) -> Dict:
246
+ data: Dict = {"item_id": self.item_id, "found": self.found}
247
+ for key, value in (
248
+ ("status", self.status),
249
+ ("url", self.url),
250
+ ("timestamp", self.timestamp),
251
+ ("fallback_age_seconds", self.fallback_age_seconds),
252
+ ("error_kind", self.error_kind),
253
+ ("error_message", self.error_message),
254
+ ):
255
+ if value is not None:
256
+ data[key] = value
257
+ return data
258
+
259
+
260
+ def run_verify(connectors: Sequence[Connector], item_id: str) -> VerifyResult:
261
+ """Re-fetches the named connector and confirms whether `item_id` still resolves to real data."""
262
+ parsed = parse_item_id(item_id)
263
+ if not parsed:
264
+ return VerifyResult(
265
+ item_id=item_id,
266
+ found=False,
267
+ error_kind="invalid-id",
268
+ error_message=(
269
+ f'Could not parse item id "{item_id}". Expected format: <source>:<native-id>, '
270
+ f"e.g. cisa-kev:CVE-2026-12345"
271
+ ),
272
+ )
273
+ source, _native_id = parsed
274
+
275
+ connector = next((c for c in connectors if c.name == source), None)
276
+ if not connector:
277
+ known = ", ".join(c.name for c in connectors)
278
+ return VerifyResult(
279
+ item_id=item_id,
280
+ found=False,
281
+ error_kind="unknown-source",
282
+ error_message=f'Unknown source "{source}" in item id "{item_id}". Known sources: {known}',
283
+ )
284
+
285
+ if connector.requires_config and not connector.is_configured():
286
+ return VerifyResult(
287
+ item_id=item_id,
288
+ found=False,
289
+ error_kind="not-configured",
290
+ error_message=f'Connector "{connector.name}" is not configured. Run "truesignal init" to see what is needed.',
291
+ )
292
+
293
+ try:
294
+ items = connector.fetch_items()
295
+ except Exception as error: # noqa: BLE001 -- mirrors src/cli-helpers.ts's catch-all around fetchItems()
296
+ return VerifyResult(
297
+ item_id=item_id,
298
+ found=False,
299
+ error_kind="network-error",
300
+ error_message=f"Failed to verify: {error}",
301
+ )
302
+
303
+ found_item = next((item for item in items if item.id == item_id), None)
304
+ if not found_item:
305
+ return VerifyResult(item_id=item_id, found=False)
306
+
307
+ return VerifyResult(
308
+ item_id=item_id,
309
+ found=True,
310
+ status=found_item.status,
311
+ url=found_item.url,
312
+ timestamp=found_item.timestamp,
313
+ fallback_age_seconds=found_item.fallback_age_seconds,
314
+ )
@@ -0,0 +1,48 @@
1
+ """
2
+ Every connector this build ships, in a fixed order. Adding a new source is: implement Connector
3
+ (truesignal/types.py) in a new module in this package, then add it to ALL_CONNECTORS below --
4
+ nothing else in the CLI or provenance layer needs to change. See CONTRIBUTING.md.
5
+
6
+ Direct port of src/truesignal/connectors/index.ts.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Optional, Sequence
11
+
12
+ from ..types import Connector
13
+ from .cisa_kev import CisaKevConnector, cisa_kev_connector
14
+ from .cloudflare_radar import CloudflareRadarConnector, cloudflare_radar_connector
15
+ from .gdelt import GdeltConnector, gdelt_connector
16
+ from .reddit import RedditConnector, reddit_connector
17
+ from .telegram import TelegramConnector, telegram_connector
18
+
19
+ ALL_CONNECTORS: Sequence[Connector] = (
20
+ cisa_kev_connector,
21
+ cloudflare_radar_connector,
22
+ reddit_connector,
23
+ telegram_connector,
24
+ gdelt_connector,
25
+ )
26
+
27
+
28
+ def get_connector(name: str) -> Optional[Connector]:
29
+ for connector in ALL_CONNECTORS:
30
+ if connector.name == name:
31
+ return connector
32
+ return None
33
+
34
+
35
+ __all__ = [
36
+ "ALL_CONNECTORS",
37
+ "get_connector",
38
+ "CisaKevConnector",
39
+ "cisa_kev_connector",
40
+ "CloudflareRadarConnector",
41
+ "cloudflare_radar_connector",
42
+ "RedditConnector",
43
+ "reddit_connector",
44
+ "TelegramConnector",
45
+ "telegram_connector",
46
+ "GdeltConnector",
47
+ "gdelt_connector",
48
+ ]