signalpipe-daemon 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SignalPipe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: signalpipe-daemon
3
+ Version: 1.0.0
4
+ Summary: User-side sender for SignalPipe v4 — streams pre-drafted, pre-approved missions and posts them with your own Reddit/X credentials. The math runs on SignalPipe; the sending runs on you.
5
+ Author: SignalPipe
6
+ License: MIT
7
+ Project-URL: Homepage, https://signalpipe.io
8
+ Keywords: signalpipe,outreach,reddit,twitter,sse,automation
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.28
19
+ Provides-Extra: reddit
20
+ Requires-Dist: praw>=7.7; extra == "reddit"
21
+ Provides-Extra: twitter
22
+ Requires-Dist: tweepy>=4.14; extra == "twitter"
23
+ Provides-Extra: all
24
+ Requires-Dist: praw>=7.7; extra == "all"
25
+ Requires-Dist: tweepy>=4.14; extra == "all"
26
+ Dynamic: license-file
27
+
28
+ # signalpipe-daemon
29
+
30
+ **The user-side sender for [SignalPipe](https://signalpipe.io) v4.**
31
+
32
+ > SignalPipe is the intelligence layer for outreach. We never see your Reddit
33
+ > credentials, never see your LLM keys, never store your prospects.
34
+ > **The math runs on us. The sending runs on you.**
35
+
36
+ This daemon holds a Server-Sent-Events stream open to your SignalPipe brain,
37
+ receives missions the brain has already scored, drafted, and approved, posts
38
+ them with **your own** platform credentials, and acknowledges the result. It
39
+ never sees your LLM keys, never scores or drafts anything itself, and stores no
40
+ prospects. It only sends.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install "signalpipe-daemon[all]" # both Reddit + X senders
46
+ pip install "signalpipe-daemon[reddit]" # Reddit only
47
+ pip install "signalpipe-daemon[twitter]" # X / Twitter only
48
+ pip install signalpipe-daemon # base; pulls only `requests`
49
+ ```
50
+
51
+ The platform SDKs (`praw`, `tweepy`) are optional extras and are imported only
52
+ when a mission for that channel actually arrives — install just the ones you use.
53
+
54
+ ## Configure
55
+
56
+ Copy `.env.example` to `.env` and fill it in, or export the variables directly.
57
+ A real environment variable always wins over a `.env` entry.
58
+
59
+ | Variable | Purpose |
60
+ | --- | --- |
61
+ | `SIGNALPIPE_KEY` | **Required.** Your operator key from the dashboard. |
62
+ | `SIGNALPIPE_API_URL` | Brain URL. Defaults to `https://api.signalpipe.io`. |
63
+ | `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` / `REDDIT_USERNAME` / `REDDIT_PASSWORD` | A Reddit *script* app on your sending account. Enables `reddit_comment` and `reddit_dm`. |
64
+ | `X_API_KEY` / `X_API_SECRET` / `X_ACCESS_TOKEN` / `X_ACCESS_SECRET` | X / Twitter app creds. Enables `twitter_reply`. |
65
+ | `MAX_TWITTER_ACTIONS_PER_DAY` | Default 10. |
66
+ | `MAX_REDDIT_DMS_PER_DAY` | Default 5. |
67
+ | `MAX_REDDIT_COMMENTS_PER_DAY` | Default 15. |
68
+
69
+ Your platform credentials stay on your machine. They are used only to talk to
70
+ Reddit / X directly and are **never** sent to SignalPipe.
71
+
72
+ ## Run
73
+
74
+ ```bash
75
+ signalpipe-daemon status # validate the key, print queue depth, exit
76
+ signalpipe-daemon run # stream missions and send them
77
+ signalpipe-daemon run --dry-run # log intended sends without posting or acking
78
+ ```
79
+
80
+ `run` holds the stream open and reconnects with exponential backoff on any
81
+ network drop. A rejected key (401) is fatal and exits non-zero; everything else
82
+ is treated as transient. Stop with `Ctrl-C`.
83
+
84
+ ## How a mission flows
85
+
86
+ 1. The brain scores a signal, drafts a reply, and (once approved) marks the
87
+ mission ready. It streams the mission to this daemon over SSE.
88
+ 2. The daemon posts the pre-written draft on the mission's channel
89
+ (`twitter_reply`, `reddit_comment`, or `reddit_dm`) using your credentials.
90
+ Missions on the `manual` channel are skipped — you send those yourself.
91
+ 3. The daemon acks the outcome. A failure tagged `banned` or `rate_limited`
92
+ tells the brain to pause your stream for a cool-down.
93
+
94
+ **Daily caps** pace your sending; when a cap is hit the mission is *skipped*, not
95
+ failed, so it remains queued and retries after the next local-midnight reset.
96
+
97
+ **Delivery:** missions are delivered at-least-once. Within a running process the
98
+ daemon will never post the same mission twice, even across reconnects — so a
99
+ dropped connection is always safe to recover from.
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,76 @@
1
+ # signalpipe-daemon
2
+
3
+ **The user-side sender for [SignalPipe](https://signalpipe.io) v4.**
4
+
5
+ > SignalPipe is the intelligence layer for outreach. We never see your Reddit
6
+ > credentials, never see your LLM keys, never store your prospects.
7
+ > **The math runs on us. The sending runs on you.**
8
+
9
+ This daemon holds a Server-Sent-Events stream open to your SignalPipe brain,
10
+ receives missions the brain has already scored, drafted, and approved, posts
11
+ them with **your own** platform credentials, and acknowledges the result. It
12
+ never sees your LLM keys, never scores or drafts anything itself, and stores no
13
+ prospects. It only sends.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install "signalpipe-daemon[all]" # both Reddit + X senders
19
+ pip install "signalpipe-daemon[reddit]" # Reddit only
20
+ pip install "signalpipe-daemon[twitter]" # X / Twitter only
21
+ pip install signalpipe-daemon # base; pulls only `requests`
22
+ ```
23
+
24
+ The platform SDKs (`praw`, `tweepy`) are optional extras and are imported only
25
+ when a mission for that channel actually arrives — install just the ones you use.
26
+
27
+ ## Configure
28
+
29
+ Copy `.env.example` to `.env` and fill it in, or export the variables directly.
30
+ A real environment variable always wins over a `.env` entry.
31
+
32
+ | Variable | Purpose |
33
+ | --- | --- |
34
+ | `SIGNALPIPE_KEY` | **Required.** Your operator key from the dashboard. |
35
+ | `SIGNALPIPE_API_URL` | Brain URL. Defaults to `https://api.signalpipe.io`. |
36
+ | `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` / `REDDIT_USERNAME` / `REDDIT_PASSWORD` | A Reddit *script* app on your sending account. Enables `reddit_comment` and `reddit_dm`. |
37
+ | `X_API_KEY` / `X_API_SECRET` / `X_ACCESS_TOKEN` / `X_ACCESS_SECRET` | X / Twitter app creds. Enables `twitter_reply`. |
38
+ | `MAX_TWITTER_ACTIONS_PER_DAY` | Default 10. |
39
+ | `MAX_REDDIT_DMS_PER_DAY` | Default 5. |
40
+ | `MAX_REDDIT_COMMENTS_PER_DAY` | Default 15. |
41
+
42
+ Your platform credentials stay on your machine. They are used only to talk to
43
+ Reddit / X directly and are **never** sent to SignalPipe.
44
+
45
+ ## Run
46
+
47
+ ```bash
48
+ signalpipe-daemon status # validate the key, print queue depth, exit
49
+ signalpipe-daemon run # stream missions and send them
50
+ signalpipe-daemon run --dry-run # log intended sends without posting or acking
51
+ ```
52
+
53
+ `run` holds the stream open and reconnects with exponential backoff on any
54
+ network drop. A rejected key (401) is fatal and exits non-zero; everything else
55
+ is treated as transient. Stop with `Ctrl-C`.
56
+
57
+ ## How a mission flows
58
+
59
+ 1. The brain scores a signal, drafts a reply, and (once approved) marks the
60
+ mission ready. It streams the mission to this daemon over SSE.
61
+ 2. The daemon posts the pre-written draft on the mission's channel
62
+ (`twitter_reply`, `reddit_comment`, or `reddit_dm`) using your credentials.
63
+ Missions on the `manual` channel are skipped — you send those yourself.
64
+ 3. The daemon acks the outcome. A failure tagged `banned` or `rate_limited`
65
+ tells the brain to pause your stream for a cool-down.
66
+
67
+ **Daily caps** pace your sending; when a cap is hit the mission is *skipped*, not
68
+ failed, so it remains queued and retries after the next local-midnight reset.
69
+
70
+ **Delivery:** missions are delivered at-least-once. Within a running process the
71
+ daemon will never post the same mission twice, even across reconnects — so a
72
+ dropped connection is always safe to recover from.
73
+
74
+ ## License
75
+
76
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "signalpipe-daemon"
7
+ version = "1.0.0"
8
+ description = "User-side sender for SignalPipe v4 — streams pre-drafted, pre-approved missions and posts them with your own Reddit/X credentials. The math runs on SignalPipe; the sending runs on you."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "SignalPipe" }]
13
+ keywords = ["signalpipe", "outreach", "reddit", "twitter", "sse", "automation"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Communications",
21
+ ]
22
+ dependencies = ["requests>=2.28"]
23
+
24
+ [project.optional-dependencies]
25
+ reddit = ["praw>=7.7"]
26
+ twitter = ["tweepy>=4.14"]
27
+ all = ["praw>=7.7", "tweepy>=4.14"]
28
+
29
+ [project.scripts]
30
+ signalpipe-daemon = "signalpipe_daemon.cli:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://signalpipe.io"
34
+
35
+ [tool.setuptools]
36
+ packages = ["signalpipe_daemon"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """SignalPipe Daemon — the user-side sender for SignalPipe v4.
2
+
3
+ The math runs on SignalPipe. The sending runs on you.
4
+
5
+ This daemon holds a Server-Sent-Events stream open to your SignalPipe brain,
6
+ receives missions the brain has already scored, drafted, and approved, posts
7
+ them with YOUR own platform credentials, and acknowledges the result. It never
8
+ sees your LLM keys, never scores or drafts anything itself, and stores no
9
+ prospects. It only sends.
10
+ """
11
+
12
+ __version__ = "1.0.0"
@@ -0,0 +1,72 @@
1
+ """Command-line entry point: `signalpipe-daemon run` / `status`."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from typing import Optional, Sequence
8
+
9
+ from . import __version__
10
+ from .client import AuthError, SignalPipeClient
11
+ from .config import load_config
12
+ from .daemon import run
13
+
14
+
15
+ def _build_parser() -> argparse.ArgumentParser:
16
+ p = argparse.ArgumentParser(
17
+ prog="signalpipe-daemon",
18
+ description="Send SignalPipe missions with your own platform "
19
+ "credentials. The math runs on SignalPipe; the sending "
20
+ "runs on you.",
21
+ )
22
+ p.add_argument("--version", action="version",
23
+ version=f"signalpipe-daemon {__version__}")
24
+ p.add_argument("--api-url", default=None,
25
+ help="Brain URL (default: $SIGNALPIPE_API_URL or "
26
+ "https://api.signalpipe.io)")
27
+ p.add_argument("--key", default=None,
28
+ help="Operator key (default: $SIGNALPIPE_KEY)")
29
+
30
+ sub = p.add_subparsers(dest="command")
31
+
32
+ run_p = sub.add_parser("run", help="Stream missions and send them.")
33
+ run_p.add_argument("--dry-run", action="store_true",
34
+ help="Log intended sends without posting or acking.")
35
+
36
+ sub.add_parser("status", help="Print account/queue status and exit.")
37
+ return p
38
+
39
+
40
+ def main(argv: Optional[Sequence[str]] = None) -> int:
41
+ parser = _build_parser()
42
+ args = parser.parse_args(argv)
43
+
44
+ config = load_config(api_url=args.api_url, key=args.key)
45
+ if not config.key:
46
+ print("error: no operator key. Set SIGNALPIPE_KEY or pass --key.",
47
+ file=sys.stderr)
48
+ return 2
49
+
50
+ command = args.command or "run"
51
+
52
+ if command == "status":
53
+ client = SignalPipeClient(config.api_url, config.key)
54
+ try:
55
+ print(json.dumps(client.status(), indent=2))
56
+ return 0
57
+ except AuthError:
58
+ print("operator key rejected (401).", file=sys.stderr)
59
+ return 2
60
+ except Exception as e: # noqa: BLE001
61
+ print(f"error: {e}", file=sys.stderr)
62
+ return 1
63
+
64
+ if command == "run":
65
+ return run(config, dry_run=getattr(args, "dry_run", False))
66
+
67
+ parser.print_help()
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ sys.exit(main())
@@ -0,0 +1,70 @@
1
+ """HTTP transport to the SignalPipe brain: SSE mission stream + ack + status."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Iterator, Tuple
5
+
6
+ import requests
7
+
8
+ from .sse import parse_sse
9
+
10
+ _CONNECT_TIMEOUT = 10
11
+ # Read timeout must exceed the server heartbeat (~30s) so a quiet-but-alive
12
+ # stream is not torn down between frames.
13
+ _READ_TIMEOUT = 90
14
+
15
+
16
+ class AuthError(Exception):
17
+ """The operator key was rejected (401) — fatal, do not retry."""
18
+
19
+
20
+ class SignalPipeClient:
21
+ def __init__(self, api_url: str, key: str):
22
+ self.api_url = api_url.rstrip("/")
23
+ self.key = key
24
+
25
+ def _auth_header(self) -> dict:
26
+ return {"Authorization": f"Bearer {self.key}"}
27
+
28
+ def status(self) -> dict:
29
+ """GET /v4/sender/status — validates the key and returns queue depth."""
30
+ r = requests.get(f"{self.api_url}/v4/sender/status",
31
+ headers=self._auth_header(), timeout=20)
32
+ if r.status_code == 401:
33
+ raise AuthError("operator key rejected (401)")
34
+ r.raise_for_status()
35
+ return r.json()
36
+
37
+ def ack(self, mission_id: str, outcome: str,
38
+ error_class: str | None = None,
39
+ platform_response: str | None = None) -> None:
40
+ """POST /v4/missions/{id}/ack — report the send result.
41
+
42
+ outcome is "success" or "failed". On failure, error_class in
43
+ {"banned","rate_limited",...} lets the brain pause this tenant's stream.
44
+ """
45
+ body: dict = {"outcome": outcome}
46
+ if error_class:
47
+ body["error_class"] = error_class
48
+ if platform_response:
49
+ body["platform_response"] = platform_response[:500]
50
+ r = requests.post(f"{self.api_url}/v4/missions/{mission_id}/ack",
51
+ json=body, headers=self._auth_header(), timeout=20)
52
+ if r.status_code == 401:
53
+ raise AuthError("operator key rejected (401)")
54
+ r.raise_for_status()
55
+
56
+ def stream_missions(self) -> Iterator[Tuple[str, dict]]:
57
+ """Hold GET /v4/missions/stream open, yielding (event_name, data) frames.
58
+
59
+ Raises AuthError on 401; lets requests exceptions propagate on a network
60
+ drop so the caller can reconnect with backoff.
61
+ """
62
+ headers = self._auth_header()
63
+ headers["Accept"] = "text/event-stream"
64
+ resp = requests.get(f"{self.api_url}/v4/missions/stream",
65
+ headers=headers, stream=True,
66
+ timeout=(_CONNECT_TIMEOUT, _READ_TIMEOUT))
67
+ if resp.status_code == 401:
68
+ raise AuthError("operator key rejected (401)")
69
+ resp.raise_for_status()
70
+ yield from parse_sse(resp.iter_lines(decode_unicode=True))
@@ -0,0 +1,115 @@
1
+ """Configuration — read entirely from environment variables (optionally a .env).
2
+
3
+ The daemon needs three things:
4
+ 1. Your SignalPipe operator key -> SIGNALPIPE_KEY
5
+ 2. The brain URL -> SIGNALPIPE_API_URL (defaults to prod)
6
+ 3. Your platform send credentials -> Reddit and/or X env vars below
7
+
8
+ Your platform credentials stay on this machine. They are used only to talk to
9
+ Reddit / X directly and are NEVER sent to SignalPipe.
10
+
11
+ Precedence: an explicit CLI flag > a real environment variable > a .env entry >
12
+ the built-in default.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass
18
+
19
+ DEFAULT_API_URL = "https://api.signalpipe.io"
20
+
21
+
22
+ def _load_dotenv(path: str = ".env") -> None:
23
+ """Minimal KEY=VALUE .env loader so we don't take a dependency on python-dotenv.
24
+
25
+ Uses os.environ.setdefault, so a real environment variable always wins over
26
+ a .env entry.
27
+ """
28
+ if not os.path.isfile(path):
29
+ return
30
+ try:
31
+ with open(path, "r", encoding="utf-8") as fh:
32
+ for line in fh:
33
+ line = line.strip()
34
+ if not line or line.startswith("#") or "=" not in line:
35
+ continue
36
+ key, _, val = line.partition("=")
37
+ val = val.strip().strip('"').strip("'")
38
+ os.environ.setdefault(key.strip(), val)
39
+ except OSError:
40
+ pass
41
+
42
+
43
+ def _first(*names: str, default: str = "") -> str:
44
+ for n in names:
45
+ v = os.getenv(n)
46
+ if v:
47
+ return v
48
+ return default
49
+
50
+
51
+ def _int_env(name: str, default: int) -> int:
52
+ try:
53
+ return int(os.getenv(name, "") or default)
54
+ except (TypeError, ValueError):
55
+ return default
56
+
57
+
58
+ @dataclass
59
+ class Config:
60
+ api_url: str
61
+ key: str
62
+ # Reddit — a "script" app's creds on the SENDING account. Enables
63
+ # reddit_comment (public first-touch) and reddit_dm.
64
+ reddit_client_id: str = ""
65
+ reddit_client_secret: str = ""
66
+ reddit_username: str = ""
67
+ reddit_password: str = ""
68
+ reddit_user_agent: str = "signalpipe-daemon/1.0"
69
+ # X / Twitter — enables twitter_reply.
70
+ x_api_key: str = ""
71
+ x_api_secret: str = ""
72
+ x_access_token: str = ""
73
+ x_access_secret: str = ""
74
+ # Daily send caps, per channel. Reset at local midnight. Anti-spam pacing.
75
+ max_twitter_per_day: int = 10
76
+ max_reddit_dms_per_day: int = 5
77
+ max_reddit_comments_per_day: int = 15
78
+
79
+ @property
80
+ def reddit_ready(self) -> bool:
81
+ return bool(
82
+ self.reddit_client_id and self.reddit_client_secret
83
+ and self.reddit_username and self.reddit_password
84
+ )
85
+
86
+ @property
87
+ def twitter_ready(self) -> bool:
88
+ return bool(
89
+ self.x_api_key and self.x_api_secret
90
+ and self.x_access_token and self.x_access_secret
91
+ )
92
+
93
+
94
+ def load_config(api_url: str | None = None, key: str | None = None,
95
+ dotenv: bool = True) -> Config:
96
+ """Build a Config from CLI overrides + environment + optional .env file."""
97
+ if dotenv:
98
+ _load_dotenv()
99
+ return Config(
100
+ api_url=(api_url or _first("SIGNALPIPE_API_URL", "MANTIDAE_API_URL",
101
+ default=DEFAULT_API_URL)).rstrip("/"),
102
+ key=key or _first("SIGNALPIPE_KEY", "MANTIDAE_KEY"),
103
+ reddit_client_id=_first("REDDIT_CLIENT_ID"),
104
+ reddit_client_secret=_first("REDDIT_CLIENT_SECRET"),
105
+ reddit_username=_first("REDDIT_USERNAME"),
106
+ reddit_password=_first("REDDIT_PASSWORD"),
107
+ reddit_user_agent=_first("REDDIT_USER_AGENT", default="signalpipe-daemon/1.0"),
108
+ x_api_key=_first("X_API_KEY"),
109
+ x_api_secret=_first("X_API_SECRET"),
110
+ x_access_token=_first("X_ACCESS_TOKEN"),
111
+ x_access_secret=_first("X_ACCESS_SECRET"),
112
+ max_twitter_per_day=_int_env("MAX_TWITTER_ACTIONS_PER_DAY", 10),
113
+ max_reddit_dms_per_day=_int_env("MAX_REDDIT_DMS_PER_DAY", 5),
114
+ max_reddit_comments_per_day=_int_env("MAX_REDDIT_COMMENTS_PER_DAY", 15),
115
+ )
@@ -0,0 +1,176 @@
1
+ """The daemon loop: hold the mission stream open, send, ack, reconnect.
2
+
3
+ This is the orchestrator. It owns no platform knowledge (senders.py) and no wire
4
+ format (client.py / sse.py) — it wires them together with a reconnect-with-
5
+ backoff policy and at-least-once delivery handling.
6
+
7
+ Delivery model: the brain streams every 'approved' mission and only stops once
8
+ it receives an ack. Within one process a `seen` set guarantees each mission is
9
+ posted at most once, even across reconnects. A lost ack therefore never causes a
10
+ double-send (the cardinal outreach sin) — at worst it leaves the brain's copy
11
+ 'approved' for later reconciliation. Exactly-once across process *restarts* needs
12
+ brain-side idempotency and is tracked separately.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import Set
18
+
19
+ from .client import AuthError, SignalPipeClient
20
+ from .config import Config
21
+ from .senders import Senders
22
+
23
+ _BACKOFF_START = 1.0
24
+ _BACKOFF_MAX = 60.0
25
+ _ACK_RETRIES = 3
26
+
27
+
28
+ def _log(msg: str) -> None:
29
+ print(f"[signalpipe] {msg}", flush=True)
30
+
31
+
32
+ def _version() -> str:
33
+ from . import __version__
34
+ return __version__
35
+
36
+
37
+ def run(config: Config, dry_run: bool = False) -> int:
38
+ """Run the sender until interrupted. Returns a process exit code."""
39
+ client = SignalPipeClient(config.api_url, config.key)
40
+ senders = Senders(config)
41
+
42
+ # Preflight: validate the key and surface account state before streaming.
43
+ try:
44
+ status = client.status()
45
+ except AuthError:
46
+ _log("operator key rejected (401). Check SIGNALPIPE_KEY.")
47
+ return 2
48
+ except Exception as e: # noqa: BLE001 — network/DNS/timeout at startup
49
+ _log(f"cannot reach brain at {config.api_url}: {e}")
50
+ return 1
51
+
52
+ _summary(config, status, dry_run)
53
+
54
+ seen: Set[str] = set() # mission ids fully handled this process
55
+ skip_logged: Set[str] = set() # skip reasons already printed once
56
+ backoff = _BACKOFF_START
57
+
58
+ try:
59
+ while True:
60
+ try:
61
+ for event, data in client.stream_missions():
62
+ if event == "connected":
63
+ backoff = _BACKOFF_START # healthy: fast reconnects
64
+ _log(f"connected — auto-fire threshold "
65
+ f"{data.get('auto_threshold')}")
66
+ elif event == "mission_ready":
67
+ _handle(client, senders,
68
+ data.get("payload") or data,
69
+ seen, skip_logged, dry_run)
70
+ elif event == "heartbeat":
71
+ if data.get("paused"):
72
+ _log(f"stream paused (backoff until "
73
+ f"{data.get('backoff_until')})")
74
+ elif event == "shutdown":
75
+ reason = data.get("reason")
76
+ if reason == "unauthorized":
77
+ _log("brain closed stream: unauthorized — exiting.")
78
+ return 2
79
+ _log(f"brain closed stream ({reason}); reconnecting.")
80
+ break
81
+ # generator exhausted: stream closed cleanly → reconnect below
82
+ except AuthError:
83
+ _log("operator key rejected mid-stream (401) — exiting.")
84
+ return 2
85
+ except KeyboardInterrupt:
86
+ raise
87
+ except Exception as e: # noqa: BLE001 — any network drop: reconnect
88
+ _log(f"stream error: {e}; reconnecting in {backoff:.0f}s")
89
+ time.sleep(backoff)
90
+ backoff = min(backoff * 2, _BACKOFF_MAX)
91
+ continue
92
+ time.sleep(backoff)
93
+ backoff = min(backoff * 2, _BACKOFF_MAX)
94
+ except KeyboardInterrupt:
95
+ _log("shutting down (Ctrl-C).")
96
+ return 0
97
+
98
+
99
+ def _handle(client, senders, mission, seen, skip_logged, dry_run) -> None:
100
+ mid = mission.get("id")
101
+ if not mid:
102
+ _log("mission_ready with no id — ignoring.")
103
+ return
104
+ if mid in seen:
105
+ return # already handled in this process (at-least-once stream)
106
+
107
+ channel = mission.get("outreach_channel") or "manual"
108
+ result = senders.send(mission, dry_run=dry_run)
109
+
110
+ # Skip: nothing was sent. Don't ack, don't mark seen — the mission stays
111
+ # 'approved' on the brain and we retry it on a future reconnect (e.g. once a
112
+ # daily cap resets, or once the operator sends a 'manual' one themselves).
113
+ if result.is_skip:
114
+ if mid not in skip_logged:
115
+ _log(f"skip {mid} [{channel}]: {result.detail}")
116
+ skip_logged.add(mid)
117
+ return
118
+
119
+ if dry_run:
120
+ _log(f"would-send {mid} [{channel}]: {result.detail}")
121
+ seen.add(mid)
122
+ return
123
+
124
+ # Past this point the mission is processed exactly once in this process,
125
+ # regardless of whether the ack lands — so we can never double-send.
126
+ seen.add(mid)
127
+
128
+ if result.outcome == "success":
129
+ _log(f"sent {mid} [{channel}]: {result.detail}")
130
+ _ack(client, mid, "success", platform_response=result.detail)
131
+ else:
132
+ _log(f"failed {mid} [{channel}] ({result.error_class}): {result.detail}")
133
+ _ack(client, mid, "failed", error_class=result.error_class,
134
+ platform_response=result.detail)
135
+
136
+
137
+ def _ack(client, mid, outcome, error_class=None, platform_response=None) -> None:
138
+ """Best-effort ack with a short retry. The mission is already in `seen`, so a
139
+ lost ack never re-triggers a send; at worst the brain's copy stays 'approved'
140
+ for later reconciliation."""
141
+ for attempt in range(_ACK_RETRIES):
142
+ try:
143
+ client.ack(mid, outcome, error_class=error_class,
144
+ platform_response=platform_response)
145
+ return
146
+ except AuthError:
147
+ raise # fatal — bubble to run()
148
+ except Exception as e: # noqa: BLE001
149
+ if attempt == _ACK_RETRIES - 1:
150
+ _log(f"ack({outcome}) for {mid} failed after retries: {e}")
151
+ return
152
+ time.sleep(2)
153
+
154
+
155
+ def _summary(config: Config, status: dict, dry_run: bool) -> None:
156
+ _log(f"SignalPipe daemon v{_version()} — the sending runs on you.")
157
+ _log(f"brain: {config.api_url}")
158
+ _log(f"account: status={status.get('status', '?')} "
159
+ f"pending={status.get('missions_pending', '?')} "
160
+ f"auto_threshold={status.get('auto_threshold', '?')}")
161
+
162
+ channels = []
163
+ if config.twitter_ready:
164
+ channels.append("twitter_reply")
165
+ if config.reddit_ready:
166
+ channels.append("reddit_comment")
167
+ channels.append("reddit_dm")
168
+ _log(f"channels: {', '.join(channels) if channels else 'NONE configured'}")
169
+ _log(f"caps/day: twitter={config.max_twitter_per_day} "
170
+ f"reddit_dm={config.max_reddit_dms_per_day} "
171
+ f"reddit_comment={config.max_reddit_comments_per_day}")
172
+ if dry_run:
173
+ _log("MODE: dry-run — will NOT send or ack; logs intended actions only.")
174
+ if not channels:
175
+ _log("WARNING: no platform credentials set — every mission will fail. "
176
+ "Configure REDDIT_* and/or X_* env vars.")
@@ -0,0 +1,218 @@
1
+ """Platform senders — Reddit (PRAW) and X/Twitter (tweepy), lazily imported.
2
+
3
+ This is the ONLY module that touches your platform credentials. praw and tweepy
4
+ are optional extras: a base install carries neither, and each client is imported
5
+ only when a mission for that channel actually arrives. So a Reddit-only operator
6
+ never needs tweepy on disk, and vice-versa.
7
+
8
+ Nothing here scores, drafts, or stores anything. A mission arrives pre-drafted
9
+ from the brain; we post the text with your creds and report the outcome.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from dataclasses import dataclass
15
+ from random import uniform
16
+ from typing import Optional
17
+
18
+ # Outcomes the brain understands, plus a local-only "skip" that is never acked.
19
+ _SUCCESS = "success"
20
+ _FAILED = "failed"
21
+ _SKIP = "skip"
22
+
23
+
24
+ @dataclass
25
+ class SendResult:
26
+ outcome: str
27
+ error_class: Optional[str] = None
28
+ detail: Optional[str] = None
29
+ sent: bool = False # True only once we have actually hit the platform API
30
+
31
+ @property
32
+ def is_skip(self) -> bool:
33
+ return self.outcome == _SKIP
34
+
35
+
36
+ def _classify_error(msg: str) -> str:
37
+ """Map a platform exception string to the brain's error_class vocabulary.
38
+
39
+ The brain pauses a tenant's stream on "banned" (1h) or "rate_limited" (5m);
40
+ "unknown" is reported for the audit trail but triggers no backoff.
41
+ """
42
+ m = msg.lower()
43
+ if "ratelimit" in m or "rate limit" in m or "429" in m or "too many" in m:
44
+ return "rate_limited"
45
+ if any(w in m for w in ("forbidden", "banned", "suspend", "403",
46
+ "401", "blocked", "not allowed")):
47
+ return "banned"
48
+ return "unknown"
49
+
50
+
51
+ def _strip_u(author: str) -> str:
52
+ """Normalize a Reddit handle: 'u/spez' / '/u/spez' -> 'spez'.
53
+
54
+ Uses prefix slicing, NOT str.lstrip — lstrip is character-set based and
55
+ would eat a leading 'u' from a name like 'umbrella'.
56
+ """
57
+ a = author.strip()
58
+ if a.startswith("/u/"):
59
+ return a[3:]
60
+ if a.startswith("u/"):
61
+ return a[2:]
62
+ return a
63
+
64
+
65
+ class Senders:
66
+ """Holds the platform clients and per-day send counters.
67
+
68
+ Clients are built on first use (lazy import of praw / tweepy) and cached for
69
+ the life of the process. Counters reset at local midnight.
70
+ """
71
+
72
+ def __init__(self, config):
73
+ self.config = config
74
+ self._tw = None
75
+ self._reddit = None
76
+ self._day = time.localtime().tm_yday
77
+ self._twitter_sent = 0
78
+ self._reddit_dms_sent = 0
79
+ self._reddit_comments_sent = 0
80
+
81
+ # --- lazy clients -----------------------------------------------------
82
+ def _twitter(self):
83
+ if self._tw is None:
84
+ import tweepy # extra: pip install signalpipe-daemon[twitter]
85
+ c = self.config
86
+ self._tw = tweepy.Client(
87
+ consumer_key=c.x_api_key,
88
+ consumer_secret=c.x_api_secret,
89
+ access_token=c.x_access_token,
90
+ access_token_secret=c.x_access_secret,
91
+ )
92
+ return self._tw
93
+
94
+ def _reddit_client(self):
95
+ if self._reddit is None:
96
+ import praw # extra: pip install signalpipe-daemon[reddit]
97
+ c = self.config
98
+ self._reddit = praw.Reddit(
99
+ client_id=c.reddit_client_id,
100
+ client_secret=c.reddit_client_secret,
101
+ username=c.reddit_username,
102
+ password=c.reddit_password,
103
+ user_agent=c.reddit_user_agent,
104
+ )
105
+ return self._reddit
106
+
107
+ # --- daily caps -------------------------------------------------------
108
+ def _reset_daily(self) -> None:
109
+ today = time.localtime().tm_yday
110
+ if today != self._day:
111
+ self._day = today
112
+ self._twitter_sent = 0
113
+ self._reddit_dms_sent = 0
114
+ self._reddit_comments_sent = 0
115
+
116
+ # --- dispatch ---------------------------------------------------------
117
+ def send(self, mission: dict, dry_run: bool = False) -> SendResult:
118
+ """Post one mission's draft on its channel and return the outcome."""
119
+ self._reset_daily()
120
+ channel = mission.get("outreach_channel") or "manual"
121
+ draft = (mission.get("draft_content") or "").strip()
122
+ target = mission.get("target") or {}
123
+
124
+ # "manual" = the brain flagged this for a human. Never auto-send; the
125
+ # operator handles it through the dashboard/plugin.
126
+ if channel == "manual":
127
+ return SendResult(_SKIP, detail="manual channel — operator handles")
128
+
129
+ if not draft:
130
+ return SendResult(_FAILED, error_class="config",
131
+ detail="empty draft_content")
132
+
133
+ if channel == "twitter_reply":
134
+ return self._send_twitter(draft, target, dry_run)
135
+ if channel == "reddit_comment":
136
+ return self._send_reddit_comment(draft, target, dry_run)
137
+ if channel == "reddit_dm":
138
+ return self._send_reddit_dm(draft, target, dry_run)
139
+
140
+ return SendResult(_FAILED, error_class="config",
141
+ detail=f"unknown channel '{channel}'")
142
+
143
+ # --- per-channel ------------------------------------------------------
144
+ def _send_twitter(self, draft, target, dry_run) -> SendResult:
145
+ if not self.config.twitter_ready:
146
+ return SendResult(_FAILED, error_class="config",
147
+ detail="X credentials not configured")
148
+ if self._twitter_sent >= self.config.max_twitter_per_day:
149
+ # Skip, not fail: the mission stays 'approved' and retries tomorrow.
150
+ return SendResult(_SKIP, detail="daily twitter cap reached")
151
+
152
+ url = (target.get("url") or "").rstrip("/")
153
+ tweet_id = url.split("/")[-1] if url else ""
154
+ if not tweet_id.isdigit():
155
+ return SendResult(_FAILED, error_class="config",
156
+ detail=f"cannot parse tweet id from '{url}'")
157
+
158
+ if dry_run:
159
+ return SendResult(_SUCCESS, sent=False,
160
+ detail=f"[dry-run] reply to tweet {tweet_id}")
161
+ try:
162
+ self._twitter().create_tweet(text=draft,
163
+ in_reply_to_tweet_id=tweet_id)
164
+ except Exception as e: # noqa: BLE001 — SDKs raise many exception types
165
+ return SendResult(_FAILED, sent=True,
166
+ error_class=_classify_error(str(e)), detail=str(e))
167
+ self._twitter_sent += 1
168
+ time.sleep(uniform(30, 90)) # anti-spam pacing between sends
169
+ return SendResult(_SUCCESS, sent=True, detail=f"replied to {tweet_id}")
170
+
171
+ def _send_reddit_comment(self, draft, target, dry_run) -> SendResult:
172
+ if not self.config.reddit_ready:
173
+ return SendResult(_FAILED, error_class="config",
174
+ detail="Reddit credentials not configured")
175
+ if self._reddit_comments_sent >= self.config.max_reddit_comments_per_day:
176
+ return SendResult(_SKIP, detail="daily reddit-comment cap reached")
177
+
178
+ url = (target.get("url") or "").strip()
179
+ if not url:
180
+ return SendResult(_FAILED, error_class="config",
181
+ detail="no target url for reddit_comment")
182
+
183
+ if dry_run:
184
+ return SendResult(_SUCCESS, sent=False,
185
+ detail=f"[dry-run] comment on {url}")
186
+ try:
187
+ self._reddit_client().submission(url=url).reply(draft)
188
+ except Exception as e: # noqa: BLE001
189
+ return SendResult(_FAILED, sent=True,
190
+ error_class=_classify_error(str(e)), detail=str(e))
191
+ self._reddit_comments_sent += 1
192
+ time.sleep(uniform(30, 90))
193
+ return SendResult(_SUCCESS, sent=True, detail=f"commented on {url}")
194
+
195
+ def _send_reddit_dm(self, draft, target, dry_run) -> SendResult:
196
+ if not self.config.reddit_ready:
197
+ return SendResult(_FAILED, error_class="config",
198
+ detail="Reddit credentials not configured")
199
+ if self._reddit_dms_sent >= self.config.max_reddit_dms_per_day:
200
+ return SendResult(_SKIP, detail="daily reddit-DM cap reached")
201
+
202
+ author = _strip_u(target.get("author") or "")
203
+ if not author:
204
+ return SendResult(_FAILED, error_class="config",
205
+ detail="no target author for reddit_dm")
206
+
207
+ if dry_run:
208
+ return SendResult(_SUCCESS, sent=False,
209
+ detail=f"[dry-run] DM to u/{author}")
210
+ try:
211
+ self._reddit_client().redditor(author).message(
212
+ subject="Quick question", message=draft)
213
+ except Exception as e: # noqa: BLE001
214
+ return SendResult(_FAILED, sent=True,
215
+ error_class=_classify_error(str(e)), detail=str(e))
216
+ self._reddit_dms_sent += 1
217
+ time.sleep(uniform(60, 180))
218
+ return SendResult(_SUCCESS, sent=True, detail=f"DM sent to u/{author}")
@@ -0,0 +1,45 @@
1
+ """Server-Sent-Events frame parser — pure stdlib (json only), no transport.
2
+
3
+ Kept separate from client.py so it carries no `requests` dependency and can be
4
+ unit-tested against a plain list of lines.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Iterable, Iterator, Tuple
10
+
11
+
12
+ def parse_sse(lines: Iterable[str]) -> Iterator[Tuple[str, dict]]:
13
+ """Turn a stream of SSE text lines into (event_name, data_dict) frames.
14
+
15
+ A frame ends on a blank line. `event:` sets the name (default "message");
16
+ `data:` lines are concatenated with newlines then JSON-decoded. Comment
17
+ lines (starting with ":") and id/retry fields are ignored. Malformed JSON is
18
+ surfaced as {"_raw": <text>} rather than silently dropped.
19
+ """
20
+ event: str | None = None
21
+ data_lines: list[str] = []
22
+ for raw in lines:
23
+ if raw is None:
24
+ continue
25
+ line = raw.rstrip("\r")
26
+ if line == "":
27
+ if event is not None or data_lines:
28
+ payload = "\n".join(data_lines)
29
+ try:
30
+ data = json.loads(payload) if payload else {}
31
+ except json.JSONDecodeError:
32
+ data = {"_raw": payload}
33
+ yield (event or "message", data)
34
+ event, data_lines = None, []
35
+ continue
36
+ if line.startswith(":"):
37
+ continue # SSE comment / keep-alive ping
38
+ field, _, value = line.partition(":")
39
+ if value.startswith(" "):
40
+ value = value[1:]
41
+ if field == "event":
42
+ event = value
43
+ elif field == "data":
44
+ data_lines.append(value)
45
+ # id / retry fields are intentionally ignored
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: signalpipe-daemon
3
+ Version: 1.0.0
4
+ Summary: User-side sender for SignalPipe v4 — streams pre-drafted, pre-approved missions and posts them with your own Reddit/X credentials. The math runs on SignalPipe; the sending runs on you.
5
+ Author: SignalPipe
6
+ License: MIT
7
+ Project-URL: Homepage, https://signalpipe.io
8
+ Keywords: signalpipe,outreach,reddit,twitter,sse,automation
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.28
19
+ Provides-Extra: reddit
20
+ Requires-Dist: praw>=7.7; extra == "reddit"
21
+ Provides-Extra: twitter
22
+ Requires-Dist: tweepy>=4.14; extra == "twitter"
23
+ Provides-Extra: all
24
+ Requires-Dist: praw>=7.7; extra == "all"
25
+ Requires-Dist: tweepy>=4.14; extra == "all"
26
+ Dynamic: license-file
27
+
28
+ # signalpipe-daemon
29
+
30
+ **The user-side sender for [SignalPipe](https://signalpipe.io) v4.**
31
+
32
+ > SignalPipe is the intelligence layer for outreach. We never see your Reddit
33
+ > credentials, never see your LLM keys, never store your prospects.
34
+ > **The math runs on us. The sending runs on you.**
35
+
36
+ This daemon holds a Server-Sent-Events stream open to your SignalPipe brain,
37
+ receives missions the brain has already scored, drafted, and approved, posts
38
+ them with **your own** platform credentials, and acknowledges the result. It
39
+ never sees your LLM keys, never scores or drafts anything itself, and stores no
40
+ prospects. It only sends.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install "signalpipe-daemon[all]" # both Reddit + X senders
46
+ pip install "signalpipe-daemon[reddit]" # Reddit only
47
+ pip install "signalpipe-daemon[twitter]" # X / Twitter only
48
+ pip install signalpipe-daemon # base; pulls only `requests`
49
+ ```
50
+
51
+ The platform SDKs (`praw`, `tweepy`) are optional extras and are imported only
52
+ when a mission for that channel actually arrives — install just the ones you use.
53
+
54
+ ## Configure
55
+
56
+ Copy `.env.example` to `.env` and fill it in, or export the variables directly.
57
+ A real environment variable always wins over a `.env` entry.
58
+
59
+ | Variable | Purpose |
60
+ | --- | --- |
61
+ | `SIGNALPIPE_KEY` | **Required.** Your operator key from the dashboard. |
62
+ | `SIGNALPIPE_API_URL` | Brain URL. Defaults to `https://api.signalpipe.io`. |
63
+ | `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` / `REDDIT_USERNAME` / `REDDIT_PASSWORD` | A Reddit *script* app on your sending account. Enables `reddit_comment` and `reddit_dm`. |
64
+ | `X_API_KEY` / `X_API_SECRET` / `X_ACCESS_TOKEN` / `X_ACCESS_SECRET` | X / Twitter app creds. Enables `twitter_reply`. |
65
+ | `MAX_TWITTER_ACTIONS_PER_DAY` | Default 10. |
66
+ | `MAX_REDDIT_DMS_PER_DAY` | Default 5. |
67
+ | `MAX_REDDIT_COMMENTS_PER_DAY` | Default 15. |
68
+
69
+ Your platform credentials stay on your machine. They are used only to talk to
70
+ Reddit / X directly and are **never** sent to SignalPipe.
71
+
72
+ ## Run
73
+
74
+ ```bash
75
+ signalpipe-daemon status # validate the key, print queue depth, exit
76
+ signalpipe-daemon run # stream missions and send them
77
+ signalpipe-daemon run --dry-run # log intended sends without posting or acking
78
+ ```
79
+
80
+ `run` holds the stream open and reconnects with exponential backoff on any
81
+ network drop. A rejected key (401) is fatal and exits non-zero; everything else
82
+ is treated as transient. Stop with `Ctrl-C`.
83
+
84
+ ## How a mission flows
85
+
86
+ 1. The brain scores a signal, drafts a reply, and (once approved) marks the
87
+ mission ready. It streams the mission to this daemon over SSE.
88
+ 2. The daemon posts the pre-written draft on the mission's channel
89
+ (`twitter_reply`, `reddit_comment`, or `reddit_dm`) using your credentials.
90
+ Missions on the `manual` channel are skipped — you send those yourself.
91
+ 3. The daemon acks the outcome. A failure tagged `banned` or `rate_limited`
92
+ tells the brain to pause your stream for a cool-down.
93
+
94
+ **Daily caps** pace your sending; when a cap is hit the mission is *skipped*, not
95
+ failed, so it remains queued and retries after the next local-midnight reset.
96
+
97
+ **Delivery:** missions are delivered at-least-once. Within a running process the
98
+ daemon will never post the same mission twice, even across reconnects — so a
99
+ dropped connection is always safe to recover from.
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ signalpipe_daemon/__init__.py
5
+ signalpipe_daemon/cli.py
6
+ signalpipe_daemon/client.py
7
+ signalpipe_daemon/config.py
8
+ signalpipe_daemon/daemon.py
9
+ signalpipe_daemon/senders.py
10
+ signalpipe_daemon/sse.py
11
+ signalpipe_daemon.egg-info/PKG-INFO
12
+ signalpipe_daemon.egg-info/SOURCES.txt
13
+ signalpipe_daemon.egg-info/dependency_links.txt
14
+ signalpipe_daemon.egg-info/entry_points.txt
15
+ signalpipe_daemon.egg-info/requires.txt
16
+ signalpipe_daemon.egg-info/top_level.txt
17
+ tests/test_daemon.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ signalpipe-daemon = signalpipe_daemon.cli:main
@@ -0,0 +1,11 @@
1
+ requests>=2.28
2
+
3
+ [all]
4
+ praw>=7.7
5
+ tweepy>=4.14
6
+
7
+ [reddit]
8
+ praw>=7.7
9
+
10
+ [twitter]
11
+ tweepy>=4.14
@@ -0,0 +1 @@
1
+ signalpipe_daemon
@@ -0,0 +1,148 @@
1
+ """Offline unit tests — pure stdlib, no network, no requests/praw/tweepy.
2
+
3
+ Deliberately imports only sse, senders, and config so the suite runs on a base
4
+ checkout with zero third-party deps installed.
5
+
6
+ python tests/test_daemon.py # plain runner
7
+ python -m pytest tests/ # also works
8
+ """
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from signalpipe_daemon.config import Config
15
+ from signalpipe_daemon.senders import Senders, _classify_error, _strip_u
16
+ from signalpipe_daemon.sse import parse_sse
17
+
18
+
19
+ def test_parse_sse():
20
+ lines = [
21
+ ": keep-alive ping",
22
+ "event: connected",
23
+ 'data: {"auto_threshold": 1.01, "heartbeat_seconds": 30}',
24
+ "",
25
+ "event: mission_ready",
26
+ 'data: {"payload": {"id": "m1", "outreach_channel": "manual"}}',
27
+ "",
28
+ "event: heartbeat",
29
+ 'data: {"paused": false}',
30
+ "",
31
+ ]
32
+ frames = list(parse_sse(lines))
33
+ assert frames[0][0] == "connected"
34
+ assert frames[0][1]["auto_threshold"] == 1.01
35
+ assert frames[1][0] == "mission_ready"
36
+ assert frames[1][1]["payload"]["id"] == "m1"
37
+ assert frames[2][0] == "heartbeat"
38
+ assert frames[2][1]["paused"] is False
39
+
40
+
41
+ def test_parse_sse_malformed_json():
42
+ frames = list(parse_sse(["data: not json", ""]))
43
+ assert frames[0][1] == {"_raw": "not json"}
44
+
45
+
46
+ def test_parse_sse_multiline_data():
47
+ frames = list(parse_sse(['data: {"a":', 'data: 1}', ""]))
48
+ assert frames[0][1] == {"a": 1}
49
+
50
+
51
+ def test_classify_error():
52
+ assert _classify_error("429 Too Many Requests") == "rate_limited"
53
+ assert _classify_error("RATELIMIT: try again later") == "rate_limited"
54
+ assert _classify_error("403 Forbidden") == "banned"
55
+ assert _classify_error("account suspended") == "banned"
56
+ assert _classify_error("connection reset by peer") == "unknown"
57
+
58
+
59
+ def test_strip_u():
60
+ assert _strip_u("u/spez") == "spez"
61
+ assert _strip_u("/u/spez") == "spez"
62
+ assert _strip_u("spez") == "spez"
63
+ assert _strip_u("umbrella") == "umbrella" # not eaten by a naive lstrip
64
+
65
+
66
+ def _cfg(**kw):
67
+ base = dict(api_url="https://x", key="k")
68
+ base.update(kw)
69
+ return Config(**base)
70
+
71
+
72
+ def _x_creds(**kw):
73
+ return _cfg(x_api_key="a", x_api_secret="b",
74
+ x_access_token="c", x_access_secret="d", **kw)
75
+
76
+
77
+ def test_dispatch_manual_skips():
78
+ r = Senders(_cfg()).send({"id": "m1", "outreach_channel": "manual",
79
+ "draft_content": "hi"})
80
+ assert r.is_skip and not r.sent
81
+
82
+
83
+ def test_dispatch_empty_draft_fails():
84
+ r = Senders(_x_creds()).send({"id": "m1", "outreach_channel": "twitter_reply",
85
+ "draft_content": " "})
86
+ assert r.outcome == "failed" and r.error_class == "config"
87
+
88
+
89
+ def test_dispatch_unknown_channel_fails():
90
+ r = Senders(_cfg()).send({"id": "m1", "outreach_channel": "carrier_pigeon",
91
+ "draft_content": "hi"})
92
+ assert r.outcome == "failed"
93
+
94
+
95
+ def test_dispatch_twitter_dry_run():
96
+ r = Senders(_x_creds()).send(
97
+ {"id": "m1", "outreach_channel": "twitter_reply",
98
+ "draft_content": "hello",
99
+ "target": {"url": "https://x.com/u/status/12345"}},
100
+ dry_run=True)
101
+ assert r.outcome == "success"
102
+ assert r.sent is False # dry-run never hits the API
103
+
104
+
105
+ def test_dispatch_twitter_no_creds_fails():
106
+ r = Senders(_cfg()).send(
107
+ {"id": "m1", "outreach_channel": "twitter_reply",
108
+ "draft_content": "hello",
109
+ "target": {"url": "https://x.com/u/status/12345"}},
110
+ dry_run=True)
111
+ assert r.outcome == "failed" and r.error_class == "config"
112
+
113
+
114
+ def test_dispatch_twitter_bad_url_fails():
115
+ r = Senders(_x_creds()).send(
116
+ {"id": "m1", "outreach_channel": "twitter_reply",
117
+ "draft_content": "hello",
118
+ "target": {"url": "https://x.com/u/no-numeric-id/"}},
119
+ dry_run=True)
120
+ assert r.outcome == "failed" and r.error_class == "config"
121
+
122
+
123
+ def test_dispatch_twitter_cap_skips():
124
+ r = Senders(_x_creds(max_twitter_per_day=0)).send(
125
+ {"id": "m1", "outreach_channel": "twitter_reply",
126
+ "draft_content": "hello",
127
+ "target": {"url": "https://x.com/u/status/12345"}},
128
+ dry_run=True)
129
+ assert r.is_skip
130
+
131
+
132
+ def test_dispatch_reddit_comment_dry_run():
133
+ cfg = _cfg(reddit_client_id="i", reddit_client_secret="s",
134
+ reddit_username="u", reddit_password="p")
135
+ r = Senders(cfg).send(
136
+ {"id": "m1", "outreach_channel": "reddit_comment",
137
+ "draft_content": "hello",
138
+ "target": {"url": "https://reddit.com/r/x/comments/abc/post/"}},
139
+ dry_run=True)
140
+ assert r.outcome == "success" and r.sent is False
141
+
142
+
143
+ if __name__ == "__main__":
144
+ tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
145
+ for t in tests:
146
+ t()
147
+ print(f" ok {t.__name__}")
148
+ print(f"\nall {len(tests)} tests passed")