message-poster 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- message_poster/__init__.py +10 -0
- message_poster/_version.py +24 -0
- message_poster/auth.py +171 -0
- message_poster/cli.py +234 -0
- message_poster/collect.py +220 -0
- message_poster/config.example.json +19 -0
- message_poster/render.py +171 -0
- message_poster/webhook.py +112 -0
- message_poster-0.1.0.dist-info/METADATA +343 -0
- message_poster-0.1.0.dist-info/RECORD +14 -0
- message_poster-0.1.0.dist-info/WHEEL +5 -0
- message_poster-0.1.0.dist-info/entry_points.txt +2 -0
- message_poster-0.1.0.dist-info/licenses/LICENSE +21 -0
- message_poster-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""message-poster — pick up email and Teams events via OAuth, POST to a webhook."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _version("message-poster")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
USER_AGENT = "message-poster/" + __version__
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
message_poster/auth.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Microsoft Graph auth and HTTP, via MSAL device-code flow.
|
|
3
|
+
|
|
4
|
+
Device code is the default on purpose:
|
|
5
|
+
- it needs no redirect URI and no listening socket, so it works on managed
|
|
6
|
+
machines and under a scheduled task;
|
|
7
|
+
- the browser step can be completed in whichever session the tenant's
|
|
8
|
+
Conditional Access policy is willing to accept, which is not necessarily
|
|
9
|
+
the machine running this code.
|
|
10
|
+
|
|
11
|
+
After the first sign-in the refresh token keeps later runs silent. Conditional
|
|
12
|
+
Access can force periodic re-auth; when that happens the silent path returns
|
|
13
|
+
nothing and we say so plainly rather than failing obscurely.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import msal
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from . import USER_AGENT # version-tracking, single definition
|
|
27
|
+
|
|
28
|
+
GRAPH = "https://graph.microsoft.com/v1.0"
|
|
29
|
+
|
|
30
|
+
# Delegated, read-only. This tool must never be able to send, delete or modify
|
|
31
|
+
# anything in the source tenant.
|
|
32
|
+
SCOPES = [
|
|
33
|
+
"Mail.Read",
|
|
34
|
+
"Chat.Read",
|
|
35
|
+
"ChannelMessage.Read.All",
|
|
36
|
+
"Team.ReadBasic.All",
|
|
37
|
+
"User.Read",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cache_path(config_dir: Path, profile: str) -> Path:
|
|
42
|
+
return config_dir / f".token-cache-{profile}.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_app(cfg: dict, config_dir: Path, profile: str):
|
|
46
|
+
cache = msal.SerializableTokenCache()
|
|
47
|
+
p = cache_path(config_dir, profile)
|
|
48
|
+
if p.is_file():
|
|
49
|
+
cache.deserialize(p.read_text(encoding="utf-8"))
|
|
50
|
+
|
|
51
|
+
authority = "https://login.microsoftonline.com/" + cfg.get(
|
|
52
|
+
"azure_tenant_id", "organizations"
|
|
53
|
+
)
|
|
54
|
+
app = msal.PublicClientApplication(
|
|
55
|
+
client_id=cfg["azure_client_id"], authority=authority, token_cache=cache
|
|
56
|
+
)
|
|
57
|
+
return app, cache
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _persist(cache, config_dir: Path, profile: str) -> None:
|
|
61
|
+
if cache.has_state_changed:
|
|
62
|
+
p = cache_path(config_dir, profile)
|
|
63
|
+
tmp = p.with_suffix(".tmp")
|
|
64
|
+
tmp.write_text(cache.serialize(), encoding="utf-8")
|
|
65
|
+
os.replace(tmp, p)
|
|
66
|
+
try:
|
|
67
|
+
os.chmod(p, 0o600) # best effort; a no-op on some filesystems
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_token(cfg: dict, config_dir: Path, profile: str, *, interactive: bool = False) -> str:
|
|
73
|
+
app, cache = _build_app(cfg, config_dir, profile)
|
|
74
|
+
|
|
75
|
+
accounts = app.get_accounts()
|
|
76
|
+
if accounts and not interactive:
|
|
77
|
+
result = app.acquire_token_silent(SCOPES, account=accounts[0])
|
|
78
|
+
if result and "access_token" in result:
|
|
79
|
+
_persist(cache, config_dir, profile)
|
|
80
|
+
return result["access_token"]
|
|
81
|
+
|
|
82
|
+
if not interactive:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"no usable cached token — run: message-poster login --profile {profile}\n"
|
|
85
|
+
"(Conditional Access can force periodic re-auth; this is that.)"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
flow = app.initiate_device_flow(scopes=SCOPES)
|
|
89
|
+
if "user_code" not in flow:
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
f"device flow failed to start: {flow.get('error_description')}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print("\n" + "=" * 68)
|
|
95
|
+
print(f" Go to: {flow['verification_uri']}")
|
|
96
|
+
print(f" Code: {flow['user_code']}")
|
|
97
|
+
print("=" * 68)
|
|
98
|
+
print(" Complete this in a browser session the tenant's Conditional")
|
|
99
|
+
print(" Access policy will accept. Waiting...\n", flush=True)
|
|
100
|
+
|
|
101
|
+
result = app.acquire_token_by_device_flow(flow) # blocks until done/expired
|
|
102
|
+
if "access_token" not in result:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"sign-in failed: {result.get('error')}: {result.get('error_description')}"
|
|
105
|
+
)
|
|
106
|
+
_persist(cache, config_dir, profile)
|
|
107
|
+
return result["access_token"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GraphClient:
|
|
111
|
+
"""Minimal Graph client: GET with paging, throttling and retry."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, cfg: dict, config_dir: Path, profile: str):
|
|
114
|
+
self.cfg = cfg
|
|
115
|
+
self.config_dir = config_dir
|
|
116
|
+
self.profile = profile
|
|
117
|
+
self._token = None
|
|
118
|
+
self._fetched_at = 0.0
|
|
119
|
+
self.session = requests.Session()
|
|
120
|
+
self.session.headers["User-Agent"] = USER_AGENT
|
|
121
|
+
|
|
122
|
+
def _auth_header(self) -> dict:
|
|
123
|
+
# Tokens last ~1h; refresh early rather than racing expiry mid-run.
|
|
124
|
+
if not self._token or time.time() - self._fetched_at > 2400:
|
|
125
|
+
self._token = get_token(self.cfg, self.config_dir, self.profile)
|
|
126
|
+
self._fetched_at = time.time()
|
|
127
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
128
|
+
|
|
129
|
+
def get(self, path: str, params: dict | None = None) -> dict:
|
|
130
|
+
url = path if path.startswith("http") else GRAPH + path
|
|
131
|
+
for attempt in range(1, 5):
|
|
132
|
+
r = self.session.get(
|
|
133
|
+
url, headers=self._auth_header(), params=params, timeout=60
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if r.status_code == 200:
|
|
137
|
+
return r.json()
|
|
138
|
+
|
|
139
|
+
# Graph throttles hard on a full-mailbox walk. Honour Retry-After.
|
|
140
|
+
if r.status_code == 429:
|
|
141
|
+
wait = int(r.headers.get("Retry-After", 2 ** attempt))
|
|
142
|
+
print(f" throttled, waiting {wait}s", flush=True)
|
|
143
|
+
time.sleep(wait)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if r.status_code == 401 and attempt == 1:
|
|
147
|
+
self._token = None # force one refresh, then give up
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if r.status_code in (500, 502, 503, 504):
|
|
151
|
+
time.sleep(2 ** attempt)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
raise RuntimeError(f"GET {url} -> {r.status_code}: {r.text[:300]}")
|
|
155
|
+
|
|
156
|
+
raise RuntimeError(f"GET {url}: gave up after retries")
|
|
157
|
+
|
|
158
|
+
def paged(self, path: str, params: dict | None = None, cap: int = 1000):
|
|
159
|
+
"""Yield items across @odata.nextLink pages, up to cap."""
|
|
160
|
+
seen = 0
|
|
161
|
+
data = self.get(path, params)
|
|
162
|
+
while True:
|
|
163
|
+
for item in data.get("value", []):
|
|
164
|
+
yield item
|
|
165
|
+
seen += 1
|
|
166
|
+
if seen >= cap:
|
|
167
|
+
return
|
|
168
|
+
nxt = data.get("@odata.nextLink")
|
|
169
|
+
if not nxt:
|
|
170
|
+
return
|
|
171
|
+
data = self.get(nxt) # nextLink already carries the query string
|
message_poster/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
message-poster — pick up email and Teams events, POST them to a webhook.
|
|
3
|
+
|
|
4
|
+
message-poster login --profile work
|
|
5
|
+
message-poster whoami --profile work
|
|
6
|
+
message-poster run --profile work --dry-run
|
|
7
|
+
message-poster run --profile work
|
|
8
|
+
|
|
9
|
+
State lives next to the config file: a watermark per profile, so each run only
|
|
10
|
+
picks up what is new. On a first run, with no saved watermark, the window
|
|
11
|
+
defaults to the last 24 hours rather than the whole mailbox — a fresh install
|
|
12
|
+
should never start by hauling years of history.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import collect as collect_mod
|
|
25
|
+
from .auth import GraphClient, get_token
|
|
26
|
+
from .render import Filters, newest_timestamp
|
|
27
|
+
from .webhook import build_payload, chunked, post_batch
|
|
28
|
+
|
|
29
|
+
ISO = "%Y-%m-%dT%H:%M:%SZ"
|
|
30
|
+
DEFAULT_LOOKBACK_HOURS = 24
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_config_dir() -> Path:
|
|
34
|
+
env = os.environ.get("MESSAGE_POSTER_HOME")
|
|
35
|
+
if env:
|
|
36
|
+
return Path(env)
|
|
37
|
+
base = os.environ.get("APPDATA") # Windows
|
|
38
|
+
if base:
|
|
39
|
+
return Path(base) / "message-poster"
|
|
40
|
+
return Path(os.path.expanduser("~")) / ".config" / "message-poster"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def log(msg):
|
|
44
|
+
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
45
|
+
print("[" + stamp + "] " + str(msg), flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_json(path: Path, default=None):
|
|
49
|
+
if path.is_file():
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
log("WARNING: " + path.name + " is not valid JSON")
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_json(path: Path, data) -> None:
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
tmp = path.with_suffix(".tmp")
|
|
60
|
+
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
|
61
|
+
os.replace(tmp, path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_since(state_entry, args) -> str:
|
|
65
|
+
"""Work out the window start.
|
|
66
|
+
|
|
67
|
+
Priority: explicit --since, then the saved watermark, then a 24h default.
|
|
68
|
+
The default matters: without it a first run would either fetch everything
|
|
69
|
+
or need a flag nobody remembers to pass.
|
|
70
|
+
"""
|
|
71
|
+
if getattr(args, "since", None):
|
|
72
|
+
return args.since
|
|
73
|
+
saved = (state_entry or {}).get("watermark")
|
|
74
|
+
if saved:
|
|
75
|
+
return saved
|
|
76
|
+
hours = getattr(args, "lookback_hours", None) or DEFAULT_LOOKBACK_HOURS
|
|
77
|
+
start = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
78
|
+
log("no saved watermark — defaulting to the last " + str(hours) + "h")
|
|
79
|
+
return start.strftime(ISO)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_login(cfg, cfg_dir, args) -> int:
|
|
83
|
+
get_token(cfg, cfg_dir, args.profile, interactive=True)
|
|
84
|
+
log("signed in")
|
|
85
|
+
return cmd_whoami(cfg, cfg_dir, args)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_whoami(cfg, cfg_dir, args) -> int:
|
|
89
|
+
me = GraphClient(cfg, cfg_dir, args.profile).get("/me")
|
|
90
|
+
print(" " + str(me.get("displayName")) + " <" + str(me.get("userPrincipalName")) + ">")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_run(cfg, cfg_dir, args) -> int:
|
|
95
|
+
state_path = cfg_dir / "state.json"
|
|
96
|
+
state = load_json(state_path, {}) or {}
|
|
97
|
+
entry = state.setdefault(args.profile, {})
|
|
98
|
+
|
|
99
|
+
since = resolve_since(entry, args)
|
|
100
|
+
log("profile=" + args.profile + " what=" + args.what + " since=" + since
|
|
101
|
+
+ (" DRY-RUN" if args.dry_run else ""))
|
|
102
|
+
|
|
103
|
+
filters = Filters(cfg)
|
|
104
|
+
gc = GraphClient(cfg, cfg_dir, args.profile)
|
|
105
|
+
|
|
106
|
+
items = []
|
|
107
|
+
try:
|
|
108
|
+
if args.what in ("chats", "all"):
|
|
109
|
+
items += collect_mod.collect_chats(gc, filters, since, cfg, log)
|
|
110
|
+
if args.what in ("channels", "all"):
|
|
111
|
+
items += collect_mod.collect_channels(gc, filters, since, cfg, log)
|
|
112
|
+
if args.what in ("mail", "all"):
|
|
113
|
+
items += collect_mod.collect_mail(gc, filters, since, cfg, log)
|
|
114
|
+
except RuntimeError as e:
|
|
115
|
+
log("FATAL: " + str(e))
|
|
116
|
+
log("If this is an auth error: message-poster login --profile " + args.profile)
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
if filters.counts:
|
|
120
|
+
log("excluded by filter: " + json.dumps(filters.counts))
|
|
121
|
+
|
|
122
|
+
# Advance to the newest item actually seen, not wall-clock now: anything
|
|
123
|
+
# arriving mid-run would otherwise be skipped forever.
|
|
124
|
+
watermark = newest_timestamp(items) or since
|
|
125
|
+
|
|
126
|
+
if not items:
|
|
127
|
+
log("nothing new")
|
|
128
|
+
entry["watermark"] = watermark
|
|
129
|
+
entry["last_run"] = datetime.now(timezone.utc).strftime(ISO)
|
|
130
|
+
save_json(state_path, state)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
log(str(len(items)) + " item(s) to post")
|
|
134
|
+
|
|
135
|
+
if args.dry_run:
|
|
136
|
+
out = cfg_dir / "dry-run"
|
|
137
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
for it in items:
|
|
139
|
+
(out / it["filename"]).write_text(it["rendered"], encoding="utf-8")
|
|
140
|
+
log("DRY RUN: wrote " + str(len(items)) + " file(s) to " + str(out)
|
|
141
|
+
+ " — nothing posted")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
batches = list(chunked(items, cfg.get("max_batch_bytes", 32 * 1024 * 1024)))
|
|
145
|
+
written = 0
|
|
146
|
+
skipped = 0
|
|
147
|
+
|
|
148
|
+
for i, batch in enumerate(batches, 1):
|
|
149
|
+
payload = build_payload(
|
|
150
|
+
args.profile, batch, watermark,
|
|
151
|
+
complete=(i == len(batches)), excluded=filters.counts,
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
res = post_batch(cfg, payload, log)
|
|
155
|
+
except RuntimeError as e:
|
|
156
|
+
log("FATAL: " + str(e))
|
|
157
|
+
log("watermark NOT advanced — the next run retries this window")
|
|
158
|
+
save_json(state_path, state)
|
|
159
|
+
return 1
|
|
160
|
+
written += res.get("written", 0) or 0
|
|
161
|
+
skipped += res.get("skipped_unchanged", 0) or 0
|
|
162
|
+
log(" batch " + str(i) + "/" + str(len(batches))
|
|
163
|
+
+ ": " + json.dumps({k: res.get(k) for k in
|
|
164
|
+
("written", "skipped_unchanged", "rejected")}))
|
|
165
|
+
|
|
166
|
+
# Only advance once every batch has landed.
|
|
167
|
+
entry["watermark"] = watermark
|
|
168
|
+
entry["last_run"] = datetime.now(timezone.utc).strftime(ISO)
|
|
169
|
+
entry["last_result"] = {"written": written, "skipped": skipped}
|
|
170
|
+
save_json(state_path, state)
|
|
171
|
+
log("done: written=" + str(written) + " skipped=" + str(skipped)
|
|
172
|
+
+ "; watermark -> " + watermark)
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv=None) -> int:
|
|
177
|
+
ap = argparse.ArgumentParser(
|
|
178
|
+
prog="message-poster",
|
|
179
|
+
description="Pick up email and Teams events via OAuth, POST them to a webhook.",
|
|
180
|
+
)
|
|
181
|
+
ap.add_argument("--config-dir", type=Path, default=default_config_dir(),
|
|
182
|
+
help="where config.json, state.json and token caches live")
|
|
183
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
184
|
+
|
|
185
|
+
def common(p):
|
|
186
|
+
p.add_argument("--profile", default="default",
|
|
187
|
+
help="named account/config to use (default: default)")
|
|
188
|
+
|
|
189
|
+
p_login = sub.add_parser("login", help="interactive device-code sign-in")
|
|
190
|
+
common(p_login)
|
|
191
|
+
|
|
192
|
+
p_who = sub.add_parser("whoami", help="verify the cached token")
|
|
193
|
+
common(p_who)
|
|
194
|
+
|
|
195
|
+
p_run = sub.add_parser("run", help="collect events and post them")
|
|
196
|
+
common(p_run)
|
|
197
|
+
p_run.add_argument("--what", choices=["chats", "channels", "mail", "all"],
|
|
198
|
+
default="all")
|
|
199
|
+
p_run.add_argument("--dry-run", action="store_true",
|
|
200
|
+
help="render locally and post nothing")
|
|
201
|
+
p_run.add_argument("--since", help="ISO8601 start; overrides the saved watermark")
|
|
202
|
+
p_run.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS,
|
|
203
|
+
help="window to use when no watermark is saved (default: 24)")
|
|
204
|
+
|
|
205
|
+
args = ap.parse_args(argv)
|
|
206
|
+
|
|
207
|
+
cfg_dir = args.config_dir
|
|
208
|
+
cfg_path = cfg_dir / "config.json"
|
|
209
|
+
cfg = load_json(cfg_path)
|
|
210
|
+
if not cfg:
|
|
211
|
+
print("no config at " + str(cfg_path), file=sys.stderr)
|
|
212
|
+
print("create it — see the example in the README", file=sys.stderr)
|
|
213
|
+
return 2
|
|
214
|
+
|
|
215
|
+
required = ["azure_client_id"]
|
|
216
|
+
if args.command == "run" and not args.dry_run:
|
|
217
|
+
required += ["webhook_url", "hmac_secret"]
|
|
218
|
+
missing = [k for k in required if not cfg.get(k)]
|
|
219
|
+
if missing:
|
|
220
|
+
print("config missing: " + ", ".join(missing), file=sys.stderr)
|
|
221
|
+
return 2
|
|
222
|
+
|
|
223
|
+
handlers = {"login": cmd_login, "whoami": cmd_whoami, "run": cmd_run}
|
|
224
|
+
try:
|
|
225
|
+
return handlers[args.command](cfg, cfg_dir, args)
|
|
226
|
+
except RuntimeError as e:
|
|
227
|
+
print("ERROR: " + str(e), file=sys.stderr)
|
|
228
|
+
return 1
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
return 130
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
sys.exit(main())
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collect message events from Microsoft Graph.
|
|
3
|
+
|
|
4
|
+
Endpoints used (all delegated, all read-only):
|
|
5
|
+
|
|
6
|
+
GET /me/chats ?$expand=members
|
|
7
|
+
GET /me/chats/{id}/messages
|
|
8
|
+
GET /me/joinedTeams
|
|
9
|
+
GET /teams/{id}/channels
|
|
10
|
+
GET /teams/{id}/channels/{id}/messages
|
|
11
|
+
GET /me/mailFolders
|
|
12
|
+
GET /me/messages ?$filter=receivedDateTime ge ...
|
|
13
|
+
|
|
14
|
+
Every function returns items in the shape the webhook receives:
|
|
15
|
+
kind, source_id, filename, rendered.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
|
|
22
|
+
from .render import Filters, render_chat, render_email, slug
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hash8(value):
|
|
26
|
+
return hashlib.sha1(value.encode()).hexdigest()[:8]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _chat_title(chat, me_id=None):
|
|
30
|
+
"""Group chats carry a topic; 1:1 chats do not, so name them by participant."""
|
|
31
|
+
if chat.get("topic"):
|
|
32
|
+
return chat["topic"]
|
|
33
|
+
names = [
|
|
34
|
+
m.get("displayName")
|
|
35
|
+
for m in (chat.get("members") or [])
|
|
36
|
+
if m.get("displayName") and m.get("userId") != me_id
|
|
37
|
+
]
|
|
38
|
+
if names:
|
|
39
|
+
extra = len(names) - 3
|
|
40
|
+
return ", ".join(names[:3]) + (" +" + str(extra) if extra > 0 else "")
|
|
41
|
+
return "Direct chat"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _readable(chat):
|
|
45
|
+
"""Skip calendar-derived stubs the caller cannot actually read.
|
|
46
|
+
|
|
47
|
+
Graph returns these with chatType 'unknownFutureValue' and no members;
|
|
48
|
+
fetching their messages fails with an ACL error.
|
|
49
|
+
"""
|
|
50
|
+
return not (chat.get("chatType") == "unknownFutureValue" and not chat.get("members"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def collect_chats(gc, filters, since, cfg, log=print):
|
|
54
|
+
items = []
|
|
55
|
+
me_id = None
|
|
56
|
+
try:
|
|
57
|
+
me_id = gc.get("/me").get("id")
|
|
58
|
+
except RuntimeError:
|
|
59
|
+
pass # only used to prettify 1:1 titles
|
|
60
|
+
|
|
61
|
+
chats = list(
|
|
62
|
+
gc.paged("/me/chats", {"$expand": "members", "$top": 50},
|
|
63
|
+
cap=cfg.get("max_chats_per_run", 300))
|
|
64
|
+
)
|
|
65
|
+
log("chats: " + str(len(chats)) + " visible")
|
|
66
|
+
|
|
67
|
+
skipped_stub = 0
|
|
68
|
+
skipped_old = 0
|
|
69
|
+
for chat in chats:
|
|
70
|
+
cid = chat.get("id", "")
|
|
71
|
+
if not cid or filters.skip_source(cid):
|
|
72
|
+
continue
|
|
73
|
+
if not _readable(chat):
|
|
74
|
+
skipped_stub += 1
|
|
75
|
+
continue
|
|
76
|
+
if since and (chat.get("lastUpdatedDateTime") or "") <= since:
|
|
77
|
+
skipped_old += 1
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
msgs = list(
|
|
82
|
+
gc.paged("/me/chats/" + cid + "/messages",
|
|
83
|
+
{"$top": 50, "$orderby": "createdDateTime desc"},
|
|
84
|
+
cap=cfg.get("max_messages_per_chat", 200))
|
|
85
|
+
)
|
|
86
|
+
except RuntimeError as e:
|
|
87
|
+
log(" skip " + cid[:28] + ": " + str(e))
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
msgs = [m for m in msgs if (m.get("body") or {}).get("content")]
|
|
91
|
+
if not msgs:
|
|
92
|
+
continue
|
|
93
|
+
msgs.sort(key=lambda m: m.get("createdDateTime") or "")
|
|
94
|
+
|
|
95
|
+
topic = _chat_title(chat, me_id)
|
|
96
|
+
rendered = render_chat(topic, "teams", msgs, filters)
|
|
97
|
+
if rendered.count("\n") < 3:
|
|
98
|
+
continue # nothing but system events
|
|
99
|
+
|
|
100
|
+
items.append({
|
|
101
|
+
"kind": "chat",
|
|
102
|
+
"source_id": cid,
|
|
103
|
+
"filename": "chat-" + slug(topic) + "-" + _hash8(cid) + ".md",
|
|
104
|
+
"rendered": rendered,
|
|
105
|
+
})
|
|
106
|
+
log(" + " + topic[:52] + " (" + str(len(msgs)) + " msgs)")
|
|
107
|
+
|
|
108
|
+
if skipped_stub or skipped_old:
|
|
109
|
+
log(" (" + str(skipped_stub) + " unreadable stubs, "
|
|
110
|
+
+ str(skipped_old) + " unchanged)")
|
|
111
|
+
return items
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def collect_channels(gc, filters, since, cfg, log=print):
|
|
115
|
+
"""Team channel posts — often where decisions land, unlike 1:1 chat."""
|
|
116
|
+
items = []
|
|
117
|
+
try:
|
|
118
|
+
teams = list(gc.paged("/me/joinedTeams", cap=50))
|
|
119
|
+
except RuntimeError as e:
|
|
120
|
+
log("channels: skipped (" + str(e) + ")")
|
|
121
|
+
return items
|
|
122
|
+
log("teams: " + str(len(teams)))
|
|
123
|
+
|
|
124
|
+
for team in teams:
|
|
125
|
+
tid = team.get("id")
|
|
126
|
+
tname = team.get("displayName") or "team"
|
|
127
|
+
if not tid:
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
channels = list(gc.paged("/teams/" + tid + "/channels", cap=50))
|
|
131
|
+
except RuntimeError as e:
|
|
132
|
+
log(" " + tname + ": no channels (" + str(e) + ")")
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
for ch in channels:
|
|
136
|
+
chid = ch.get("id")
|
|
137
|
+
chname = ch.get("displayName") or "channel"
|
|
138
|
+
key = tid + "/" + str(chid)
|
|
139
|
+
if not chid or filters.skip_source(key):
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
msgs = list(
|
|
143
|
+
gc.paged("/teams/" + tid + "/channels/" + chid + "/messages",
|
|
144
|
+
{"$top": 50},
|
|
145
|
+
cap=cfg.get("max_messages_per_chat", 200))
|
|
146
|
+
)
|
|
147
|
+
except RuntimeError as e:
|
|
148
|
+
log(" " + tname + "/" + chname + ": " + str(e))
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
msgs = [m for m in msgs if (m.get("body") or {}).get("content")]
|
|
152
|
+
if since:
|
|
153
|
+
msgs = [m for m in msgs if (m.get("createdDateTime") or "") > since]
|
|
154
|
+
if not msgs:
|
|
155
|
+
continue
|
|
156
|
+
msgs.sort(key=lambda m: m.get("createdDateTime") or "")
|
|
157
|
+
|
|
158
|
+
topic = tname + " / " + chname
|
|
159
|
+
rendered = render_chat(topic, "teams", msgs, filters)
|
|
160
|
+
if rendered.count("\n") < 3:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
items.append({
|
|
164
|
+
"kind": "chat",
|
|
165
|
+
"source_id": key,
|
|
166
|
+
"filename": "chat-" + slug(topic) + "-" + _hash8(key) + ".md",
|
|
167
|
+
"rendered": rendered,
|
|
168
|
+
})
|
|
169
|
+
log(" + " + topic[:52] + " (" + str(len(msgs)) + " msgs)")
|
|
170
|
+
return items
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def collect_mail(gc, filters, since, cfg, log=print):
|
|
174
|
+
items = []
|
|
175
|
+
account = cfg.get("account", "unknown")
|
|
176
|
+
|
|
177
|
+
# Resolve excluded folder names to ids, so exclusion can be checked against
|
|
178
|
+
# each message's parentFolderId.
|
|
179
|
+
excluded_ids = set()
|
|
180
|
+
try:
|
|
181
|
+
for f in gc.paged("/me/mailFolders", {"$top": 100}, cap=200):
|
|
182
|
+
if filters.skip_folder(f.get("displayName", "")):
|
|
183
|
+
excluded_ids.add(f.get("id"))
|
|
184
|
+
except RuntimeError as e:
|
|
185
|
+
log("mailFolders unavailable (" + str(e) + "); folder excludes not applied")
|
|
186
|
+
|
|
187
|
+
params = {
|
|
188
|
+
"$top": 50,
|
|
189
|
+
"$orderby": "receivedDateTime desc",
|
|
190
|
+
"$select": ("id,subject,from,toRecipients,receivedDateTime,"
|
|
191
|
+
"body,bodyPreview,parentFolderId,hasAttachments"),
|
|
192
|
+
}
|
|
193
|
+
if since:
|
|
194
|
+
params["$filter"] = "receivedDateTime ge " + since
|
|
195
|
+
|
|
196
|
+
raw = list(gc.paged("/me/messages", params, cap=cfg.get("max_mail_per_run", 500)))
|
|
197
|
+
log("mail: " + str(len(raw)) + " candidates")
|
|
198
|
+
|
|
199
|
+
for m in raw:
|
|
200
|
+
mid = m.get("id", "")
|
|
201
|
+
if not mid or filters.skip_source(mid):
|
|
202
|
+
continue
|
|
203
|
+
if m.get("parentFolderId") in excluded_ids:
|
|
204
|
+
filters.bump("folder:excluded")
|
|
205
|
+
continue
|
|
206
|
+
frm = (m.get("from") or {}).get("emailAddress", {}).get("address", "")
|
|
207
|
+
if filters.skip_sender(frm):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
stamp = (m.get("receivedDateTime") or "")[:10].replace("-", "")
|
|
211
|
+
subj = slug(m.get("subject") or "no-subject", 40)
|
|
212
|
+
items.append({
|
|
213
|
+
"kind": "email",
|
|
214
|
+
"source_id": mid,
|
|
215
|
+
"filename": "mail-" + stamp + "-" + subj + "-" + _hash8(mid) + ".md",
|
|
216
|
+
"rendered": render_email(m, account, filters),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
log("mail: " + str(len(items)) + " after filters")
|
|
220
|
+
return items
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"azure_client_id": "<application id from your Azure app registration>",
|
|
3
|
+
"azure_tenant_id": "organizations",
|
|
4
|
+
"account": "you@example.com",
|
|
5
|
+
"webhook_url": "https://ingest.example.com/ingest",
|
|
6
|
+
"hmac_secret": "<shared secret, same value the receiver holds>",
|
|
7
|
+
"webhook_headers": {},
|
|
8
|
+
"max_mail_per_run": 500,
|
|
9
|
+
"max_chats_per_run": 300,
|
|
10
|
+
"max_messages_per_chat": 200,
|
|
11
|
+
"max_batch_bytes": 33554432,
|
|
12
|
+
"gzip_over_bytes": 65536,
|
|
13
|
+
"filters": {
|
|
14
|
+
"exclude_folders": ["Junk Email", "Deleted Items"],
|
|
15
|
+
"exclude_sender_patterns": ["payroll@", "noreply@"],
|
|
16
|
+
"optout_source_ids": [],
|
|
17
|
+
"redact_patterns": []
|
|
18
|
+
}
|
|
19
|
+
}
|
message_poster/render.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render Graph objects to markdown, and filter what is allowed to leave.
|
|
3
|
+
|
|
4
|
+
Rendering happens client-side so that only the text which will actually be
|
|
5
|
+
posted ever leaves the tenant: no raw API payloads, no directory GUIDs, no
|
|
6
|
+
delta tokens, no attachment URLs.
|
|
7
|
+
|
|
8
|
+
Two output shapes, both plain text and both trivially greppable:
|
|
9
|
+
|
|
10
|
+
chat -> chat-<slug>-<hash8>.md
|
|
11
|
+
# Chat: <topic> (<channel>)
|
|
12
|
+
[YYYY-MM-DDTHH:MM] Sender: message
|
|
13
|
+
|
|
14
|
+
email -> mail-<YYYYMMDD>-<slug>-<hash8>.md
|
|
15
|
+
From:/To:/Subject:/Date:/Account: headers, blank line, body
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import unicodedata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def slug(value: str, maxlen: int = 50) -> str:
|
|
25
|
+
value = unicodedata.normalize("NFKD", value or "")
|
|
26
|
+
value = value.encode("ascii", "ignore").decode("ascii")
|
|
27
|
+
value = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower()
|
|
28
|
+
return value[:maxlen] or "untitled"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def html_to_text(html: str) -> str:
|
|
32
|
+
"""Chat and mail bodies are HTML. Keep the text, drop the markup."""
|
|
33
|
+
if not html:
|
|
34
|
+
return ""
|
|
35
|
+
s = re.sub(r"(?is)<(script|style).*?</\1>", "", html)
|
|
36
|
+
s = re.sub(r"(?i)<br\s*/?>", "\n", s)
|
|
37
|
+
s = re.sub(r"(?i)</(p|div|li|tr)>", "\n", s)
|
|
38
|
+
s = re.sub(r"(?i)<li[^>]*>", "- ", s)
|
|
39
|
+
s = re.sub(r"<[^>]+>", "", s)
|
|
40
|
+
for a, b in (
|
|
41
|
+
(" ", " "), ("&", "&"), ("<", "<"),
|
|
42
|
+
(">", ">"), (""", '"'), ("'", "'"),
|
|
43
|
+
):
|
|
44
|
+
s = s.replace(a, b)
|
|
45
|
+
return re.sub(r"\n{3,}", "\n\n", s).strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Filters:
|
|
49
|
+
"""Deny-list applied before anything is rendered or posted.
|
|
50
|
+
|
|
51
|
+
When mirroring a whole mailbox the failure mode inverts: anything NOT
|
|
52
|
+
excluded here gets sent. These rules are the safety mechanism, not an
|
|
53
|
+
optimisation, and deserve a deliberate read before an unattended run.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, cfg: dict):
|
|
57
|
+
f = (cfg or {}).get("filters", {})
|
|
58
|
+
self.folders = {x.lower() for x in f.get("exclude_folders", [])}
|
|
59
|
+
self.senders = [x.lower() for x in f.get("exclude_sender_patterns", [])]
|
|
60
|
+
self.optout = set(f.get("optout_source_ids", []))
|
|
61
|
+
self.redactions = [re.compile(p, re.I) for p in f.get("redact_patterns", [])]
|
|
62
|
+
self.counts: dict = {}
|
|
63
|
+
|
|
64
|
+
def bump(self, rule: str) -> None:
|
|
65
|
+
self.counts[rule] = self.counts.get(rule, 0) + 1
|
|
66
|
+
|
|
67
|
+
def skip_folder(self, name: str) -> bool:
|
|
68
|
+
if (name or "").lower() in self.folders:
|
|
69
|
+
self.bump("folder:" + str(name))
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def skip_source(self, source_id: str) -> bool:
|
|
74
|
+
if source_id in self.optout:
|
|
75
|
+
self.bump("optout")
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def skip_sender(self, addr: str) -> bool:
|
|
80
|
+
a = (addr or "").lower()
|
|
81
|
+
for pat in self.senders:
|
|
82
|
+
if pat in a:
|
|
83
|
+
self.bump("sender:" + pat)
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def redact(self, text: str) -> str:
|
|
88
|
+
for rx in self.redactions:
|
|
89
|
+
text, n = rx.subn("[REDACTED]", text)
|
|
90
|
+
if n:
|
|
91
|
+
self.bump("redacted")
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_chat(topic: str, channel: str, messages: list, filters: Filters) -> str:
|
|
96
|
+
lines = ["# Chat: " + (topic or "Untitled") + " (" + channel + ")", ""]
|
|
97
|
+
for m in messages:
|
|
98
|
+
ts = (m.get("createdDateTime") or "")[:16] # YYYY-MM-DDTHH:MM
|
|
99
|
+
if not ts:
|
|
100
|
+
continue
|
|
101
|
+
frm = m.get("from") or {}
|
|
102
|
+
sender = (
|
|
103
|
+
(frm.get("user") or {}).get("displayName")
|
|
104
|
+
or (frm.get("application") or {}).get("displayName")
|
|
105
|
+
or "Unknown"
|
|
106
|
+
)
|
|
107
|
+
body = html_to_text((m.get("body") or {}).get("content", ""))
|
|
108
|
+
if not body.strip():
|
|
109
|
+
continue # system events and reactions carry no text
|
|
110
|
+
body = filters.redact(body)
|
|
111
|
+
|
|
112
|
+
att = m.get("attachments") or []
|
|
113
|
+
if att:
|
|
114
|
+
names = ", ".join(
|
|
115
|
+
a.get("name") or a.get("contentType") or "file" for a in att
|
|
116
|
+
)
|
|
117
|
+
body += "\n[attachments: " + names + "]" # metadata only, never bytes
|
|
118
|
+
|
|
119
|
+
lines.append("[" + ts + "] " + sender + ": " + body.replace("\n", "\n "))
|
|
120
|
+
lines.append("")
|
|
121
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_email(msg: dict, account: str, filters: Filters) -> str:
|
|
125
|
+
frm = (msg.get("from") or {}).get("emailAddress", {})
|
|
126
|
+
to = ", ".join(
|
|
127
|
+
r.get("emailAddress", {}).get("address", "")
|
|
128
|
+
for r in (msg.get("toRecipients") or [])
|
|
129
|
+
)
|
|
130
|
+
body = html_to_text((msg.get("body") or {}).get("content", "")) or (
|
|
131
|
+
msg.get("bodyPreview") or ""
|
|
132
|
+
)
|
|
133
|
+
body = filters.redact(body)
|
|
134
|
+
|
|
135
|
+
hdr = [
|
|
136
|
+
"From: " + str(frm.get("name", "")) + " <" + str(frm.get("address", "")) + ">",
|
|
137
|
+
"To: " + to,
|
|
138
|
+
"Subject: " + str(msg.get("subject", "(no subject)")),
|
|
139
|
+
"Date: " + str(msg.get("receivedDateTime", "")),
|
|
140
|
+
"Account: " + account,
|
|
141
|
+
]
|
|
142
|
+
att = msg.get("attachments") or []
|
|
143
|
+
if att:
|
|
144
|
+
hdr.append(
|
|
145
|
+
"Attachments: "
|
|
146
|
+
+ ", ".join(
|
|
147
|
+
str(a.get("name", "file")) + " (" + str(a.get("size", "?")) + "b)"
|
|
148
|
+
for a in att
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return "\n".join(hdr) + "\n\n" + body.strip() + "\n"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def newest_timestamp(items: list):
|
|
155
|
+
"""Highest timestamp actually seen — a safer watermark than wall-clock now.
|
|
156
|
+
|
|
157
|
+
Using now() risks skipping anything that arrives while the run is in
|
|
158
|
+
flight; using the newest item actually processed cannot.
|
|
159
|
+
"""
|
|
160
|
+
best = None
|
|
161
|
+
for it in items:
|
|
162
|
+
for line in it["rendered"].splitlines():
|
|
163
|
+
if line.startswith("[") and len(line) > 17:
|
|
164
|
+
ts = line[1:17]
|
|
165
|
+
elif line.startswith("Date: "):
|
|
166
|
+
ts = line[6:].strip()[:16]
|
|
167
|
+
else:
|
|
168
|
+
continue
|
|
169
|
+
if best is None or ts > best:
|
|
170
|
+
best = ts
|
|
171
|
+
return (best + ":00Z") if best and len(best) == 16 else None
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
POST collected events to a webhook.
|
|
3
|
+
|
|
4
|
+
The payload is deliberately plain: a profile name, a run id, a watermark, and a
|
|
5
|
+
list of items each carrying pre-rendered markdown. What the receiver does with
|
|
6
|
+
them — store, index, forward, discard — is none of this tool's business.
|
|
7
|
+
|
|
8
|
+
Every request is signed with HMAC-SHA256 over "<timestamp>.<raw body>" so the
|
|
9
|
+
receiver can verify both origin and integrity, and reject replays. Optional
|
|
10
|
+
extra headers (for an identity-aware proxy in front of the receiver) are passed
|
|
11
|
+
through verbatim from config.
|
|
12
|
+
|
|
13
|
+
NOTE: requests, never urllib. Some proxies and WAFs reject unknown or absent
|
|
14
|
+
User-Agent strings outright, which surfaces as a confusing 403 that looks like
|
|
15
|
+
an auth failure. A real session with an explicit User-Agent avoids it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import gzip
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
|
|
29
|
+
from . import USER_AGENT # version-tracking, single definition
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_payload(profile, items, watermark, complete=True, excluded=None):
|
|
33
|
+
run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "-" + os.urandom(3).hex()
|
|
34
|
+
return {
|
|
35
|
+
"profile": profile,
|
|
36
|
+
"run_id": run_id,
|
|
37
|
+
"items": items,
|
|
38
|
+
"watermark": watermark,
|
|
39
|
+
"complete": complete,
|
|
40
|
+
"excluded_by_filter": excluded or {},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sign(secret, body, timestamp):
|
|
45
|
+
"""HMAC-SHA256 over '<timestamp>.<body>'.
|
|
46
|
+
|
|
47
|
+
Binding the timestamp into the signed bytes is what makes a replay window
|
|
48
|
+
meaningful: an old body cannot be re-sent with a fresh timestamp without
|
|
49
|
+
invalidating the signature.
|
|
50
|
+
"""
|
|
51
|
+
return "sha256=" + hmac.new(
|
|
52
|
+
secret.encode(), str(timestamp).encode() + b"." + body, hashlib.sha256
|
|
53
|
+
).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def post_batch(cfg, payload, log=print):
|
|
57
|
+
body = json.dumps(payload).encode()
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"User-Agent": USER_AGENT,
|
|
62
|
+
}
|
|
63
|
+
headers.update(cfg.get("webhook_headers") or {})
|
|
64
|
+
|
|
65
|
+
if len(body) > cfg.get("gzip_over_bytes", 65536):
|
|
66
|
+
body = gzip.compress(body)
|
|
67
|
+
headers["Content-Encoding"] = "gzip"
|
|
68
|
+
|
|
69
|
+
ts = int(time.time())
|
|
70
|
+
headers["X-Timestamp"] = str(ts)
|
|
71
|
+
headers["X-Signature"] = sign(cfg["hmac_secret"], body, ts)
|
|
72
|
+
|
|
73
|
+
url = cfg["webhook_url"]
|
|
74
|
+
last = None
|
|
75
|
+
for attempt in range(1, 4):
|
|
76
|
+
try:
|
|
77
|
+
r = requests.post(url, data=body, headers=headers, timeout=60)
|
|
78
|
+
if r.status_code in (200, 207):
|
|
79
|
+
try:
|
|
80
|
+
return r.json()
|
|
81
|
+
except ValueError:
|
|
82
|
+
return {"status_code": r.status_code, "text": r.text[:200]}
|
|
83
|
+
# A 4xx other than 429 is our own bug; retrying will not fix it.
|
|
84
|
+
if 400 <= r.status_code < 500 and r.status_code != 429:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"HTTP " + str(r.status_code) + ": " + r.text[:200]
|
|
87
|
+
)
|
|
88
|
+
last = "HTTP " + str(r.status_code) + ": " + r.text[:120]
|
|
89
|
+
except requests.RequestException as e:
|
|
90
|
+
last = str(e)
|
|
91
|
+
if attempt < 3:
|
|
92
|
+
wait = 2 ** attempt
|
|
93
|
+
log(" retry " + str(attempt) + "/3 in " + str(wait) + "s (" + str(last) + ")")
|
|
94
|
+
time.sleep(wait)
|
|
95
|
+
|
|
96
|
+
raise RuntimeError("upload failed after 3 attempts: " + str(last))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def chunked(items, max_bytes):
|
|
100
|
+
"""Split so no single POST exceeds the receiver's body cap."""
|
|
101
|
+
batch = []
|
|
102
|
+
size = 0
|
|
103
|
+
for it in items:
|
|
104
|
+
n = len(it["rendered"].encode()) + 400
|
|
105
|
+
if batch and size + n > max_bytes:
|
|
106
|
+
yield batch
|
|
107
|
+
batch = []
|
|
108
|
+
size = 0
|
|
109
|
+
batch.append(it)
|
|
110
|
+
size += n
|
|
111
|
+
if batch:
|
|
112
|
+
yield batch
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: message-poster
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mirror mail and chat from Microsoft Graph to a webhook, as plain markdown
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/omarmciver/message-poster
|
|
7
|
+
Project-URL: Issues, https://github.com/omarmciver/message-poster/issues
|
|
8
|
+
Keywords: microsoft-graph,outlook,teams,export,mirror,webhook
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Communications :: Email
|
|
13
|
+
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: msal>=1.28
|
|
18
|
+
Requires-Dist: requests>=2.31
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# message-poster
|
|
24
|
+
|
|
25
|
+
Pick up recent **email, Teams chats and Teams channel posts** from Microsoft
|
|
26
|
+
Graph, render them to plain markdown, and POST them to a webhook you control.
|
|
27
|
+
|
|
28
|
+
What the receiver does with the payload — store it, index it, feed it to
|
|
29
|
+
something else, drop it — is out of scope. This tool's only job is to collect
|
|
30
|
+
and deliver.
|
|
31
|
+
|
|
32
|
+
- **Read-only.** Delegated Graph scopes only; it cannot send, delete or modify
|
|
33
|
+
anything in the source tenant.
|
|
34
|
+
- **Rendered client-side.** Only the text that will actually be posted leaves
|
|
35
|
+
the tenant — no raw API payloads, no directory GUIDs, no delta tokens, no
|
|
36
|
+
attachment URLs.
|
|
37
|
+
- **Attachments are metadata only.** Filename, size and type. Never bytes.
|
|
38
|
+
- **Signed.** Every request carries an HMAC-SHA256 signature over the exact
|
|
39
|
+
bytes sent.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install message-poster
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.9+. Two dependencies (`msal`, `requests`) — deliberately small,
|
|
48
|
+
because this installs on managed machines where every extra package is a
|
|
49
|
+
question somebody has to answer.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 1. Register an Azure app (see below) and write config.json
|
|
55
|
+
# 2. Sign in — device code, so no redirect URI and no listening socket
|
|
56
|
+
message-poster login --profile work
|
|
57
|
+
|
|
58
|
+
# 3. Check the token works
|
|
59
|
+
message-poster whoami --profile work
|
|
60
|
+
|
|
61
|
+
# 4. See what would be sent, without sending it
|
|
62
|
+
message-poster run --profile work --dry-run
|
|
63
|
+
|
|
64
|
+
# 5. For real
|
|
65
|
+
message-poster run --profile work
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Commands
|
|
69
|
+
|
|
70
|
+
| Command | What it does |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `login` | Interactive device-code sign-in. Prints a URL and a code; caches the refresh token afterwards. |
|
|
73
|
+
| `whoami` | Fetches `/me` with the cached token. The fastest way to tell auth from everything else. |
|
|
74
|
+
| `run` | Collects events since the watermark and POSTs them. |
|
|
75
|
+
|
|
76
|
+
`run` options:
|
|
77
|
+
|
|
78
|
+
| Flag | Default | Meaning |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `--what {chats,channels,mail,all}` | `all` | Which sources to collect. |
|
|
81
|
+
| `--dry-run` | off | Render to `<config-dir>/dry-run/` and post nothing. |
|
|
82
|
+
| `--since ISO8601` | — | Explicit window start. Overrides the saved watermark. |
|
|
83
|
+
| `--lookback-hours N` | `24` | Window to use when no watermark is saved. |
|
|
84
|
+
|
|
85
|
+
`--profile NAME` (default `default`) selects a named account within one config
|
|
86
|
+
directory; each profile keeps its own token cache and its own watermark.
|
|
87
|
+
`--config-dir PATH` overrides where everything lives.
|
|
88
|
+
|
|
89
|
+
Exit codes: `0` success, `1` a run or auth failure, `2` a config problem,
|
|
90
|
+
`130` interrupted.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
Config lives at `~/.config/message-poster/config.json`
|
|
95
|
+
(`%APPDATA%\message-poster\config.json` on Windows). Override with
|
|
96
|
+
`--config-dir` or the `MESSAGE_POSTER_HOME` environment variable.
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"azure_client_id": "<application id from your Azure app registration>",
|
|
101
|
+
"azure_tenant_id": "organizations",
|
|
102
|
+
"account": "you@example.com",
|
|
103
|
+
"webhook_url": "https://ingest.example.com/ingest",
|
|
104
|
+
"hmac_secret": "<shared secret, same value the receiver holds>",
|
|
105
|
+
"webhook_headers": {},
|
|
106
|
+
"max_mail_per_run": 500,
|
|
107
|
+
"max_chats_per_run": 300,
|
|
108
|
+
"max_messages_per_chat": 200,
|
|
109
|
+
"max_batch_bytes": 33554432,
|
|
110
|
+
"gzip_over_bytes": 65536,
|
|
111
|
+
"filters": {
|
|
112
|
+
"exclude_folders": ["Junk Email", "Deleted Items"],
|
|
113
|
+
"exclude_sender_patterns": ["payroll@", "noreply@"],
|
|
114
|
+
"optout_source_ids": [],
|
|
115
|
+
"redact_patterns": []
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| Key | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `azure_client_id` | Application (client) ID of your app registration. Required. |
|
|
123
|
+
| `azure_tenant_id` | `organizations`, `common`, or a specific tenant ID. |
|
|
124
|
+
| `account` | Written into each email's `Account:` header, so a receiver can tell mailboxes apart. |
|
|
125
|
+
| `webhook_url` | Where batches are POSTed. Required for a real run. |
|
|
126
|
+
| `hmac_secret` | Shared secret for request signing. Required for a real run. |
|
|
127
|
+
| `webhook_headers` | Free-form map merged into every request — see below. |
|
|
128
|
+
| `max_batch_bytes` | Split threshold, so no single POST exceeds the receiver's body cap. |
|
|
129
|
+
| `gzip_over_bytes` | Bodies larger than this are gzipped. |
|
|
130
|
+
|
|
131
|
+
`config.json`, `state.json` and `.token-cache-*.json` all live in the config
|
|
132
|
+
directory and none of them belong in version control.
|
|
133
|
+
|
|
134
|
+
### `webhook_headers`
|
|
135
|
+
|
|
136
|
+
A free-form map merged into every request. This is where an identity-aware
|
|
137
|
+
proxy's credentials go, so the tool needs no knowledge of any particular proxy:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
"webhook_headers": {
|
|
141
|
+
"Proxy-Authorization": "Bearer <token>",
|
|
142
|
+
"X-Tenant": "acme"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Filters are a **deny-list**
|
|
147
|
+
|
|
148
|
+
Read this twice before an unattended run. When you mirror a whole mailbox the
|
|
149
|
+
failure mode inverts: **anything not excluded gets sent.** These rules are the
|
|
150
|
+
safety mechanism, not an optimisation.
|
|
151
|
+
|
|
152
|
+
| Key | Effect |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `exclude_folders` | Mail folder display names to skip entirely. |
|
|
155
|
+
| `exclude_sender_patterns` | Substring match, case-insensitive, against the sender address. |
|
|
156
|
+
| `optout_source_ids` | Chat IDs, `teamId/channelId` pairs or message IDs to never collect. |
|
|
157
|
+
| `redact_patterns` | Regexes; every match becomes `[REDACTED]` before the text is posted. |
|
|
158
|
+
|
|
159
|
+
Every exclusion is counted and reported in the payload's `excluded_by_filter`,
|
|
160
|
+
so a receiver can see that filtering happened without seeing what was filtered.
|
|
161
|
+
|
|
162
|
+
## Azure app registration
|
|
163
|
+
|
|
164
|
+
This is the main setup hurdle. In the Azure portal, under **App registrations**:
|
|
165
|
+
|
|
166
|
+
1. **New registration.** Any name. No redirect URI is needed.
|
|
167
|
+
2. Under **Authentication**, enable **"Allow public client flows"** — the
|
|
168
|
+
device-code flow will not start without it.
|
|
169
|
+
3. Under **API permissions**, add these **delegated** Microsoft Graph
|
|
170
|
+
permissions, all read-only:
|
|
171
|
+
- `Mail.Read`
|
|
172
|
+
- `Chat.Read`
|
|
173
|
+
- `ChannelMessage.Read.All`
|
|
174
|
+
- `Team.ReadBasic.All`
|
|
175
|
+
- `User.Read`
|
|
176
|
+
4. Copy the **Application (client) ID** into `azure_client_id`.
|
|
177
|
+
|
|
178
|
+
Some tenants require an administrator to grant consent for
|
|
179
|
+
`ChannelMessage.Read.All`. If channel collection comes back empty while chats
|
|
180
|
+
and mail work, that is usually why.
|
|
181
|
+
|
|
182
|
+
## The watermark
|
|
183
|
+
|
|
184
|
+
State lives in `state.json` next to the config, one entry per profile. Two
|
|
185
|
+
rules matter:
|
|
186
|
+
|
|
187
|
+
- **The watermark is the newest message actually seen**, not wall-clock `now()`.
|
|
188
|
+
Using `now()` would permanently skip anything that arrived while the run was
|
|
189
|
+
in flight.
|
|
190
|
+
- **It only advances after every batch has landed.** A failed POST leaves it
|
|
191
|
+
where it was, so the next run retries that window.
|
|
192
|
+
|
|
193
|
+
On a first run with no saved watermark the window defaults to the **last 24
|
|
194
|
+
hours**, not the whole mailbox. `--lookback-hours` widens it and `--since`
|
|
195
|
+
overrides it outright. There is deliberately no `--backfill`: a fresh install
|
|
196
|
+
should never start by hauling years of history through a webhook.
|
|
197
|
+
|
|
198
|
+
## Payload contract
|
|
199
|
+
|
|
200
|
+
Anyone can write a receiver. A batch is POSTed as JSON:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"profile": "work",
|
|
205
|
+
"run_id": "20260916T180000Z-a1b2c3",
|
|
206
|
+
"watermark": "2026-09-16T17:55:00Z",
|
|
207
|
+
"complete": true,
|
|
208
|
+
"excluded_by_filter": {"sender:payroll@": 3},
|
|
209
|
+
"items": [
|
|
210
|
+
{
|
|
211
|
+
"kind": "chat",
|
|
212
|
+
"source_id": "19:abc...@thread.v2",
|
|
213
|
+
"filename": "chat-project-sync-a1b2c3d4.md",
|
|
214
|
+
"rendered": "# Chat: Project Sync (teams)\n\n[2026-09-16T09:10] Alice Chen: ...\n"
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Field | Meaning |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `profile` | The `--profile` the run used. |
|
|
223
|
+
| `run_id` | Unique per batch-set: `<UTC timestamp>-<6 hex>`. |
|
|
224
|
+
| `watermark` | Where the sender intends to resume. |
|
|
225
|
+
| `complete` | `false` on every batch but the last of a run. Wait for `true` before treating the window as fully delivered. |
|
|
226
|
+
| `excluded_by_filter` | Counts per rule. Diagnostic only. |
|
|
227
|
+
| `items[].kind` | `chat` or `email`. |
|
|
228
|
+
| `items[].source_id` | Stable Graph identifier for the conversation or message. |
|
|
229
|
+
| `items[].filename` | Suggested filename. Safe: `^[A-Za-z0-9._-]+$`. |
|
|
230
|
+
| `items[].rendered` | The markdown. This is the content. |
|
|
231
|
+
|
|
232
|
+
A successful receiver responds `200` or `207`. If it returns JSON, the keys
|
|
233
|
+
`written`, `skipped_unchanged` and `rejected` are logged by the sender; any
|
|
234
|
+
other body is ignored.
|
|
235
|
+
|
|
236
|
+
`rendered` comes in two shapes:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
# Chat: <topic> (teams)
|
|
240
|
+
|
|
241
|
+
[2026-09-16T09:10] Alice Chen: message text
|
|
242
|
+
continued lines are indented four spaces
|
|
243
|
+
[attachments: budget.xlsx]
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
From: Alice Chen <alice@example.com>
|
|
248
|
+
To: bob@example.com
|
|
249
|
+
Subject: Quarterly numbers
|
|
250
|
+
Date: 2026-09-16T09:10:00Z
|
|
251
|
+
Account: you@example.com
|
|
252
|
+
Attachments: budget.xlsx (2048b)
|
|
253
|
+
|
|
254
|
+
Body text, HTML stripped.
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Verifying a request
|
|
258
|
+
|
|
259
|
+
Each POST carries:
|
|
260
|
+
|
|
261
|
+
| Header | Value |
|
|
262
|
+
|---|---|
|
|
263
|
+
| `X-Timestamp` | Unix seconds when the request was signed. |
|
|
264
|
+
| `X-Signature` | `sha256=<hmac_sha256(secret, "<X-Timestamp>." + raw_body)>` |
|
|
265
|
+
| `Content-Encoding` | `gzip`, if the body exceeded `gzip_over_bytes`. |
|
|
266
|
+
|
|
267
|
+
**The signature covers the compressed bytes as sent.** Verify before you
|
|
268
|
+
decompress.
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
import hashlib, hmac
|
|
272
|
+
|
|
273
|
+
def verify(secret, raw_body, timestamp, signature):
|
|
274
|
+
expected = "sha256=" + hmac.new(
|
|
275
|
+
secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256
|
|
276
|
+
).hexdigest()
|
|
277
|
+
return hmac.compare_digest(expected, signature)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Two things a receiver should also do:
|
|
281
|
+
|
|
282
|
+
- **Reject timestamps outside about ±5 minutes** of its own clock. Binding the
|
|
283
|
+
timestamp into the signed bytes is what makes that window meaningful — an old
|
|
284
|
+
body cannot be replayed under a fresh timestamp without breaking the
|
|
285
|
+
signature.
|
|
286
|
+
- **Treat `(source_id, sha256(rendered))` as an idempotency key.** Chats are
|
|
287
|
+
re-sent whole when they change, so the same `source_id` will arrive more than
|
|
288
|
+
once; the content hash is what tells a genuine update from a repeat.
|
|
289
|
+
|
|
290
|
+
## Delivery behaviour
|
|
291
|
+
|
|
292
|
+
- Batches are split so none exceeds `max_batch_bytes`.
|
|
293
|
+
- Bodies over `gzip_over_bytes` are gzipped.
|
|
294
|
+
- **429 and 5xx retry** three times with exponential backoff.
|
|
295
|
+
- **Any other 4xx fails immediately.** A signature or config error will not fix
|
|
296
|
+
itself by retrying.
|
|
297
|
+
- Graph throttling (`429`) is honoured via `Retry-After` during collection.
|
|
298
|
+
|
|
299
|
+
## Running it unattended
|
|
300
|
+
|
|
301
|
+
`login` is interactive exactly once; after that the cached refresh token keeps
|
|
302
|
+
runs silent, so `run` is safe in cron or a scheduled task. Conditional Access
|
|
303
|
+
can still force periodic re-auth — when it does, the run exits `1` and tells
|
|
304
|
+
you to sign in again rather than failing obscurely.
|
|
305
|
+
|
|
306
|
+
Device code was chosen for precisely this reason: it needs no redirect URI and
|
|
307
|
+
no listening socket, so the browser step can be completed in whichever session
|
|
308
|
+
the tenant's Conditional Access policy is willing to accept — not necessarily
|
|
309
|
+
the machine running the tool.
|
|
310
|
+
|
|
311
|
+
## Development
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
pip install -e ".[dev]"
|
|
315
|
+
pytest -q
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Releasing
|
|
319
|
+
|
|
320
|
+
The **git tag is the version**. `setuptools-scm` derives it at build time, so
|
|
321
|
+
there is no version to bump in a file and nothing that can disagree with the
|
|
322
|
+
tag:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
git tag v0.1.1
|
|
326
|
+
git push origin v0.1.1
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
That runs the tests, builds, verifies the built version matches the tag, and
|
|
330
|
+
publishes to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
|
|
331
|
+
— no API token is stored anywhere — then attaches the artifacts to a GitHub
|
|
332
|
+
Release.
|
|
333
|
+
|
|
334
|
+
To rehearse the whole path without spending a version number, run the Release
|
|
335
|
+
workflow manually from the Actions tab: a `workflow_dispatch` publishes to
|
|
336
|
+
**TestPyPI** instead of PyPI.
|
|
337
|
+
|
|
338
|
+
A build from an untagged commit gets a `.devN+g<sha>` suffix, and the release
|
|
339
|
+
job refuses to publish it under a real version number.
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
message_poster/__init__.py,sha256=xrBKRA6I7GMFsFFZz8CxKy0Yp6bz49hfu6hrQvTfdGU,375
|
|
2
|
+
message_poster/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
message_poster/auth.py,sha256=ZdU5lvhAggeEzXZwpM85r3omNX-VcA0V0LXcBMxR_Ag,5955
|
|
4
|
+
message_poster/cli.py,sha256=jt-7hoiF89TGzAT5B-9R-1tNmEhQEIqEJh91rqpQYUg,8298
|
|
5
|
+
message_poster/collect.py,sha256=GzZHiaI-8Dgb2dBWYMkwzgsN4uJG-Pp3PysJr5W5TuA,7346
|
|
6
|
+
message_poster/config.example.json,sha256=blrFQiD4-1GYbpH0w9IlWLGou_9CONfCGPXNZ-Zy_Xs,627
|
|
7
|
+
message_poster/render.py,sha256=CtvPupw5oz0FDamlDeE_1eOIB4PpR_ddY8FwwhsZIss,5917
|
|
8
|
+
message_poster/webhook.py,sha256=D5gFo4N4R-zuVSGw5ftbEPVaRBS--eRrfM2NJF1V4mw,3674
|
|
9
|
+
message_poster-0.1.0.dist-info/licenses/LICENSE,sha256=jtFXXGHSU3uBIfHbaMfC9YaHg6bcsVnRObY5_c8HImY,1068
|
|
10
|
+
message_poster-0.1.0.dist-info/METADATA,sha256=m6Lxg6CCFc2SPTENK09S6mPBfH3BcKlCiyuEINNeJaU,12145
|
|
11
|
+
message_poster-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
message_poster-0.1.0.dist-info/entry_points.txt,sha256=QzR-Z5cSSlg4OPKnrpSdqyYqg8kYB0zGnV4rv6oPr2g,59
|
|
13
|
+
message_poster-0.1.0.dist-info/top_level.txt,sha256=8FhrYyGlwHxZ_WYDwco4HivNk7K1G0z_2EkyVU2-NZc,15
|
|
14
|
+
message_poster-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omar McIver
|
|
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 @@
|
|
|
1
|
+
message_poster
|