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.
- signalpipe_daemon-1.0.0/LICENSE +21 -0
- signalpipe_daemon-1.0.0/PKG-INFO +103 -0
- signalpipe_daemon-1.0.0/README.md +76 -0
- signalpipe_daemon-1.0.0/pyproject.toml +36 -0
- signalpipe_daemon-1.0.0/setup.cfg +4 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/__init__.py +12 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/cli.py +72 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/client.py +70 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/config.py +115 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/daemon.py +176 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/senders.py +218 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon/sse.py +45 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon.egg-info/PKG-INFO +103 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon.egg-info/SOURCES.txt +17 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon.egg-info/dependency_links.txt +1 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon.egg-info/entry_points.txt +2 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon.egg-info/requires.txt +11 -0
- signalpipe_daemon-1.0.0/signalpipe_daemon.egg-info/top_level.txt +1 -0
- signalpipe_daemon-1.0.0/tests/test_daemon.py +148 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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")
|