From 73fb67b3bfb3282ff81cbe95bfedb819a314ad0f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 15:01:32 -0700 Subject: [PATCH] fix: cog re-registration, thread NotFound, follow-up date, and error/empty states I1: move cog registration + tree.sync into ChorusBot.setup_hook (called once before gateway connect) instead of on_ready (fires on every reconnect), preventing duplicate cogs/listeners/poll loops after a gateway RESUME. I2 (email_ingest.py, addressed alongside I1 for IMAP stability): none here, handled separately. I3/I4: TriageList.vue now renders a follow_up_date column so it actually surfaces back to Donna. App.vue adds a "Show completed" checkbox bound to includeDone, matching what docs/smoke-test.md step 7 already describes. I5: add frontend/tests/ItemModal.spec.js covering stagesForType() options for donation/other/unset types and the type-change stage reset regression. I6: App.vue wraps loadItems/openItem in try/catch with a calm (no-panic) error message, and shows calm empty-state copy when there are zero items. Also fixes thread_ingest.py: standalone threads not started from a message raise discord.NotFound on fetch_message; now caught and returns early. --- bot/chorus_bot/cogs/thread_ingest.py | 6 ++- bot/chorus_bot/main.py | 30 ++++++++++---- frontend/src/App.vue | 47 +++++++++++++++++++-- frontend/src/components/TriageList.vue | 4 +- frontend/tests/ItemModal.spec.js | 56 ++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 frontend/tests/ItemModal.spec.js diff --git a/bot/chorus_bot/cogs/thread_ingest.py b/bot/chorus_bot/cogs/thread_ingest.py index ad298ef..cb0a5aa 100644 --- a/bot/chorus_bot/cogs/thread_ingest.py +++ b/bot/chorus_bot/cogs/thread_ingest.py @@ -16,7 +16,11 @@ class ThreadIngestCog(commands.Cog): if thread.parent_id != self.config.inbox_channel_id: return - parent_message = await thread.parent.fetch_message(thread.id) + try: + parent_message = await thread.parent.fetch_message(thread.id) + except discord.NotFound: + # Standalone thread not started from a message -- nothing to ingest. + return if not parent_message.embeds: return diff --git a/bot/chorus_bot/main.py b/bot/chorus_bot/main.py index 34d0a2f..c64733e 100644 --- a/bot/chorus_bot/main.py +++ b/bot/chorus_bot/main.py @@ -9,25 +9,39 @@ from chorus_bot.cogs.thread_ingest import setup as setup_thread_ingest from chorus_bot.cogs.subscribe import setup as setup_subscribe -def build_bot() -> commands.Bot: +class ChorusBot(commands.Bot): + """Bot subclass that registers cogs + syncs the command tree exactly once, + in setup_hook (called before the gateway connects), instead of on_ready + (which fires on every RESUME/reconnect and would re-register cogs, + raising or duplicating listeners/loops each time).""" + + def __init__(self, config, backend_client: BackendClient, **kwargs): + super().__init__(**kwargs) + self.config = config + self.backend_client = backend_client + + async def setup_hook(self): + await setup_manual_share(self, self.config) + await setup_email_ingest(self, self.config) + await setup_thread_ingest(self, self.config, self.backend_client) + await setup_subscribe(self, self.config) + await self.tree.sync(guild=discord.Object(id=self.config.guild_id)) + + +def build_bot(config, backend_client: BackendClient) -> ChorusBot: intents = discord.Intents.default() intents.message_content = True intents.members = True - return commands.Bot(command_prefix="!", intents=intents) + return ChorusBot(config, backend_client, command_prefix="!", intents=intents) async def main(): config = load_config() - bot = build_bot() backend_client = BackendClient(config.backend_base_url) + bot = build_bot(config, backend_client) @bot.event async def on_ready(): - await setup_manual_share(bot, config) - await setup_email_ingest(bot, config) - await setup_thread_ingest(bot, config, backend_client) - await setup_subscribe(bot, config) - await bot.tree.sync(guild=discord.Object(id=config.guild_id)) print(f"Chorus bot ready as {bot.user}") await bot.start(config.discord_token) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9e0a4cf..60a4d35 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,13 +6,25 @@ import { fetchItems, fetchItem } from './api.js' const items = ref([]) const selectedItem = ref(null) +const includeDone = ref(false) +const errorMessage = ref('') async function loadItems() { - items.value = await fetchItems() + try { + errorMessage.value = '' + items.value = await fetchItems({ includeDone: includeDone.value }) + } catch (err) { + errorMessage.value = "Couldn't load items right now - try refreshing" + } } async function openItem(id) { - selectedItem.value = await fetchItem(id) + try { + errorMessage.value = '' + selectedItem.value = await fetchItem(id) + } catch (err) { + errorMessage.value = "Couldn't load that item right now - try refreshing" + } } function closeModal() { @@ -30,7 +42,36 @@ onMounted(loadItems) + + diff --git a/frontend/src/components/TriageList.vue b/frontend/src/components/TriageList.vue index 6343064..0f90d24 100644 --- a/frontend/src/components/TriageList.vue +++ b/frontend/src/components/TriageList.vue @@ -14,6 +14,7 @@ const emit = defineEmits(['select-item']) Date Type Stage + Follow-up date @@ -24,10 +25,11 @@ const emit = defineEmits(['select-item']) @click="emit('select-item', item.id)" > - {{ item.sender_id || '—' }} + {{ item.sender_id || 'not set' }} {{ new Date(item.captured_at).toLocaleDateString() }} {{ item.type || 'unset' }} {{ item.stage }} + {{ item.follow_up_date || '-' }} diff --git a/frontend/tests/ItemModal.spec.js b/frontend/tests/ItemModal.spec.js new file mode 100644 index 0000000..855f68b --- /dev/null +++ b/frontend/tests/ItemModal.spec.js @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ItemModal from '../src/components/ItemModal.vue' + +vi.mock('../src/api.js', () => ({ + updateItem: vi.fn(async (id, fields) => ({ id, ...fields })), +})) + +function makeItem(overrides = {}) { + return { + id: 1, + modality: 'bh_email', + sender_id: 'jane@example.com', + raw_content: 'I have some books to donate.', + type: null, + stage: 'new', + notes: '', + follow_up_date: '', + ...overrides, + } +} + +describe('ItemModal', () => { + it('offers the donation stages when item.type is donation', () => { + const wrapper = mount(ItemModal, { props: { item: makeItem({ type: 'donation', stage: 'new' }) } }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual([ + 'new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', + ]) + }) + + it('offers only new/done stages when item.type is other', () => { + const wrapper = mount(ItemModal, { props: { item: makeItem({ type: 'other', stage: 'new' }) } }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual(['new', 'done']) + }) + + it('offers only the new stage when item.type is unset/null', () => { + const wrapper = mount(ItemModal, { props: { item: makeItem({ type: null, stage: 'new' }) } }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual(['new']) + }) + + it('resets stage to new when type changes away from donation while stage is pickup_scheduled', async () => { + const wrapper = mount(ItemModal, { + props: { item: makeItem({ type: 'donation', stage: 'pickup_scheduled' }) }, + }) + const stageSelect = wrapper.findAll('select')[1] + expect(stageSelect.element.value).toBe('pickup_scheduled') + + const typeSelect = wrapper.findAll('select')[0] + await typeSelect.setValue('other') + + expect(wrapper.findAll('select')[1].element.value).toBe('new') + }) +})