Skip to content

Commit b9cd975

Browse files
authored
Close lost Discord gig posts
Close Discord gig forum posts when status changes to lost and keep dashboard/command thread status sync behavior consistent.
1 parent 73c34dc commit b9cd975

4 files changed

Lines changed: 344 additions & 18 deletions

File tree

apps/discord_bot/src/five08/discord_bot/cogs/jobs.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2373,7 +2373,18 @@ async def _rename_gig_thread_for_status(
23732373
base_title = stripped_title
23742374
base_title = base_title.strip() or f"Discord gig {thread.id}"
23752375
next_name = f"[{status_marker}] {base_title}"[:100]
2376-
if thread.name == next_name:
2376+
should_close_thread = status is EngagementStatus.LOST
2377+
was_lost_thread = parse_status_from_title(raw_title) is EngagementStatus.LOST
2378+
is_locked = bool(getattr(thread, "locked", False))
2379+
is_archived = bool(getattr(thread, "archived", False))
2380+
needs_rename = thread.name != next_name
2381+
needs_reopen = (
2382+
not should_close_thread and was_lost_thread and (is_locked or is_archived)
2383+
)
2384+
needs_close = should_close_thread and (not is_locked or not is_archived)
2385+
needs_unarchive_for_rename = needs_rename and is_archived
2386+
needs_restore_closed = should_close_thread and needs_unarchive_for_rename
2387+
if not needs_rename and not needs_close and not needs_reopen:
23772388
return next_name
23782389

23792390
if thread.guild is None or thread.guild.me is None:
@@ -2382,9 +2393,14 @@ async def _rename_gig_thread_for_status(
23822393
if not permissions.manage_threads:
23832394
raise PermissionError("missing_manage_threads_permission")
23842395

2385-
if thread.archived:
2396+
if needs_reopen:
2397+
await thread.edit(locked=False, archived=False, reason=reason)
2398+
elif needs_unarchive_for_rename:
23862399
await thread.edit(archived=False, reason=reason)
2387-
await thread.edit(name=next_name, reason=reason)
2400+
if needs_rename:
2401+
await thread.edit(name=next_name, reason=reason)
2402+
if needs_close or needs_restore_closed:
2403+
await thread.edit(locked=True, archived=True, reason=reason)
23882404
return next_name
23892405

23902406
@staticmethod
@@ -3747,10 +3763,15 @@ async def update_gig_status(
37473763
)
37483764
return
37493765

3766+
close_note = (
3767+
" and closed this thread"
3768+
if normalized_status is EngagementStatus.LOST
3769+
else ""
3770+
)
37503771
await interaction.followup.send(
37513772
"✅ Updated status to "
3752-
f"**{status_label(normalized_status)}** and renamed this thread to "
3753-
f"`{next_title}`.",
3773+
f"**{status_label(normalized_status)}**, renamed this thread to "
3774+
f"`{next_title}`{close_note}.",
37543775
ephemeral=True,
37553776
)
37563777

apps/discord_bot/src/five08/discord_bot/utils/internal_api.py

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
from pydantic import BaseModel, ValidationError
1212

1313
from five08.discord_bot.config import settings
14-
from five08.engagements import normalize_engagement_status, strip_status_from_title
14+
from five08.engagements import (
15+
EngagementStatus,
16+
normalize_engagement_status,
17+
parse_status_from_title,
18+
strip_status_from_title,
19+
)
1520

1621
logger = logging.getLogger(__name__)
1722

@@ -335,36 +340,70 @@ async def _update_gig_thread_status(
335340
**permission_payload,
336341
}, 403
337342

338-
base_title = strip_status_from_title(channel.name) or channel.name
343+
raw_title = str(channel.name or "").strip()
344+
stripped_title = strip_status_from_title(raw_title)
345+
if (
346+
parse_status_from_title(raw_title) is not EngagementStatus.UNKNOWN
347+
and stripped_title == raw_title
348+
):
349+
base_title = ""
350+
else:
351+
base_title = stripped_title
339352
base_title = base_title.strip() or f"Discord gig {thread_id}"
340353
next_name = f"[{status_marker}] {base_title}"[:100]
341-
if channel.name == next_name:
354+
should_close_thread = normalized_status is EngagementStatus.LOST
355+
was_lost_thread = parse_status_from_title(raw_title) is EngagementStatus.LOST
356+
is_locked = bool(getattr(channel, "locked", False))
357+
is_archived = bool(getattr(channel, "archived", False))
358+
needs_rename = channel.name != next_name
359+
needs_reopen = (
360+
not should_close_thread and was_lost_thread and (is_locked or is_archived)
361+
)
362+
needs_close = should_close_thread and (not is_locked or not is_archived)
363+
needs_unarchive_for_rename = needs_rename and is_archived
364+
needs_restore_closed = should_close_thread and needs_unarchive_for_rename
365+
if not needs_rename and not needs_close and not needs_reopen:
342366
return {
343367
"status": "unchanged",
344368
"thread_id": str(thread_id),
345369
"title": next_name,
370+
"closed": should_close_thread,
346371
}, 200
347372

348373
try:
349-
if channel.archived:
374+
if needs_reopen:
350375
await channel.edit(
376+
locked=False,
351377
archived=False,
352378
reason="Dashboard gig status update",
353379
)
354-
await channel.edit(
355-
name=next_name,
356-
reason="Dashboard gig status update",
357-
)
380+
elif needs_unarchive_for_rename:
381+
await channel.edit(
382+
archived=False,
383+
reason="Dashboard gig status update",
384+
)
385+
if needs_rename:
386+
await channel.edit(
387+
name=next_name,
388+
reason="Dashboard gig status update",
389+
)
390+
if needs_close or needs_restore_closed:
391+
await channel.edit(
392+
locked=True,
393+
archived=True,
394+
reason="Dashboard gig status update",
395+
)
358396
except discord.Forbidden:
359-
return {"error": "thread_rename_forbidden"}, 403
397+
return {"error": "thread_update_forbidden"}, 403
360398
except discord.HTTPException as exc:
361-
logger.warning("Failed renaming gig thread %s: %s", thread_id, exc)
362-
return {"error": "thread_rename_failed"}, 502
399+
logger.warning("Failed updating gig thread %s: %s", thread_id, exc)
400+
return {"error": "thread_update_failed"}, 502
363401

364402
return {
365403
"status": "updated",
366404
"thread_id": str(thread_id),
367405
"title": next_name,
406+
"closed": should_close_thread,
368407
}, 200
369408

370409
async def gig_thread_status_handler(self, request: web.Request) -> web.Response:

tests/unit/test_internal_api.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
from types import SimpleNamespace
55
import json
6-
from unittest.mock import AsyncMock, Mock
6+
from unittest.mock import AsyncMock, Mock, call
77

88
import discord
99
import pytest
@@ -162,6 +162,182 @@ def permissions_for(self, _member: object) -> SimpleNamespace:
162162
reason="Dashboard gig status update",
163163
)
164164

165+
@pytest.mark.asyncio
166+
async def test_update_gig_thread_status_uses_fallback_for_marker_only_title(
167+
self, internal_api_routes, monkeypatch: pytest.MonkeyPatch
168+
):
169+
"""Dashboard status sync should not stack markers for marker-only titles."""
170+
171+
class FakeThread:
172+
id = 123
173+
name = "[RECRUITING]"
174+
archived = False
175+
locked = False
176+
guild = SimpleNamespace(me=object())
177+
178+
def __init__(self) -> None:
179+
self.edit = AsyncMock()
180+
181+
def permissions_for(self, _member: object) -> SimpleNamespace:
182+
return SimpleNamespace(
183+
manage_threads=True,
184+
view_channel=True,
185+
send_messages_in_threads=True,
186+
)
187+
188+
thread = FakeThread()
189+
monkeypatch.setattr(
190+
"five08.discord_bot.utils.internal_api.discord.Thread",
191+
FakeThread,
192+
)
193+
internal_api_routes.bot.get_channel.return_value = thread
194+
195+
result, status_code = await internal_api_routes._update_gig_thread_status(
196+
GigThreadStatusRequest(thread_id="123", status="filled")
197+
)
198+
199+
assert status_code == 200
200+
assert result["title"] == "[FILLED] Discord gig 123"
201+
thread.edit.assert_awaited_once_with(
202+
name="[FILLED] Discord gig 123",
203+
reason="Dashboard gig status update",
204+
)
205+
206+
@pytest.mark.asyncio
207+
async def test_update_gig_thread_status_closes_lost_thread(
208+
self, internal_api_routes, monkeypatch: pytest.MonkeyPatch
209+
):
210+
"""Dashboard lost status changes should lock and archive the Discord thread."""
211+
212+
class FakeThread:
213+
id = 123
214+
name = "[RECRUITING] Old gig"
215+
archived = False
216+
locked = False
217+
guild = SimpleNamespace(me=object())
218+
219+
def __init__(self) -> None:
220+
self.edit = AsyncMock()
221+
222+
def permissions_for(self, _member: object) -> SimpleNamespace:
223+
return SimpleNamespace(
224+
manage_threads=True,
225+
view_channel=True,
226+
send_messages_in_threads=True,
227+
)
228+
229+
thread = FakeThread()
230+
monkeypatch.setattr(
231+
"five08.discord_bot.utils.internal_api.discord.Thread",
232+
FakeThread,
233+
)
234+
internal_api_routes.bot.get_channel.return_value = thread
235+
236+
result, status_code = await internal_api_routes._update_gig_thread_status(
237+
GigThreadStatusRequest(thread_id="123", status="lost")
238+
)
239+
240+
assert status_code == 200
241+
assert result["status"] == "updated"
242+
assert result["title"] == "[LOST] Old gig"
243+
assert result["closed"] is True
244+
assert thread.edit.await_args_list == [
245+
call(name="[LOST] Old gig", reason="Dashboard gig status update"),
246+
call(
247+
locked=True,
248+
archived=True,
249+
reason="Dashboard gig status update",
250+
),
251+
]
252+
253+
@pytest.mark.asyncio
254+
async def test_update_gig_thread_status_reopens_non_lost_thread(
255+
self, internal_api_routes, monkeypatch: pytest.MonkeyPatch
256+
):
257+
"""Moving a closed lost thread away from lost should make it usable again."""
258+
259+
class FakeThread:
260+
id = 123
261+
name = "[LOST] Old gig"
262+
archived = True
263+
locked = True
264+
guild = SimpleNamespace(me=object())
265+
266+
def __init__(self) -> None:
267+
self.edit = AsyncMock()
268+
269+
def permissions_for(self, _member: object) -> SimpleNamespace:
270+
return SimpleNamespace(
271+
manage_threads=True,
272+
view_channel=True,
273+
send_messages_in_threads=True,
274+
)
275+
276+
thread = FakeThread()
277+
monkeypatch.setattr(
278+
"five08.discord_bot.utils.internal_api.discord.Thread",
279+
FakeThread,
280+
)
281+
internal_api_routes.bot.get_channel.return_value = thread
282+
283+
result, status_code = await internal_api_routes._update_gig_thread_status(
284+
GigThreadStatusRequest(thread_id="123", status="recruiting")
285+
)
286+
287+
assert status_code == 200
288+
assert result["status"] == "updated"
289+
assert result["title"] == "[RECRUITING] Old gig"
290+
assert result["closed"] is False
291+
assert thread.edit.await_args_list == [
292+
call(
293+
locked=False,
294+
archived=False,
295+
reason="Dashboard gig status update",
296+
),
297+
call(name="[RECRUITING] Old gig", reason="Dashboard gig status update"),
298+
]
299+
300+
@pytest.mark.asyncio
301+
async def test_update_gig_thread_status_preserves_moderator_lock(
302+
self, internal_api_routes, monkeypatch: pytest.MonkeyPatch
303+
):
304+
"""Non-lost status sync should not clear locks unrelated to lost closure."""
305+
306+
class FakeThread:
307+
id = 123
308+
name = "[RECRUITING] Old gig"
309+
archived = False
310+
locked = True
311+
guild = SimpleNamespace(me=object())
312+
313+
def __init__(self) -> None:
314+
self.edit = AsyncMock()
315+
316+
def permissions_for(self, _member: object) -> SimpleNamespace:
317+
return SimpleNamespace(
318+
manage_threads=True,
319+
view_channel=True,
320+
send_messages_in_threads=True,
321+
)
322+
323+
thread = FakeThread()
324+
monkeypatch.setattr(
325+
"five08.discord_bot.utils.internal_api.discord.Thread",
326+
FakeThread,
327+
)
328+
internal_api_routes.bot.get_channel.return_value = thread
329+
330+
result, status_code = await internal_api_routes._update_gig_thread_status(
331+
GigThreadStatusRequest(thread_id="123", status="filled")
332+
)
333+
334+
assert status_code == 200
335+
assert result["title"] == "[FILLED] Old gig"
336+
thread.edit.assert_awaited_once_with(
337+
name="[FILLED] Old gig",
338+
reason="Dashboard gig status update",
339+
)
340+
165341
@pytest.mark.asyncio
166342
async def test_update_gig_thread_status_reports_missing_manage_threads(
167343
self, internal_api_routes, monkeypatch: pytest.MonkeyPatch

0 commit comments

Comments
 (0)