Skip to content

Commit 7bb501b

Browse files
authored
Merge pull request #62 from downstairs-dawgs/dominik/listen-mode
Add clacks listen command for polling new messages
2 parents c77af9c + 7db1eb4 commit 7bb501b

File tree

7 files changed

+539
-3
lines changed

7 files changed

+539
-3
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "slack-clacks"
3-
version = "0.4.3"
3+
version = "0.5.0"
44
description = "the default mode of degenerate communication."
55
readme = "README.md"
66
license = { text = "MIT" }

src/slack_clacks/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from slack_clacks.auth.cli import generate_cli as generate_auth_cli
55
from slack_clacks.configuration.cli import generate_cli as generate_config_cli
6+
from slack_clacks.listen.cli import generate_listen_parser
67
from slack_clacks.messaging.cli import (
78
generate_delete_parser,
89
generate_react_parser,
@@ -95,4 +96,12 @@ def generate_cli() -> argparse.ArgumentParser:
9596
help=skill_parser.description,
9697
)
9798

99+
listen_parser = generate_listen_parser()
100+
subparsers.add_parser(
101+
"listen",
102+
parents=[listen_parser],
103+
add_help=False,
104+
help=listen_parser.description,
105+
)
106+
98107
return parser
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Slack channel listening operations.
3+
"""

src/slack_clacks/listen/cli.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""
2+
CLI for listen command.
3+
"""
4+
5+
import argparse
6+
import json
7+
import sys
8+
9+
from slack_clacks.auth.client import create_client
10+
from slack_clacks.configuration.database import (
11+
ensure_db_updated,
12+
get_current_context,
13+
get_session,
14+
)
15+
from slack_clacks.listen.operations import listen_channel
16+
from slack_clacks.messaging.operations import (
17+
resolve_channel_id,
18+
resolve_user_id,
19+
)
20+
21+
22+
def handle_listen(args: argparse.Namespace) -> None:
23+
ensure_db_updated(config_dir=args.config_dir)
24+
with get_session(args.config_dir) as session:
25+
context = get_current_context(session)
26+
if context is None:
27+
raise ValueError(
28+
"No active authentication context. Authenticate with: clacks auth login"
29+
)
30+
31+
client = create_client(context.access_token, context.app_type)
32+
33+
# Resolve channel
34+
channel_id = resolve_channel_id(client, args.channel, session, context.name)
35+
36+
# Resolve from_user if specified
37+
from_user_id: str | None = None
38+
if args.from_user:
39+
from_user_id = resolve_user_id(
40+
client, args.from_user, session, context.name
41+
)
42+
43+
messages_received = 0
44+
45+
try:
46+
for msg in listen_channel(
47+
client,
48+
channel_id,
49+
thread_ts=args.thread_ts,
50+
interval=args.interval,
51+
timeout=args.timeout,
52+
include_history=args.include_history,
53+
):
54+
# Filter by from_user if specified
55+
if from_user_id and msg.get("user") != from_user_id:
56+
continue
57+
58+
# Filter out bot messages unless --include-bots
59+
if not args.include_bots:
60+
if msg.get("bot_id") or msg.get("subtype") == "bot_message":
61+
continue
62+
63+
messages_received += 1
64+
line = json.dumps(msg)
65+
args.outfile.write(line + "\n")
66+
args.outfile.flush()
67+
68+
except KeyboardInterrupt:
69+
pass
70+
finally:
71+
# Print final status to stderr
72+
status = {"status": "stopped", "messages_received": messages_received}
73+
print(json.dumps(status), file=sys.stderr)
74+
75+
76+
def generate_listen_parser() -> argparse.ArgumentParser:
77+
parser = argparse.ArgumentParser(
78+
description="Listen for new messages in a channel or thread",
79+
formatter_class=argparse.RawDescriptionHelpFormatter,
80+
)
81+
82+
parser.add_argument(
83+
"channel",
84+
type=str,
85+
help="Channel name, ID, or alias (e.g., #general, C123456)",
86+
)
87+
parser.add_argument(
88+
"-D",
89+
"--config-dir",
90+
type=str,
91+
help="Configuration directory (default: platform-specific user config dir)",
92+
)
93+
parser.add_argument(
94+
"--thread",
95+
dest="thread_ts",
96+
type=str,
97+
help="Thread timestamp to listen to replies instead of channel",
98+
)
99+
parser.add_argument(
100+
"--from",
101+
dest="from_user",
102+
type=str,
103+
help="Filter messages by sender (name, ID, or alias)",
104+
)
105+
parser.add_argument(
106+
"--timeout",
107+
type=float,
108+
default=None,
109+
help="Exit after N seconds (default: infinite)",
110+
)
111+
parser.add_argument(
112+
"--interval",
113+
type=float,
114+
default=2.0,
115+
help="Poll interval in seconds (default: 2.0)",
116+
)
117+
parser.add_argument(
118+
"--include-history",
119+
type=int,
120+
default=0,
121+
help="Include last N messages on start (default: 0)",
122+
)
123+
parser.add_argument(
124+
"--include-bots",
125+
action="store_true",
126+
help="Include bot messages (excluded by default)",
127+
)
128+
parser.add_argument(
129+
"-o",
130+
"--outfile",
131+
type=argparse.FileType("a"),
132+
default=sys.stdout,
133+
help="Output file for NDJSON results (default: stdout)",
134+
)
135+
parser.set_defaults(func=handle_listen)
136+
137+
return parser
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Core listen operations using Slack Web API.
3+
"""
4+
5+
import time
6+
from collections.abc import Iterator
7+
from datetime import datetime, timezone
8+
from typing import Any
9+
10+
from slack_sdk import WebClient
11+
12+
13+
def listen_channel(
14+
client: WebClient,
15+
channel_id: str,
16+
thread_ts: str | None = None,
17+
interval: float = 2.0,
18+
timeout: float | None = None,
19+
include_history: int = 0,
20+
) -> Iterator[dict]:
21+
"""
22+
Yield new messages as they appear in channel or thread.
23+
24+
Args:
25+
client: Slack WebClient instance
26+
channel_id: Channel ID to listen to
27+
thread_ts: If provided, listen to thread replies instead of channel
28+
interval: Poll interval in seconds (default: 2.0)
29+
timeout: Exit after this many seconds (default: None = infinite)
30+
include_history: Include last N messages on start (default: 0)
31+
32+
Yields:
33+
Message dicts with 'received_at' ISO timestamp added
34+
"""
35+
start_time = time.monotonic()
36+
latest_ts: str | None = None
37+
38+
# Fetch history if requested
39+
if include_history > 0:
40+
messages: list[Any]
41+
if thread_ts:
42+
response = client.conversations_replies(
43+
channel=channel_id, ts=thread_ts, limit=include_history
44+
)
45+
messages = response.get("messages", [])
46+
# First message is parent, rest are replies
47+
if len(messages) > 1:
48+
messages = messages[1:] # Skip parent
49+
else:
50+
messages = []
51+
else:
52+
response = client.conversations_history(
53+
channel=channel_id, limit=include_history
54+
)
55+
messages = response.get("messages", [])
56+
57+
# Messages come in reverse chronological order, reverse to chronological
58+
messages = list(reversed(messages))
59+
60+
for msg in messages:
61+
msg["received_at"] = datetime.now(timezone.utc).isoformat()
62+
yield msg
63+
# Track latest timestamp seen
64+
msg_ts = msg.get("ts")
65+
if msg_ts and (latest_ts is None or msg_ts > latest_ts):
66+
latest_ts = msg_ts
67+
68+
# If no history fetched, start from now
69+
if latest_ts is None:
70+
latest_ts = str(time.time())
71+
72+
# Poll for new messages
73+
while True:
74+
if timeout is not None:
75+
elapsed = time.monotonic() - start_time
76+
if elapsed >= timeout:
77+
break
78+
79+
time.sleep(interval)
80+
81+
# Add epsilon to make oldest exclusive (Slack's oldest is inclusive)
82+
exclusive_oldest = str(float(latest_ts) + 0.000001)
83+
84+
if thread_ts:
85+
response = client.conversations_replies(
86+
channel=channel_id, ts=thread_ts, oldest=exclusive_oldest
87+
)
88+
messages = response.get("messages", [])
89+
# Filter out parent and already-seen messages
90+
messages = [
91+
m
92+
for m in messages
93+
if m.get("ts") != thread_ts and m.get("ts", "") > latest_ts
94+
]
95+
else:
96+
response = client.conversations_history(
97+
channel=channel_id, oldest=exclusive_oldest
98+
)
99+
messages = response.get("messages", [])
100+
# Filter already-seen messages (defensive deduplication)
101+
messages = [m for m in messages if m.get("ts", "") > latest_ts]
102+
103+
# Messages come in reverse chronological order, reverse to chronological
104+
messages = list(reversed(messages))
105+
106+
for msg in messages:
107+
msg["received_at"] = datetime.now(timezone.utc).isoformat()
108+
yield msg
109+
# Track latest timestamp seen
110+
msg_ts = msg.get("ts")
111+
if msg_ts and (latest_ts is None or msg_ts > latest_ts):
112+
latest_ts = msg_ts

src/slack_clacks/skill/content.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
---
77
name: clacks
88
description: >-
9-
Send and read Slack messages using clacks CLI.
10-
Use when user asks to send Slack messages, read channels, or interact with Slack.
9+
Send and read Slack messages using clacks CLI. Use when user asks to send
10+
Slack messages, read channels, wait for responses, or interact with Slack.
1111
---
1212
1313
# Slack Integration via Clacks
@@ -102,6 +102,54 @@
102102
uvx --from slack-clacks clacks rolodex list -T channel
103103
```
104104
105+
## Listening for Messages
106+
107+
Listen for new messages in a channel (outputs NDJSON, one message per line):
108+
```bash
109+
uvx --from slack-clacks clacks listen "#general"
110+
```
111+
112+
Listen with history (fetch last N messages first):
113+
```bash
114+
uvx --from slack-clacks clacks listen "#general" --include-history 5
115+
```
116+
117+
Listen to thread replies:
118+
```bash
119+
uvx --from slack-clacks clacks listen "#general" --thread "1234567890.123456"
120+
```
121+
122+
Filter by sender (wait for response from specific user):
123+
```bash
124+
uvx --from slack-clacks clacks listen "#general" --from "@username"
125+
```
126+
127+
Set timeout (exit after N seconds):
128+
```bash
129+
uvx --from slack-clacks clacks listen "#general" --timeout 300
130+
```
131+
132+
Options:
133+
- `--interval SECONDS` - Poll interval (default: 2.0)
134+
- `--include-bots` - Include bot messages (excluded by default)
135+
- `-o FILE` - Write to file instead of stdout
136+
137+
### When to Use Listen
138+
139+
Use `clacks listen` when:
140+
- Waiting for a response from someone after sending a message
141+
- Monitoring a channel for new activity
142+
- Waiting for a specific user to reply in a thread
143+
144+
Example workflow - send message and wait for reply:
145+
```bash
146+
# Send a message
147+
uvx --from slack-clacks clacks send -c "#general" -m "Question for @alice"
148+
149+
# Wait for alice's response (timeout after 5 minutes)
150+
uvx --from slack-clacks clacks listen "#general" --from "@alice" --timeout 300
151+
```
152+
105153
## Context Management
106154
107155
List available contexts:
@@ -122,4 +170,5 @@
122170
## Output
123171
124172
All commands output JSON to stdout.
173+
The `listen` command outputs NDJSON (one JSON object per line).
125174
"""

0 commit comments

Comments
 (0)