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.
This commit is contained in:
pyr0ball 2026-07-13 15:01:32 -07:00
parent 75ee9f8117
commit 73fb67b3bf
5 changed files with 130 additions and 13 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)
<template>
<div class="container">
<h1>Chorus</h1>
<TriageList :items="items" @select-item="openItem" />
<label class="include-done-toggle">
<input type="checkbox" v-model="includeDone" @change="loadItems" />
Show completed
</label>
<p v-if="errorMessage" class="status-message">{{ errorMessage }}</p>
<p v-else-if="items.length === 0" class="status-message">
Nothing to triage right now.
</p>
<TriageList v-else :items="items" @select-item="openItem" />
<ItemModal :item="selectedItem" @close="closeModal" @saved="onSaved" />
</div>
</template>
<style scoped>
.include-done-toggle {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
color: var(--color-text-muted);
font-size: 0.9rem;
}
.status-message {
color: var(--color-text-muted);
padding: var(--spacing-md);
}
</style>

View file

@ -14,6 +14,7 @@ const emit = defineEmits(['select-item'])
<th>Date</th>
<th>Type</th>
<th>Stage</th>
<th>Follow-up date</th>
</tr>
</thead>
<tbody>
@ -24,10 +25,11 @@ const emit = defineEmits(['select-item'])
@click="emit('select-item', item.id)"
>
<td><ModalityBadge :modality="item.modality" /></td>
<td>{{ item.sender_id || '' }}</td>
<td>{{ item.sender_id || 'not set' }}</td>
<td>{{ new Date(item.captured_at).toLocaleDateString() }}</td>
<td>{{ item.type || 'unset' }}</td>
<td>{{ item.stage }}</td>
<td>{{ item.follow_up_date || '-' }}</td>
</tr>
</tbody>
</table>

View file

@ -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')
})
})