taskuary 0.2.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.
- taskuary/__init__.py +2 -0
- taskuary/agents.py +218 -0
- taskuary/artifacts.py +209 -0
- taskuary/aws.py +226 -0
- taskuary/azure.py +334 -0
- taskuary/blackboard.py +150 -0
- taskuary/channels.py +768 -0
- taskuary/ci.py +237 -0
- taskuary/cli.py +47 -0
- taskuary/coder.py +132 -0
- taskuary/config.py +62 -0
- taskuary/db.py +63 -0
- taskuary/desktop.py +72 -0
- taskuary/devtools.py +352 -0
- taskuary/digest.py +104 -0
- taskuary/docsync.py +124 -0
- taskuary/github.py +136 -0
- taskuary/histgen.py +220 -0
- taskuary/imapmail.py +150 -0
- taskuary/ingest.py +344 -0
- taskuary/learn.py +181 -0
- taskuary/llm.py +175 -0
- taskuary/logs.py +17 -0
- taskuary/mcp.py +78 -0
- taskuary/messengers.py +205 -0
- taskuary/mssql.py +64 -0
- taskuary/outbound.py +221 -0
- taskuary/phone.py +88 -0
- taskuary/pm.py +310 -0
- taskuary/policy.py +59 -0
- taskuary/proof.py +194 -0
- taskuary/proposals.py +132 -0
- taskuary/reports.py +508 -0
- taskuary/reshape.py +201 -0
- taskuary/responder.py +199 -0
- taskuary/routing.py +115 -0
- taskuary/scopes.py +88 -0
- taskuary/server.py +1506 -0
- taskuary/store.py +711 -0
- taskuary/templates/coder.md +35 -0
- taskuary/templates/digest.md +5 -0
- taskuary/templates/learned.md +31 -0
- taskuary/templates/soul.md +44 -0
- taskuary/templates/style.md +14 -0
- taskuary/templates/triage.md +22 -0
- taskuary/terminal.py +971 -0
- taskuary/toil.py +57 -0
- taskuary/triage.py +123 -0
- taskuary/verdicts.py +73 -0
- taskuary/web/assets/index-6GBZ9nXN.css +32 -0
- taskuary/web/assets/index-Cjj87C2X.js +401 -0
- taskuary/web/favicon.ico +0 -0
- taskuary/web/favicon.png +0 -0
- taskuary/web/index.html +25 -0
- taskuary/whatsapp/bridge.mjs +101 -0
- taskuary/whatsapp/package.json +11 -0
- taskuary-0.2.0.dist-info/METADATA +424 -0
- taskuary-0.2.0.dist-info/RECORD +62 -0
- taskuary-0.2.0.dist-info/WHEEL +5 -0
- taskuary-0.2.0.dist-info/entry_points.txt +3 -0
- taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
- taskuary-0.2.0.dist-info/top_level.txt +1 -0
taskuary/channels.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""Channel connectors - the cards on the Connectors tab: Outlook mail + Microsoft Teams
|
|
2
|
+
(Graph, app-only client credentials) and GitHub (fine-grained PAT). test_connector is a
|
|
3
|
+
live probe (token/chat-read/repo-discovery); poll_channels is the scheduled ingest that
|
|
4
|
+
funnels mail and chats through the same triage as everything else. Credentials left blank
|
|
5
|
+
fall back to AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET env vars.
|
|
6
|
+
"""
|
|
7
|
+
import base64, hashlib, json, os, re, time
|
|
8
|
+
from html import unescape
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
import requests
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from .github import _h as gh_headers, list_accessible_repos
|
|
14
|
+
from .ingest import ingest_message
|
|
15
|
+
|
|
16
|
+
GRAPH = 'https://graph.microsoft.com/v1.0'
|
|
17
|
+
MAIL_SELECT = 'id,subject,from,receivedDateTime,sentDateTime,bodyPreview,body,conversationId,webLink,hasAttachments,isRead'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cfg(c): return json.loads(c.get('ConfigJson') or '{}')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def graph_creds(store, c):
|
|
24
|
+
"""Effective Graph credentials for a connector: its own, else the Outlook connector's
|
|
25
|
+
saved app (Teams shares it by design), else the AZURE_* env vars (in graph_token).
|
|
26
|
+
Returns (cfg, secret, borrowed_from_outlook)."""
|
|
27
|
+
cfg, sec = _cfg(c), c.get('Secret')
|
|
28
|
+
if c['Type'] != 'outlook' and not (cfg.get('client_id') and sec):
|
|
29
|
+
o = store.get_connector_by_type('outlook', with_secret=True)
|
|
30
|
+
ocfg = _cfg(o) if o else {}
|
|
31
|
+
if o and (ocfg.get('client_id') or o.get('Secret')):
|
|
32
|
+
return {**ocfg, **{k: v for k, v in cfg.items() if v}}, sec or o.get('Secret'), True
|
|
33
|
+
return cfg, sec, False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def graph_token(cfg: dict, secret: str = None) -> str:
|
|
37
|
+
tid = cfg.get('tenant_id') or os.getenv('AZURE_TENANT_ID')
|
|
38
|
+
cid = cfg.get('client_id') or os.getenv('AZURE_CLIENT_ID')
|
|
39
|
+
sec = secret or os.getenv('AZURE_CLIENT_SECRET')
|
|
40
|
+
if not (tid and cid and sec):
|
|
41
|
+
raise RuntimeError('need tenant_id + client_id + a secret (or AZURE_* env vars on the server)')
|
|
42
|
+
r = requests.post(f'https://login.microsoftonline.com/{tid}/oauth2/v2.0/token', timeout=20,
|
|
43
|
+
data={'client_id': cid, 'client_secret': sec, 'grant_type': 'client_credentials',
|
|
44
|
+
'scope': 'https://graph.microsoft.com/.default'})
|
|
45
|
+
if r.status_code != 200: raise RuntimeError(f'token failed ({r.status_code}): {r.text[:300]}')
|
|
46
|
+
return r.json()['access_token']
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def github_discover(store, c: dict, actor='owner') -> dict:
|
|
50
|
+
"""A PAT is ALL the config: authenticate, list reachable repos, add each as a source
|
|
51
|
+
(they become the Board's repo choices) and write the repo map into SOUL.md."""
|
|
52
|
+
tok = c.get('Secret')
|
|
53
|
+
if not tok: raise RuntimeError('no PAT saved yet - paste one under Credentials')
|
|
54
|
+
u = requests.get('https://api.github.com/user', headers=gh_headers(tok), timeout=20)
|
|
55
|
+
u.raise_for_status()
|
|
56
|
+
repos = list_accessible_repos(tok)
|
|
57
|
+
have = {s['Address'] for s in store.list_sources(active_only=False) if s['Channel'] == 'github'}
|
|
58
|
+
added = 0
|
|
59
|
+
for rp in repos:
|
|
60
|
+
if rp['full_name'] not in have:
|
|
61
|
+
store.save_source({'Channel': 'github', 'Address': rp['full_name'], 'ConnectorId': c['ConnectorId'],
|
|
62
|
+
'Active': 1, 'Owner': actor}, actor)
|
|
63
|
+
added += 1
|
|
64
|
+
from .docsync import sync_connections, update_repo_map
|
|
65
|
+
from .llm import build_llm
|
|
66
|
+
try: llm = build_llm(store)
|
|
67
|
+
except Exception: llm = None
|
|
68
|
+
update_repo_map(store, repos, actor, tok=tok, llm=llm)
|
|
69
|
+
sync_connections(store, actor)
|
|
70
|
+
return {'login': u.json().get('login'), 'repos': len(repos), 'added': added}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _slack(tok, method, post=False, **params):
|
|
74
|
+
url = f'https://slack.com/api/{method}'
|
|
75
|
+
hdr = {'Authorization': f'Bearer {tok}'}
|
|
76
|
+
# write methods (conversations.mark) are POST-only; the read ones are happy either way
|
|
77
|
+
r = requests.post(url, data=params, timeout=20, headers=hdr) if post \
|
|
78
|
+
else requests.get(url, params=params, timeout=20, headers=hdr)
|
|
79
|
+
r.raise_for_status()
|
|
80
|
+
j = r.json()
|
|
81
|
+
if not j.get('ok'): raise RuntimeError(f"slack {method}: {j.get('error')}")
|
|
82
|
+
return j
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
ACTOR_DISCOVER = 'connector-test'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_connector(store, cid: int) -> dict:
|
|
89
|
+
"""Live credential + access probe; the result (or failure) lands on the connector row."""
|
|
90
|
+
c = store.get_connector(cid, with_secret=True)
|
|
91
|
+
if not c: raise ValueError('connector not found')
|
|
92
|
+
cfg, t0 = _cfg(c), time.time()
|
|
93
|
+
try:
|
|
94
|
+
if c['Type'] in ('outlook', 'teams'):
|
|
95
|
+
gcfg, gsec, borrowed = graph_creds(store, c)
|
|
96
|
+
own = bool(cfg.get('client_id') and c.get('Secret'))
|
|
97
|
+
tok = graph_token(gcfg, gsec)
|
|
98
|
+
detail = 'Graph token OK' + ('' if own else
|
|
99
|
+
" (using the Outlook connector's credentials)" if borrowed
|
|
100
|
+
else ' (using server env credentials)')
|
|
101
|
+
if c['Type'] == 'teams':
|
|
102
|
+
src = next((s for s in store.list_sources(active_only=False)
|
|
103
|
+
if s['Channel'] == 'teams' and '@' in (s['Address'] or '')), None)
|
|
104
|
+
if src:
|
|
105
|
+
# probe the road the POLLER takes, or a green card means nothing
|
|
106
|
+
msgs = _teams_delta(tok, src['Address'], _utc(datetime.now() - timedelta(days=7)), cap=100)
|
|
107
|
+
people = {(((m.get('from') or {}).get('user') or {}).get('displayName'))
|
|
108
|
+
for m in msgs if ((m.get('from') or {}).get('user'))}
|
|
109
|
+
detail = (f"chat read OK for {src['Address']} - {len(msgs)} messages in the last 7 days across "
|
|
110
|
+
f"{len({m.get('chatId') for m in msgs})} chats, {len(people - {None})} people")
|
|
111
|
+
else:
|
|
112
|
+
detail += ' - add a Teams source (user UPN) to probe chat access'
|
|
113
|
+
elif c['Type'] == 'github':
|
|
114
|
+
d = github_discover(store, c)
|
|
115
|
+
detail = f"authenticated as {d['login']} · {d['repos']} repos discovered · {d['added']} new sources · repo map written to SOUL.md"
|
|
116
|
+
elif c['Type'] == 'slack':
|
|
117
|
+
if not c.get('Secret'): raise RuntimeError('no bot token saved - paste an xoxb- token under Credentials')
|
|
118
|
+
a = _slack(c['Secret'], 'auth.test')
|
|
119
|
+
detail = f"authenticated as {a.get('user')} in {a.get('team')}"
|
|
120
|
+
src = next((s for s in store.list_sources(active_only=False) if s['Channel'] == 'slack'), None)
|
|
121
|
+
if src:
|
|
122
|
+
_slack(c['Secret'], 'conversations.history', channel=src['Address'], limit=1)
|
|
123
|
+
detail += f" · channel read OK for {src['Address']}"
|
|
124
|
+
else:
|
|
125
|
+
detail += ' - add a channel ID under Sources to probe reads'
|
|
126
|
+
elif c['Type'] in ('gmail', 'imap'):
|
|
127
|
+
from .imapmail import test_imap
|
|
128
|
+
detail = test_imap(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
129
|
+
elif c['Type'] == 'telegram':
|
|
130
|
+
from .messengers import tg_test
|
|
131
|
+
detail = tg_test(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
132
|
+
elif c['Type'] == 'whatsapp':
|
|
133
|
+
from .messengers import wa_test
|
|
134
|
+
detail = wa_test(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
135
|
+
elif c['Type'] in ('jira', 'asana', 'monday', 'clickup', 'todoist'):
|
|
136
|
+
from . import pm
|
|
137
|
+
detail = pm.test(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
138
|
+
elif c['Type'] in ('gitlab', 'azdo', 'linear', 'trello', 'notion', 'discord', 'sentry', 'pagerduty'):
|
|
139
|
+
from . import devtools
|
|
140
|
+
detail = devtools.test(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
141
|
+
elif c['Type'] == 'mssql':
|
|
142
|
+
from .mssql import test as mssql_test
|
|
143
|
+
conn_cfg = _cfg(c)
|
|
144
|
+
if c.get('Secret'): conn_cfg.setdefault('password', c['Secret'])
|
|
145
|
+
r = mssql_test(conn_cfg)
|
|
146
|
+
if not r['ok']: raise RuntimeError(r['error'])
|
|
147
|
+
detail = f"connected · {r['version']} · db {r['database']}"
|
|
148
|
+
elif c['Type'] == 'database':
|
|
149
|
+
from .db import test as db_test
|
|
150
|
+
from .reports import database_connection
|
|
151
|
+
r = db_test(database_connection(store))
|
|
152
|
+
if not r['ok']: raise RuntimeError(r['error'])
|
|
153
|
+
detail = r['detail']
|
|
154
|
+
elif c['Type'] in ('aws', 'azure'):
|
|
155
|
+
# Test also DISCOVERS: the keys/app are asked what they can see, and every
|
|
156
|
+
# bucket, log group, container and workspace lands under Sources with its own
|
|
157
|
+
# mode picker (report by default - nothing is polled until you say so)
|
|
158
|
+
from .reports import aws_connection, azure_connection
|
|
159
|
+
mod = __import__(f'taskuary.{c["Type"]}', fromlist=['x'])
|
|
160
|
+
conn_cfg = (aws_connection if c['Type'] == 'aws' else azure_connection)(store)
|
|
161
|
+
r = mod.test(conn_cfg)
|
|
162
|
+
if not r['ok']: raise RuntimeError(r['error'])
|
|
163
|
+
detail = r['detail']
|
|
164
|
+
# the same app registration usually reaches the DIRECTORY too, and that is a
|
|
165
|
+
# whole family of reports (people, groups, licences, sign-ins) the card would
|
|
166
|
+
# otherwise never mention - so Test says which of them this app can do
|
|
167
|
+
if c['Type'] == 'azure':
|
|
168
|
+
try:
|
|
169
|
+
from .azure import test_entra
|
|
170
|
+
e = test_entra(conn_cfg)
|
|
171
|
+
detail += ' · ' + (e['detail'] if e['ok'] else f"Entra: {e['error']}")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
detail += f' · Entra probe failed: {str(e)[:100]}'
|
|
174
|
+
try:
|
|
175
|
+
d = mod.discover(store, conn_cfg, c['ConnectorId'], ACTOR_DISCOVER)
|
|
176
|
+
detail += f" · {d['found']} objects visible, {d['added']} new under Sources"
|
|
177
|
+
# "0 objects visible" alone reads as a broken feature; the hint says which
|
|
178
|
+
# permission is missing, because that is always what an empty result means
|
|
179
|
+
if d.get('hint'): detail += f" — {d['hint']}"
|
|
180
|
+
except Exception as e:
|
|
181
|
+
detail += f' · discovery failed: {str(e)[:120]}'
|
|
182
|
+
elif c['Type'] == 'prometheus':
|
|
183
|
+
from .reports import run_prometheus, prometheus_connection
|
|
184
|
+
head, _ = run_prometheus({**prometheus_connection(store), 'query': 'vector(1)', 'max_rows': 1})
|
|
185
|
+
detail = f'query OK ({head}) - build the reports on the Reports tab'
|
|
186
|
+
elif c['Type'] == 'datadog':
|
|
187
|
+
from .reports import datadog_connection
|
|
188
|
+
dd = datadog_connection(store)
|
|
189
|
+
site = (dd.get('site') or 'datadoghq.com').strip()
|
|
190
|
+
r = requests.get(f'https://api.{site}/api/v1/validate', timeout=20,
|
|
191
|
+
headers={'DD-API-KEY': dd.get('api_key') or ''})
|
|
192
|
+
if r.status_code != 200 or not r.json().get('valid'):
|
|
193
|
+
raise RuntimeError(f'Datadog rejected the API key ({r.status_code})')
|
|
194
|
+
detail = f'API key valid on {site}' + ('' if dd.get('app_key') else ' - add the application key for monitor reads')
|
|
195
|
+
elif c['Type'] == 'winrm':
|
|
196
|
+
import subprocess
|
|
197
|
+
host = cfg.get('host')
|
|
198
|
+
if not host: raise RuntimeError('no host set - enter the machine name (e.g. AZWEB01)')
|
|
199
|
+
p = subprocess.run(['powershell', '-NoProfile', '-NonInteractive', '-Command',
|
|
200
|
+
f'Test-WSMan -ComputerName {host} -ErrorAction Stop | Out-Null; '
|
|
201
|
+
f'Invoke-Command -ComputerName {host} -ScriptBlock {{ $env:COMPUTERNAME }}'],
|
|
202
|
+
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=60)
|
|
203
|
+
if p.returncode != 0:
|
|
204
|
+
raise RuntimeError((p.stderr or p.stdout or 'WinRM unreachable')[:400]
|
|
205
|
+
+ ' - if this is a box you RDP into, PS remoting may need enabling: '
|
|
206
|
+
'run Enable-PSRemoting -Force on it once (elevated)')
|
|
207
|
+
detail = f"remote run OK on {(p.stdout or '').strip() or host} (your Windows credentials)"
|
|
208
|
+
elif c['Type'] in ('anthropic', 'openai', 'azure_openai', 'openrouter', 'ollama'):
|
|
209
|
+
from .llm import test_ai
|
|
210
|
+
detail = test_ai(store, cid)
|
|
211
|
+
else:
|
|
212
|
+
raise RuntimeError(f"no test for connector type '{c['Type']}'")
|
|
213
|
+
store.touch_connector(cid)
|
|
214
|
+
return {'ok': True, 'ms': int((time.time() - t0) * 1000), 'detail': detail}
|
|
215
|
+
except Exception as e:
|
|
216
|
+
store.touch_connector(cid, str(e))
|
|
217
|
+
return {'ok': False, 'ms': int((time.time() - t0) * 1000), 'detail': str(e)[:500]}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_DROP = re.compile(r'(?is)<(script|style|head)[^>]*>.*?</\1>')
|
|
221
|
+
_BLOCK = re.compile(r'(?i)<br\s*/?>|</(p|div|tr|li|h[1-6]|blockquote|table)>')
|
|
222
|
+
|
|
223
|
+
def _clean(html):
|
|
224
|
+
"""HTML mail -> readable text. Block ends become NEWLINES: collapsing every whitespace
|
|
225
|
+
run (the old behaviour) mashed the reply and the quoted 'From:/Sent:/To:' history into
|
|
226
|
+
one wall of text, which no reader - human or model - could take apart."""
|
|
227
|
+
from html import unescape
|
|
228
|
+
txt = _BLOCK.sub('\n', _DROP.sub(' ', html or ''))
|
|
229
|
+
txt = unescape(re.sub(r'<[^>]+>', ' ', txt))
|
|
230
|
+
txt = re.sub(r'[^\S\n]+', ' ', txt.replace('\xa0', ' '))
|
|
231
|
+
return re.sub(r'\n{3,}', '\n\n', re.sub(r' ?\n ?', '\n', txt)).strip()
|
|
232
|
+
|
|
233
|
+
# Graph's bodyPreview is capped at 255 chars - reading it FIRST truncated every stored mail,
|
|
234
|
+
# so the panel (and the agents) only ever saw the opening sentence. Full body wins.
|
|
235
|
+
def _body(m): return (_clean((m.get('body') or {}).get('content')) or m.get('bodyPreview') or '')[:20000]
|
|
236
|
+
|
|
237
|
+
# What rode along with the mail. Screenshots of the thing that is broken ARE the ask half the
|
|
238
|
+
# time ("see below"), and a text-only funnel threw them away.
|
|
239
|
+
ATT_MAX, ATT_BYTES = 12, 12 * 1024 * 1024 # per message: how many, and how big each may be
|
|
240
|
+
_SAFE = re.compile(r'[^A-Za-z0-9._-]+')
|
|
241
|
+
|
|
242
|
+
def save_attachments(store, mid: int, items: list, ext_prefix: str) -> int:
|
|
243
|
+
"""Write the bytes to disk and the metadata to the db. Items are Graph fileAttachments;
|
|
244
|
+
anything without contentBytes (an attached mail, a OneDrive link) is recorded WITHOUT a
|
|
245
|
+
path, so the panel can still say it was there and point at the original."""
|
|
246
|
+
import base64
|
|
247
|
+
from .artifacts import attachment_dir
|
|
248
|
+
n = 0
|
|
249
|
+
for i, a in enumerate(items[:ATT_MAX]):
|
|
250
|
+
ext_id = f"{ext_prefix}:{a.get('id') or i}"
|
|
251
|
+
if store.attachment_exists(ext_id): continue
|
|
252
|
+
name = (a.get('name') or f'attachment-{i}')[:120]
|
|
253
|
+
raw = a.get('contentBytes')
|
|
254
|
+
path = None
|
|
255
|
+
if raw:
|
|
256
|
+
try: data = base64.b64decode(raw)
|
|
257
|
+
except Exception: data = b''
|
|
258
|
+
if data and len(data) <= ATT_BYTES:
|
|
259
|
+
f = attachment_dir(mid) / f'{i}-{_SAFE.sub("_", name)}'
|
|
260
|
+
f.write_bytes(data)
|
|
261
|
+
path = str(f)
|
|
262
|
+
store.add_attachment({'MessageId': mid, 'ExternalId': ext_id, 'Name': name,
|
|
263
|
+
'ContentType': a.get('contentType') or 'application/octet-stream',
|
|
264
|
+
'Size': int(a.get('size') or 0), 'ContentId': a.get('contentId'),
|
|
265
|
+
'Inline': 1 if a.get('isInline') else 0, 'Path': path})
|
|
266
|
+
n += 1
|
|
267
|
+
return n
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def mail_attachments(tok: str, upn: str, graph_id: str) -> list:
|
|
271
|
+
"""One message's attachments from Graph, raw. Called only when the mail says it has some -
|
|
272
|
+
an extra request per mail otherwise, for nothing."""
|
|
273
|
+
r = requests.get(f'{GRAPH}/users/{upn}/messages/{graph_id}/attachments',
|
|
274
|
+
headers={'Authorization': f'Bearer {tok}'}, timeout=60)
|
|
275
|
+
r.raise_for_status()
|
|
276
|
+
return r.json().get('value', [])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def fetch_mail_attachments(store, mid: int, tok: str, upn: str, graph_id: str) -> int:
|
|
280
|
+
return save_attachments(store, mid, mail_attachments(tok, upn, graph_id), f'graph:{graph_id}')
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def images_for_triage(store, items: list) -> list:
|
|
284
|
+
"""[(media_type, base64)] for the pictures on a mail, straight from the Graph payload - so
|
|
285
|
+
triage can SEE them. They have to be read before the message row exists: the attachments used
|
|
286
|
+
to be saved after ingest, which meant the one classifying "See below." never saw what was
|
|
287
|
+
below it, and filed a screenshot of a stack trace as informational."""
|
|
288
|
+
from .llm import VISION_BYTES, VISION_MAX, VISION_TYPES
|
|
289
|
+
if str(store.get_settings().get('vision_enabled') or '1') != '1': return []
|
|
290
|
+
out = []
|
|
291
|
+
for a in items:
|
|
292
|
+
if len(out) >= VISION_MAX: break
|
|
293
|
+
ct = str(a.get('contentType') or '').split(';')[0].lower()
|
|
294
|
+
raw = a.get('contentBytes')
|
|
295
|
+
if ct not in VISION_TYPES or not raw or int(a.get('size') or 0) > VISION_BYTES: continue
|
|
296
|
+
out.append((ct, raw)) # Graph already hands it over base64-encoded
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _local(iso):
|
|
301
|
+
try: return datetime.fromisoformat(iso.replace('Z', '+00:00')).astimezone().strftime('%Y-%m-%d %H:%M:%S')
|
|
302
|
+
except ValueError: return iso
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _mail_msgs(tok, upn, since, folder='inbox'):
|
|
306
|
+
# folder-scoped - a bare /messages spans every folder including Sent Items, which made
|
|
307
|
+
# the owner's own replies come back through the funnel as inbound work
|
|
308
|
+
r = requests.get(f'{GRAPH}/users/{upn}/mailFolders/{folder}/messages',
|
|
309
|
+
headers={'Authorization': f'Bearer {tok}'}, timeout=30,
|
|
310
|
+
params={'$top': 25, '$orderby': 'receivedDateTime desc', '$select': MAIL_SELECT,
|
|
311
|
+
'$filter': f'receivedDateTime gt {since}'})
|
|
312
|
+
r.raise_for_status()
|
|
313
|
+
return r.json().get('value', [])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def ingest_own_message(store, msg: dict, why: str) -> int:
|
|
317
|
+
"""Anything YOU sent - a mail reply, a line in a chat - never gets its own timeline row
|
|
318
|
+
and never becomes work: when the conversation already has a task it rides along INSIDE
|
|
319
|
+
the chain (a 'context' message + a history entry, so the panel shows it was answered).
|
|
320
|
+
No matching chain -> nothing stored at all."""
|
|
321
|
+
if store.message_exists(msg['external_id']): return 0
|
|
322
|
+
conv = msg.get('conversation_id')
|
|
323
|
+
tid = next((s['task_id'] for s in store.snapshots() if conv and conv in s['conversation_ids']), None)
|
|
324
|
+
if not tid: return 0
|
|
325
|
+
mid = store.add_message({'TaskId': tid, 'ExternalId': msg['external_id'], 'ConversationId': conv,
|
|
326
|
+
'Channel': msg['channel'], 'SourceName': msg.get('source_name'),
|
|
327
|
+
'Subject': msg.get('subject'), 'FromName': 'You', 'FromEmail': msg.get('from_email'),
|
|
328
|
+
'SentAt': msg.get('sent_at'), 'BodyText': msg.get('body'),
|
|
329
|
+
'SourceLink': msg.get('source_link'), 'Status': 'context'})
|
|
330
|
+
store.add_route(mid, tid, 'attach', None, why, [], 'router')
|
|
331
|
+
store.add_comment(tid, 'you', 'human', f"You replied: {(msg.get('body') or '')[:300]}")
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def ingest_outbound_mail(store, mailbox: str, m: dict) -> int:
|
|
336
|
+
return ingest_own_message(store, {
|
|
337
|
+
'external_id': f"graph:{m['id']}", 'channel': 'email', 'source_name': mailbox,
|
|
338
|
+
'subject': m.get('subject'), 'from_email': mailbox, 'body': _body(m),
|
|
339
|
+
'conversation_id': m.get('conversationId'), 'source_link': m.get('webLink'),
|
|
340
|
+
'sent_at': _local(m.get('receivedDateTime') or m.get('sentDateTime') or '')},
|
|
341
|
+
'your reply on this thread - kept for context')
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ── Teams chats ─────────────────────────────────────────────────────────────────────
|
|
345
|
+
# Three roads out of Graph, and only one works:
|
|
346
|
+
# /chats/{id}/messages - refuses (403) every chat HOSTED IN ANOTHER ORG'S TENANT, which
|
|
347
|
+
# is exactly where the external-vendor threads live;
|
|
348
|
+
# /chats/getAllMessages - rejects every lastModifiedDateTime filter we can form and pages
|
|
349
|
+
# OLDEST first, so it hands back years of bot attachment posts;
|
|
350
|
+
# .../getAllMessages/delta - takes the filter, pages NEWEST first, and includes those
|
|
351
|
+
# externally-hosted chats. That is the one.
|
|
352
|
+
def _utc(dt) -> str:
|
|
353
|
+
from datetime import timezone
|
|
354
|
+
return dt.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _teams_delta(tok, upn, since_iso, cap=200):
|
|
358
|
+
"""Every chat message this user can see since a timestamp, newest first."""
|
|
359
|
+
url = f'{GRAPH}/users/{upn}/chats/getAllMessages/delta'
|
|
360
|
+
params, out = {'$top': 50, '$filter': f'lastModifiedDateTime gt {since_iso}'}, []
|
|
361
|
+
while url and len(out) < cap:
|
|
362
|
+
r = requests.get(url, headers={'Authorization': f'Bearer {tok}'}, params=params, timeout=45)
|
|
363
|
+
if r.status_code == 403:
|
|
364
|
+
raise RuntimeError('token OK but chat read DENIED (403) - app-only Chat.Read.All is a Microsoft '
|
|
365
|
+
'protected API: submit the approval form for this app registration')
|
|
366
|
+
r.raise_for_status()
|
|
367
|
+
j = r.json()
|
|
368
|
+
out += j.get('value', [])
|
|
369
|
+
url, params = j.get('@odata.nextLink'), None # the nextLink carries the filter itself
|
|
370
|
+
return out[:cap]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _chat_meta(tok, chat_id, cache):
|
|
374
|
+
"""(topic, kind) for a chat. Readable even for chats whose MESSAGES are refused, so a
|
|
375
|
+
group thread keeps its real name on the timeline."""
|
|
376
|
+
if chat_id in cache: return cache[chat_id]
|
|
377
|
+
meta = ('', 'chat')
|
|
378
|
+
try:
|
|
379
|
+
r = requests.get(f'{GRAPH}/chats/{chat_id}', headers={'Authorization': f'Bearer {tok}'}, timeout=20)
|
|
380
|
+
if r.status_code == 200:
|
|
381
|
+
j = r.json()
|
|
382
|
+
meta = (j.get('topic') or '', j.get('chatType') or 'chat')
|
|
383
|
+
except requests.RequestException as e:
|
|
384
|
+
logger.debug(f'teams chat lookup failed for {chat_id[:24]}: {e}')
|
|
385
|
+
cache[chat_id] = meta
|
|
386
|
+
return meta
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _graph_user(tok, oid, cache):
|
|
390
|
+
"""AAD object id -> (name, address). Cached per poll: the sender's address is what memory
|
|
391
|
+
notes, skip-sender rules and the whole triage funnel key on."""
|
|
392
|
+
if oid in cache: return cache[oid]
|
|
393
|
+
who = ('Teams user', '')
|
|
394
|
+
try:
|
|
395
|
+
r = requests.get(f'{GRAPH}/users/{oid}', headers={'Authorization': f'Bearer {tok}'}, timeout=20,
|
|
396
|
+
params={'$select': 'displayName,mail,userPrincipalName'})
|
|
397
|
+
if r.status_code == 200:
|
|
398
|
+
j = r.json()
|
|
399
|
+
who = (j.get('displayName') or 'Teams user', j.get('mail') or j.get('userPrincipalName') or '')
|
|
400
|
+
except requests.RequestException as e:
|
|
401
|
+
logger.debug(f'teams user lookup failed for {oid}: {e}')
|
|
402
|
+
cache[oid] = who
|
|
403
|
+
return who
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# A picture in a Teams message is not an attachment. Graph reports attachments: [] and puts
|
|
407
|
+
# the image in the BODY as <img src=".../hostedContents/{id}/$value">, which _clean strips
|
|
408
|
+
# along with every other tag - so "the screenshot IS the ask" worked for mail and silently
|
|
409
|
+
# lost the picture on chat. Same shape as a Graph fileAttachment coming out, so the existing
|
|
410
|
+
# pipeline (images_for_triage before the row exists, save_attachments after) is reused whole.
|
|
411
|
+
_HOSTED = re.compile(r'<img[^>]+src="([^"]*?/hostedContents/[^"]*?)"', re.I)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def hosted_images(tok: str, html: str, cap: int = 4) -> list:
|
|
415
|
+
out = []
|
|
416
|
+
for i, url in enumerate(dict.fromkeys(_HOSTED.findall(html or '')).keys()):
|
|
417
|
+
if i >= cap: break
|
|
418
|
+
try:
|
|
419
|
+
r = requests.get(unescape(url), headers={'Authorization': f'Bearer {tok}'}, timeout=30)
|
|
420
|
+
r.raise_for_status()
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.warning(f'teams hosted image fetch failed: {e}')
|
|
423
|
+
continue
|
|
424
|
+
ct = (r.headers.get('Content-Type') or 'image/png').split(';')[0].strip()
|
|
425
|
+
out.append({'id': hashlib.sha1(url.encode()).hexdigest()[:16],
|
|
426
|
+
'name': f"image-{i + 1}.{(ct.split('/')[-1] or 'png').replace('jpeg', 'jpg')}",
|
|
427
|
+
'contentType': ct, 'size': len(r.content), 'isInline': True,
|
|
428
|
+
'contentBytes': base64.b64encode(r.content).decode()})
|
|
429
|
+
return out
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def ingest_teams_chats(store, upn: str, tok: str, since, llm=None, file_only=False, read_it=False) -> int:
|
|
433
|
+
"""Teams as an inbound channel: each chat is a conversation (so a thread keeps building
|
|
434
|
+
ONE task, like a mail thread), each human message an item on the timeline. Bot posts,
|
|
435
|
+
call-started events, deletions and empty bodies are not messages anybody has to act on."""
|
|
436
|
+
since_iso, users, chats, n = _utc(since), {}, {}, 0
|
|
437
|
+
touched = set() # chats to mark read once, not per message
|
|
438
|
+
me = ''
|
|
439
|
+
try:
|
|
440
|
+
r = requests.get(f'{GRAPH}/users/{upn}', headers={'Authorization': f'Bearer {tok}'},
|
|
441
|
+
params={'$select': 'id'}, timeout=20)
|
|
442
|
+
me = r.json().get('id', '') if r.status_code == 200 else ''
|
|
443
|
+
except requests.RequestException:
|
|
444
|
+
pass
|
|
445
|
+
for m in reversed(_teams_delta(tok, upn, since_iso)): # oldest first, so threads read in order
|
|
446
|
+
user = (m.get('from') or {}).get('user') or {}
|
|
447
|
+
body = _clean((m.get('body') or {}).get('content'))
|
|
448
|
+
if m.get('messageType') != 'message' or m.get('deletedDateTime') or not user.get('id') or not body: continue
|
|
449
|
+
cid = m.get('chatId') or ''
|
|
450
|
+
topic, kind = _chat_meta(tok, cid, chats) if cid else ('', 'chat')
|
|
451
|
+
name, addr = _graph_user(tok, user['id'], users)
|
|
452
|
+
name = user.get('displayName') or name
|
|
453
|
+
# fetched BEFORE triage, like the mail path: the classifier has to see the screenshot
|
|
454
|
+
# to judge it, and afterwards is too late
|
|
455
|
+
raw_html = (m.get('body') or {}).get('content') or ''
|
|
456
|
+
atts = hosted_images(tok, raw_html)
|
|
457
|
+
# a picture we could not FETCH must not vanish silently the way it used to. Graph
|
|
458
|
+
# refuses hostedContents unless the app registration carries ChatMessage.Read.All, and
|
|
459
|
+
# "the screenshot was the whole ask" is exactly the message you cannot afford to read
|
|
460
|
+
# as a sentence with a hole in it, so the row says what is missing.
|
|
461
|
+
#
|
|
462
|
+
# And it is NOT a missing consent, which is what this comment used to claim: the token
|
|
463
|
+
# carries ChatMessage.Read.All and hostedContents still answers 403 AclCheckFailed -
|
|
464
|
+
# a MEMBERSHIP check on the chat, not a scope check. An app-only connection is not in
|
|
465
|
+
# the chat, so no permission grant fixes it; that would take delegated auth.
|
|
466
|
+
missed = len(set(_HOSTED.findall(raw_html))) - len(atts)
|
|
467
|
+
if missed > 0:
|
|
468
|
+
body += ('\n\n[' + f"{missed} image{'s' if missed > 1 else ''} in this message could not be read: "
|
|
469
|
+
'Microsoft refused the download for THIS chat (403) - it happens on group '
|
|
470
|
+
'threads with external participants, whatever the app is consented for. '
|
|
471
|
+
'Images in your other chats come through normally. Open it in Teams to see it.]')
|
|
472
|
+
common = {'external_id': f'teams:{cid}:{m["id"]}', 'channel': 'teams',
|
|
473
|
+
'subject': topic or (f'Teams chat with {name}' if kind == 'oneOnOne' else f'Teams {kind}'),
|
|
474
|
+
'body': body[:20000], 'conversation_id': f'teams:{cid}',
|
|
475
|
+
'sent_at': _local(m.get('createdDateTime') or ''), 'source_link': m.get('webUrl'),
|
|
476
|
+
'source_name': upn, 'images': images_for_triage(store, atts)}
|
|
477
|
+
if user['id'] == me: # your own chat lines are context, never work
|
|
478
|
+
n += ingest_own_message(store, {**common, 'from_name': 'You', 'from_email': upn},
|
|
479
|
+
'your message in this chat - kept for context')
|
|
480
|
+
continue
|
|
481
|
+
out = ingest_message(store, {**common, 'from_name': name, 'from_email': addr}, llm=llm, file_only=file_only)
|
|
482
|
+
n += out['status'] != 'duplicate'
|
|
483
|
+
if atts and out.get('message_id') and out['status'] != 'duplicate':
|
|
484
|
+
try: save_attachments(store, out['message_id'], atts, f'teams:{m["id"]}')
|
|
485
|
+
except Exception as e: logger.warning(f'saving teams images for {m["id"]} failed: {e}')
|
|
486
|
+
if cid: touched.add(cid)
|
|
487
|
+
for cid in touched if read_it else ():
|
|
488
|
+
mark_chat_read(tok, upn, cid, me)
|
|
489
|
+
return n
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
CH2SRC = {'outlook': 'email', 'teams': 'teams', 'slack': 'slack', 'github': 'github',
|
|
493
|
+
'telegram': 'telegram', 'whatsapp': 'whatsapp', 'gmail': 'email', 'imap': 'email',
|
|
494
|
+
'jira': 'jira', 'asana': 'asana', 'monday': 'monday',
|
|
495
|
+
'clickup': 'clickup', 'todoist': 'todoist',
|
|
496
|
+
'gitlab': 'gitlab', 'azdo': 'azdo', 'linear': 'linear', 'trello': 'trello',
|
|
497
|
+
'notion': 'notion', 'discord': 'discord', 'sentry': 'sentry', 'pagerduty': 'pagerduty',
|
|
498
|
+
# cloud objects are DISCOVERED sources: each carries its own mode (report/feed/tasks/off)
|
|
499
|
+
'aws': 'aws', 'azure': 'azure'}
|
|
500
|
+
# A cloud object's mode lives on the SOURCE, not the connector - one bucket can feed the
|
|
501
|
+
# Timeline while the next is only a report. 'report' (the default) polls nothing at all.
|
|
502
|
+
CLOUD = ('aws', 'azure')
|
|
503
|
+
# Connections polled ONCE per connector: their cursor lives on the connector (telegram's
|
|
504
|
+
# getUpdates offset, whatsapp's bridge seq) or their API is 'assigned to me' with no
|
|
505
|
+
# per-source dimension at all. Their source row is a label, so the poll must not depend
|
|
506
|
+
# on one existing - see poll_channels.
|
|
507
|
+
PER_CONNECTOR = ('telegram', 'whatsapp', 'jira', 'asana', 'monday', 'clickup', 'todoist',
|
|
508
|
+
'gitlab', 'azdo', 'linear', 'trello', 'notion', 'sentry', 'pagerduty')
|
|
509
|
+
|
|
510
|
+
def _cloud_explicit(store, channel) -> bool:
|
|
511
|
+
"""Any discovered object set to feed or tasks? Then the connector is polled even
|
|
512
|
+
without a trigger/feed role - the per-object picker carries the intent, the same deal
|
|
513
|
+
github's per-repo pickers get."""
|
|
514
|
+
return any(json.loads(s.get('ConfigJson') or '{}').get('mode') in ('feed', 'tasks')
|
|
515
|
+
for s in store.list_sources() if s['Channel'] == channel)
|
|
516
|
+
TQ_ISSUE = re.compile(r'^\[TQ-\d{4}\]') # issues the coder itself opened - never ingest those back
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def gh_modes(src: dict, file_only: bool) -> tuple:
|
|
520
|
+
"""(issues_mode, prs_mode) for a repo. An EXPLICIT picker beats the connector's role -
|
|
521
|
+
'issues: tasks' means tasks even on a tool-only card; unconfigured kinds follow the role,
|
|
522
|
+
and PRs default off. One place, because the poller needs the same answer as the ingest
|
|
523
|
+
to know whether this source was even looked at."""
|
|
524
|
+
try: modes = json.loads(src.get('ConfigJson') or '{}')
|
|
525
|
+
except ValueError: modes = {}
|
|
526
|
+
default_mode = 'feed' if file_only else 'tasks'
|
|
527
|
+
return (modes.get('issues') or default_mode, modes.get('prs') or 'off')
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def ingest_github_issues(store, src: dict, tok: str, since, llm=None, file_only=False) -> int:
|
|
531
|
+
"""GitHub as an INBOUND channel: new issues - and, per repo, pull requests - land on the
|
|
532
|
+
Timeline and go through the same triage as mail. What each KIND does is the source's own
|
|
533
|
+
call (ConfigJson {"issues": "tasks|feed|off", "prs": ...}), because an open-source repo
|
|
534
|
+
usually wants PRs SEEN but not auto-worked. Every item leads with who wrote it and
|
|
535
|
+
GitHub's own author_association, so triage can weigh a stranger's PR on a public repo
|
|
536
|
+
for what it is - and github items never auto-dispatch a coder (no_auto): a popular repo
|
|
537
|
+
would otherwise start an agent per drive-by PR. Issues Taskuary opened for its own tasks
|
|
538
|
+
are skipped, otherwise the coder would file work against itself forever."""
|
|
539
|
+
repo = src['Address']
|
|
540
|
+
issues_mode, prs_mode = gh_modes(src, file_only)
|
|
541
|
+
if issues_mode == 'off' and prs_mode == 'off': return 0
|
|
542
|
+
n = 0
|
|
543
|
+
from .github import list_items
|
|
544
|
+
for i in reversed(list_items(tok, repo, since=since.astimezone().isoformat())):
|
|
545
|
+
if TQ_ISSUE.match(i.get('title') or ''): continue
|
|
546
|
+
is_pr = 'pull_request' in i
|
|
547
|
+
mode = prs_mode if is_pr else issues_mode
|
|
548
|
+
if mode == 'off': continue
|
|
549
|
+
who = (i.get('user') or {}).get('login') or 'github'
|
|
550
|
+
# WHO is asking is part of the ask on a public repo - triage reads this line first
|
|
551
|
+
head = f"[{'pull request' if is_pr else 'issue'} by {who} - association: {i.get('author_association') or 'NONE'}]"
|
|
552
|
+
out = ingest_message(store, {
|
|
553
|
+
'external_id': f"gh:{repo}#{i['number']}", 'channel': 'github',
|
|
554
|
+
'subject': f"{repo}#{i['number']} {i.get('title') or ''}".strip(),
|
|
555
|
+
'body': f"{head}\n{(i.get('body') or '(no description)')[:20000]}",
|
|
556
|
+
'from_name': who, 'from_email': f'{who}@users.noreply.github.com',
|
|
557
|
+
'conversation_id': f"gh:{repo}#{i['number']}", 'sent_at': _local(i.get('updated_at') or ''),
|
|
558
|
+
'source_link': i.get('html_url'), 'source_name': repo, 'no_auto': True},
|
|
559
|
+
llm=llm, file_only=mode == 'feed')
|
|
560
|
+
n += out['status'] != 'duplicate'
|
|
561
|
+
return n
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _gh_explicit(store) -> bool:
|
|
565
|
+
"""Any repo whose issues/PRs picker is set to something live - that IS the trigger intent,
|
|
566
|
+
whatever the connector card's role says."""
|
|
567
|
+
for s in store.list_sources():
|
|
568
|
+
if s['Channel'] != 'github': continue
|
|
569
|
+
try: m = json.loads(s.get('ConfigJson') or '{}')
|
|
570
|
+
except ValueError: m = {}
|
|
571
|
+
if {'tasks', 'feed'} & {m.get('issues'), m.get('prs')}: return True
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ── "the hub has read this" ─────────────────────────────────────────────────────────────
|
|
576
|
+
# One switch (Settings > Sync & startup) decides whether reading an item here also marks it
|
|
577
|
+
# read THERE, so the mailbox and the chat list stop showing work the funnel already took.
|
|
578
|
+
# Every marker is best-effort by design: a missing consent (Graph wants Mail.ReadWrite,
|
|
579
|
+
# Slack conversations.mark) must never cost the ingest that already succeeded, so failures
|
|
580
|
+
# are logged once and swallowed. Protocols with no read state for a bot - Telegram, Discord,
|
|
581
|
+
# the trackers - simply have no marker, and the switch is a no-op for them.
|
|
582
|
+
def wants_read(store) -> bool:
|
|
583
|
+
try: return str(store.get_settings().get('mark_read_enabled') or '0') == '1'
|
|
584
|
+
except Exception: return False
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def mark_mail_read(tok: str, upn: str, graph_id: str):
|
|
588
|
+
try:
|
|
589
|
+
requests.patch(f'{GRAPH}/users/{upn}/messages/{graph_id}', timeout=20,
|
|
590
|
+
headers={'Authorization': f'Bearer {tok}'}, json={'isRead': True}).raise_for_status()
|
|
591
|
+
except Exception as e: logger.warning(f'marking mail {graph_id} read failed: {e}')
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def mark_chat_read(tok: str, upn: str, chat_id: str, user_id: str = ''):
|
|
595
|
+
"""Teams reads a CHAT, not a message - Graph offers no per-message read state. The body
|
|
596
|
+
wants the directory OBJECT ID, which the poller already looked up for 'is this me'."""
|
|
597
|
+
try:
|
|
598
|
+
requests.post(f'{GRAPH}/users/{upn}/chats/{chat_id}/markChatReadForUser', timeout=20,
|
|
599
|
+
headers={'Authorization': f'Bearer {tok}'},
|
|
600
|
+
json={'user': {'id': user_id or upn}}).raise_for_status()
|
|
601
|
+
except Exception as e: logger.warning(f'marking chat {chat_id} read failed: {e}')
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def mark_slack_read(tok: str, channel: str, ts: str):
|
|
605
|
+
try: _slack(tok, 'conversations.mark', post=True, channel=channel, ts=ts)
|
|
606
|
+
except Exception as e: logger.warning(f'marking slack {channel} read failed: {e}')
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Every poll reaches back a little PAST its own watermark, and this is not belt-and-braces -
|
|
610
|
+
# without it a message that arrives moments before a poll is lost for good. Graph's
|
|
611
|
+
# getAllMessages/delta is eventually consistent: a Teams message sent at 15:29:11 was not yet
|
|
612
|
+
# in the delta when the 15:29:19 poll asked for it, so that poll saw nothing and moved the
|
|
613
|
+
# watermark to 15:29:19 anyway - eight seconds PAST the message. Every later poll asked for
|
|
614
|
+
# "newer than 15:29:19" and the message was permanently on the wrong side of the line. Nobody
|
|
615
|
+
# would ever have found it by re-syncing, because re-syncing is what buried it.
|
|
616
|
+
#
|
|
617
|
+
# Every channel here works the same way (a watermark that jumps to now), so every channel had
|
|
618
|
+
# the same hole. Re-reading a few minutes is free: ingest_message dedupes on external_id in its
|
|
619
|
+
# FIRST line, before policies and before any AI call.
|
|
620
|
+
POLL_OVERLAP = timedelta(minutes=5)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _since(s, backfill_days: int = 0):
|
|
624
|
+
"""How far back to ask this source for. `backfill_days` WIDENS the window without moving the
|
|
625
|
+
watermark - what the app does on startup, because whatever arrived while it was shut down was
|
|
626
|
+
never polled by anyone, and 'since I last ran' is the wrong question after a weekend off."""
|
|
627
|
+
last = (datetime.fromisoformat(s['LastPolledAt'].replace(' ', 'T')) - POLL_OVERLAP
|
|
628
|
+
if s.get('LastPolledAt') else datetime.now() - timedelta(days=1))
|
|
629
|
+
return min(last, datetime.now() - timedelta(days=backfill_days)) if backfill_days else last
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def poll_channels(store, backfill_days: int = 0) -> int:
|
|
633
|
+
"""Ingest new items for every connection the owner marked as a TRIGGER, through the
|
|
634
|
+
same triage funnel (incl. the configured AI, if any). A connection without the trigger
|
|
635
|
+
role is still usable by agents and reports - it just never creates work on its own.
|
|
636
|
+
Failures land on the card. `backfill_days` reaches further back than the watermark - see
|
|
637
|
+
_since; it is how startup catches up on mail that arrived while the app was closed."""
|
|
638
|
+
from .llm import build_llm
|
|
639
|
+
from .store import roles_of
|
|
640
|
+
try: llm = build_llm(store)
|
|
641
|
+
except Exception: llm = None
|
|
642
|
+
read_it = wants_read(store) # asked once per run, not once per message
|
|
643
|
+
n = 0
|
|
644
|
+
for c in store.list_connectors():
|
|
645
|
+
if not c['Active'] or c['Type'] not in CH2SRC: continue
|
|
646
|
+
roles = roles_of(c)
|
|
647
|
+
# trigger = becomes work; feed = shows on the timeline and stops there; neither = never
|
|
648
|
+
# polled - EXCEPT github, where the per-repo issue/PR pickers carry the intent: two
|
|
649
|
+
# switches where one reads as enough was a trap (a repo set to "PRs: tasks" on a
|
|
650
|
+
# tool-only card, a Sync that pulled nothing, and no error anywhere).
|
|
651
|
+
if (not roles & {'trigger', 'feed'} and not (c['Type'] == 'github' and _gh_explicit(store))
|
|
652
|
+
and not (c['Type'] in CLOUD and _cloud_explicit(store, CH2SRC[c['Type']]))): continue
|
|
653
|
+
file_only = 'trigger' not in roles
|
|
654
|
+
full = store.get_connector(c['ConnectorId'], with_secret=True)
|
|
655
|
+
try:
|
|
656
|
+
if c['Type'] in ('outlook', 'teams'):
|
|
657
|
+
gcfg, gsec, _ = graph_creds(store, full)
|
|
658
|
+
tok = graph_token(gcfg, gsec)
|
|
659
|
+
else:
|
|
660
|
+
tok = full.get('Secret')
|
|
661
|
+
# ── connections whose SOURCE ROW IS ONLY A MARKER poll once per connector, and
|
|
662
|
+
# they must poll even with NO source row at all. This used to live inside the
|
|
663
|
+
# per-source loop, so a Telegram card whose '*' marker was never created (Test
|
|
664
|
+
# skipped) or was deleted polled NOTHING: getUpdates never ran, so no chat could
|
|
665
|
+
# ever announce itself, and Sync now looked broken with no error anywhere.
|
|
666
|
+
if c['Type'] in PER_CONNECTOR:
|
|
667
|
+
mine = [x for x in store.list_sources()
|
|
668
|
+
if x['Channel'] == CH2SRC[c['Type']]
|
|
669
|
+
and (not x.get('ConnectorId') or x['ConnectorId'] == c['ConnectorId'])]
|
|
670
|
+
since = _since(mine[0] if mine else {}, backfill_days)
|
|
671
|
+
if c['Type'] in ('telegram', 'whatsapp'):
|
|
672
|
+
from . import messengers
|
|
673
|
+
poll = messengers.poll_telegram if c['Type'] == 'telegram' else messengers.poll_whatsapp
|
|
674
|
+
n += poll(store, full, mine, llm, file_only)
|
|
675
|
+
elif c['Type'] in ('jira', 'asana', 'monday', 'clickup', 'todoist'):
|
|
676
|
+
from . import pm
|
|
677
|
+
n += pm.poll(store, full, since, llm, file_only)
|
|
678
|
+
else:
|
|
679
|
+
from . import devtools
|
|
680
|
+
n += devtools.poll(store, full, since, llm, file_only)
|
|
681
|
+
for s in mine: store.touch_source(s['SourceId'])
|
|
682
|
+
store.touch_connector(c['ConnectorId'])
|
|
683
|
+
continue
|
|
684
|
+
for s in store.list_sources():
|
|
685
|
+
if s['Channel'] != CH2SRC[c['Type']]: continue
|
|
686
|
+
# a source belongs to ONE connector: outlook and an IMAP mailbox are both
|
|
687
|
+
# channel 'email', and without this the Graph poller tried the Gmail address.
|
|
688
|
+
# (Orphans are adopted at startup, so ownership is always present now.)
|
|
689
|
+
if s.get('ConnectorId') and s['ConnectorId'] != c['ConnectorId']: continue
|
|
690
|
+
since = _since(s, backfill_days)
|
|
691
|
+
if c['Type'] == 'outlook':
|
|
692
|
+
since_iso = since.astimezone().isoformat()
|
|
693
|
+
# your replies ride along as CONTEXT: attached to the thread's task,
|
|
694
|
+
# visible on the timeline, never triaged into work
|
|
695
|
+
for m in reversed(_mail_msgs(tok, s['Address'], since_iso, folder='sentitems')):
|
|
696
|
+
n += ingest_outbound_mail(store, s['Address'], m)
|
|
697
|
+
for m in reversed(_mail_msgs(tok, s['Address'], since_iso)):
|
|
698
|
+
frm = (m.get('from') or {}).get('emailAddress') or {}
|
|
699
|
+
if (frm.get('address') or '').lower() == s['Address'].lower():
|
|
700
|
+
continue # the mailbox's own mail (moved copies, self-sends) is never inbound work
|
|
701
|
+
# the screenshot IS the ask in a "see below" mail, so it is fetched BEFORE
|
|
702
|
+
# triage and handed to it - then saved once the message row exists
|
|
703
|
+
atts = []
|
|
704
|
+
if m.get('hasAttachments'):
|
|
705
|
+
try: atts = mail_attachments(tok, s['Address'], m['id'])
|
|
706
|
+
except Exception as e: logger.warning(f"attachments for {m['id']} failed: {e}")
|
|
707
|
+
out = ingest_message(store, file_only=file_only, msg={
|
|
708
|
+
'external_id': f"graph:{m['id']}", 'channel': 'email',
|
|
709
|
+
'subject': m.get('subject'), 'body': _body(m),
|
|
710
|
+
'from_name': frm.get('name'), 'from_email': frm.get('address'),
|
|
711
|
+
'conversation_id': m.get('conversationId'), 'sent_at': _local(m.get('receivedDateTime') or ''),
|
|
712
|
+
'source_link': m.get('webLink'), 'source_name': s['Address'],
|
|
713
|
+
'images': images_for_triage(store, atts)}, llm=llm)
|
|
714
|
+
n += out['status'] != 'duplicate'
|
|
715
|
+
if atts and out.get('message_id') and out['status'] != 'duplicate':
|
|
716
|
+
try: save_attachments(store, out['message_id'], atts, f"graph:{m['id']}")
|
|
717
|
+
except Exception as e: logger.warning(f"saving attachments for {m['id']} failed: {e}")
|
|
718
|
+
# a duplicate is still mail the hub has read - the flag may just be
|
|
719
|
+
# older than the switch, and skipping it would strand those bold rows
|
|
720
|
+
if read_it and not m.get('isRead'): mark_mail_read(tok, s['Address'], m['id'])
|
|
721
|
+
elif c['Type'] == 'teams':
|
|
722
|
+
n += ingest_teams_chats(store, s['Address'], tok, since, llm, file_only, read_it)
|
|
723
|
+
elif c['Type'] == 'github':
|
|
724
|
+
# a repo with BOTH kinds off was not read, so its watermark must not
|
|
725
|
+
# move: advancing it would step over the issues sitting there, and
|
|
726
|
+
# switching the repo on later would only ever see what came next
|
|
727
|
+
if set(gh_modes(s, file_only)) == {'off'}: continue
|
|
728
|
+
n += ingest_github_issues(store, s, tok, since, llm, file_only)
|
|
729
|
+
elif c['Type'] in ('gmail', 'imap'):
|
|
730
|
+
# one poll per connector (the UID watermark lives there); its own source only
|
|
731
|
+
from . import imapmail
|
|
732
|
+
if s['ConnectorId'] != c['ConnectorId']: continue
|
|
733
|
+
n += imapmail.poll_imap(store, full, [s], llm, file_only, backfill_days)
|
|
734
|
+
elif c['Type'] in CLOUD:
|
|
735
|
+
# per SOURCE: each discovered object carries its own mode, and 'report'
|
|
736
|
+
# (the default) means the Reports tab may use it but nothing is polled.
|
|
737
|
+
# The picker OUTRANKS the card's role, like github's per-repo pickers:
|
|
738
|
+
# 'tasks' on a tool-only card means tasks, not a filed feed row.
|
|
739
|
+
mode = json.loads(s.get('ConfigJson') or '{}').get('mode') or 'report'
|
|
740
|
+
if mode not in ('feed', 'tasks'): continue
|
|
741
|
+
from .reports import aws_connection, azure_connection
|
|
742
|
+
mod = __import__(f'taskuary.{c["Type"]}', fromlist=['x'])
|
|
743
|
+
conn_cfg = (aws_connection if c['Type'] == 'aws' else azure_connection)(store)
|
|
744
|
+
n += mod.poll_source(store, conn_cfg, s, since, llm, mode == 'feed')
|
|
745
|
+
elif c['Type'] == 'discord':
|
|
746
|
+
# per SOURCE, like slack: each watched channel id is its own source
|
|
747
|
+
from . import devtools
|
|
748
|
+
n += devtools.poll_discord(store, full, s, since, llm, file_only)
|
|
749
|
+
elif c['Type'] == 'slack':
|
|
750
|
+
hist = _slack(tok, 'conversations.history', channel=s['Address'],
|
|
751
|
+
oldest=since.timestamp(), limit=25)
|
|
752
|
+
msgs = [m for m in reversed(hist.get('messages', [])) if not m.get('subtype')]
|
|
753
|
+
# the channel's read cursor is ONE timestamp - the newest line we took
|
|
754
|
+
if read_it and msgs: mark_slack_read(tok, s['Address'], msgs[-1].get('ts'))
|
|
755
|
+
for m in msgs:
|
|
756
|
+
out = ingest_message(store, file_only=file_only, msg={
|
|
757
|
+
'external_id': f"slack:{s['Address']}:{m.get('ts')}", 'channel': 'slack',
|
|
758
|
+
'subject': None, 'body': m.get('text'), 'from_name': m.get('user'),
|
|
759
|
+
'conversation_id': f"slack:{s['Address']}",
|
|
760
|
+
'sent_at': datetime.fromtimestamp(float(m.get('ts', 0))).strftime('%Y-%m-%d %H:%M:%S'),
|
|
761
|
+
'source_name': s['Address']}, llm=llm)
|
|
762
|
+
n += out['status'] != 'duplicate'
|
|
763
|
+
store.touch_source(s['SourceId'])
|
|
764
|
+
store.touch_connector(c['ConnectorId'])
|
|
765
|
+
except Exception as e:
|
|
766
|
+
logger.warning(f"channel poll failed ({c['Type']}): {e}")
|
|
767
|
+
store.touch_connector(c['ConnectorId'], str(e))
|
|
768
|
+
return n
|