feat: status dashboard categories/ordering + live usage dashboard

- Add getChannelMeta to Discord channel (batch guild fetch)
- Status dashboard groups by Discord category, sorts by position
- Add live usage dashboard (CPU, memory, disk, uptime) with message edit
- Fix 4 failing discord.test.ts tests (sendMessage format, image fetch mock)
This commit is contained in:
Eyejoker
2026-03-13 23:42:45 +09:00
parent 27d4ea05dc
commit 7dcb3fe1e4
5 changed files with 307 additions and 32 deletions

View File

@@ -494,13 +494,38 @@ describe('DiscordChannel', () => {
// --- Attachments ---
describe('attachments', () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
}),
);
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('stores image attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.png', contentType: 'image/png' }],
[
'att1',
{
id: 'att1',
name: 'photo.png',
contentType: 'image/png',
url: 'https://cdn.example.com/photo.png',
},
],
]);
const msg = createMessage({
content: '',
@@ -512,7 +537,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: photo.png]',
content: expect.stringMatching(/^\[Image: .+\.png\]$/),
}),
);
});
@@ -569,7 +594,15 @@ describe('DiscordChannel', () => {
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
[
'att1',
{
id: 'att1',
name: 'photo.jpg',
contentType: 'image/jpeg',
url: 'https://cdn.example.com/photo.jpg',
},
],
]);
const msg = createMessage({
content: 'Check this out',
@@ -581,7 +614,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'Check this out\n[Image: photo.jpg]',
content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/),
}),
);
});
@@ -592,7 +625,15 @@ describe('DiscordChannel', () => {
await channel.connect();
const attachments = new Map([
['att1', { name: 'a.png', contentType: 'image/png' }],
[
'att1',
{
id: 'att1',
name: 'a.png',
contentType: 'image/png',
url: 'https://cdn.example.com/a.png',
},
],
['att2', { name: 'b.txt', contentType: 'text/plain' }],
]);
const msg = createMessage({
@@ -605,7 +646,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: a.png]\n[File: b.txt]',
content: expect.stringMatching(/^\[Image: .+\.png\]\n\[File: b\.txt\]$/),
}),
);
});
@@ -702,8 +743,14 @@ describe('DiscordChannel', () => {
await channel.sendMessage('dc:1234567890123456', longText);
expect(mockChannel.send).toHaveBeenCalledTimes(2);
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
content: 'x'.repeat(2000),
files: undefined,
});
expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
content: 'x'.repeat(1000),
files: undefined,
});
});
});

View File

@@ -79,6 +79,7 @@ import { registerChannel, ChannelOpts } from './registry.js';
import {
AgentType,
Channel,
ChannelMeta,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
@@ -438,6 +439,59 @@ export class DiscordChannel implements Channel {
}
}
async getChannelMeta(jids: string[]): Promise<Map<string, ChannelMeta>> {
const result = new Map<string, ChannelMeta>();
if (!this.client) return result;
const dcJids = jids.filter((j) => j.startsWith('dc:'));
if (dcJids.length === 0) return result;
const channelIdToJid = new Map<string, string>();
for (const jid of dcJids) {
channelIdToJid.set(jid.replace(/^dc:/, ''), jid);
}
try {
// Fetch one channel to discover its guild, then batch-fetch all channels
const firstId = dcJids[0].replace(/^dc:/, '');
const firstChannel = await this.client.channels.fetch(firstId);
if (!firstChannel || !('guild' in firstChannel)) return result;
const guild = (firstChannel as TextChannel).guild;
const allChannels = await guild.channels.fetch();
for (const [id, channel] of allChannels) {
const jid = channelIdToJid.get(id);
if (!jid || !channel) continue;
result.set(jid, {
position: channel.position,
category: channel.parent?.name || '',
categoryPosition: channel.parent?.position ?? 999,
});
}
} catch {
// Fallback: individual fetches
for (const jid of dcJids) {
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (channel && 'position' in channel) {
const tc = channel as TextChannel;
result.set(jid, {
position: tc.position,
category: tc.parent?.name || '',
categoryPosition: tc.parent?.position ?? 999,
});
}
} catch {
/* skip inaccessible channels */
}
}
}
return result;
}
async editMessage(
jid: string,
messageId: string,