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/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)
|