juno-code 1.0.36 → 1.0.38
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.
- package/README.md +170 -0
- package/dist/bin/cli.js +143 -5
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +143 -5
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +18 -1
- package/dist/index.mjs.map +1 -1
- package/dist/templates/scripts/install_requirements.sh +3 -0
- package/dist/templates/scripts/run_until_completion.sh +227 -11
- package/dist/templates/scripts/slack_fetch.py +717 -0
- package/dist/templates/scripts/slack_fetch.sh +269 -0
- package/dist/templates/scripts/slack_respond.py +693 -0
- package/dist/templates/scripts/slack_respond.sh +263 -0
- package/dist/templates/scripts/slack_state.py +383 -0
- package/package.json +8 -2
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Slack Respond - Send kanban agent responses back to Slack.
|
|
4
|
+
|
|
5
|
+
This script reads completed kanban tasks and sends their agent responses
|
|
6
|
+
back to Slack as threaded replies to the original messages.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Matches kanban tasks with original Slack messages
|
|
10
|
+
- Sends responses as threaded replies
|
|
11
|
+
- Tracks sent responses to avoid duplicates
|
|
12
|
+
- Supports filtering by tag (e.g., slack-input)
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python slack_respond.py # Process all tasks with responses
|
|
16
|
+
python slack_respond.py --tag slack-input # Only slack-input tagged tasks
|
|
17
|
+
python slack_respond.py --dry-run --verbose # Test mode
|
|
18
|
+
|
|
19
|
+
Environment Variables:
|
|
20
|
+
SLACK_BOT_TOKEN Slack bot token (required, starts with xoxb-)
|
|
21
|
+
LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, List, Optional, Any
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from dotenv import load_dotenv
|
|
36
|
+
from slack_sdk import WebClient
|
|
37
|
+
from slack_sdk.errors import SlackApiError
|
|
38
|
+
except ImportError as e:
|
|
39
|
+
print(f"Error: Missing required dependencies: {e}")
|
|
40
|
+
print("Please run: pip install slack_sdk python-dotenv")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
# Import local state managers
|
|
44
|
+
try:
|
|
45
|
+
from slack_state import SlackStateManager, ResponseStateManager
|
|
46
|
+
except ImportError:
|
|
47
|
+
script_dir = Path(__file__).parent
|
|
48
|
+
sys.path.insert(0, str(script_dir))
|
|
49
|
+
from slack_state import SlackStateManager, ResponseStateManager
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Configure logging
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
57
|
+
"""Configure logging for the application."""
|
|
58
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
59
|
+
|
|
60
|
+
env_log_level = os.getenv('LOG_LEVEL', '').upper()
|
|
61
|
+
if env_log_level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
|
62
|
+
log_level = getattr(logging, env_log_level)
|
|
63
|
+
|
|
64
|
+
logging.basicConfig(
|
|
65
|
+
level=log_level,
|
|
66
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
67
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_kanban_tasks(
|
|
72
|
+
kanban_script: str,
|
|
73
|
+
tag: Optional[str] = None,
|
|
74
|
+
status: Optional[str] = None,
|
|
75
|
+
limit: int = 10000
|
|
76
|
+
) -> List[Dict[str, Any]]:
|
|
77
|
+
"""
|
|
78
|
+
Get kanban tasks from the kanban.sh script.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
kanban_script: Path to kanban.sh script
|
|
82
|
+
tag: Optional tag to filter by
|
|
83
|
+
status: Optional status to filter by
|
|
84
|
+
limit: Maximum number of tasks to retrieve (default: 10000 to ensure all tasks)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of task dicts
|
|
88
|
+
"""
|
|
89
|
+
cmd = [kanban_script, 'list', '--limit', str(limit)]
|
|
90
|
+
|
|
91
|
+
if tag:
|
|
92
|
+
cmd.extend(['--tag', tag])
|
|
93
|
+
if status:
|
|
94
|
+
cmd.extend(['--status', status])
|
|
95
|
+
|
|
96
|
+
logger.debug(f"Running: {' '.join(cmd)}")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
result = subprocess.run(
|
|
100
|
+
cmd,
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=30
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
logger.error(f"Kanban command failed: {result.stderr}")
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
tasks = json.loads(result.stdout)
|
|
112
|
+
if isinstance(tasks, list):
|
|
113
|
+
return tasks
|
|
114
|
+
logger.warning(f"Unexpected kanban output format: {type(tasks)}")
|
|
115
|
+
return []
|
|
116
|
+
except json.JSONDecodeError as e:
|
|
117
|
+
logger.error(f"Failed to parse kanban output: {e}")
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
except subprocess.TimeoutExpired:
|
|
121
|
+
logger.error("Kanban command timed out")
|
|
122
|
+
return []
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Error running kanban command: {e}")
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalize_text(text: str) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Normalize text for comparison by removing Slack-specific formatting.
|
|
131
|
+
|
|
132
|
+
Handles:
|
|
133
|
+
- Slack link formatting: <url|label> -> label, <url> -> url
|
|
134
|
+
- Slack user/channel mentions: <@U123> -> @user, <#C123|channel> -> #channel
|
|
135
|
+
- Markdown formatting: *bold*, _italic_, ~strike~, `code`
|
|
136
|
+
- Multiple whitespace: collapses to single space
|
|
137
|
+
- JSON-like content: normalizes quotes and escapes
|
|
138
|
+
- Trailing/leading whitespace
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
text: The text to normalize
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Normalized text suitable for comparison
|
|
145
|
+
"""
|
|
146
|
+
if not text:
|
|
147
|
+
return ''
|
|
148
|
+
|
|
149
|
+
# Remove Slack link formatting: <url|label> -> label
|
|
150
|
+
text = re.sub(r'<([^|>]+)\|([^>]+)>', r'\2', text)
|
|
151
|
+
# Remove bare Slack links: <url> -> url (remove angle brackets)
|
|
152
|
+
text = re.sub(r'<(https?://[^>]+)>', r'\1', text)
|
|
153
|
+
# Remove mailto links: <mailto:email|email> -> email
|
|
154
|
+
text = re.sub(r'<mailto:([^|>]+)\|([^>]+)>', r'\2', text)
|
|
155
|
+
text = re.sub(r'<mailto:([^>]+)>', r'\1', text)
|
|
156
|
+
|
|
157
|
+
# Remove Slack user mentions: <@U123ABC> -> @user
|
|
158
|
+
text = re.sub(r'<@[A-Z0-9]+>', '@user', text)
|
|
159
|
+
# Remove Slack channel mentions: <#C123|channel> -> #channel
|
|
160
|
+
text = re.sub(r'<#[A-Z0-9]+\|([^>]+)>', r'#\1', text)
|
|
161
|
+
text = re.sub(r'<#[A-Z0-9]+>', '#channel', text)
|
|
162
|
+
|
|
163
|
+
# Remove Slack markdown: *bold* -> bold, _italic_ -> italic
|
|
164
|
+
# Note: We don't remove backticks from code as they affect meaning
|
|
165
|
+
text = re.sub(r'\*([^*]+)\*', r'\1', text)
|
|
166
|
+
text = re.sub(r'_([^_]+)_', r'\1', text)
|
|
167
|
+
text = re.sub(r'~([^~]+)~', r'\1', text)
|
|
168
|
+
|
|
169
|
+
# Normalize JSON-like content: handle escaped quotes
|
|
170
|
+
# This helps match when JSON is stored differently
|
|
171
|
+
text = text.replace('\\"', '"')
|
|
172
|
+
text = text.replace("\\'", "'")
|
|
173
|
+
|
|
174
|
+
# Normalize whitespace: collapse multiple spaces/newlines to single space
|
|
175
|
+
text = re.sub(r'\s+', ' ', text)
|
|
176
|
+
|
|
177
|
+
# Strip leading/trailing whitespace
|
|
178
|
+
text = text.strip()
|
|
179
|
+
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def compute_text_similarity(text1: str, text2: str) -> float:
|
|
184
|
+
"""
|
|
185
|
+
Compute similarity ratio between two texts.
|
|
186
|
+
|
|
187
|
+
Uses a simple character-based comparison that works well for
|
|
188
|
+
detecting if two texts are essentially the same with minor
|
|
189
|
+
formatting differences.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
text1: First text
|
|
193
|
+
text2: Second text
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Similarity ratio between 0.0 and 1.0
|
|
197
|
+
"""
|
|
198
|
+
if not text1 or not text2:
|
|
199
|
+
return 0.0
|
|
200
|
+
|
|
201
|
+
if text1 == text2:
|
|
202
|
+
return 1.0
|
|
203
|
+
|
|
204
|
+
# Use difflib for sequence matching
|
|
205
|
+
from difflib import SequenceMatcher
|
|
206
|
+
return SequenceMatcher(None, text1, text2).ratio()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def find_matching_message(
|
|
210
|
+
task: Dict[str, Any],
|
|
211
|
+
slack_state: SlackStateManager,
|
|
212
|
+
similarity_threshold: float = 0.85
|
|
213
|
+
) -> Optional[Dict[str, Any]]:
|
|
214
|
+
"""
|
|
215
|
+
Find the Slack message that corresponds to a kanban task.
|
|
216
|
+
|
|
217
|
+
Uses multiple strategies with fallback:
|
|
218
|
+
1. Look up by task_id in state (preferred, exact match)
|
|
219
|
+
2. Exact text match (fast path)
|
|
220
|
+
3. Normalized text match (handles formatting differences)
|
|
221
|
+
4. Prefix match with normalization
|
|
222
|
+
5. Fuzzy match with similarity threshold (catches minor edits)
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
task: Kanban task dict
|
|
226
|
+
slack_state: SlackStateManager with processed messages
|
|
227
|
+
similarity_threshold: Minimum similarity ratio for fuzzy matching (0.0-1.0)
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Message data dict or None if not found
|
|
231
|
+
"""
|
|
232
|
+
task_id = task.get('id')
|
|
233
|
+
task_body = task.get('body', '')
|
|
234
|
+
|
|
235
|
+
if not task_body:
|
|
236
|
+
logger.debug(f"Task {task_id} has empty body, skipping text match")
|
|
237
|
+
# Only try task_id lookup for empty body tasks
|
|
238
|
+
message = slack_state.get_message_for_task(task_id)
|
|
239
|
+
if message:
|
|
240
|
+
logger.debug(f"Found message for task {task_id} by task_id lookup (empty body)")
|
|
241
|
+
return message
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
# Strategy 1: Look up by task_id (most reliable)
|
|
245
|
+
message = slack_state.get_message_for_task(task_id)
|
|
246
|
+
if message:
|
|
247
|
+
logger.debug(f"Found message for task {task_id} by task_id lookup")
|
|
248
|
+
return message
|
|
249
|
+
|
|
250
|
+
# Pre-compute normalized task body for comparison strategies
|
|
251
|
+
normalized_task_body = normalize_text(task_body)
|
|
252
|
+
normalized_task_body_short = normalized_task_body[:500] if len(normalized_task_body) > 500 else normalized_task_body
|
|
253
|
+
|
|
254
|
+
# Track best fuzzy match for Strategy 5
|
|
255
|
+
best_match: Optional[Dict[str, Any]] = None
|
|
256
|
+
best_similarity: float = 0.0
|
|
257
|
+
|
|
258
|
+
for msg in slack_state.messages:
|
|
259
|
+
msg_text = msg.get('text', '')
|
|
260
|
+
if not msg_text:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
# Strategy 2: Exact text match (fast path)
|
|
264
|
+
if msg_text == task_body:
|
|
265
|
+
logger.debug(f"Found message for task {task_id} by exact text match")
|
|
266
|
+
return msg
|
|
267
|
+
|
|
268
|
+
# Strategy 3: Normalized text match
|
|
269
|
+
normalized_msg_text = normalize_text(msg_text)
|
|
270
|
+
|
|
271
|
+
if normalized_msg_text == normalized_task_body:
|
|
272
|
+
logger.debug(f"Found message for task {task_id} by normalized text match")
|
|
273
|
+
return msg
|
|
274
|
+
|
|
275
|
+
# Strategy 4: Prefix match with normalization
|
|
276
|
+
# Check if task body starts with message text (agent may have appended)
|
|
277
|
+
if normalized_task_body.startswith(normalized_msg_text):
|
|
278
|
+
logger.debug(f"Found message for task {task_id} by normalized prefix match (task starts with msg)")
|
|
279
|
+
return msg
|
|
280
|
+
|
|
281
|
+
# Check if message text starts with task body (message may be longer)
|
|
282
|
+
normalized_msg_short = normalized_msg_text[:500] if len(normalized_msg_text) > 500 else normalized_msg_text
|
|
283
|
+
if normalized_msg_text.startswith(normalized_task_body_short):
|
|
284
|
+
logger.debug(f"Found message for task {task_id} by normalized prefix match (msg starts with task)")
|
|
285
|
+
return msg
|
|
286
|
+
|
|
287
|
+
# Strategy 5: Fuzzy match - compute similarity for later
|
|
288
|
+
# Only compute if texts are reasonably close in length (within 2x)
|
|
289
|
+
len_ratio = len(normalized_msg_text) / max(len(normalized_task_body), 1)
|
|
290
|
+
if 0.5 <= len_ratio <= 2.0:
|
|
291
|
+
similarity = compute_text_similarity(normalized_msg_text, normalized_task_body)
|
|
292
|
+
if similarity > best_similarity:
|
|
293
|
+
best_similarity = similarity
|
|
294
|
+
best_match = msg
|
|
295
|
+
|
|
296
|
+
# Strategy 5: Return best fuzzy match if above threshold
|
|
297
|
+
if best_match and best_similarity >= similarity_threshold:
|
|
298
|
+
logger.debug(
|
|
299
|
+
f"Found message for task {task_id} by fuzzy match "
|
|
300
|
+
f"(similarity={best_similarity:.2f}, threshold={similarity_threshold})"
|
|
301
|
+
)
|
|
302
|
+
return best_match
|
|
303
|
+
|
|
304
|
+
logger.debug(f"No matching message found for task {task_id}")
|
|
305
|
+
if best_match:
|
|
306
|
+
logger.debug(f" Best fuzzy match had similarity={best_similarity:.2f}, below threshold={similarity_threshold}")
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def send_slack_response(
|
|
311
|
+
client: WebClient,
|
|
312
|
+
channel_id: str,
|
|
313
|
+
thread_ts: str,
|
|
314
|
+
task_id: str,
|
|
315
|
+
response_text: str,
|
|
316
|
+
dry_run: bool = False
|
|
317
|
+
) -> Optional[str]:
|
|
318
|
+
"""
|
|
319
|
+
Send a response to Slack as a threaded reply.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
client: Slack WebClient instance
|
|
323
|
+
channel_id: Slack channel ID
|
|
324
|
+
thread_ts: Thread timestamp to reply to
|
|
325
|
+
task_id: Kanban task ID (for prefixing)
|
|
326
|
+
response_text: The response text to send
|
|
327
|
+
dry_run: If True, don't actually send
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Response message timestamp if sent, None if failed
|
|
331
|
+
"""
|
|
332
|
+
# Format the response with task ID
|
|
333
|
+
formatted_response = f"**Task ID: {task_id}**\n\n{response_text}"
|
|
334
|
+
|
|
335
|
+
if dry_run:
|
|
336
|
+
logger.info(f"[DRY RUN] Would send to channel {channel_id}, thread {thread_ts}:")
|
|
337
|
+
logger.info(f"[DRY RUN] {formatted_response[:200]}...")
|
|
338
|
+
return "dry-run-ts"
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
result = client.chat_postMessage(
|
|
342
|
+
channel=channel_id,
|
|
343
|
+
thread_ts=thread_ts,
|
|
344
|
+
text=formatted_response
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if result.get('ok'):
|
|
348
|
+
response_ts = result.get('ts')
|
|
349
|
+
logger.info(f"Sent response for task {task_id} (ts: {response_ts})")
|
|
350
|
+
return response_ts
|
|
351
|
+
else:
|
|
352
|
+
logger.error(f"Slack API returned ok=False: {result}")
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
except SlackApiError as e:
|
|
356
|
+
error = e.response.get('error', 'unknown_error')
|
|
357
|
+
logger.error(f"Slack API error: {error}")
|
|
358
|
+
|
|
359
|
+
if error == 'ratelimited':
|
|
360
|
+
retry_after = int(e.response.headers.get('Retry-After', 60))
|
|
361
|
+
logger.warning(f"Rate limited, should retry after {retry_after} seconds")
|
|
362
|
+
elif error == 'channel_not_found':
|
|
363
|
+
logger.error("Channel not found - check channel configuration")
|
|
364
|
+
elif error == 'invalid_auth':
|
|
365
|
+
logger.error("Invalid auth token - check SLACK_BOT_TOKEN")
|
|
366
|
+
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def find_kanban_script(project_dir: Path) -> Optional[str]:
|
|
371
|
+
"""Find the kanban.sh script in the project."""
|
|
372
|
+
candidates = [
|
|
373
|
+
project_dir / '.juno_task' / 'scripts' / 'kanban.sh',
|
|
374
|
+
project_dir / 'scripts' / 'kanban.sh',
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
for path in candidates:
|
|
378
|
+
if path.exists():
|
|
379
|
+
return str(path)
|
|
380
|
+
|
|
381
|
+
logger.error("Could not find kanban.sh script")
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
SLACK_TOKEN_DOCS_URL = "https://api.slack.com/tutorials/tracks/getting-a-token"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def validate_slack_environment() -> tuple[Optional[str], list[str]]:
|
|
389
|
+
"""
|
|
390
|
+
Validate Slack environment variables are properly configured.
|
|
391
|
+
|
|
392
|
+
Checks for SLACK_BOT_TOKEN in environment or .env file.
|
|
393
|
+
The token should start with 'xoxb-' for bot tokens.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Tuple of (bot_token, errors)
|
|
397
|
+
- bot_token: The Slack bot token if found, None otherwise
|
|
398
|
+
- errors: List of error messages if validation failed
|
|
399
|
+
"""
|
|
400
|
+
errors = []
|
|
401
|
+
|
|
402
|
+
# Check for bot token
|
|
403
|
+
bot_token = os.getenv('SLACK_BOT_TOKEN')
|
|
404
|
+
if not bot_token:
|
|
405
|
+
errors.append(
|
|
406
|
+
"SLACK_BOT_TOKEN not found.\n"
|
|
407
|
+
" Set it via environment variable or in a .env file:\n"
|
|
408
|
+
" export SLACK_BOT_TOKEN=xoxb-your-token-here\n"
|
|
409
|
+
" Or add to .env file:\n"
|
|
410
|
+
" SLACK_BOT_TOKEN=xoxb-your-token-here\n"
|
|
411
|
+
f"\n To generate a Slack bot token, visit:\n"
|
|
412
|
+
f" {SLACK_TOKEN_DOCS_URL}\n"
|
|
413
|
+
"\n Required OAuth scopes for bot token:\n"
|
|
414
|
+
" - channels:history (read messages from public channels)\n"
|
|
415
|
+
" - channels:read (list public channels)\n"
|
|
416
|
+
" - groups:history (read messages from private channels)\n"
|
|
417
|
+
" - groups:read (list private channels)\n"
|
|
418
|
+
" - users:read (get user info for message authors)\n"
|
|
419
|
+
" - chat:write (send messages as the bot)"
|
|
420
|
+
)
|
|
421
|
+
elif not bot_token.startswith('xoxb-'):
|
|
422
|
+
errors.append(
|
|
423
|
+
f"SLACK_BOT_TOKEN appears invalid (should start with 'xoxb-').\n"
|
|
424
|
+
f" Current value starts with: {bot_token[:10]}...\n"
|
|
425
|
+
f" Bot tokens from Slack start with 'xoxb-'.\n"
|
|
426
|
+
f" To generate a valid bot token, visit:\n"
|
|
427
|
+
f" {SLACK_TOKEN_DOCS_URL}"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return bot_token, errors
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def print_env_help() -> None:
|
|
434
|
+
"""Print help message about configuring Slack environment variables."""
|
|
435
|
+
print("\n" + "=" * 70)
|
|
436
|
+
print("Slack Integration - Environment Configuration")
|
|
437
|
+
print("=" * 70)
|
|
438
|
+
print("""
|
|
439
|
+
Required Environment Variables:
|
|
440
|
+
SLACK_BOT_TOKEN Your Slack bot token (starts with xoxb-)
|
|
441
|
+
|
|
442
|
+
Optional Environment Variables:
|
|
443
|
+
LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
444
|
+
|
|
445
|
+
Configuration Methods:
|
|
446
|
+
1. Environment variables:
|
|
447
|
+
export SLACK_BOT_TOKEN=xoxb-your-token-here
|
|
448
|
+
|
|
449
|
+
2. .env file (in project root):
|
|
450
|
+
SLACK_BOT_TOKEN=xoxb-your-token-here
|
|
451
|
+
|
|
452
|
+
Generating a Slack Bot Token:
|
|
453
|
+
1. Go to https://api.slack.com/apps and create a new app
|
|
454
|
+
2. Under "OAuth & Permissions", add the required scopes:
|
|
455
|
+
- channels:history, channels:read (public channels)
|
|
456
|
+
- groups:history, groups:read (private channels)
|
|
457
|
+
- users:read (user info)
|
|
458
|
+
- chat:write (send messages)
|
|
459
|
+
3. Install the app to your workspace
|
|
460
|
+
4. Copy the "Bot User OAuth Token" (starts with xoxb-)
|
|
461
|
+
|
|
462
|
+
Full tutorial: """ + SLACK_TOKEN_DOCS_URL + """
|
|
463
|
+
|
|
464
|
+
Example .env file:
|
|
465
|
+
SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN-HERE
|
|
466
|
+
LOG_LEVEL=INFO
|
|
467
|
+
""")
|
|
468
|
+
print("=" * 70 + "\n")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main_loop(args: argparse.Namespace) -> int:
|
|
472
|
+
"""Main processing loop."""
|
|
473
|
+
# Load environment variables from .env file
|
|
474
|
+
# load_dotenv() looks for .env in current directory and parent directories
|
|
475
|
+
load_dotenv()
|
|
476
|
+
|
|
477
|
+
# Also try loading from project root .env if running from a subdirectory
|
|
478
|
+
project_root = Path.cwd()
|
|
479
|
+
env_file = project_root / '.env'
|
|
480
|
+
if env_file.exists():
|
|
481
|
+
load_dotenv(env_file)
|
|
482
|
+
|
|
483
|
+
# Also check .juno_task/.env for project-specific config
|
|
484
|
+
juno_env_file = project_root / '.juno_task' / '.env'
|
|
485
|
+
if juno_env_file.exists():
|
|
486
|
+
load_dotenv(juno_env_file)
|
|
487
|
+
|
|
488
|
+
# Setup logging
|
|
489
|
+
setup_logging(verbose=args.verbose)
|
|
490
|
+
|
|
491
|
+
logger.info("=" * 70)
|
|
492
|
+
logger.info("Slack Respond - Sending agent responses to Slack")
|
|
493
|
+
logger.info("=" * 70)
|
|
494
|
+
|
|
495
|
+
# Validate environment
|
|
496
|
+
bot_token, errors = validate_slack_environment()
|
|
497
|
+
|
|
498
|
+
if errors:
|
|
499
|
+
for error in errors:
|
|
500
|
+
logger.error(error)
|
|
501
|
+
print_env_help()
|
|
502
|
+
return 1
|
|
503
|
+
|
|
504
|
+
# Find project root and kanban script
|
|
505
|
+
project_dir = Path.cwd()
|
|
506
|
+
kanban_script = find_kanban_script(project_dir)
|
|
507
|
+
if not kanban_script:
|
|
508
|
+
logger.error("Cannot find kanban.sh script. Is the project initialized?")
|
|
509
|
+
return 1
|
|
510
|
+
|
|
511
|
+
# Initialize Slack client
|
|
512
|
+
logger.info("Initializing Slack client...")
|
|
513
|
+
client = WebClient(token=bot_token)
|
|
514
|
+
|
|
515
|
+
# Test connection
|
|
516
|
+
try:
|
|
517
|
+
auth_response = client.auth_test()
|
|
518
|
+
team_name = auth_response.get('team')
|
|
519
|
+
logger.info(f"Connected to Slack workspace: {team_name}")
|
|
520
|
+
except SlackApiError as e:
|
|
521
|
+
logger.error(f"Failed to connect to Slack: {e.response['error']}")
|
|
522
|
+
return 1
|
|
523
|
+
|
|
524
|
+
# Initialize state managers
|
|
525
|
+
state_dir = project_dir / '.juno_task' / 'slack'
|
|
526
|
+
slack_state_file = state_dir / 'slack.ndjson'
|
|
527
|
+
response_state_file = state_dir / 'responses_sent.ndjson'
|
|
528
|
+
|
|
529
|
+
logger.info(f"Loading Slack message state: {slack_state_file}")
|
|
530
|
+
slack_state = SlackStateManager(str(slack_state_file))
|
|
531
|
+
|
|
532
|
+
logger.info(f"Loading response state: {response_state_file}")
|
|
533
|
+
response_state = ResponseStateManager(str(response_state_file))
|
|
534
|
+
|
|
535
|
+
if args.reset_tracker:
|
|
536
|
+
confirm = input("WARNING: This will reset the response tracker. Type 'yes' to confirm: ")
|
|
537
|
+
if confirm.lower() == 'yes':
|
|
538
|
+
response_state.reset_state()
|
|
539
|
+
logger.info("Response tracker reset")
|
|
540
|
+
else:
|
|
541
|
+
logger.info("Reset cancelled")
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
if args.dry_run:
|
|
545
|
+
logger.info("Running in DRY RUN mode - no responses will be sent")
|
|
546
|
+
|
|
547
|
+
logger.info(f"Slack messages loaded: {slack_state.get_message_count()}")
|
|
548
|
+
logger.info(f"Responses already sent: {response_state.get_sent_count()}")
|
|
549
|
+
logger.info("-" * 70)
|
|
550
|
+
|
|
551
|
+
# Get kanban tasks
|
|
552
|
+
tasks = get_kanban_tasks(kanban_script, tag=args.tag)
|
|
553
|
+
logger.info(f"Found {len(tasks)} kanban tasks")
|
|
554
|
+
|
|
555
|
+
# Process tasks
|
|
556
|
+
total_tasks = 0
|
|
557
|
+
matched_tasks = 0
|
|
558
|
+
sent_responses = 0
|
|
559
|
+
already_sent = 0
|
|
560
|
+
errors = 0
|
|
561
|
+
|
|
562
|
+
for task in tasks:
|
|
563
|
+
task_id = task.get('id')
|
|
564
|
+
task_body = task.get('body', '')
|
|
565
|
+
agent_response = task.get('agent_response', '')
|
|
566
|
+
|
|
567
|
+
total_tasks += 1
|
|
568
|
+
|
|
569
|
+
# Skip if no response
|
|
570
|
+
if not agent_response or agent_response == 'null':
|
|
571
|
+
logger.debug(f"Task {task_id}: No agent response, skipping")
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
# Find matching Slack message
|
|
575
|
+
message = find_matching_message(task, slack_state)
|
|
576
|
+
if not message:
|
|
577
|
+
logger.debug(f"Task {task_id}: No matching Slack message found")
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
matched_tasks += 1
|
|
581
|
+
|
|
582
|
+
message_ts = message.get('ts')
|
|
583
|
+
thread_ts = message.get('thread_ts', message_ts)
|
|
584
|
+
channel_id = message.get('channel_id')
|
|
585
|
+
author = message.get('author_name', 'unknown')
|
|
586
|
+
|
|
587
|
+
logger.debug(f"Task {task_id}: Found match - ts={message_ts}, author={author}")
|
|
588
|
+
|
|
589
|
+
# Check if already sent
|
|
590
|
+
if response_state.was_response_sent(task_id, message_ts):
|
|
591
|
+
logger.info(f"Task {task_id}: Already sent response to {author} (skipping)")
|
|
592
|
+
already_sent += 1
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
# Send response
|
|
596
|
+
logger.info(f"Task {task_id}: Sending response to {author}")
|
|
597
|
+
|
|
598
|
+
response_ts = send_slack_response(
|
|
599
|
+
client,
|
|
600
|
+
channel_id,
|
|
601
|
+
thread_ts,
|
|
602
|
+
task_id,
|
|
603
|
+
agent_response,
|
|
604
|
+
dry_run=args.dry_run
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if response_ts:
|
|
608
|
+
sent_responses += 1
|
|
609
|
+
|
|
610
|
+
# Record sent
|
|
611
|
+
if not args.dry_run:
|
|
612
|
+
response_state.record_sent(task_id, message_ts, channel_id, response_ts)
|
|
613
|
+
|
|
614
|
+
logger.info(f" ✓ Response sent successfully")
|
|
615
|
+
else:
|
|
616
|
+
errors += 1
|
|
617
|
+
logger.error(f" ✗ Failed to send response")
|
|
618
|
+
|
|
619
|
+
# Summary
|
|
620
|
+
logger.info("")
|
|
621
|
+
logger.info("=" * 70)
|
|
622
|
+
logger.info("Summary:")
|
|
623
|
+
logger.info(f" Total tasks processed: {total_tasks}")
|
|
624
|
+
logger.info(f" Tasks matched with Slack messages: {matched_tasks}")
|
|
625
|
+
logger.info(f" Responses sent: {sent_responses}")
|
|
626
|
+
logger.info(f" Already sent (skipped): {already_sent}")
|
|
627
|
+
if errors > 0:
|
|
628
|
+
logger.error(f" Errors: {errors}")
|
|
629
|
+
|
|
630
|
+
if args.dry_run:
|
|
631
|
+
logger.info("(Dry run mode - no messages were actually sent)")
|
|
632
|
+
|
|
633
|
+
return 0 if errors == 0 else 1
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def main() -> int:
|
|
637
|
+
"""Main entry point."""
|
|
638
|
+
parser = argparse.ArgumentParser(
|
|
639
|
+
description='Send kanban agent responses back to Slack',
|
|
640
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
641
|
+
epilog="""
|
|
642
|
+
Examples:
|
|
643
|
+
%(prog)s # Process all tasks
|
|
644
|
+
%(prog)s --tag slack-input # Only slack-input tagged tasks
|
|
645
|
+
%(prog)s --dry-run --verbose # Test mode
|
|
646
|
+
%(prog)s --reset-tracker # Reset sent tracker (WARNING: re-sends all)
|
|
647
|
+
|
|
648
|
+
Environment Variables:
|
|
649
|
+
SLACK_BOT_TOKEN Slack bot token (required)
|
|
650
|
+
LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
651
|
+
|
|
652
|
+
Notes:
|
|
653
|
+
- Only sends responses for tasks with non-empty agent_response
|
|
654
|
+
- Matches tasks to Slack messages by task_id or body text
|
|
655
|
+
- Responses are prefixed with "**Task ID: {id}**"
|
|
656
|
+
- Tracks sent responses in .juno_task/slack/responses_sent.ndjson
|
|
657
|
+
"""
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
parser.add_argument(
|
|
661
|
+
'--tag',
|
|
662
|
+
help='Filter tasks by tag (e.g., slack-input)'
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
parser.add_argument(
|
|
666
|
+
'--dry-run',
|
|
667
|
+
action='store_true',
|
|
668
|
+
help='Show what would be sent without actually sending'
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
parser.add_argument(
|
|
672
|
+
'--verbose', '-v',
|
|
673
|
+
action='store_true',
|
|
674
|
+
help='Enable DEBUG level logging'
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
parser.add_argument(
|
|
678
|
+
'--reset-tracker',
|
|
679
|
+
action='store_true',
|
|
680
|
+
help='Reset response tracker (WARNING: will re-send all responses)'
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
args = parser.parse_args()
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
return main_loop(args)
|
|
687
|
+
except Exception as e:
|
|
688
|
+
logger.error(f"Fatal error: {e}", exc_info=True)
|
|
689
|
+
return 1
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
if __name__ == '__main__':
|
|
693
|
+
sys.exit(main())
|