taskboy 0.1.1__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.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
taskboy/slack.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""slack intake and outbound notifications: bolt asyncapp in socket mode.
|
|
2
|
+
|
|
3
|
+
intake: app_mention -> dedup -> authz allowlist -> accept_task -> ack in the originating thread.
|
|
4
|
+
outbound: SlackNotifier implements the notifier interface over the bolt web client.
|
|
5
|
+
|
|
6
|
+
all decision logic lives in plain functions (handle_mention, authorization_failure) so tests
|
|
7
|
+
never need bolt objects or http mocking — the bolt wiring in build() is a thin shell.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import traceback
|
|
14
|
+
|
|
15
|
+
from slack_bolt.async_app import AsyncApp
|
|
16
|
+
from slack_sdk.errors import SlackApiError
|
|
17
|
+
|
|
18
|
+
from taskboy import settings, skills
|
|
19
|
+
from taskboy.config import Config, Role, SlackConfig, role_for
|
|
20
|
+
from taskboy.models import BLOCKED, EFFORT_LEVELS, QUEUED, Task
|
|
21
|
+
from taskboy.mrkdwn import to_mrkdwn
|
|
22
|
+
from taskboy.orchestrator import accept_task
|
|
23
|
+
from taskboy.personality import load as load_text_file
|
|
24
|
+
from taskboy.redact import redactor
|
|
25
|
+
from taskboy.slack_users import cached_user_profile
|
|
26
|
+
from taskboy.started_messages import pick as pick_started_message
|
|
27
|
+
from taskboy.store import Store, TransitionRaced
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("taskboy.slack")
|
|
30
|
+
|
|
31
|
+
THREAD_CONTEXT_MAX_MESSAGES = 30
|
|
32
|
+
THREAD_CONTEXT_MAX_CHARS = 6000
|
|
33
|
+
THREAD_CONTEXT_BOT_MESSAGE_MAX_CHARS = 500
|
|
34
|
+
SNIPPET_THRESHOLD = 3500
|
|
35
|
+
|
|
36
|
+
# non-transient channel-delivery failures (#83): the channel stays read-only/gone on retry, so
|
|
37
|
+
# these are worth a fallback delivery instead of a raise-and-lose-the-message loop.
|
|
38
|
+
UNDELIVERABLE_CHANNEL_ERRORS = frozenset({"restricted_action_read_only_channel", "channel_not_found", "not_in_channel", "is_archived"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def authorization_failure(slack: SlackConfig, roles: dict[str, Role], team_id: str, channel_id: str, user_id: str, channel_type: str | None = None, bot_name: str = "Agent") -> str | None:
|
|
42
|
+
"""returns a refusal reason, or None when the mention is authorized (SLK-002).
|
|
43
|
+
|
|
44
|
+
socket mode's app-level token already binds us to one workspace, so a missing team field is tolerated.
|
|
45
|
+
an empty channel allowlist means "any channel the bot was invited to" — the invite is the gate.
|
|
46
|
+
"""
|
|
47
|
+
if slack.team_id and team_id and team_id != slack.team_id:
|
|
48
|
+
return f"this workspace is not authorized to use {bot_name}"
|
|
49
|
+
if slack.allowed_channels and not channel_id.startswith("D") and channel_type != "im" and channel_id not in slack.allowed_channels:
|
|
50
|
+
return f"this channel is not on {bot_name}'s allowlist — ask an admin to add it"
|
|
51
|
+
if role_for(roles, user_id) is None:
|
|
52
|
+
return f"you do not have a configured role for {bot_name} — ask an admin to add you"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def clean_text(text: str, bot_user_id: str) -> str:
|
|
57
|
+
return re.sub(rf"<@{re.escape(bot_user_id)}>", "", text).strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_help_request(text: str) -> bool:
|
|
61
|
+
"""narrow on purpose: broader matching (e.g. "please help me") would swallow ordinary task requests."""
|
|
62
|
+
stripped = text.strip().lower()
|
|
63
|
+
return stripped == "help" or bool(re.match(r"/help(\s|$)", stripped))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def help_text(config: Config) -> str:
|
|
67
|
+
loaded = load_text_file(config.help_path)
|
|
68
|
+
return loaded[0] if loaded else ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_effort(raw: str | None) -> str | None:
|
|
72
|
+
"""tolerant mapping of admin-typed effort onto the sdk's levels; None when unrecognizable."""
|
|
73
|
+
if not raw:
|
|
74
|
+
return None
|
|
75
|
+
cleaned = raw.strip().lower().replace("-", "")
|
|
76
|
+
if not cleaned:
|
|
77
|
+
return None
|
|
78
|
+
if cleaned in EFFORT_LEVELS:
|
|
79
|
+
return cleaned
|
|
80
|
+
for level in EFFORT_LEVELS:
|
|
81
|
+
if level.startswith(cleaned):
|
|
82
|
+
return level
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def extract_overrides(text: str, model_aliases: list[str]) -> tuple[str | None, str | None, str]:
|
|
87
|
+
"""pull the legacy `model:alias` or admin `!<alias>[-<effort>]` override directive out of the request text, stripping it from the returned text; matches only known catalog aliases so ordinary exclamation marks in prose are never eaten."""
|
|
88
|
+
match = re.search(r"\bmodel:([a-z0-9-]+)\b", text)
|
|
89
|
+
if match:
|
|
90
|
+
return match.group(1), None, (text[: match.start()] + text[match.end() :]).strip()
|
|
91
|
+
if not model_aliases:
|
|
92
|
+
return None, None, text
|
|
93
|
+
aliases = sorted(model_aliases, key=len, reverse=True)
|
|
94
|
+
pattern = rf"(?<![\w!])!({'|'.join(re.escape(a) for a in aliases)})(?:-([a-zA-Z-]+))?\b"
|
|
95
|
+
match = re.search(pattern, text, flags=re.IGNORECASE)
|
|
96
|
+
if not match:
|
|
97
|
+
return None, None, text
|
|
98
|
+
model = match.group(1).lower()
|
|
99
|
+
effort = normalize_effort(match.group(2))
|
|
100
|
+
return model, effort, (text[: match.start()] + text[match.end() :]).strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def fetch_thread_transcript(client, channel_id: str, thread_ts: str, exclude_ts: str, store: Store | None = None, debug=None, bot_user_id: str | None = None, bot_name: str = "Agent") -> str | None:
|
|
104
|
+
messages: list[dict] = []
|
|
105
|
+
cursor = None
|
|
106
|
+
for _ in range(5):
|
|
107
|
+
kwargs = {"channel": channel_id, "ts": thread_ts, "limit": 200}
|
|
108
|
+
if cursor:
|
|
109
|
+
kwargs["cursor"] = cursor
|
|
110
|
+
try:
|
|
111
|
+
response = await client.conversations_replies(**kwargs)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning("conversations.replies failed for %s/%s", channel_id, thread_ts, exc_info=True)
|
|
114
|
+
if store is not None:
|
|
115
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "conversations_replies", "channel_id": channel_id, "thread_ts": thread_ts})
|
|
116
|
+
if debug is not None:
|
|
117
|
+
await debug.system_error("slack", f"conversations.replies failed: {e}")
|
|
118
|
+
if not messages:
|
|
119
|
+
return None
|
|
120
|
+
break
|
|
121
|
+
messages.extend(response.get("messages") or [])
|
|
122
|
+
next_cursor = str((response.get("response_metadata") or {}).get("next_cursor") or "")
|
|
123
|
+
if not next_cursor or next_cursor == cursor:
|
|
124
|
+
break
|
|
125
|
+
cursor = next_cursor
|
|
126
|
+
lines = []
|
|
127
|
+
for message in messages:
|
|
128
|
+
text = str(message.get("text") or "").strip()
|
|
129
|
+
if message.get("ts") == exclude_ts or not text:
|
|
130
|
+
continue
|
|
131
|
+
user = str(message.get("user") or "")
|
|
132
|
+
if user == bot_user_id:
|
|
133
|
+
lines.append(f"{bot_name}: {text[:THREAD_CONTEXT_BOT_MESSAGE_MAX_CHARS]}")
|
|
134
|
+
else:
|
|
135
|
+
lines.append(f"<@{user}>: {text}")
|
|
136
|
+
transcript = "\n".join(lines[-THREAD_CONTEXT_MAX_MESSAGES:])
|
|
137
|
+
if len(transcript) > THREAD_CONTEXT_MAX_CHARS:
|
|
138
|
+
marker = "(earlier messages omitted)\n"
|
|
139
|
+
transcript = marker + transcript[-(THREAD_CONTEXT_MAX_CHARS - len(marker)) :]
|
|
140
|
+
return transcript or None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def fetch_dm_transcript(client, channel_id: str, exclude_ts: str, bot_user_id: str, store: Store | None = None, bot_name: str = "Agent", limit: int = 20) -> str | None:
|
|
144
|
+
"""Fetch recent DM context in chronological order; failures never block a reply."""
|
|
145
|
+
try:
|
|
146
|
+
response = await client.conversations_history(channel=channel_id, limit=limit)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning("conversations.history failed for %s", channel_id, exc_info=True)
|
|
149
|
+
if store is not None:
|
|
150
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "conversations_history", "channel_id": channel_id})
|
|
151
|
+
return None
|
|
152
|
+
lines = []
|
|
153
|
+
for message in reversed(response.get("messages") or []):
|
|
154
|
+
text = str(message.get("text") or "").strip()
|
|
155
|
+
if message.get("ts") == exclude_ts or not text:
|
|
156
|
+
continue
|
|
157
|
+
user = str(message.get("user") or "")
|
|
158
|
+
speaker = bot_name if user == bot_user_id or message.get("bot_id") else f"<@{user}>"
|
|
159
|
+
lines.append(f"{speaker}: {text}")
|
|
160
|
+
transcript = "\n".join(lines[-limit:])
|
|
161
|
+
if len(transcript) > THREAD_CONTEXT_MAX_CHARS:
|
|
162
|
+
marker = "(earlier messages omitted)\n"
|
|
163
|
+
transcript = marker + transcript[-(THREAD_CONTEXT_MAX_CHARS - len(marker)) :]
|
|
164
|
+
return transcript or None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def fetch_user_profile(store: Store, client, user_id: str, debug=None) -> dict | None:
|
|
168
|
+
try:
|
|
169
|
+
return await cached_user_profile(store, client, user_id)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.warning("users.info failed for %s", user_id, exc_info=True)
|
|
172
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "users_info", "user_id": user_id})
|
|
173
|
+
if debug is not None:
|
|
174
|
+
await debug.system_error("slack", f"users.info failed for {user_id}: {e}")
|
|
175
|
+
return store.get_slack_user(user_id)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def handle_thread_answer(store: Store, notifier, task: Task, answer_text: str, user_id: str) -> str:
|
|
179
|
+
"""route a thread reply as the answers to a blocked task's pending questions, and requeue it for resume."""
|
|
180
|
+
recorded = store.answer_questions(task.task_id, answer_text, user_id)
|
|
181
|
+
if recorded is None:
|
|
182
|
+
return "no_pending_questions"
|
|
183
|
+
store.add_event(task.task_id, "questions_answered", {"user": user_id})
|
|
184
|
+
try:
|
|
185
|
+
store.transition(task.task_id, BLOCKED, QUEUED, "resumed: requester answered questions", resume_session_id=task.session_id)
|
|
186
|
+
except TransitionRaced:
|
|
187
|
+
return "not_blocked" # cancelled or already resumed; the answer stays recorded for the next run's prompt
|
|
188
|
+
await notifier.answer(task.slack_channel_id, task.slack_thread_ts, "Got it — resuming with your answers.")
|
|
189
|
+
return "answered"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def handle_thread_reply(store: Store, notifier, event: dict, event_id: str | None, bot_user_id: str) -> str:
|
|
193
|
+
"""a plain (no-mention) channel reply only matters when the thread's latest task is waiting on answers; everything else is ignored."""
|
|
194
|
+
if event.get("subtype") or event.get("bot_id") or event.get("user") == bot_user_id:
|
|
195
|
+
return "ignored"
|
|
196
|
+
text = str(event.get("text") or "").strip()
|
|
197
|
+
thread_ts = str(event.get("thread_ts") or "")
|
|
198
|
+
if not thread_ts or not text or f"<@{bot_user_id}>" in text:
|
|
199
|
+
return "ignored" # mentions are the app_mention handler's job
|
|
200
|
+
task = store.latest_task_in_thread(str(event.get("channel") or ""), thread_ts)
|
|
201
|
+
if task is None or task.state != BLOCKED or task.slack_user_id != event.get("user") or store.pending_questions_for(task.task_id) is None:
|
|
202
|
+
return "ignored"
|
|
203
|
+
if event_id and store.slack_event_seen(event_id):
|
|
204
|
+
return "duplicate_event"
|
|
205
|
+
return await handle_thread_answer(store, notifier, task, text, str(event.get("user") or ""))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def handle_mention(store: Store, config: Config, notifier, event: dict, event_id: str | None, bot_user_id: str, quick=None, client=None) -> tuple[Task | None, str]:
|
|
209
|
+
"""the full intake decision for one app_mention event. returns (task, status) for tests and logging."""
|
|
210
|
+
if event_id and store.slack_event_seen(event_id):
|
|
211
|
+
return None, "duplicate_event" # slack redelivery — never a second task (SLK-008)
|
|
212
|
+
team_id = event.get("team", "")
|
|
213
|
+
channel_id = event.get("channel", "")
|
|
214
|
+
user_id = event.get("user", "")
|
|
215
|
+
message_ts = event.get("ts", "")
|
|
216
|
+
thread_ts = event.get("thread_ts") or message_ts
|
|
217
|
+
text = clean_text(event.get("text", ""), bot_user_id)
|
|
218
|
+
debug = getattr(notifier, "debug", None)
|
|
219
|
+
profile = await fetch_user_profile(store, client, user_id, debug) if client is not None and user_id else store.get_slack_user(user_id)
|
|
220
|
+
debug_ref = await debug.open_thread(user_id=user_id, channel_id=channel_id, request_text=text, user_profile=profile) if debug is not None else None
|
|
221
|
+
debug_thread_ts, debug_permalink = debug_ref or (None, None)
|
|
222
|
+
|
|
223
|
+
refusal = authorization_failure(config.slack, config.roles, team_id, channel_id, user_id, str(event.get("channel_type") or ""), bot_name=config.agent_name)
|
|
224
|
+
if refusal:
|
|
225
|
+
store.record_intake_denial(team_id, channel_id, user_id, refusal)
|
|
226
|
+
if debug is not None:
|
|
227
|
+
await debug.intake_refusal(debug_thread_ts, refusal)
|
|
228
|
+
await notifier.refuse_intake(channel_id, thread_ts, refusal)
|
|
229
|
+
return None, "unauthorized"
|
|
230
|
+
|
|
231
|
+
model_aliases = list((config.raw.get("models") or {}).keys())
|
|
232
|
+
override, effort, text = extract_overrides(text, model_aliases)
|
|
233
|
+
role = role_for(config.roles, user_id)
|
|
234
|
+
if not (role and role.model_override):
|
|
235
|
+
override = None # directive stripped but not honored for non-admins
|
|
236
|
+
effort = None
|
|
237
|
+
if not text:
|
|
238
|
+
reason = f"tell me what you need, e.g. `@{config.agent_name} investigate PROJ-123`"
|
|
239
|
+
if debug is not None:
|
|
240
|
+
await debug.intake_refusal(debug_thread_ts, reason)
|
|
241
|
+
await notifier.refuse_intake(channel_id, thread_ts, reason)
|
|
242
|
+
return None, "empty"
|
|
243
|
+
|
|
244
|
+
if is_help_request(text) and (guide := help_text(config)):
|
|
245
|
+
await notifier.answer(channel_id, thread_ts, guide)
|
|
246
|
+
if debug is not None:
|
|
247
|
+
await debug.post(debug_thread_ts, "Answered `/help` — no task created.")
|
|
248
|
+
return None, "help"
|
|
249
|
+
invocation = skills.parse_invocation(text)
|
|
250
|
+
if text.startswith("/"):
|
|
251
|
+
names = skills.available(settings.SKILLS_ROOT)
|
|
252
|
+
name = invocation[0] if invocation else ""
|
|
253
|
+
if invocation is None or name not in names:
|
|
254
|
+
available = ", ".join(f"/{item}" for item in names)
|
|
255
|
+
reason = f"unknown skill /{name} — available: {available}"
|
|
256
|
+
if debug is not None:
|
|
257
|
+
await debug.intake_refusal(debug_thread_ts, reason)
|
|
258
|
+
await notifier.refuse_intake(channel_id, thread_ts, reason)
|
|
259
|
+
return None, "unknown_skill"
|
|
260
|
+
|
|
261
|
+
# a mention inside an existing thread is a follow-up: link lineage
|
|
262
|
+
parent = store.latest_task_in_thread(channel_id, thread_ts) if thread_ts != message_ts else None
|
|
263
|
+
# a requester reply while their task is waiting on follow-up questions is the answer, not a new task
|
|
264
|
+
if parent is not None and parent.state == BLOCKED and parent.slack_user_id == user_id and store.pending_questions_for(parent.task_id) is not None:
|
|
265
|
+
answer_status = await handle_thread_answer(store, notifier, parent, text, user_id)
|
|
266
|
+
if answer_status == "answered":
|
|
267
|
+
if debug is not None:
|
|
268
|
+
await debug.post(parent.debug_thread_ts, f"Requester answered follow-up questions; task `{parent.task_id}` resumes.", parent.task_id)
|
|
269
|
+
return parent, "answered"
|
|
270
|
+
if answer_status == "not_blocked":
|
|
271
|
+
# the answer was recorded for the next run, but the task raced out of blocked (cancelled or already resumed)
|
|
272
|
+
await notifier.answer(channel_id, thread_ts, "I recorded your answers, but that task is no longer waiting on them.")
|
|
273
|
+
return parent, "answer_raced"
|
|
274
|
+
# no_pending_questions: the round was answered out from under us — treat as a normal follow-up
|
|
275
|
+
thread_context = await fetch_thread_transcript(client, channel_id, thread_ts, message_ts, store, debug, bot_user_id, config.agent_name) if thread_ts != message_ts and client is not None else None
|
|
276
|
+
quick_attempted = quick is not None and override is None and effort is None and invocation is None
|
|
277
|
+
pre_classification = None
|
|
278
|
+
triage_usage = None
|
|
279
|
+
if quick_attempted:
|
|
280
|
+
answered_task, pre_classification = await quick.try_answer(
|
|
281
|
+
channel_id=channel_id,
|
|
282
|
+
thread_ts=thread_ts,
|
|
283
|
+
user_id=user_id,
|
|
284
|
+
text=text,
|
|
285
|
+
parent=parent,
|
|
286
|
+
team_id=team_id,
|
|
287
|
+
message_ts=message_ts,
|
|
288
|
+
thread_context=thread_context,
|
|
289
|
+
debug_thread_ts=debug_thread_ts,
|
|
290
|
+
debug_permalink=debug_permalink,
|
|
291
|
+
)
|
|
292
|
+
if answered_task is not None:
|
|
293
|
+
if debug is not None:
|
|
294
|
+
await debug.quick_answer(answered_task)
|
|
295
|
+
answer = answered_task.reply or answered_task.result_summary or ""
|
|
296
|
+
if debug_permalink:
|
|
297
|
+
answer += f"\n\n[Additional Details]({debug_permalink})"
|
|
298
|
+
await notifier.answer(channel_id, thread_ts, answer)
|
|
299
|
+
return None, "quick_answer"
|
|
300
|
+
if pre_classification is not None:
|
|
301
|
+
pre_classification = dict(pre_classification)
|
|
302
|
+
triage_usage = pre_classification.pop("_triage_usage", None)
|
|
303
|
+
task, status = await accept_task(
|
|
304
|
+
store,
|
|
305
|
+
config,
|
|
306
|
+
notifier,
|
|
307
|
+
team_id=team_id,
|
|
308
|
+
channel_id=channel_id,
|
|
309
|
+
thread_ts=thread_ts,
|
|
310
|
+
message_ts=message_ts,
|
|
311
|
+
user_id=user_id,
|
|
312
|
+
text=text,
|
|
313
|
+
parent_task_id=parent.task_id if parent else None,
|
|
314
|
+
model_override=override,
|
|
315
|
+
effort_override=effort,
|
|
316
|
+
thread_context=thread_context,
|
|
317
|
+
pre_classification=pre_classification,
|
|
318
|
+
debug_thread_ts=debug_thread_ts,
|
|
319
|
+
debug_permalink=debug_permalink,
|
|
320
|
+
)
|
|
321
|
+
if triage_usage and task is not None and status != "duplicate":
|
|
322
|
+
store.add_usage(task.task_id, "triage", quick.model_id, **triage_usage)
|
|
323
|
+
if quick_attempted and task is not None and status != "duplicate":
|
|
324
|
+
store.add_event(task.task_id, "quick_escalated", {"escalated": True})
|
|
325
|
+
if status in ("paused", "queue_full"):
|
|
326
|
+
reason = "intake is paused right now — try again soon" if status == "paused" else "the task queue is full — try again later"
|
|
327
|
+
if debug is not None:
|
|
328
|
+
await debug.intake_refusal(debug_thread_ts, reason)
|
|
329
|
+
await notifier.refuse_intake(channel_id, thread_ts, reason)
|
|
330
|
+
return task, status
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def handle_dm(store: Store, config: Config, notifier, event: dict, event_id: str | None, bot_user_id: str, quick, client) -> str:
|
|
334
|
+
"""Handle one direct message as chat unless the user explicitly mentions the bot."""
|
|
335
|
+
if event.get("subtype") or event.get("bot_id") or event.get("user") == bot_user_id:
|
|
336
|
+
return "ignored"
|
|
337
|
+
text = str(event.get("text") or "").strip()
|
|
338
|
+
if f"<@{bot_user_id}>" in text:
|
|
339
|
+
_, status = await handle_mention(store, config, notifier, event, event_id, bot_user_id, quick=quick, client=client)
|
|
340
|
+
return status
|
|
341
|
+
if event_id and store.slack_event_seen(event_id):
|
|
342
|
+
return "duplicate_event"
|
|
343
|
+
team_id = str(event.get("team") or "")
|
|
344
|
+
channel_id = str(event.get("channel") or "")
|
|
345
|
+
user_id = str(event.get("user") or "")
|
|
346
|
+
message_ts = str(event.get("ts") or "")
|
|
347
|
+
refusal = authorization_failure(config.slack, config.roles, team_id, channel_id, user_id, str(event.get("channel_type") or ""), bot_name=config.agent_name)
|
|
348
|
+
if refusal:
|
|
349
|
+
store.record_intake_denial(team_id, channel_id, user_id, refusal)
|
|
350
|
+
await notifier.answer(channel_id, None, refusal)
|
|
351
|
+
return "unauthorized"
|
|
352
|
+
if not text:
|
|
353
|
+
return "ignored"
|
|
354
|
+
if is_help_request(text) and (guide := help_text(config)):
|
|
355
|
+
await notifier.answer(channel_id, None, guide)
|
|
356
|
+
return "help"
|
|
357
|
+
if quick is None:
|
|
358
|
+
await notifier.answer(channel_id, None, f"This needs a full task. Mention `@{config.agent_name}` to start one.")
|
|
359
|
+
return "escalate"
|
|
360
|
+
history = await fetch_dm_transcript(client, channel_id, message_ts, bot_user_id, store, config.agent_name)
|
|
361
|
+
task, status = await quick.chat(channel_id=channel_id, user_id=user_id, text=text, team_id=team_id, message_ts=message_ts, history=history)
|
|
362
|
+
if status == "rate_limited":
|
|
363
|
+
await notifier.answer(channel_id, None, f"I’ve hit the quick-chat limit for you this hour. Try again later, or mention `@{config.agent_name}` to start a full task.")
|
|
364
|
+
elif status == "escalate":
|
|
365
|
+
await notifier.answer(channel_id, None, f"This needs a full task. Mention `@{config.agent_name}` to start one.")
|
|
366
|
+
else:
|
|
367
|
+
await notifier.answer(channel_id, None, task.reply or task.result_summary or "")
|
|
368
|
+
return status
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class SlackNotifier:
|
|
372
|
+
"""posts lifecycle updates to the originating thread (SLK-005/006/007). all outbound text is redacted."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, client, progress_min_interval_seconds: int = 60, ack_reaction: bool = True, debug=None, task_started_messages_path: str | None = None, store: Store | None = None, reviewer_name: str = "Reviewer", dashboard_url: str = ""):
|
|
375
|
+
self.client = client
|
|
376
|
+
self.progress_min_interval_seconds = progress_min_interval_seconds
|
|
377
|
+
self.ack_reaction = ack_reaction
|
|
378
|
+
self._last_progress: dict[str, float] = {}
|
|
379
|
+
self.debug = debug
|
|
380
|
+
self.task_started_messages_path = task_started_messages_path
|
|
381
|
+
self.store = store
|
|
382
|
+
self.reviewer_name = reviewer_name
|
|
383
|
+
self.dashboard_url = dashboard_url.rstrip("/")
|
|
384
|
+
|
|
385
|
+
def _record_error(self, operation: str, error: Exception, task_id: str | None = None) -> None:
|
|
386
|
+
if self.store is not None:
|
|
387
|
+
self.store.add_error("slack", type(error).__name__, str(error), task_id=task_id, traceback=traceback.format_exc(), context={"operation": operation})
|
|
388
|
+
|
|
389
|
+
async def _deliver_fallback(self, user_id: str, text: str, task_id: str | None = None) -> None:
|
|
390
|
+
"""dm the requester when the channel is read-only/gone (#83); debug feed if that fails."""
|
|
391
|
+
if user_id:
|
|
392
|
+
try:
|
|
393
|
+
opened = await self.client.conversations_open(users=user_id)
|
|
394
|
+
channel_id = str((opened.get("channel") or {}).get("id") or "")
|
|
395
|
+
if not channel_id:
|
|
396
|
+
raise RuntimeError("slack did not return a dm channel")
|
|
397
|
+
await self.client.chat_postMessage(channel=channel_id, text=f"_(the original channel is read-only or unreachable, so I'm DMing you instead)_\n{text}")
|
|
398
|
+
return
|
|
399
|
+
except Exception as e:
|
|
400
|
+
self._record_error("dm_fallback", e, task_id)
|
|
401
|
+
if self.debug is not None:
|
|
402
|
+
await self.debug.system_error("slack", f"channel is read-only/unreachable and no DM fallback landed; message: {text}")
|
|
403
|
+
|
|
404
|
+
async def _post(self, task: Task, text: str, **kwargs) -> None:
|
|
405
|
+
redacted = redactor.redact(to_mrkdwn(text))
|
|
406
|
+
post = {"channel": task.slack_channel_id, "text": redacted, **kwargs}
|
|
407
|
+
if task.slack_team_id == "github":
|
|
408
|
+
if not task.slack_channel_id:
|
|
409
|
+
return
|
|
410
|
+
else:
|
|
411
|
+
post["thread_ts"] = task.slack_thread_ts
|
|
412
|
+
try:
|
|
413
|
+
await self.client.chat_postMessage(**post)
|
|
414
|
+
except Exception as e:
|
|
415
|
+
self._record_error("chat_postMessage", e, task.task_id)
|
|
416
|
+
if isinstance(e, SlackApiError) and e.response.get("error") in UNDELIVERABLE_CHANNEL_ERRORS:
|
|
417
|
+
# system-origin tasks (slack_team_id = "github", slack_user_id = "cli") have no real Slack user to DM — go straight to debug
|
|
418
|
+
fallback_user_id = task.slack_user_id if task.slack_team_id != "github" else ""
|
|
419
|
+
await self._deliver_fallback(fallback_user_id, redacted, task.task_id)
|
|
420
|
+
return
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
async def progress(self, task: Task, message: str) -> None:
|
|
424
|
+
# agent-declared milestones, rate-limited per task (SLK-006); dropped posts stay in the audit trail
|
|
425
|
+
if self.debug is not None:
|
|
426
|
+
await self.debug.progress(task, message)
|
|
427
|
+
if task.schedule_name:
|
|
428
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
429
|
+
now = time.monotonic()
|
|
430
|
+
if now - self._last_progress.get(task.task_id, 0.0) < self.progress_min_interval_seconds:
|
|
431
|
+
return
|
|
432
|
+
self._last_progress[task.task_id] = now
|
|
433
|
+
await self._post(task, message)
|
|
434
|
+
|
|
435
|
+
async def ensure_debug(self, task: Task) -> None:
|
|
436
|
+
if self.debug is not None and not task.debug_thread_ts:
|
|
437
|
+
opened = await self.debug.open_thread(user_id=task.slack_user_id, channel_id=task.slack_channel_id, request_text=task.request_text, user_profile=self.store.get_slack_user(task.slack_user_id) if self.store else None)
|
|
438
|
+
if opened:
|
|
439
|
+
task.debug_thread_ts, task.debug_permalink = opened
|
|
440
|
+
if self.store is not None:
|
|
441
|
+
self.store.set_fields(task.task_id, debug_thread_ts=task.debug_thread_ts, debug_permalink=task.debug_permalink)
|
|
442
|
+
|
|
443
|
+
async def ack(self, task: Task) -> None:
|
|
444
|
+
await self.ensure_debug(task)
|
|
445
|
+
if task.schedule_name:
|
|
446
|
+
return # scheduled tasks announce themselves in started(); no "On it." quip
|
|
447
|
+
if self.ack_reaction and task.slack_team_id != "github":
|
|
448
|
+
try:
|
|
449
|
+
await self.client.reactions_add(channel=task.slack_channel_id, timestamp=task.slack_message_ts, name="eyes")
|
|
450
|
+
return
|
|
451
|
+
except Exception as e:
|
|
452
|
+
self._record_error("reactions_add", e, task.task_id)
|
|
453
|
+
logger.warning("reactions.add failed for %s — falling back to text ack", task.task_id)
|
|
454
|
+
await self._post(task, "On it.")
|
|
455
|
+
|
|
456
|
+
async def started(self, task: Task) -> None:
|
|
457
|
+
if task.schedule_name:
|
|
458
|
+
details = f"Details: Model: {task.model_alias or 'n/a'} Task ID: {task.task_id}"
|
|
459
|
+
line = f"[{details}]({task.debug_permalink})" if task.debug_permalink else details
|
|
460
|
+
text = f"Scheduled Task Started {task.schedule_name}\n{line}"
|
|
461
|
+
if self.debug is not None:
|
|
462
|
+
await self.debug.classified(task)
|
|
463
|
+
await self.debug.started(task)
|
|
464
|
+
await self._post(task, text, unfurl_links=False, unfurl_media=False)
|
|
465
|
+
return
|
|
466
|
+
label = f"Details - Model: {task.model_alias} - Task ID: {task.task_id}" if task.model_alias else f"Details - Task ID: {task.task_id}"
|
|
467
|
+
task_line = f"[{label}]({task.debug_permalink})" if task.debug_permalink else ""
|
|
468
|
+
if task.persona == "reviewer":
|
|
469
|
+
selected = pick_started_message(self.task_started_messages_path, "reviewer")
|
|
470
|
+
if selected:
|
|
471
|
+
text = selected.replace("{reviewer_name}", self.reviewer_name) + (f"\n{task_line}" if task_line else "")
|
|
472
|
+
else:
|
|
473
|
+
text = f"{self.reviewer_name} started a PR review" + (f"\n{task_line}" if task_line else "")
|
|
474
|
+
else:
|
|
475
|
+
selected = pick_started_message(self.task_started_messages_path, "agent")
|
|
476
|
+
if selected:
|
|
477
|
+
text = selected + (f"\n{task_line}" if task_line else "")
|
|
478
|
+
else:
|
|
479
|
+
model = f" on `{task.model_alias}`" if task.model_alias else ""
|
|
480
|
+
text = f"Task started{model}." + (f"\n{task_line}" if task_line else "")
|
|
481
|
+
if self.debug is not None:
|
|
482
|
+
await self.debug.classified(task)
|
|
483
|
+
await self.debug.started(task)
|
|
484
|
+
await self._post(task, text, unfurl_links=False, unfurl_media=False)
|
|
485
|
+
|
|
486
|
+
async def completed(self, task: Task) -> None:
|
|
487
|
+
self._last_progress.pop(task.task_id, None)
|
|
488
|
+
text = task.reply or task.result_summary or ""
|
|
489
|
+
links: list[str] = []
|
|
490
|
+
seen: set[str] = set()
|
|
491
|
+
if self.store is not None:
|
|
492
|
+
for artifact in self.store.artifacts_for(task.task_id):
|
|
493
|
+
url = str(artifact.get("url") or "")
|
|
494
|
+
kind = artifact.get("kind")
|
|
495
|
+
if kind not in ("pull_request", "jira_issue") or not url or url in text or url in seen:
|
|
496
|
+
continue
|
|
497
|
+
seen.add(url)
|
|
498
|
+
links.append(f"{'PR' if kind == 'pull_request' else 'Jira'}: {url}")
|
|
499
|
+
links_text = "\n".join(links)
|
|
500
|
+
if links_text:
|
|
501
|
+
text = f"{text}\n\n{links_text}" if text else links_text
|
|
502
|
+
if task.persona == "reviewer":
|
|
503
|
+
text = f"*{self.reviewer_name} finished a PR review*\n{text}"
|
|
504
|
+
elif not text:
|
|
505
|
+
text = "*Done*"
|
|
506
|
+
if self.debug is not None:
|
|
507
|
+
await self.debug.completed(task)
|
|
508
|
+
if task.schedule_name:
|
|
509
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
510
|
+
if len(text) > SNIPPET_THRESHOLD and task.slack_team_id != "github":
|
|
511
|
+
try:
|
|
512
|
+
initial_comment = "*Done* — full response attached."
|
|
513
|
+
if links_text:
|
|
514
|
+
initial_comment += f"\n\n{links_text}"
|
|
515
|
+
await self.client.files_upload_v2(
|
|
516
|
+
channel=task.slack_channel_id,
|
|
517
|
+
thread_ts=task.slack_thread_ts,
|
|
518
|
+
filename=f"{task.task_id}-report.md",
|
|
519
|
+
title=f"Task {task.task_id} report",
|
|
520
|
+
content=redactor.redact(task.reply or task.result_summary or ""),
|
|
521
|
+
initial_comment=redactor.redact(to_mrkdwn(initial_comment)),
|
|
522
|
+
)
|
|
523
|
+
return
|
|
524
|
+
except Exception as e:
|
|
525
|
+
self._record_error("files_upload_v2", e, task.task_id)
|
|
526
|
+
logger.warning("report upload failed for %s — falling back to inline post", task.task_id, exc_info=True)
|
|
527
|
+
text = f"*Done*\n{text}"
|
|
528
|
+
if task.slack_team_id == "github" and not task.slack_channel_id:
|
|
529
|
+
return
|
|
530
|
+
await self._post(task, text, unfurl_links=False, unfurl_media=False)
|
|
531
|
+
|
|
532
|
+
async def failed(self, task: Task, error: str) -> None:
|
|
533
|
+
self._last_progress.pop(task.task_id, None)
|
|
534
|
+
if self.debug is not None:
|
|
535
|
+
await self.debug.failed(task, error)
|
|
536
|
+
if task.schedule_name:
|
|
537
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
538
|
+
await self._post(task, f"*Failed*\n{error}")
|
|
539
|
+
|
|
540
|
+
async def blocked(self, task: Task) -> None:
|
|
541
|
+
self._last_progress.pop(task.task_id, None)
|
|
542
|
+
if self.debug is not None:
|
|
543
|
+
await self.debug.blocked(task)
|
|
544
|
+
if task.schedule_name:
|
|
545
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
546
|
+
await self._post(task, f"*Blocked*\n{task.blocked_reason or ''}\nReply in this thread to continue.")
|
|
547
|
+
|
|
548
|
+
async def issue_blocked(self, task: Task, issue: dict) -> None:
|
|
549
|
+
"""this task is cancelled and its issue is either reopened as `proposed` or, if it had already opened a PR,
|
|
550
|
+
left tracking that PR instead (#87); points at the issue instead of the now-inert thread reply."""
|
|
551
|
+
self._last_progress.pop(task.task_id, None)
|
|
552
|
+
if self.debug is not None:
|
|
553
|
+
await self.debug.blocked(task)
|
|
554
|
+
if task.schedule_name:
|
|
555
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
556
|
+
reason = task.blocked_reason or "the task could not continue"
|
|
557
|
+
if issue.get("status") == "in_review":
|
|
558
|
+
text = f"*Blocked* — it had already opened {issue['pr_url']}, so I'm tracking that PR on issue #{issue['id']} instead of reopening it.\n{reason}"
|
|
559
|
+
else:
|
|
560
|
+
text = f"*Blocked* — reopened issue #{issue['id']} as `proposed` so you can pick up where this left off.\n{reason}"
|
|
561
|
+
if self.dashboard_url:
|
|
562
|
+
text += f"\n{self.dashboard_url}/issues?issue={issue['id']}"
|
|
563
|
+
await self._post(task, text)
|
|
564
|
+
|
|
565
|
+
async def questions(self, task: Task, questions: str) -> None:
|
|
566
|
+
self._last_progress.pop(task.task_id, None)
|
|
567
|
+
if self.debug is not None:
|
|
568
|
+
await self.debug.questions(task, questions)
|
|
569
|
+
if task.schedule_name:
|
|
570
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
571
|
+
await self._post(task, f"*I need a few answers before I continue*\n{questions}\n\n<@{task.slack_user_id}> reply in this thread with your answers by number and I'll pick the task back up.")
|
|
572
|
+
|
|
573
|
+
async def recovered(self, task: Task) -> None:
|
|
574
|
+
if self.debug is not None:
|
|
575
|
+
await self.debug.recovered(task)
|
|
576
|
+
if task.schedule_name:
|
|
577
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
578
|
+
await self._post(task, "The orchestrator restarted; this task was requeued and will resume.")
|
|
579
|
+
|
|
580
|
+
async def refused(self, task: Task, reason: str) -> None:
|
|
581
|
+
if self.debug is not None:
|
|
582
|
+
if task.classification_json:
|
|
583
|
+
await self.debug.classified(task)
|
|
584
|
+
await self.debug.refused(task, reason)
|
|
585
|
+
if task.schedule_name:
|
|
586
|
+
return # debug feed already threads this into the debug channel; skip the duplicate top-level post
|
|
587
|
+
await self._post(task, f"Can't take this task: {reason}")
|
|
588
|
+
|
|
589
|
+
async def refuse_intake(self, channel_id: str, thread_ts: str, reason: str) -> None:
|
|
590
|
+
# refusals for mentions that never became a task (SLK-010)
|
|
591
|
+
try:
|
|
592
|
+
await self.client.chat_postMessage(channel=channel_id, thread_ts=thread_ts, text=redactor.redact(to_mrkdwn(reason)))
|
|
593
|
+
except Exception as e:
|
|
594
|
+
self._record_error("refuse_intake", e)
|
|
595
|
+
raise
|
|
596
|
+
|
|
597
|
+
async def answer(self, channel_id: str, thread_ts: str | None, text: str) -> None:
|
|
598
|
+
post = {"channel": channel_id, "text": redactor.redact(to_mrkdwn(text)), "unfurl_links": False, "unfurl_media": False}
|
|
599
|
+
if thread_ts is not None:
|
|
600
|
+
post["thread_ts"] = thread_ts
|
|
601
|
+
try:
|
|
602
|
+
await self.client.chat_postMessage(**post)
|
|
603
|
+
except Exception as e:
|
|
604
|
+
self._record_error("answer", e)
|
|
605
|
+
raise
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
async def build(store: Store, config: Config, bot_token: str) -> tuple[AsyncApp, SlackNotifier]:
|
|
609
|
+
"""wire the bolt app: resolve our bot user id, register the mention handler."""
|
|
610
|
+
app = AsyncApp(token=bot_token)
|
|
611
|
+
from taskboy.debug_feed import DebugFeed
|
|
612
|
+
|
|
613
|
+
debug = DebugFeed(app.client, store, config.slack.debug_channel, dashboard_url=config.dashboard.public_url) if config.slack.debug_channel else None
|
|
614
|
+
notifier = SlackNotifier(app.client, config.progress_min_interval_seconds, config.slack.ack_reaction, debug=debug, task_started_messages_path=config.slack.task_started_messages_path, store=store, reviewer_name=config.reviewer.name, dashboard_url=config.dashboard.public_url)
|
|
615
|
+
quick = None
|
|
616
|
+
if (config.raw.get("quick_answer") or {}).get("enabled"):
|
|
617
|
+
from taskboy.quick import QuickAnswer
|
|
618
|
+
|
|
619
|
+
quick = QuickAnswer(store, config, debug=debug)
|
|
620
|
+
try:
|
|
621
|
+
auth = await app.client.auth_test()
|
|
622
|
+
except Exception as e:
|
|
623
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "auth_test"})
|
|
624
|
+
if debug is not None:
|
|
625
|
+
await debug.system_error("slack", f"auth.test failed: {e}")
|
|
626
|
+
raise
|
|
627
|
+
bot_user_id = auth["user_id"]
|
|
628
|
+
|
|
629
|
+
@app.event("app_mention")
|
|
630
|
+
async def on_mention(body, event):
|
|
631
|
+
if str(event.get("channel") or "").startswith("D"):
|
|
632
|
+
return
|
|
633
|
+
try:
|
|
634
|
+
task, status = await handle_mention(store, config, notifier, event, body.get("event_id"), bot_user_id, quick=quick, client=app.client)
|
|
635
|
+
logger.info("mention handled: status=%s task=%s", status, task.task_id if task else None)
|
|
636
|
+
except Exception as e:
|
|
637
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "handle_mention"})
|
|
638
|
+
if debug is not None:
|
|
639
|
+
await debug.system_error("slack", f"mention handling failed: {e}")
|
|
640
|
+
logger.exception("mention handling failed")
|
|
641
|
+
|
|
642
|
+
@app.event("message")
|
|
643
|
+
async def on_message(body, event):
|
|
644
|
+
if event.get("channel_type") != "im":
|
|
645
|
+
# channel traffic only matters when it answers a blocked task's questions (needs the message.channels event subscription)
|
|
646
|
+
try:
|
|
647
|
+
status = await handle_thread_reply(store, notifier, event, body.get("event_id"), bot_user_id)
|
|
648
|
+
if status != "ignored":
|
|
649
|
+
logger.info("thread reply handled: status=%s", status)
|
|
650
|
+
except Exception as e:
|
|
651
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "handle_thread_reply"})
|
|
652
|
+
if debug is not None:
|
|
653
|
+
await debug.system_error("slack", f"thread reply handling failed: {e}")
|
|
654
|
+
logger.exception("thread reply handling failed")
|
|
655
|
+
return
|
|
656
|
+
try:
|
|
657
|
+
dm_event = {**event, "team": event.get("team") or body.get("team_id") or (body.get("team") or {}).get("id") or ""}
|
|
658
|
+
status = await handle_dm(store, config, notifier, dm_event, body.get("event_id"), bot_user_id, quick, app.client)
|
|
659
|
+
logger.info("dm handled: status=%s", status)
|
|
660
|
+
except Exception as e:
|
|
661
|
+
store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "handle_dm"})
|
|
662
|
+
if debug is not None:
|
|
663
|
+
await debug.system_error("slack", f"dm handling failed: {e}")
|
|
664
|
+
logger.exception("dm handling failed")
|
|
665
|
+
|
|
666
|
+
return app, notifier
|