Skip to content

Commit 688fa4f

Browse files
barckcodeclaude
andcommitted
Add Markdown to Slack mrkdwn conversion and fix polling duplicates
- Add md_to_mrkdwn() to convert Markdown output to Slack mrkdwn format (headers, bold, links, horizontal rules) - Apply conversion in both slack_handler and api before posting to Slack - CronJob now moves tasks to In Progress (status 4) before notifying Bender, preventing duplicate polling of the same task Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 25ceb70 commit 688fa4f

5 files changed

Lines changed: 114 additions & 5 deletions

File tree

helm/templates/cronjob.yaml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,46 @@ data:
2929
log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; }
3030
error() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; }
3131
32+
move_to_in_progress() {
33+
task_json="$1"
34+
task_id=$(echo "$task_json" | jq -r '.id')
35+
headline=$(echo "$task_json" | jq -r '.headline // empty')
36+
description=$(echo "$task_json" | jq -r '.description // empty')
37+
tags=$(echo "$task_json" | jq -r '.tags // empty')
38+
sprint=$(echo "$task_json" | jq -r '.sprint // empty')
39+
priority=$(echo "$task_json" | jq -r '.priority // "3"')
40+
milestoneid=$(echo "$task_json" | jq -r '.milestoneid // empty')
41+
42+
update_response=$(curl -s "${LEANTIME_URL}/api/jsonrpc" \
43+
-H "x-api-key: ${LEANTIME_API_KEY}" \
44+
-H "Content-Type: application/json" \
45+
-d "{
46+
\"jsonrpc\": \"2.0\",
47+
\"method\": \"leantime.rpc.tickets.updateTicket\",
48+
\"params\": {
49+
\"values\": {
50+
\"id\": ${task_id},
51+
\"projectId\": ${PROJECT_ID},
52+
\"headline\": \"${headline}\",
53+
\"status\": \"4\",
54+
\"priority\": \"${priority}\",
55+
\"sprint\": ${sprint},
56+
\"editorId\": ${EDITOR_ID},
57+
\"tags\": \"${tags}\",
58+
\"milestoneid\": ${milestoneid}
59+
}
60+
},
61+
\"id\": 1
62+
}" 2>&1)
63+
64+
if printf '%s' "$update_response" | tr -d '\000-\011\013-\037' | jq -e '.result' > /dev/null 2>&1; then
65+
log "Task $task_id moved to In Progress (status 4)"
66+
else
67+
error "Failed to move task $task_id to In Progress"
68+
return 1
69+
fi
70+
}
71+
3272
notify_bender() {
3373
task_id=$(echo "$1" | jq -r '.id')
3474
@@ -133,7 +173,9 @@ data:
133173
task_id=$(echo "$task" | jq -r '.id')
134174
log "Processing task $task_id..."
135175
if validate_task "$task"; then
136-
notify_bender "$task"
176+
if move_to_in_progress "$task"; then
177+
notify_bender "$task"
178+
fi
137179
fi
138180
task_count=$((task_count + 1))
139181
fi

src/bender/api.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from bender.claude_code import ClaudeCodeError, invoke_claude
1212
from bender.config import Settings
1313
from bender.session_manager import SessionManager
14-
from bender.slack_utils import SLACK_MSG_LIMIT, split_text
14+
from bender.slack_utils import SLACK_MSG_LIMIT, md_to_mrkdwn, split_text
1515

1616
logger = logging.getLogger(__name__)
1717

@@ -105,7 +105,8 @@ async def invoke(request: InvokeRequest) -> InvokeResponse:
105105
) from exc
106106

107107
# Post the response in the thread, splitting long messages
108-
chunks = split_text(response.result, SLACK_MSG_LIMIT)
108+
formatted = md_to_mrkdwn(response.result)
109+
chunks = split_text(formatted, SLACK_MSG_LIMIT)
109110
for chunk in chunks:
110111
await slack_client.chat_postMessage(
111112
channel=request.channel,

src/bender/slack_handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from bender.claude_code import ClaudeCodeError, invoke_claude
99
from bender.config import Settings
1010
from bender.session_manager import SessionManager
11-
from bender.slack_utils import SLACK_MSG_LIMIT, split_text
11+
from bender.slack_utils import SLACK_MSG_LIMIT, md_to_mrkdwn, split_text
1212

1313
logger = logging.getLogger(__name__)
1414

@@ -86,6 +86,8 @@ def _strip_mention(text: str) -> str:
8686

8787
async def _post_response(say, text: str, thread_ts: str) -> None:
8888
"""Post a response in the thread, splitting if it exceeds Slack's limit."""
89+
text = md_to_mrkdwn(text)
90+
8991
if len(text) <= SLACK_MSG_LIMIT:
9092
await say(text=text, thread_ts=thread_ts)
9193
return

src/bender/slack_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
11
"""Shared Slack utilities — message splitting and formatting."""
22

3+
import re
4+
35
# Slack message character limit
46
SLACK_MSG_LIMIT = 4000
57

68

9+
def md_to_mrkdwn(text: str) -> str:
10+
"""Convert standard Markdown to Slack mrkdwn format."""
11+
lines = text.split("\n")
12+
result: list[str] = []
13+
14+
for line in lines:
15+
# Headers → bold (Slack has no heading syntax)
16+
line = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", line)
17+
18+
# Horizontal rules → empty line
19+
if re.match(r"^---+\s*$", line):
20+
result.append("")
21+
continue
22+
23+
# Bold: **text** → *text*
24+
line = re.sub(r"\*\*(.+?)\*\*", r"*\1*", line)
25+
26+
# Markdown links: [text](url) → <url|text>
27+
line = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>", line)
28+
29+
result.append(line)
30+
31+
return "\n".join(result)
32+
33+
734
def split_text(text: str, max_length: int = SLACK_MSG_LIMIT) -> list[str]:
835
"""Split text into chunks, preferring to break at newlines."""
936
chunks: list[str] = []

tests/test_slack_utils.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for the Slack utilities module."""
22

3-
from bender.slack_utils import SLACK_MSG_LIMIT, split_text
3+
from bender.slack_utils import SLACK_MSG_LIMIT, md_to_mrkdwn, split_text
44

55

66
class TestSlackMsgLimit:
@@ -63,3 +63,40 @@ def test_newline_stripped_between_chunks(self) -> None:
6363
result = split_text(text, 6)
6464
assert result[0] == "abcde"
6565
assert result[1] == "fghij"
66+
67+
68+
class TestMdToMrkdwn:
69+
"""Tests for the md_to_mrkdwn function."""
70+
71+
def test_headers_to_bold(self) -> None:
72+
"""Markdown headers become bold text."""
73+
assert md_to_mrkdwn("## Hello") == "*Hello*"
74+
assert md_to_mrkdwn("### World") == "*World*"
75+
assert md_to_mrkdwn("# Title") == "*Title*"
76+
77+
def test_bold_double_asterisk(self) -> None:
78+
"""Double asterisks become single asterisks."""
79+
assert md_to_mrkdwn("this is **bold** text") == "this is *bold* text"
80+
81+
def test_links(self) -> None:
82+
"""Markdown links become Slack links."""
83+
assert md_to_mrkdwn("[click](https://example.com)") == "<https://example.com|click>"
84+
85+
def test_horizontal_rule(self) -> None:
86+
"""Horizontal rules become empty lines."""
87+
assert md_to_mrkdwn("above\n---\nbelow") == "above\n\nbelow"
88+
89+
def test_plain_text_unchanged(self) -> None:
90+
"""Plain text passes through unchanged."""
91+
assert md_to_mrkdwn("just plain text") == "just plain text"
92+
93+
def test_code_blocks_preserved(self) -> None:
94+
"""Code blocks are not modified."""
95+
text = "```\nkubectl get pods\n```"
96+
assert md_to_mrkdwn(text) == text
97+
98+
def test_combined(self) -> None:
99+
"""Multiple conversions in one message."""
100+
md = "## Task\n**Client:** helmcode\n[Link](https://example.com)"
101+
expected = "*Task*\n*Client:* helmcode\n<https://example.com|Link>"
102+
assert md_to_mrkdwn(md) == expected

0 commit comments

Comments
 (0)