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.
Files changed (62) hide show
  1. taskuary/__init__.py +2 -0
  2. taskuary/agents.py +218 -0
  3. taskuary/artifacts.py +209 -0
  4. taskuary/aws.py +226 -0
  5. taskuary/azure.py +334 -0
  6. taskuary/blackboard.py +150 -0
  7. taskuary/channels.py +768 -0
  8. taskuary/ci.py +237 -0
  9. taskuary/cli.py +47 -0
  10. taskuary/coder.py +132 -0
  11. taskuary/config.py +62 -0
  12. taskuary/db.py +63 -0
  13. taskuary/desktop.py +72 -0
  14. taskuary/devtools.py +352 -0
  15. taskuary/digest.py +104 -0
  16. taskuary/docsync.py +124 -0
  17. taskuary/github.py +136 -0
  18. taskuary/histgen.py +220 -0
  19. taskuary/imapmail.py +150 -0
  20. taskuary/ingest.py +344 -0
  21. taskuary/learn.py +181 -0
  22. taskuary/llm.py +175 -0
  23. taskuary/logs.py +17 -0
  24. taskuary/mcp.py +78 -0
  25. taskuary/messengers.py +205 -0
  26. taskuary/mssql.py +64 -0
  27. taskuary/outbound.py +221 -0
  28. taskuary/phone.py +88 -0
  29. taskuary/pm.py +310 -0
  30. taskuary/policy.py +59 -0
  31. taskuary/proof.py +194 -0
  32. taskuary/proposals.py +132 -0
  33. taskuary/reports.py +508 -0
  34. taskuary/reshape.py +201 -0
  35. taskuary/responder.py +199 -0
  36. taskuary/routing.py +115 -0
  37. taskuary/scopes.py +88 -0
  38. taskuary/server.py +1506 -0
  39. taskuary/store.py +711 -0
  40. taskuary/templates/coder.md +35 -0
  41. taskuary/templates/digest.md +5 -0
  42. taskuary/templates/learned.md +31 -0
  43. taskuary/templates/soul.md +44 -0
  44. taskuary/templates/style.md +14 -0
  45. taskuary/templates/triage.md +22 -0
  46. taskuary/terminal.py +971 -0
  47. taskuary/toil.py +57 -0
  48. taskuary/triage.py +123 -0
  49. taskuary/verdicts.py +73 -0
  50. taskuary/web/assets/index-6GBZ9nXN.css +32 -0
  51. taskuary/web/assets/index-Cjj87C2X.js +401 -0
  52. taskuary/web/favicon.ico +0 -0
  53. taskuary/web/favicon.png +0 -0
  54. taskuary/web/index.html +25 -0
  55. taskuary/whatsapp/bridge.mjs +101 -0
  56. taskuary/whatsapp/package.json +11 -0
  57. taskuary-0.2.0.dist-info/METADATA +424 -0
  58. taskuary-0.2.0.dist-info/RECORD +62 -0
  59. taskuary-0.2.0.dist-info/WHEEL +5 -0
  60. taskuary-0.2.0.dist-info/entry_points.txt +3 -0
  61. taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
  62. 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