juno-code 1.0.35 → 1.0.37
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 +534 -64
- package/dist/bin/cli.js +583 -181
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +582 -180
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/index.js +20 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +20 -3
- package/dist/index.mjs.map +1 -1
- package/dist/templates/scripts/clean_logs_folder.sh +0 -0
- package/dist/templates/scripts/install_requirements.sh +3 -0
- package/dist/templates/scripts/run_until_completion.sh +418 -0
- 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 +691 -0
- package/dist/templates/scripts/slack_respond.sh +263 -0
- package/dist/templates/scripts/slack_state.py +383 -0
- package/dist/templates/services/README.md +43 -0
- package/dist/templates/services/claude.py +1 -1
- package/dist/templates/services/codex.py +4 -4
- package/dist/templates/services/gemini.py +473 -0
- package/package.json +10 -4
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Slack Fetch - Fetch messages from Slack and create kanban tasks.
|
|
4
|
+
|
|
5
|
+
This script monitors a Slack channel for new messages and creates
|
|
6
|
+
kanban tasks from them. It uses persistent state tracking to avoid
|
|
7
|
+
processing the same message twice.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Channel monitoring with configurable intervals
|
|
11
|
+
- Default --once mode for cron/scheduled jobs
|
|
12
|
+
- Persistent state tracking (no duplicate processing)
|
|
13
|
+
- Automatic kanban task creation with slack-input tag
|
|
14
|
+
- Graceful shutdown on SIGINT/SIGTERM
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python slack_fetch.py --channel bug-reports --once
|
|
18
|
+
python slack_fetch.py --channel feature-requests --continuous
|
|
19
|
+
python slack_fetch.py --channel general --dry-run --verbose
|
|
20
|
+
|
|
21
|
+
Environment Variables:
|
|
22
|
+
SLACK_BOT_TOKEN Slack bot token (required, starts with xoxb-)
|
|
23
|
+
SLACK_CHANNEL Default channel to monitor
|
|
24
|
+
CHECK_INTERVAL_SECONDS Polling interval in seconds (default: 60)
|
|
25
|
+
LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import signal
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Dict, List, Optional, Any
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from dotenv import load_dotenv
|
|
42
|
+
from slack_sdk import WebClient
|
|
43
|
+
from slack_sdk.errors import SlackApiError
|
|
44
|
+
except ImportError as e:
|
|
45
|
+
print(f"Error: Missing required dependencies: {e}")
|
|
46
|
+
print("Please run: pip install slack_sdk python-dotenv")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
# Import local state manager
|
|
50
|
+
try:
|
|
51
|
+
from slack_state import SlackStateManager
|
|
52
|
+
except ImportError:
|
|
53
|
+
# Fallback: try importing from same directory
|
|
54
|
+
script_dir = Path(__file__).parent
|
|
55
|
+
sys.path.insert(0, str(script_dir))
|
|
56
|
+
from slack_state import SlackStateManager
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Global shutdown flag
|
|
60
|
+
shutdown_requested = False
|
|
61
|
+
|
|
62
|
+
# Configure logging
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def setup_logging(verbose: bool = False, log_file: Optional[str] = None) -> None:
|
|
67
|
+
"""Configure logging for the application."""
|
|
68
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
69
|
+
|
|
70
|
+
env_log_level = os.getenv('LOG_LEVEL', '').upper()
|
|
71
|
+
if env_log_level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
|
|
72
|
+
log_level = getattr(logging, env_log_level)
|
|
73
|
+
|
|
74
|
+
logging.basicConfig(
|
|
75
|
+
level=log_level,
|
|
76
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
77
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if log_file:
|
|
81
|
+
log_path = Path(log_file)
|
|
82
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
file_handler = logging.FileHandler(log_path)
|
|
84
|
+
file_handler.setLevel(log_level)
|
|
85
|
+
file_handler.setFormatter(
|
|
86
|
+
logging.Formatter(
|
|
87
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
88
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
logging.getLogger().addHandler(file_handler)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def signal_handler(signum: int, frame) -> None:
|
|
95
|
+
"""Handle shutdown signals gracefully."""
|
|
96
|
+
global shutdown_requested
|
|
97
|
+
signal_name = signal.Signals(signum).name
|
|
98
|
+
logger.info(f"Received {signal_name}, initiating graceful shutdown...")
|
|
99
|
+
shutdown_requested = True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_channel_id(client: WebClient, channel_name: str) -> Optional[str]:
|
|
103
|
+
"""
|
|
104
|
+
Resolve channel name to channel ID.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
client: Slack WebClient instance
|
|
108
|
+
channel_name: Channel name (with or without #) or channel ID
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Channel ID or None if not found
|
|
112
|
+
"""
|
|
113
|
+
# If already looks like a channel ID, return as-is
|
|
114
|
+
if channel_name.startswith('C') and len(channel_name) >= 9:
|
|
115
|
+
logger.debug(f"Channel '{channel_name}' appears to be a channel ID")
|
|
116
|
+
return channel_name
|
|
117
|
+
|
|
118
|
+
# Strip # prefix if present
|
|
119
|
+
channel_name = channel_name.lstrip('#')
|
|
120
|
+
logger.info(f"Resolving channel name '{channel_name}' to ID...")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Try public channels
|
|
124
|
+
result = client.conversations_list(types="public_channel")
|
|
125
|
+
for channel in result.get('channels', []):
|
|
126
|
+
if channel.get('name') == channel_name:
|
|
127
|
+
channel_id = channel.get('id')
|
|
128
|
+
logger.info(f"Found channel '{channel_name}' with ID: {channel_id}")
|
|
129
|
+
return channel_id
|
|
130
|
+
|
|
131
|
+
# Try private channels
|
|
132
|
+
result = client.conversations_list(types="private_channel")
|
|
133
|
+
for channel in result.get('channels', []):
|
|
134
|
+
if channel.get('name') == channel_name:
|
|
135
|
+
channel_id = channel.get('id')
|
|
136
|
+
logger.info(f"Found private channel '{channel_name}' with ID: {channel_id}")
|
|
137
|
+
return channel_id
|
|
138
|
+
|
|
139
|
+
logger.error(f"Channel '{channel_name}' not found")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
except SlackApiError as e:
|
|
143
|
+
logger.error(f"Error resolving channel name: {e.response['error']}")
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_user_info(client: WebClient, user_id: str) -> str:
|
|
148
|
+
"""Get user display name from user ID."""
|
|
149
|
+
try:
|
|
150
|
+
result = client.users_info(user=user_id)
|
|
151
|
+
user = result.get('user', {})
|
|
152
|
+
profile = user.get('profile', {})
|
|
153
|
+
display_name = (
|
|
154
|
+
profile.get('display_name') or
|
|
155
|
+
profile.get('real_name') or
|
|
156
|
+
user.get('name') or
|
|
157
|
+
user_id
|
|
158
|
+
)
|
|
159
|
+
return display_name
|
|
160
|
+
except SlackApiError as e:
|
|
161
|
+
logger.warning(f"Could not fetch user info for {user_id}: {e.response['error']}")
|
|
162
|
+
return user_id
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fetch_channel_messages(
|
|
166
|
+
client: WebClient,
|
|
167
|
+
channel_id: str,
|
|
168
|
+
oldest_ts: Optional[str] = None,
|
|
169
|
+
limit: int = 100
|
|
170
|
+
) -> List[Dict[str, Any]]:
|
|
171
|
+
"""
|
|
172
|
+
Fetch messages from a Slack channel.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
client: Slack WebClient instance
|
|
176
|
+
channel_id: Slack channel ID
|
|
177
|
+
oldest_ts: Optional timestamp to fetch messages after
|
|
178
|
+
limit: Maximum number of messages to fetch
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of message dicts
|
|
182
|
+
"""
|
|
183
|
+
logger.debug(f"Fetching messages from channel {channel_id} (oldest_ts={oldest_ts})")
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
kwargs = {'channel': channel_id, 'limit': limit}
|
|
187
|
+
if oldest_ts:
|
|
188
|
+
kwargs['oldest'] = oldest_ts
|
|
189
|
+
|
|
190
|
+
result = client.conversations_history(**kwargs)
|
|
191
|
+
messages = result.get('messages', [])
|
|
192
|
+
|
|
193
|
+
# Filter to only user messages (exclude bot messages, system messages)
|
|
194
|
+
messages = [
|
|
195
|
+
m for m in messages
|
|
196
|
+
if m.get('type') == 'message' and not m.get('subtype')
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
logger.debug(f"Fetched {len(messages)} messages")
|
|
200
|
+
return messages
|
|
201
|
+
|
|
202
|
+
except SlackApiError as e:
|
|
203
|
+
error_msg = e.response.get('error', 'unknown_error')
|
|
204
|
+
logger.error(f"Error fetching messages: {error_msg}")
|
|
205
|
+
|
|
206
|
+
if error_msg == 'ratelimited':
|
|
207
|
+
retry_after = int(e.response.headers.get('Retry-After', 60))
|
|
208
|
+
logger.warning(f"Rate limited, should retry after {retry_after} seconds")
|
|
209
|
+
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def sanitize_tag(tag: str) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Sanitize a tag to be compatible with kanban validation.
|
|
216
|
+
|
|
217
|
+
Kanban tags only allow: letters, numbers, underscores (_), and hyphens (-).
|
|
218
|
+
This function replaces colons and other special characters with underscores.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
tag: The raw tag string
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Sanitized tag compatible with kanban system
|
|
225
|
+
"""
|
|
226
|
+
import re
|
|
227
|
+
# Replace spaces and colons with underscores
|
|
228
|
+
tag = tag.replace(' ', '_').replace(':', '_')
|
|
229
|
+
# Remove any remaining invalid characters (keep only alphanumeric, _, -)
|
|
230
|
+
tag = re.sub(r'[^a-zA-Z0-9_-]', '_', tag)
|
|
231
|
+
# Collapse multiple underscores
|
|
232
|
+
tag = re.sub(r'_+', '_', tag)
|
|
233
|
+
# Remove leading/trailing underscores
|
|
234
|
+
tag = tag.strip('_')
|
|
235
|
+
return tag
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def create_kanban_task(
|
|
239
|
+
message_text: str,
|
|
240
|
+
author_name: str,
|
|
241
|
+
tags: List[str],
|
|
242
|
+
kanban_script: str,
|
|
243
|
+
dry_run: bool = False
|
|
244
|
+
) -> Optional[str]:
|
|
245
|
+
"""
|
|
246
|
+
Create a kanban task from a Slack message.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
message_text: The message content
|
|
250
|
+
author_name: The author's display name
|
|
251
|
+
tags: List of tags to apply
|
|
252
|
+
kanban_script: Path to kanban.sh script
|
|
253
|
+
dry_run: If True, don't actually create the task
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Task ID if created, None if failed or dry run
|
|
257
|
+
"""
|
|
258
|
+
# Build the kanban command
|
|
259
|
+
# Escape single quotes in message text
|
|
260
|
+
escaped_text = message_text.replace("'", "'\\''")
|
|
261
|
+
|
|
262
|
+
# Build tags argument
|
|
263
|
+
tags_arg = ','.join(tags) if tags else 'slack-input'
|
|
264
|
+
|
|
265
|
+
if dry_run:
|
|
266
|
+
logger.info(f"[DRY RUN] Would create task: {message_text[:100]}...")
|
|
267
|
+
logger.debug(f"[DRY RUN] Tags: {tags_arg}")
|
|
268
|
+
return "dry-run-task-id"
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
# Run kanban create command
|
|
272
|
+
cmd = [kanban_script, 'create', message_text, '--tags', tags_arg]
|
|
273
|
+
logger.debug(f"Running: {' '.join(cmd[:3])}...")
|
|
274
|
+
|
|
275
|
+
result = subprocess.run(
|
|
276
|
+
cmd,
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
timeout=30
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if result.returncode == 0:
|
|
283
|
+
# Parse output to get task ID
|
|
284
|
+
try:
|
|
285
|
+
output = json.loads(result.stdout)
|
|
286
|
+
if isinstance(output, list) and len(output) > 0:
|
|
287
|
+
task_id = output[0].get('id')
|
|
288
|
+
logger.info(f"Created kanban task: {task_id}")
|
|
289
|
+
return task_id
|
|
290
|
+
except json.JSONDecodeError:
|
|
291
|
+
logger.warning(f"Could not parse kanban output: {result.stdout[:200]}")
|
|
292
|
+
# Still return success if command succeeded
|
|
293
|
+
return "unknown-task-id"
|
|
294
|
+
else:
|
|
295
|
+
logger.error(f"Failed to create kanban task: {result.stderr}")
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
except subprocess.TimeoutExpired:
|
|
299
|
+
logger.error("Kanban command timed out")
|
|
300
|
+
return None
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.error(f"Error creating kanban task: {e}")
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def process_messages(
|
|
307
|
+
messages: List[Dict[str, Any]],
|
|
308
|
+
channel_name: str,
|
|
309
|
+
channel_id: str,
|
|
310
|
+
client: WebClient,
|
|
311
|
+
state_mgr: SlackStateManager,
|
|
312
|
+
kanban_script: str,
|
|
313
|
+
dry_run: bool = False
|
|
314
|
+
) -> int:
|
|
315
|
+
"""
|
|
316
|
+
Process new messages: create kanban tasks and update state.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
messages: List of message dicts
|
|
320
|
+
channel_name: Channel name for logging
|
|
321
|
+
channel_id: Channel ID
|
|
322
|
+
client: Slack WebClient for user lookups
|
|
323
|
+
state_mgr: SlackStateManager instance
|
|
324
|
+
kanban_script: Path to kanban.sh script
|
|
325
|
+
dry_run: If True, don't create tasks
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Number of messages processed
|
|
329
|
+
"""
|
|
330
|
+
if not messages:
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
logger.info(f"Processing {len(messages)} new messages from #{channel_name}")
|
|
334
|
+
processed = 0
|
|
335
|
+
|
|
336
|
+
# Sort messages oldest first
|
|
337
|
+
messages.sort(key=lambda m: m.get('ts', '0'))
|
|
338
|
+
|
|
339
|
+
for msg in messages:
|
|
340
|
+
ts = msg.get('ts')
|
|
341
|
+
user_id = msg.get('user', 'unknown')
|
|
342
|
+
text = msg.get('text', '')
|
|
343
|
+
|
|
344
|
+
# Skip empty messages
|
|
345
|
+
if not text.strip():
|
|
346
|
+
logger.debug(f"Skipping empty message ts={ts}")
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
# Skip already processed
|
|
350
|
+
if state_mgr.is_processed(ts):
|
|
351
|
+
logger.debug(f"Skipping already processed message ts={ts}")
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Get user info
|
|
355
|
+
author_name = get_user_info(client, user_id)
|
|
356
|
+
|
|
357
|
+
# Convert timestamp to ISO date
|
|
358
|
+
try:
|
|
359
|
+
date_str = datetime.fromtimestamp(float(ts)).isoformat()
|
|
360
|
+
except (ValueError, TypeError):
|
|
361
|
+
date_str = ts
|
|
362
|
+
|
|
363
|
+
logger.info(f"New message from {author_name}: {text[:50]}{'...' if len(text) > 50 else ''}")
|
|
364
|
+
|
|
365
|
+
# Create kanban task
|
|
366
|
+
# Create author tag with sanitization (colons not allowed in kanban tags)
|
|
367
|
+
author_tag = sanitize_tag(f'author_{author_name}')
|
|
368
|
+
tags = ['slack-input', author_tag]
|
|
369
|
+
task_id = create_kanban_task(text, author_name, tags, kanban_script, dry_run)
|
|
370
|
+
|
|
371
|
+
if task_id:
|
|
372
|
+
# Record in state
|
|
373
|
+
message_data = {
|
|
374
|
+
'text': text,
|
|
375
|
+
'author': user_id,
|
|
376
|
+
'author_name': author_name,
|
|
377
|
+
'date': date_str,
|
|
378
|
+
'channel': channel_name,
|
|
379
|
+
'channel_id': channel_id,
|
|
380
|
+
'thread_ts': msg.get('thread_ts', ts)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if not dry_run:
|
|
384
|
+
state_mgr.mark_processed(ts, task_id, message_data)
|
|
385
|
+
|
|
386
|
+
processed += 1
|
|
387
|
+
else:
|
|
388
|
+
logger.warning(f"Failed to create task for message ts={ts}")
|
|
389
|
+
|
|
390
|
+
return processed
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def find_kanban_script(project_dir: Path) -> Optional[str]:
|
|
394
|
+
"""Find the kanban.sh script in the project."""
|
|
395
|
+
candidates = [
|
|
396
|
+
project_dir / '.juno_task' / 'scripts' / 'kanban.sh',
|
|
397
|
+
project_dir / 'scripts' / 'kanban.sh',
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
for path in candidates:
|
|
401
|
+
if path.exists():
|
|
402
|
+
return str(path)
|
|
403
|
+
|
|
404
|
+
logger.error("Could not find kanban.sh script")
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
SLACK_TOKEN_DOCS_URL = "https://api.slack.com/tutorials/tracks/getting-a-token"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def validate_slack_environment() -> tuple[Optional[str], Optional[str], list[str]]:
|
|
412
|
+
"""
|
|
413
|
+
Validate Slack environment variables are properly configured.
|
|
414
|
+
|
|
415
|
+
Checks for SLACK_BOT_TOKEN in environment or .env file.
|
|
416
|
+
The token should start with 'xoxb-' for bot tokens.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Tuple of (bot_token, channel, errors)
|
|
420
|
+
- bot_token: The Slack bot token if found, None otherwise
|
|
421
|
+
- channel: The Slack channel if found, None otherwise
|
|
422
|
+
- errors: List of error messages if validation failed
|
|
423
|
+
"""
|
|
424
|
+
errors = []
|
|
425
|
+
|
|
426
|
+
# Check for bot token
|
|
427
|
+
bot_token = os.getenv('SLACK_BOT_TOKEN')
|
|
428
|
+
if not bot_token:
|
|
429
|
+
errors.append(
|
|
430
|
+
"SLACK_BOT_TOKEN not found.\n"
|
|
431
|
+
" Set it via environment variable or in a .env file:\n"
|
|
432
|
+
" export SLACK_BOT_TOKEN=xoxb-your-token-here\n"
|
|
433
|
+
" Or add to .env file:\n"
|
|
434
|
+
" SLACK_BOT_TOKEN=xoxb-your-token-here\n"
|
|
435
|
+
f"\n To generate a Slack bot token, visit:\n"
|
|
436
|
+
f" {SLACK_TOKEN_DOCS_URL}\n"
|
|
437
|
+
"\n Required OAuth scopes for bot token:\n"
|
|
438
|
+
" - channels:history (read messages from public channels)\n"
|
|
439
|
+
" - channels:read (list public channels)\n"
|
|
440
|
+
" - groups:history (read messages from private channels)\n"
|
|
441
|
+
" - groups:read (list private channels)\n"
|
|
442
|
+
" - users:read (get user info for message authors)\n"
|
|
443
|
+
" - chat:write (optional, for slack_respond.py)"
|
|
444
|
+
)
|
|
445
|
+
elif not bot_token.startswith('xoxb-'):
|
|
446
|
+
errors.append(
|
|
447
|
+
f"SLACK_BOT_TOKEN appears invalid (should start with 'xoxb-').\n"
|
|
448
|
+
f" Current value starts with: {bot_token[:10]}...\n"
|
|
449
|
+
f" Bot tokens from Slack start with 'xoxb-'.\n"
|
|
450
|
+
f" To generate a valid bot token, visit:\n"
|
|
451
|
+
f" {SLACK_TOKEN_DOCS_URL}"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Check for channel (optional at validation, but warn)
|
|
455
|
+
channel = os.getenv('SLACK_CHANNEL')
|
|
456
|
+
|
|
457
|
+
return bot_token, channel, errors
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def print_env_help() -> None:
|
|
461
|
+
"""Print help message about configuring Slack environment variables."""
|
|
462
|
+
print("\n" + "=" * 70)
|
|
463
|
+
print("Slack Integration - Environment Configuration")
|
|
464
|
+
print("=" * 70)
|
|
465
|
+
print("""
|
|
466
|
+
Required Environment Variables:
|
|
467
|
+
SLACK_BOT_TOKEN Your Slack bot token (starts with xoxb-)
|
|
468
|
+
|
|
469
|
+
Optional Environment Variables:
|
|
470
|
+
SLACK_CHANNEL Default channel to monitor (can also use --channel flag)
|
|
471
|
+
CHECK_INTERVAL_SECONDS Polling interval in seconds (default: 60)
|
|
472
|
+
LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
473
|
+
|
|
474
|
+
Configuration Methods:
|
|
475
|
+
1. Environment variables:
|
|
476
|
+
export SLACK_BOT_TOKEN=xoxb-your-token-here
|
|
477
|
+
export SLACK_CHANNEL=bug-reports
|
|
478
|
+
|
|
479
|
+
2. .env file (in project root):
|
|
480
|
+
SLACK_BOT_TOKEN=xoxb-your-token-here
|
|
481
|
+
SLACK_CHANNEL=bug-reports
|
|
482
|
+
|
|
483
|
+
Generating a Slack Bot Token:
|
|
484
|
+
1. Go to https://api.slack.com/apps and create a new app
|
|
485
|
+
2. Under "OAuth & Permissions", add the required scopes:
|
|
486
|
+
- channels:history, channels:read (public channels)
|
|
487
|
+
- groups:history, groups:read (private channels)
|
|
488
|
+
- users:read (user info)
|
|
489
|
+
- chat:write (for slack_respond.py)
|
|
490
|
+
3. Install the app to your workspace
|
|
491
|
+
4. Copy the "Bot User OAuth Token" (starts with xoxb-)
|
|
492
|
+
|
|
493
|
+
Full tutorial: """ + SLACK_TOKEN_DOCS_URL + """
|
|
494
|
+
|
|
495
|
+
Example .env file:
|
|
496
|
+
SLACK_BOT_TOKEN=xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwxyz
|
|
497
|
+
SLACK_CHANNEL=bug-reports
|
|
498
|
+
CHECK_INTERVAL_SECONDS=120
|
|
499
|
+
LOG_LEVEL=INFO
|
|
500
|
+
""")
|
|
501
|
+
print("=" * 70 + "\n")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def main_loop(args: argparse.Namespace) -> int:
|
|
505
|
+
"""Main monitoring loop."""
|
|
506
|
+
# Load environment variables from .env file
|
|
507
|
+
# load_dotenv() looks for .env in current directory and parent directories
|
|
508
|
+
load_dotenv()
|
|
509
|
+
|
|
510
|
+
# Also try loading from project root .env if running from a subdirectory
|
|
511
|
+
project_root = Path.cwd()
|
|
512
|
+
env_file = project_root / '.env'
|
|
513
|
+
if env_file.exists():
|
|
514
|
+
load_dotenv(env_file)
|
|
515
|
+
|
|
516
|
+
# Also check .juno_task/.env for project-specific config
|
|
517
|
+
juno_env_file = project_root / '.juno_task' / '.env'
|
|
518
|
+
if juno_env_file.exists():
|
|
519
|
+
load_dotenv(juno_env_file)
|
|
520
|
+
|
|
521
|
+
# Setup logging
|
|
522
|
+
setup_logging(verbose=args.verbose)
|
|
523
|
+
|
|
524
|
+
logger.info("=" * 70)
|
|
525
|
+
logger.info("Slack Fetch - Creating kanban tasks from Slack messages")
|
|
526
|
+
logger.info("=" * 70)
|
|
527
|
+
|
|
528
|
+
# Validate environment
|
|
529
|
+
bot_token, env_channel, errors = validate_slack_environment()
|
|
530
|
+
|
|
531
|
+
if errors:
|
|
532
|
+
for error in errors:
|
|
533
|
+
logger.error(error)
|
|
534
|
+
print_env_help()
|
|
535
|
+
return 1
|
|
536
|
+
|
|
537
|
+
# Get channel from args or env
|
|
538
|
+
channel = args.channel or env_channel
|
|
539
|
+
if not channel:
|
|
540
|
+
logger.error("No channel specified. Use --channel or set SLACK_CHANNEL environment variable")
|
|
541
|
+
print("\nHint: Set SLACK_CHANNEL in your .env file or pass --channel flag")
|
|
542
|
+
return 1
|
|
543
|
+
|
|
544
|
+
# Find project root and kanban script
|
|
545
|
+
project_dir = Path.cwd()
|
|
546
|
+
kanban_script = find_kanban_script(project_dir)
|
|
547
|
+
if not kanban_script:
|
|
548
|
+
logger.error("Cannot find kanban.sh script. Is the project initialized?")
|
|
549
|
+
return 1
|
|
550
|
+
|
|
551
|
+
# Initialize Slack client
|
|
552
|
+
logger.info("Initializing Slack client...")
|
|
553
|
+
client = WebClient(token=bot_token)
|
|
554
|
+
|
|
555
|
+
# Test connection
|
|
556
|
+
try:
|
|
557
|
+
auth_response = client.auth_test()
|
|
558
|
+
team_name = auth_response.get('team')
|
|
559
|
+
logger.info(f"Connected to Slack workspace: {team_name}")
|
|
560
|
+
except SlackApiError as e:
|
|
561
|
+
logger.error(f"Failed to connect to Slack: {e.response['error']}")
|
|
562
|
+
return 1
|
|
563
|
+
|
|
564
|
+
# Resolve channel
|
|
565
|
+
channel_id = get_channel_id(client, channel)
|
|
566
|
+
if not channel_id:
|
|
567
|
+
logger.error(f"Could not find channel '{channel}'. Make sure the bot is invited.")
|
|
568
|
+
return 1
|
|
569
|
+
|
|
570
|
+
# Initialize state manager
|
|
571
|
+
state_dir = project_dir / '.juno_task' / 'slack'
|
|
572
|
+
state_file = state_dir / 'slack.ndjson'
|
|
573
|
+
logger.info(f"Initializing state manager: {state_file}")
|
|
574
|
+
state_mgr = SlackStateManager(str(state_file))
|
|
575
|
+
|
|
576
|
+
# Get check interval
|
|
577
|
+
check_interval = int(os.getenv('CHECK_INTERVAL_SECONDS', 60))
|
|
578
|
+
|
|
579
|
+
# Register signal handlers
|
|
580
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
581
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
582
|
+
|
|
583
|
+
if args.dry_run:
|
|
584
|
+
logger.info("Running in DRY RUN mode - no tasks will be created")
|
|
585
|
+
|
|
586
|
+
logger.info(f"Monitoring channel #{channel} (ID: {channel_id})")
|
|
587
|
+
logger.info(f"Check interval: {check_interval} seconds")
|
|
588
|
+
logger.info(f"Mode: {'once' if args.once else 'continuous'}")
|
|
589
|
+
logger.info("-" * 70)
|
|
590
|
+
|
|
591
|
+
# Main loop
|
|
592
|
+
iteration = 0
|
|
593
|
+
total_processed = 0
|
|
594
|
+
|
|
595
|
+
while not shutdown_requested:
|
|
596
|
+
iteration += 1
|
|
597
|
+
logger.debug(f"Starting iteration {iteration}")
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
# Get last timestamp
|
|
601
|
+
last_ts = state_mgr.get_last_timestamp()
|
|
602
|
+
|
|
603
|
+
# Fetch new messages
|
|
604
|
+
messages = fetch_channel_messages(client, channel_id, oldest_ts=last_ts)
|
|
605
|
+
|
|
606
|
+
# Filter out already processed
|
|
607
|
+
new_messages = [m for m in messages if not state_mgr.is_processed(m.get('ts', ''))]
|
|
608
|
+
|
|
609
|
+
if new_messages:
|
|
610
|
+
processed = process_messages(
|
|
611
|
+
new_messages,
|
|
612
|
+
channel,
|
|
613
|
+
channel_id,
|
|
614
|
+
client,
|
|
615
|
+
state_mgr,
|
|
616
|
+
kanban_script,
|
|
617
|
+
dry_run=args.dry_run
|
|
618
|
+
)
|
|
619
|
+
total_processed += processed
|
|
620
|
+
logger.info(f"Processed {processed} messages (total: {total_processed})")
|
|
621
|
+
else:
|
|
622
|
+
logger.debug("No new messages")
|
|
623
|
+
|
|
624
|
+
# Exit if --once mode
|
|
625
|
+
if args.once:
|
|
626
|
+
logger.info("--once mode: exiting after single check")
|
|
627
|
+
break
|
|
628
|
+
|
|
629
|
+
# Sleep
|
|
630
|
+
if not shutdown_requested:
|
|
631
|
+
logger.debug(f"Sleeping for {check_interval} seconds...")
|
|
632
|
+
time.sleep(check_interval)
|
|
633
|
+
|
|
634
|
+
except KeyboardInterrupt:
|
|
635
|
+
logger.info("Keyboard interrupt received")
|
|
636
|
+
break
|
|
637
|
+
except Exception as e:
|
|
638
|
+
logger.error(f"Error in main loop: {e}", exc_info=True)
|
|
639
|
+
if args.once:
|
|
640
|
+
return 1
|
|
641
|
+
time.sleep(check_interval)
|
|
642
|
+
|
|
643
|
+
# Shutdown
|
|
644
|
+
logger.info("-" * 70)
|
|
645
|
+
logger.info(f"Shutting down. Created {total_processed} kanban tasks.")
|
|
646
|
+
logger.info(f"Total processed messages: {state_mgr.get_message_count()}")
|
|
647
|
+
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def main() -> int:
|
|
652
|
+
"""Main entry point."""
|
|
653
|
+
parser = argparse.ArgumentParser(
|
|
654
|
+
description='Fetch Slack messages and create kanban tasks',
|
|
655
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
656
|
+
epilog="""
|
|
657
|
+
Examples:
|
|
658
|
+
%(prog)s --channel bug-reports # Run once (default)
|
|
659
|
+
%(prog)s --channel feature-requests --continuous # Continuous monitoring
|
|
660
|
+
%(prog)s --channel general --dry-run --verbose # Test mode
|
|
661
|
+
|
|
662
|
+
Environment Variables:
|
|
663
|
+
SLACK_BOT_TOKEN Slack bot token (required)
|
|
664
|
+
SLACK_CHANNEL Default channel to monitor
|
|
665
|
+
CHECK_INTERVAL_SECONDS Polling interval (default: 60)
|
|
666
|
+
LOG_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO)
|
|
667
|
+
|
|
668
|
+
Notes:
|
|
669
|
+
- Messages are tagged with 'slack-input' and 'author_<name>'
|
|
670
|
+
- State is persisted to .juno_task/slack/slack.ndjson
|
|
671
|
+
- Use Ctrl+C for graceful shutdown
|
|
672
|
+
"""
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
parser.add_argument(
|
|
676
|
+
'--channel',
|
|
677
|
+
help='Slack channel name to monitor (with or without #)'
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
mode_group = parser.add_mutually_exclusive_group()
|
|
681
|
+
mode_group.add_argument(
|
|
682
|
+
'--once',
|
|
683
|
+
dest='once',
|
|
684
|
+
action='store_true',
|
|
685
|
+
default=True,
|
|
686
|
+
help='Run once and exit (DEFAULT)'
|
|
687
|
+
)
|
|
688
|
+
mode_group.add_argument(
|
|
689
|
+
'--continuous',
|
|
690
|
+
dest='once',
|
|
691
|
+
action='store_false',
|
|
692
|
+
help='Run continuously with polling'
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
parser.add_argument(
|
|
696
|
+
'--dry-run',
|
|
697
|
+
action='store_true',
|
|
698
|
+
help='Show what would be done without creating tasks'
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
parser.add_argument(
|
|
702
|
+
'--verbose', '-v',
|
|
703
|
+
action='store_true',
|
|
704
|
+
help='Enable DEBUG level logging'
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
args = parser.parse_args()
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
return main_loop(args)
|
|
711
|
+
except Exception as e:
|
|
712
|
+
logger.error(f"Fatal error: {e}", exc_info=True)
|
|
713
|
+
return 1
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
if __name__ == '__main__':
|
|
717
|
+
sys.exit(main())
|