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:
parent
75ee9f8117
commit
73fb67b3bf
5 changed files with 130 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
56
frontend/tests/ItemModal.spec.js
Normal file
56
frontend/tests/ItemModal.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue