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/llm.py ADDED
@@ -0,0 +1,175 @@
1
+ """The triage brain -> one provider-agnostic llm(system, user) -> str callable, the shape
2
+ triage.classify_intent expects. Which brain is the owner's choice (setting `triage_ai`):
3
+
4
+ '' first ACTIVE AI connector with a key (anthropic/openai/azure_openai/
5
+ openrouter) - or keyless ollama, for a local model
6
+
7
+ connector:<type> that specific AI connector
8
+ cli:<agent> your CODING CLI does the triage too - one headless run per message,
9
+ same brain that works the tasks, no second API key to buy
10
+
11
+ Cloud keys are cheap and instant per message; a CLI run is slower and heavier but keeps
12
+ everything on one model (and one bill). Configure it in Settings -> Triage & routing.
13
+ """
14
+ import base64, json, mimetypes, requests
15
+ from pathlib import Path
16
+
17
+ AI_TYPES = ('anthropic', 'openai', 'azure_openai', 'openrouter', 'ollama')
18
+
19
+ # What a vision model will look at. "See below." is half the mail this app reads, and below was
20
+ # a screenshot - a text-only funnel filed the sentence and threw the actual ask away.
21
+ VISION_TYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp')
22
+ VISION_MAX, VISION_BYTES = 4, 5 * 1024 * 1024 # per call: how many images, and how big each
23
+
24
+
25
+ def readable_images(store, message_ids, cap: int = VISION_MAX) -> list:
26
+ """[(media_type, base64)] for the images on these messages, or [] when the owner has vision
27
+ switched off. SVG and PDF are skipped: no provider takes them as image input."""
28
+ if str(store.get_settings().get('vision_enabled') or '1') != '1': return []
29
+ out = []
30
+ for mid in message_ids or []:
31
+ for a in store.list_attachments(mid):
32
+ if len(out) >= cap: return out
33
+ ct = str(a.get('ContentType') or '').split(';')[0].lower()
34
+ path = a.get('Path')
35
+ if not path: continue
36
+ if ct not in VISION_TYPES:
37
+ ct = mimetypes.guess_type(path)[0] or ''
38
+ if ct not in VISION_TYPES: continue
39
+ f = Path(path)
40
+ try:
41
+ if not f.is_file() or f.stat().st_size > VISION_BYTES: continue
42
+ out.append((ct, base64.b64encode(f.read_bytes()).decode()))
43
+ except OSError:
44
+ continue
45
+ return out
46
+ # Triage answers with a one-line JSON object, so it needs almost nothing. A report SUMMARY
47
+ # needs room - and on a reasoning model a small budget is spent thinking and the visible
48
+ # answer comes back EMPTY, which is how reports ended up filing raw data with no summary.
49
+ MAX_TOKENS = 400
50
+
51
+
52
+ def make_cli_llm(store, agent_name: str, model: str = None):
53
+ """A CLI agent as the classifier: prompt in on stdin, JSON out. The repo working dir
54
+ is dropped - triage is about the message, not about any checkout.
55
+
56
+ And the MODEL drops a tier: `light_model` on the agent profile (Connectors > AI CLI
57
+ agents) is what runs here - triage, drafts, summaries, the digest - while the profile's
58
+ main `model` stays reserved for the coding sessions. One brain, two gears: the classifier
59
+ reads one email; it does not need the model that rewrites your codebase."""
60
+ row = store.get_agent(agent_name)
61
+ if not row: return None
62
+ prof = {k: v for k, v in json.loads(row.get('Config') or '{}').items() if k not in ('cwd', 'cwd_map')}
63
+ light = str(prof.get('light_model') or '')
64
+ if light.startswith('effort:'):
65
+ # codex on a ChatGPT plan serves ONLY the plan's models - no mini/nano tier exists -
66
+ # so its cheap gear is reasoning effort on the same model (verified: -c
67
+ # model_reasoning_effort=low answers in a fraction of the tokens)
68
+ prof['args'] = list(prof.get('args') or []) + ['-c', f"model_reasoning_effort={light.split(':', 1)[1].strip()}"]
69
+ elif light:
70
+ prof['model'] = light
71
+ if model: prof['model'] = model # an explicit per-job model outranks the light gear
72
+ prof['timeout'] = min(int(prof.get('timeout') or 300), 300)
73
+ def llm(system, user, max_tokens=MAX_TOKENS, images=None):
74
+ """max_tokens is advisory here - a CLI has no such flag; the system prompt already says
75
+ how long the answer should be. `images` is accepted and dropped: a CLI reads files off
76
+ disk itself, and the prompt already names their paths."""
77
+ from .agents import run_cli
78
+ out, _sid, _diff = run_cli(prof, f'{system}\n\n{user}', lambda *a: None)
79
+ return out
80
+ return llm
81
+
82
+
83
+ def build_llm(store, pick=None, model=None):
84
+ """The brain named by `pick` ('' = first active AI connector, 'connector:<type>',
85
+ 'cli:<agent>'), defaulting to the triage_ai setting - callers like reports may name
86
+ their OWN brain and model per job instead of riding the triage tier."""
87
+ pick = (pick if pick is not None else store.get_settings().get('triage_ai') or '').strip()
88
+ if pick.startswith('cli:'): return make_cli_llm(store, pick[4:], model)
89
+ want = pick[10:] if pick.startswith('connector:') else None
90
+ for c in store.list_connectors():
91
+ # a local model server (ollama) is the one brain that needs no key to be real
92
+ ready = c['Active'] and (c['HasSecret'] or c['Type'] == 'ollama')
93
+ if c['Type'] in AI_TYPES and ready and (not want or c['Type'] == want):
94
+ full = store.get_connector(c['ConnectorId'], with_secret=True)
95
+ cfg = json.loads(full.get('ConfigJson') or '{}')
96
+ if model: cfg = {**cfg, 'model': model}
97
+ return make_llm(full['Type'], cfg, full.get('Secret'))
98
+ return None
99
+
100
+
101
+ def make_llm(t, cfg: dict, key: str):
102
+ if not key and t != 'ollama': raise RuntimeError('no API key saved - paste one under Credentials')
103
+ if t == 'anthropic':
104
+ import anthropic
105
+ cli = anthropic.Anthropic(api_key=key)
106
+ model = cfg.get('model') or 'claude-opus-5'
107
+ def llm(system, user, max_tokens=MAX_TOKENS, images=None):
108
+ # images FIRST: every provider reads a picture better when the question follows it
109
+ content = ([{'type': 'image', 'source': {'type': 'base64', 'media_type': ct, 'data': b64}}
110
+ for ct, b64 in (images or [])] + [{'type': 'text', 'text': user}]) if images else user
111
+ r = cli.messages.create(model=model, max_tokens=max_tokens, system=system,
112
+ messages=[{'role': 'user', 'content': content}])
113
+ if r.stop_reason == 'refusal': raise RuntimeError('model refused the request')
114
+ return next((b.text for b in r.content if b.type == 'text'), '')
115
+ return llm
116
+ if t == 'openai':
117
+ urls = ['https://api.openai.com/v1/chat/completions']
118
+ headers, model = {'Authorization': f'Bearer {key}'}, cfg.get('model') or 'gpt-4o-mini'
119
+ elif t == 'openrouter':
120
+ # one key, the whole catalog behind the OpenAI schema - open-weights models included.
121
+ # Model strings are OpenRouter's names ('meta-llama/llama-3.3-70b-instruct', ...);
122
+ # 'openrouter/auto' lets their router pick, so an empty model box still works.
123
+ urls = ['https://openrouter.ai/api/v1/chat/completions']
124
+ headers, model = {'Authorization': f'Bearer {key}', 'X-Title': 'Taskuary'}, cfg.get('model') or 'openrouter/auto'
125
+ elif t == 'ollama':
126
+ # a LOCAL server speaking the OpenAI surface. Ollama's port out of the box, but base_url
127
+ # reaches LM Studio (:1234), llama.cpp, vLLM - anything /v1-compatible - so open-source
128
+ # models triage your mail without a byte leaving the machine. No key unless the server
129
+ # demands one; the model must be named because only `ollama list` knows what's pulled.
130
+ base = (cfg.get('base_url') or 'http://127.0.0.1:11434').rstrip('/')
131
+ if not cfg.get('model'): raise RuntimeError('a local brain needs its model named - `ollama list` shows what is installed')
132
+ urls, model = [f'{base}/v1/chat/completions'], cfg['model']
133
+ headers = {'Authorization': f'Bearer {key}'} if key else {}
134
+ elif t == 'azure_openai':
135
+ ep = (cfg.get('endpoint') or '').rstrip('/')
136
+ if not (ep and cfg.get('deployment')): raise RuntimeError('azure_openai needs endpoint + deployment')
137
+ # Azure's v1 surface first (no api-version, OpenAI-compatible, all params work);
138
+ # legacy deployments URL as fallback for resources without it. An explicit
139
+ # api_version in the config skips straight to legacy with that version.
140
+ legacy = f"{ep}/openai/deployments/{cfg['deployment']}/chat/completions?api-version={cfg.get('api_version') or '2024-12-01-preview'}"
141
+ urls = [legacy] if cfg.get('api_version') else [f'{ep}/openai/v1/chat/completions', legacy]
142
+ headers, model = {'api-key': key}, cfg['deployment']
143
+ else:
144
+ raise RuntimeError(f'unknown AI connector type: {t}')
145
+
146
+ def llm(system, user, max_tokens=MAX_TOKENS, images=None):
147
+ # two independent compat axes: newer models reject max_tokens ("use
148
+ # max_completion_tokens"), older Azure api-versions reject max_completion_tokens,
149
+ # and older Azure resources 404 the v1 url - walk the grid until one works
150
+ content = ([{'type': 'image_url', 'image_url': {'url': f'data:{ct};base64,{b64}'}}
151
+ for ct, b64 in (images or [])] + [{'type': 'text', 'text': user}]) if images else user
152
+ msgs = [{'role': 'system', 'content': system}, {'role': 'user', 'content': content}]
153
+ last = None
154
+ for url in urls:
155
+ for tok_param in ('max_completion_tokens', 'max_tokens'):
156
+ body = {'messages': msgs, tok_param: max_tokens}
157
+ if model: body['model'] = model
158
+ # a local model may spend its first call loading weights off disk - give it room
159
+ r = requests.post(url, headers=headers, json=body, timeout=180 if t == 'ollama' else 60)
160
+ if r.status_code == 200:
161
+ return r.json()['choices'][0]['message']['content']
162
+ last = r
163
+ if r.status_code == 404: break # wrong surface -> next url
164
+ if not (r.status_code == 400 and 'max_completion_tokens' in r.text):
165
+ raise RuntimeError(f'{t} error {r.status_code} at {url.split("?")[0]}: {r.text[:300]}')
166
+ raise RuntimeError(f'{t} error {last.status_code} at {urls[-1].split("?")[0]}: {last.text[:300]}')
167
+ return llm
168
+
169
+
170
+ def test_ai(store, cid: int) -> str:
171
+ """Real round trip through the configured model; returns a detail string or raises."""
172
+ c = store.get_connector(cid, with_secret=True)
173
+ out = make_llm(c['Type'], json.loads(c.get('ConfigJson') or '{}'), c.get('Secret'))(
174
+ 'Reply with exactly: ok', 'ping')
175
+ return f'model responded: {(out or "").strip()[:80]} - wired into intent triage'
taskuary/logs.py ADDED
@@ -0,0 +1,17 @@
1
+ """Logging: INFO on the console (DEBUG with --debug), always-DEBUG rotating file at
2
+ ~/.taskuary/taskuary.log - so 'it crashed / nothing happened' is always diagnosable
3
+ after the fact. Every API request, report run, connector test and ingest logs here.
4
+ """
5
+ import sys
6
+ from loguru import logger
7
+
8
+ from . import config
9
+
10
+
11
+ def setup(debug: bool = False):
12
+ logger.remove()
13
+ logger.add(sys.stderr, level='DEBUG' if debug else 'INFO',
14
+ format='<green>{time:HH:mm:ss}</green> <level>{level: <7}</level> {message}')
15
+ logger.add(config.home() / 'taskuary.log', level='DEBUG', rotation='5 MB', retention=3,
16
+ enqueue=True, format='{time:YYYY-MM-DD HH:mm:ss} {level: <7} {name}:{line} {message}')
17
+ logger.info(f"log file: {config.home() / 'taskuary.log'}" + (' (debug console)' if debug else ''))
taskuary/mcp.py ADDED
@@ -0,0 +1,78 @@
1
+ """MCP connector for reports: call any MCP server's tool on a schedule and file the result
2
+ on the timeline. Config: {"cmd": "npx", "args": [...], "tool": "query", "tool_args": {...},
3
+ "env": {...}}. Minimal stdio JSON-RPC client (initialize -> initialized -> tools/call), no
4
+ SDK dependency - keeps the single-exe desktop build lean. Spec: modelcontextprotocol.io.
5
+ """
6
+ import json, os, subprocess, threading, queue
7
+
8
+ PROTOCOL = '2025-06-18'
9
+
10
+
11
+ class MCPClient:
12
+ """One short-lived stdio session with an MCP server."""
13
+
14
+ def __init__(self, cmd, args=None, env=None, timeout=60):
15
+ self.timeout, self._id = timeout, 0
16
+ self.p = subprocess.Popen([cmd] + list(args or []), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
17
+ stderr=subprocess.DEVNULL, text=True, encoding='utf-8',
18
+ env={**os.environ, **(env or {})}, shell=False)
19
+ self.q = queue.Queue()
20
+ threading.Thread(target=self._pump, daemon=True).start()
21
+
22
+ def _pump(self):
23
+ for line in self.p.stdout:
24
+ line = line.strip()
25
+ if line:
26
+ try: self.q.put(json.loads(line))
27
+ except ValueError: pass
28
+
29
+ def _send(self, msg):
30
+ self.p.stdin.write(json.dumps(msg) + '\n'); self.p.stdin.flush()
31
+
32
+ def request(self, method, params=None):
33
+ self._id += 1
34
+ self._send({'jsonrpc': '2.0', 'id': self._id, 'method': method, **({'params': params} if params else {})})
35
+ while True:
36
+ m = self.q.get(timeout=self.timeout)
37
+ if m.get('id') == self._id:
38
+ if 'error' in m: raise RuntimeError(f"{method}: {m['error'].get('message', m['error'])}")
39
+ return m.get('result', {})
40
+
41
+ def start(self):
42
+ self.request('initialize', {'protocolVersion': PROTOCOL, 'capabilities': {},
43
+ 'clientInfo': {'name': 'taskuary', 'version': '0.1'}})
44
+ self._send({'jsonrpc': '2.0', 'method': 'notifications/initialized'})
45
+ return self
46
+
47
+ def list_tools(self): return self.request('tools/list').get('tools', [])
48
+ def call_tool(self, name, args=None): return self.request('tools/call', {'name': name, 'arguments': args or {}})
49
+
50
+ def close(self):
51
+ try: self.p.stdin.close(); self.p.terminate()
52
+ except Exception: pass
53
+
54
+
55
+ def _session(cfg):
56
+ if not cfg.get('cmd'): raise ValueError('mcp connector needs "cmd" (the MCP server command)')
57
+ return MCPClient(cfg['cmd'], cfg.get('args'), cfg.get('env'), int(cfg.get('timeout', 60))).start()
58
+
59
+
60
+ def list_tools(cfg) -> list:
61
+ c = _session(cfg)
62
+ try: return [{'name': t['name'], 'description': t.get('description', '')[:200]} for t in c.list_tools()]
63
+ finally: c.close()
64
+
65
+
66
+ def run_report(cfg):
67
+ """Report executor: call cfg['tool'] and return (headline, text content)."""
68
+ c = _session(cfg)
69
+ try:
70
+ args = cfg.get('tool_args') or {}
71
+ if isinstance(args, str): args = json.loads(args or '{}')
72
+ res = c.call_tool(cfg['tool'], args)
73
+ if res.get('isError'): raise RuntimeError(str(res.get('content'))[:500])
74
+ texts = [b.get('text', '') for b in res.get('content', []) if b.get('type') == 'text']
75
+ body = '\n'.join(t for t in texts if t) or json.dumps(res, default=str)
76
+ return f"{cfg['tool']} ok", body[:4000]
77
+ finally:
78
+ c.close()
taskuary/messengers.py ADDED
@@ -0,0 +1,205 @@
1
+ """Telegram and WhatsApp as inbound channels - the personal-messenger half of the funnel.
2
+
3
+ Telegram is light: a bot token and plain HTTPS (getUpdates / sendMessage), so it is built in
4
+ entirely. WhatsApp has no sanctioned API for a personal account - the working road is Baileys,
5
+ a Node library speaking the WhatsApp Web protocol - so Taskuary does NOT embed it: a small
6
+ bridge script (taskuary/whatsapp/bridge.mjs) runs beside the app with its own npm install, and
7
+ this module just polls the bridge over localhost HTTP. Heavy dependency, separate install;
8
+ Taskuary's side is ~40 lines either way.
9
+
10
+ Both are CHAT: messages land with a conversation id per chat, replies go back into the same
11
+ chat, and the responder already knows not to sign chat messages.
12
+ """
13
+ import base64, json
14
+ import requests
15
+ from loguru import logger
16
+
17
+ TG_API = 'https://api.telegram.org'
18
+ TG_LIMIT = 25 # messages per poll, like the other channels
19
+ WA_URL = 'http://127.0.0.1:8977' # the bridge's default; override in the connector config
20
+
21
+
22
+ def _cfg(c): return json.loads(c.get('ConfigJson') or '{}')
23
+
24
+
25
+ # ── Telegram ─────────────────────────────────────────────────────────────────────────────
26
+ def tg(token: str, method: str, **params):
27
+ r = requests.post(f'{TG_API}/bot{token}/{method}', json=params, timeout=30)
28
+ j = r.json()
29
+ if not j.get('ok'): raise RuntimeError(f"telegram {method}: {j.get('description') or r.status_code}")
30
+ return j['result']
31
+
32
+
33
+ def tg_test(store, c) -> str:
34
+ """getMe proves the token; a '*' source is added so the poller has something to walk -
35
+ it is a LISTENING marker only, never an admit-everything: a bot is public, and anyone
36
+ who finds it can message it. Chats announce themselves in getUpdates and are registered
37
+ OFF under Sources with their chat id; only the ones the owner flips on become work."""
38
+ if not c.get('Secret'): raise RuntimeError('no bot token saved - paste the token @BotFather gave you under Credentials')
39
+ me = tg(c['Secret'], 'getMe')
40
+ if not any(s['Channel'] == 'telegram' for s in store.list_sources(active_only=False)):
41
+ store.save_source({'Channel': 'telegram', 'Address': '*', 'ConnectorId': c['ConnectorId'], 'Active': 1}, 'connector-test')
42
+ return (f"authenticated as @{me.get('username')} - message the bot (or add it to a group), Sync, "
43
+ f"and the chat appears under Sources with its chat id, OFF. Flip on the chats that are "
44
+ f"yours; every other chat stays out (a public bot can be messaged by anyone)")
45
+
46
+
47
+ def _tg_photo(token: str, m: dict) -> list:
48
+ """The largest rendition of an attached photo/document, shaped like a Graph fileAttachment
49
+ so channels.save_attachments and vision reuse the one pipeline."""
50
+ out = []
51
+ for kind, meta in (('photo', (m.get('photo') or [])[-1:]), ('document', [m['document']] if m.get('document') else [])):
52
+ for f in meta:
53
+ try:
54
+ path = tg(token, 'getFile', file_id=f['file_id']).get('file_path') or ''
55
+ data = requests.get(f'{TG_API}/file/bot{token}/{path}', timeout=60).content
56
+ name = f.get('file_name') or (path.rsplit('/', 1)[-1] or f'{kind}.jpg')
57
+ ct = f.get('mime_type') or ('image/jpeg' if kind == 'photo' else 'application/octet-stream')
58
+ out.append({'id': f['file_id'][:60], 'name': name, 'contentType': ct,
59
+ 'size': len(data), 'contentBytes': base64.b64encode(data).decode(),
60
+ 'isInline': kind == 'photo'})
61
+ except Exception as e:
62
+ logger.warning(f'telegram file fetch failed: {e}')
63
+ return out
64
+
65
+
66
+ def poll_telegram(store, c, sources: list, llm=None, file_only=False) -> int:
67
+ """getUpdates with the offset watermark kept on the connector - Telegram's own cursor, so a
68
+ restart never re-ingests.
69
+
70
+ Only chats the owner switched ON become work. A bot is PUBLIC - anyone who finds it can
71
+ message it, and 'blank takes every chat' was an open door for spam-as-tasks. An unknown
72
+ chat is registered instead: it shows up under Sources with its chat id, off, and flipping
73
+ it on admits it from the next message onward. That registration is also how you FIND a
74
+ chat id - message the bot once and read it off the card."""
75
+ from datetime import datetime
76
+ from .channels import images_for_triage, save_attachments
77
+ from .ingest import ingest_message
78
+ tok, cfg = c['Secret'], _cfg(c)
79
+ if not tok: return 0
80
+ # every telegram source ever seen, on or off - a report source in the same list (the
81
+ # seeded Morning digest) must never become a chat-id nothing can match
82
+ known = {s['Address']: s for s in store.list_sources(active_only=False)
83
+ if s.get('Channel') == 'telegram' and s.get('Address')}
84
+ want = {a for a, s in known.items() if a != '*' and s.get('Active')}
85
+ ups = tg(tok, 'getUpdates', offset=int(cfg.get('tg_offset') or 0), limit=TG_LIMIT,
86
+ allowed_updates=['message'])
87
+ n = 0
88
+ for u in ups:
89
+ m = u.get('message') or {}
90
+ chat, frm = m.get('chat') or {}, m.get('from') or {}
91
+ cid = str(chat.get('id') or '')
92
+ if not cid or frm.get('is_bot'): continue
93
+ # a reply in the NOTIFY chat may be a verdict on a pinged review ("approve") - it is
94
+ # handled before the approve-first filter, so the notify chat never needs a source
95
+ # row and the owner's verdicts never become work (see phone.py)
96
+ from . import phone
97
+ if phone.intercept(store, 'telegram', cid, m.get('text') or m.get('caption') or '',
98
+ (m.get('reply_to_message') or {}).get('text')):
99
+ continue
100
+ if cid not in want:
101
+ if cid not in known: # first sight of this chat: register it OFF, ingest nothing
102
+ title = chat.get('title') or ' '.join(x for x in (frm.get('first_name'), frm.get('last_name')) if x) \
103
+ or frm.get('username') or 'chat'
104
+ store.save_source({'Channel': 'telegram', 'Address': cid, 'ConnectorId': c['ConnectorId'],
105
+ 'Active': 0, 'Owner': f'discovered: {title}'[:80]}, 'telegram-poll')
106
+ known[cid] = {'Address': cid, 'Active': 0}
107
+ logger.info(f'telegram: chat {cid} ({title}) discovered - registered OFF under Sources')
108
+ continue
109
+ text = m.get('text') or m.get('caption') or ''
110
+ atts = _tg_photo(tok, m) if (m.get('photo') or m.get('document')) else []
111
+ if not text and not atts: continue
112
+ who = ' '.join(x for x in (frm.get('first_name'), frm.get('last_name')) if x) or frm.get('username') or 'someone'
113
+ out = ingest_message(store, file_only=file_only, msg={
114
+ 'external_id': f"telegram:{cid}:{m.get('message_id')}", 'channel': 'telegram',
115
+ 'subject': None, 'body': text or '(no text - see the attachment)',
116
+ 'from_name': who, 'from_email': f"@{frm['username']}" if frm.get('username') else None,
117
+ 'conversation_id': f'telegram:{cid}',
118
+ 'sent_at': datetime.fromtimestamp(m.get('date') or 0).strftime('%Y-%m-%d %H:%M:%S'),
119
+ 'source_name': chat.get('title') or who,
120
+ 'images': images_for_triage(store, atts)}, llm=llm)
121
+ n += out['status'] != 'duplicate'
122
+ if atts and out.get('message_id') and out['status'] != 'duplicate':
123
+ try: save_attachments(store, out['message_id'], atts, f"telegram:{cid}:{m.get('message_id')}")
124
+ except Exception as e: logger.warning(f'telegram attachments failed: {e}')
125
+ if ups:
126
+ store.set_connector_config(c['ConnectorId'], {**cfg, 'tg_offset': ups[-1]['update_id'] + 1})
127
+ return n
128
+
129
+
130
+ def tg_send(store, chat_id: str, body: str) -> dict:
131
+ c = store.get_connector_by_type('telegram', with_secret=True)
132
+ if not (c and c.get('Secret')): raise RuntimeError('the Telegram connection is not set up')
133
+ tg(c['Secret'], 'sendMessage', chat_id=int(chat_id), text=body[:4000])
134
+ return {'channel': 'telegram', 'chat': chat_id}
135
+
136
+
137
+ # ── WhatsApp (via the Baileys bridge) ────────────────────────────────────────────────────
138
+ def _wa(c, path, body=None):
139
+ url = (_cfg(c).get('bridge_url') or WA_URL).rstrip('/')
140
+ try:
141
+ r = requests.post(f'{url}{path}', json=body, timeout=20) if body is not None \
142
+ else requests.get(f'{url}{path}', timeout=20)
143
+ except requests.ConnectionError:
144
+ raise RuntimeError(f'the WhatsApp bridge is not running at {url} - start it: '
145
+ f'cd taskuary/whatsapp && npm install && node bridge.mjs')
146
+ if r.status_code >= 300: raise RuntimeError(f'bridge {path} failed ({r.status_code}): {r.text[:200]}')
147
+ return r.json()
148
+
149
+
150
+ def wa_test(store, c) -> str:
151
+ st = _wa(c, '/status')
152
+ if not st.get('connected'):
153
+ raise RuntimeError('bridge is running but WhatsApp is not paired yet - '
154
+ + (f"enter code {st['pairingCode']} on your phone (Linked devices)" if st.get('pairingCode')
155
+ else 'scan the QR the bridge printed in its own terminal'))
156
+ if not any(s['Channel'] == 'whatsapp' for s in store.list_sources(active_only=False)):
157
+ store.save_source({'Channel': 'whatsapp', 'Address': '*', 'ConnectorId': c['ConnectorId'], 'Active': 1}, 'connector-test')
158
+ return f"paired as {st.get('me') or 'your account'} - chats flow in on the next sync"
159
+
160
+
161
+ def poll_whatsapp(store, c, sources: list, llm=None, file_only=False) -> int:
162
+ """The bridge keeps a sequence number per message; ours is on the connector, so nothing is
163
+ read twice and a bridge restart just resets both to live traffic."""
164
+ from datetime import datetime
165
+ from .ingest import ingest_message
166
+ cfg = _cfg(c)
167
+ want = {s['Address'] for s in sources
168
+ if s.get('Channel', 'whatsapp') == 'whatsapp' and s['Address'] and s['Address'] != '*'}
169
+ out = _wa(c, f"/messages?after={int(cfg.get('wa_seq') or 0)}")
170
+ n, took = 0, []
171
+ from . import phone
172
+ for m in out.get('messages', []):
173
+ jid = m.get('jid') or ''
174
+ if not jid: continue
175
+ # the WhatsApp bridge is the owner's OWN account, so a verdict they type in the
176
+ # notify chat arrives as fromMe - intercept runs before that filter (phone.py also
177
+ # recognizes and swallows our own pings echoing back through the bridge)
178
+ if (m.get('text') or '').strip() and phone.intercept(store, 'whatsapp', jid, m['text']):
179
+ continue
180
+ if m.get('fromMe') or (want and jid not in want): continue
181
+ if not (m.get('text') or '').strip(): continue
182
+ r = ingest_message(store, file_only=file_only, msg={
183
+ 'external_id': f"whatsapp:{jid}:{m.get('id')}", 'channel': 'whatsapp',
184
+ 'subject': None, 'body': m['text'], 'from_name': m.get('name') or jid.split('@')[0],
185
+ 'conversation_id': f'whatsapp:{jid}',
186
+ 'sent_at': datetime.fromtimestamp(m.get('ts') or 0).strftime('%Y-%m-%d %H:%M:%S'),
187
+ 'source_name': ('group chat' if m.get('group') else m.get('name')) or 'WhatsApp'}, llm=llm)
188
+ n += r['status'] != 'duplicate'
189
+ took.append(m.get('id'))
190
+ # blue ticks on what the funnel took, when the owner asked for it - best effort, and
191
+ # never at the cost of the poll: an unpaired or restarted bridge just does not mark
192
+ from .channels import wants_read
193
+ if took and wants_read(store):
194
+ try: _wa(c, '/read', {'ids': [i for i in took if i]})
195
+ except Exception as e: logger.warning(f'marking whatsapp read failed: {e}')
196
+ if out.get('seq') is not None:
197
+ store.set_connector_config(c['ConnectorId'], {**cfg, 'wa_seq': out['seq']})
198
+ return n
199
+
200
+
201
+ def wa_send(store, jid: str, body: str) -> dict:
202
+ c = store.get_connector_by_type('whatsapp', with_secret=True)
203
+ if not c: raise RuntimeError('the WhatsApp connection is not set up')
204
+ _wa(c, '/send', {'jid': jid, 'text': body[:4000]})
205
+ return {'channel': 'whatsapp', 'chat': jid}
taskuary/mssql.py ADDED
@@ -0,0 +1,64 @@
1
+ """Microsoft SQL Server connector - local-first via pyodbc. Structured config (no DSN
2
+ strings to hand-craft): {"server", "database", "auth": "windows"|"sql", "username",
3
+ "password", "driver", "query"}. Windows auth is the default because local SQL Server is
4
+ the primary use case; `driver` auto-picks the newest installed 'ODBC Driver NN for SQL
5
+ Server'. TrustServerCertificate=yes because Driver 18 mandates encryption and local
6
+ instances rarely have real certs. A raw "conn_str" overrides everything built.
7
+ """
8
+
9
+
10
+ def drivers() -> list:
11
+ """Installed SQL Server ODBC drivers, best (newest) first."""
12
+ import pyodbc
13
+ ds = [d for d in pyodbc.drivers() if 'SQL Server' in d]
14
+ return sorted(ds, key=lambda d: ('ODBC Driver' in d, d), reverse=True)
15
+
16
+
17
+ def conn_str(cfg: dict) -> str:
18
+ if cfg.get('conn_str'): return cfg['conn_str']
19
+ drv = cfg.get('driver') or (drivers() or ['ODBC Driver 17 for SQL Server'])[0]
20
+ parts = [f'DRIVER={{{drv}}}', f"SERVER={cfg.get('server') or 'localhost'}"]
21
+ if cfg.get('database'): parts.append(f"DATABASE={cfg['database']}")
22
+ if (cfg.get('auth') or 'windows') == 'windows': parts.append('Trusted_Connection=yes')
23
+ else: parts += [f"UID={cfg.get('username', '')}", f"PWD={cfg.get('password', '')}"]
24
+ parts.append('TrustServerCertificate=yes')
25
+ return ';'.join(parts)
26
+
27
+
28
+ def _connect(cs: str):
29
+ import pyodbc
30
+ return pyodbc.connect(cs, timeout=int(10))
31
+
32
+
33
+ def run_query(cfg: dict, limit: int = None) -> list:
34
+ """Execute cfg['query'], return up to `limit` rows as dicts (cfg['max_rows'] wins)."""
35
+ from .reports import row_limit
36
+ limit = limit or row_limit(cfg)[0]
37
+ with _connect(conn_str(cfg)) as cx:
38
+ cur = cx.cursor().execute(cfg['query'])
39
+ cols = [c[0] for c in cur.description or []]
40
+ return [dict(zip(cols, r)) for r in cur.fetchmany(limit)]
41
+
42
+
43
+ def test(cfg: dict) -> dict:
44
+ """Connect and report server version + current db; never raises - errors come back as data."""
45
+ try:
46
+ with _connect(conn_str(cfg)) as cx:
47
+ ver, db = cx.cursor().execute('SELECT @@VERSION, DB_NAME()').fetchone()
48
+ return {'ok': True, 'version': str(ver).split('\n')[0][:200], 'database': db}
49
+ except Exception as e:
50
+ return {'ok': False, 'error': str(e)[:500]}
51
+
52
+
53
+ def run_report(cfg: dict):
54
+ """Report executor: (headline, summary) for the timeline. One row past the limit is
55
+ fetched so the headline can admit when the result was cut (see reports.rows_out)."""
56
+ from .reports import row_limit, rows_out
57
+ lim, mine = row_limit(cfg)
58
+ if cfg.get('dsn'): # back-compat: sqlalchemy-style dsn from old configs
59
+ import sqlalchemy
60
+ with sqlalchemy.create_engine(cfg['dsn']).connect() as cx:
61
+ rows = [dict(r._mapping) for r in cx.execute(sqlalchemy.text(cfg['query'])).fetchmany(lim + 1)]
62
+ else:
63
+ rows = run_query(cfg, lim + 1)
64
+ return rows_out(rows, lim, mine=mine)