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.
@@ -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-YOUR-BOT-TOKEN-HERE
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())