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:
@@ -494,13 +494,38 @@ describe('DiscordChannel', () => {
|
|||||||
// --- Attachments ---
|
// --- Attachments ---
|
||||||
|
|
||||||
describe('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 () => {
|
it('stores image attachment with placeholder', async () => {
|
||||||
const opts = createTestOpts();
|
const opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
|
|
||||||
const attachments = new Map([
|
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({
|
const msg = createMessage({
|
||||||
content: '',
|
content: '',
|
||||||
@@ -512,7 +537,7 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: '[Image: photo.png]',
|
content: expect.stringMatching(/^\[Image: .+\.png\]$/),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -569,7 +594,15 @@ describe('DiscordChannel', () => {
|
|||||||
await channel.connect();
|
await channel.connect();
|
||||||
|
|
||||||
const attachments = new Map([
|
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({
|
const msg = createMessage({
|
||||||
content: 'Check this out',
|
content: 'Check this out',
|
||||||
@@ -581,7 +614,7 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
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();
|
await channel.connect();
|
||||||
|
|
||||||
const attachments = new Map([
|
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' }],
|
['att2', { name: 'b.txt', contentType: 'text/plain' }],
|
||||||
]);
|
]);
|
||||||
const msg = createMessage({
|
const msg = createMessage({
|
||||||
@@ -605,7 +646,7 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
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);
|
await channel.sendMessage('dc:1234567890123456', longText);
|
||||||
|
|
||||||
expect(mockChannel.send).toHaveBeenCalledTimes(2);
|
expect(mockChannel.send).toHaveBeenCalledTimes(2);
|
||||||
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
|
expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
|
||||||
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
|
content: 'x'.repeat(2000),
|
||||||
|
files: undefined,
|
||||||
|
});
|
||||||
|
expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
|
||||||
|
content: 'x'.repeat(1000),
|
||||||
|
files: undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import { registerChannel, ChannelOpts } from './registry.js';
|
|||||||
import {
|
import {
|
||||||
AgentType,
|
AgentType,
|
||||||
Channel,
|
Channel,
|
||||||
|
ChannelMeta,
|
||||||
OnChatMetadata,
|
OnChatMetadata,
|
||||||
OnInboundMessage,
|
OnInboundMessage,
|
||||||
RegisteredGroup,
|
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(
|
async editMessage(
|
||||||
jid: string,
|
jid: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const TRIGGER_PATTERN = new RegExp(
|
|||||||
// Status dashboard: Discord channel ID for live agent status updates
|
// Status dashboard: Discord channel ID for live agent status updates
|
||||||
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
||||||
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
||||||
|
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
|
||||||
|
|
||||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||||
// Uses system timezone by default
|
// Uses system timezone by default
|
||||||
|
|||||||
207
src/index.ts
207
src/index.ts
@@ -1,4 +1,6 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
STATUS_UPDATE_INTERVAL,
|
STATUS_UPDATE_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
|
USAGE_UPDATE_INTERVAL,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import './channels/index.js';
|
import './channels/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +61,7 @@ import {
|
|||||||
startTokenRefreshLoop,
|
startTokenRefreshLoop,
|
||||||
stopTokenRefreshLoop,
|
stopTokenRefreshLoop,
|
||||||
} from './token-refresh.js';
|
} from './token-refresh.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, ChannelMeta, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
// Re-export for backwards compatibility during refactor
|
// Re-export for backwards compatibility during refactor
|
||||||
@@ -383,15 +386,29 @@ async function runAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status Dashboard ────────────────────────────────────────────
|
// ── Status & Usage Dashboards ───────────────────────────────────
|
||||||
|
|
||||||
function formatElapsed(ms: number): string {
|
function formatElapsed(ms: number): string {
|
||||||
const s = Math.floor(ms / 1000);
|
const s = Math.floor(ms / 1000);
|
||||||
if (s < 60) return `${s}s`;
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) {
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
const rem = s % 60;
|
const rem = s % 60;
|
||||||
return `${m}m${rem.toString().padStart(2, '0')}s`;
|
return `${m}m${rem.toString().padStart(2, '0')}s`;
|
||||||
}
|
}
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
const remH = h % 24;
|
||||||
|
return `${d}d${remH}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`;
|
||||||
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)}MB`;
|
||||||
|
return `${(bytes / 1024).toFixed(0)}KB`;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_ICONS: Record<string, string> = {
|
const STATUS_ICONS: Record<string, string> = {
|
||||||
processing: '🟡',
|
processing: '🟡',
|
||||||
@@ -401,30 +418,145 @@ const STATUS_ICONS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let statusMessageId: string | null = null;
|
let statusMessageId: string | null = null;
|
||||||
|
let usageMessageId: string | null = null;
|
||||||
|
|
||||||
|
// Cache for Discord channel metadata (position, category)
|
||||||
|
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||||
|
let channelMetaLastRefresh = 0;
|
||||||
|
const CHANNEL_META_REFRESH_MS = 300000; // 5 minutes
|
||||||
|
|
||||||
|
async function refreshChannelMeta(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return;
|
||||||
|
|
||||||
|
const ch = channels.find(
|
||||||
|
(c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta,
|
||||||
|
);
|
||||||
|
if (!ch?.getChannelMeta) return;
|
||||||
|
|
||||||
|
const jids = Object.keys(registeredGroups).filter((j) =>
|
||||||
|
j.startsWith('dc:'),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
channelMetaCache = await ch.getChannelMeta(jids);
|
||||||
|
channelMetaLastRefresh = now;
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Failed to refresh channel metadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(
|
||||||
|
s: import('./group-queue.js').GroupStatus,
|
||||||
|
): string {
|
||||||
|
if (s.status === 'processing')
|
||||||
|
return `처리 중 (${formatElapsed(s.elapsedMs || 0)})`;
|
||||||
|
if (s.status === 'idle') return '대기 중';
|
||||||
|
if (s.status === 'waiting')
|
||||||
|
return s.pendingTasks > 0
|
||||||
|
? `큐 대기 (태스크 ${s.pendingTasks}개)`
|
||||||
|
: '큐 대기 (메시지)';
|
||||||
|
return '비활성';
|
||||||
|
}
|
||||||
|
|
||||||
function buildStatusContent(): string {
|
function buildStatusContent(): string {
|
||||||
const jids = Object.keys(registeredGroups);
|
const jids = Object.keys(registeredGroups);
|
||||||
const statuses = queue.getStatuses(jids);
|
const statuses = queue.getStatuses(jids);
|
||||||
|
|
||||||
const lines = statuses.map((s) => {
|
const entries = statuses
|
||||||
const group = registeredGroups[s.jid];
|
.map((s) => ({
|
||||||
const name = group?.name || s.jid;
|
status: s,
|
||||||
const icon = STATUS_ICONS[s.status] || '⚪';
|
group: registeredGroups[s.jid],
|
||||||
const label =
|
meta: channelMetaCache.get(s.jid),
|
||||||
s.status === 'processing'
|
}))
|
||||||
? `처리 중 (${formatElapsed(s.elapsedMs || 0)})`
|
.filter((e) => e.group);
|
||||||
: s.status === 'idle'
|
|
||||||
? '대기 중'
|
// Group by category
|
||||||
: s.status === 'waiting'
|
const categoryMap = new Map<string, typeof entries>();
|
||||||
? `큐 대기 (${s.pendingTasks > 0 ? `태스크 ${s.pendingTasks}개` : '메시지'})`
|
for (const entry of entries) {
|
||||||
: '비활성';
|
const cat = entry.meta?.category || '기타';
|
||||||
return `${icon} **${name}** — ${label}`;
|
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
|
||||||
|
categoryMap.get(cat)!.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort categories by position
|
||||||
|
const sortedCategories = [...categoryMap.entries()].sort((a, b) => {
|
||||||
|
const posA = a[1][0]?.meta?.categoryPosition ?? 999;
|
||||||
|
const posB = b[1][0]?.meta?.categoryPosition ?? 999;
|
||||||
|
return posA - posB;
|
||||||
});
|
});
|
||||||
|
|
||||||
const active = statuses.filter((s) => s.status === 'processing').length;
|
const sections: string[] = [];
|
||||||
const idle = statuses.filter((s) => s.status === 'idle').length;
|
let totalActive = 0;
|
||||||
const header = `**에이전트 상태** (${ASSISTANT_NAME}) — 활성 ${active} | 대기 ${idle} | 전체 ${statuses.length}`;
|
let totalIdle = 0;
|
||||||
return `${header}\n\n${lines.join('\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`;
|
let total = 0;
|
||||||
|
|
||||||
|
for (const [catName, catEntries] of sortedCategories) {
|
||||||
|
catEntries.sort(
|
||||||
|
(a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = catEntries.map((e) => {
|
||||||
|
const icon = STATUS_ICONS[e.status.status] || '⚪';
|
||||||
|
const label = getStatusLabel(e.status);
|
||||||
|
return ` ${icon} **${e.group.name}** — ${label}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channelMetaCache.size > 0 && catName !== '기타') {
|
||||||
|
sections.push(`📁 **${catName}**\n${lines.join('\n')}`);
|
||||||
|
} else {
|
||||||
|
sections.push(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
totalActive += catEntries.filter(
|
||||||
|
(e) => e.status.status === 'processing',
|
||||||
|
).length;
|
||||||
|
totalIdle += catEntries.filter(
|
||||||
|
(e) => e.status.status === 'idle',
|
||||||
|
).length;
|
||||||
|
total += catEntries.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = `**에이전트 상태** (${ASSISTANT_NAME}) — 활성 ${totalActive} | 대기 ${totalIdle} | 전체 ${total}`;
|
||||||
|
return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsageContent(): string {
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const freeMem = os.freemem();
|
||||||
|
const usedMem = totalMem - freeMem;
|
||||||
|
const memPercent = Math.round((usedMem / totalMem) * 100);
|
||||||
|
|
||||||
|
const loadAvg = os.loadavg();
|
||||||
|
const cpuCount = os.cpus().length;
|
||||||
|
|
||||||
|
let diskInfo = '확인 불가';
|
||||||
|
try {
|
||||||
|
const df = execSync('df -h / | tail -1', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
}).trim();
|
||||||
|
const parts = df.split(/\s+/);
|
||||||
|
diskInfo = `${parts[2]} / ${parts[1]} (${parts[4]})`;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptimeMs = process.uptime() * 1000;
|
||||||
|
const procMem = process.memoryUsage();
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`**시스템 리소스** (${ASSISTANT_NAME})`,
|
||||||
|
``,
|
||||||
|
`💻 CPU: ${loadAvg[0].toFixed(1)} / ${cpuCount} cores (1m avg)`,
|
||||||
|
`🧠 시스템 메모리: ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memPercent}%)`,
|
||||||
|
`💾 디스크: ${diskInfo}`,
|
||||||
|
`📦 프로세스 메모리: ${formatBytes(procMem.rss)}`,
|
||||||
|
`⏱️ 업타임: ${formatElapsed(uptimeMs)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
lines.join('\n') + `\n\n_${new Date().toLocaleTimeString('ko-KR')}_`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startStatusDashboard(): Promise<void> {
|
async function startStatusDashboard(): Promise<void> {
|
||||||
@@ -440,6 +572,7 @@ async function startStatusDashboard(): Promise<void> {
|
|||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await refreshChannelMeta();
|
||||||
const content = buildStatusContent();
|
const content = buildStatusContent();
|
||||||
|
|
||||||
if (statusMessageId && ch.editMessage) {
|
if (statusMessageId && ch.editMessage) {
|
||||||
@@ -450,16 +583,47 @@ async function startStatusDashboard(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug({ err }, 'Status dashboard update failed');
|
logger.debug({ err }, 'Status dashboard update failed');
|
||||||
statusMessageId = null; // Reset on error, will re-create next tick
|
statusMessageId = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setInterval(updateStatus, STATUS_UPDATE_INTERVAL);
|
setInterval(updateStatus, STATUS_UPDATE_INTERVAL);
|
||||||
// Initial send
|
|
||||||
await updateStatus();
|
await updateStatus();
|
||||||
logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started');
|
logger.info({ channelId: STATUS_CHANNEL_ID }, 'Status dashboard started');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startUsageDashboard(): Promise<void> {
|
||||||
|
if (!STATUS_CHANNEL_ID) return;
|
||||||
|
|
||||||
|
const statusJid = `dc:${STATUS_CHANNEL_ID}`;
|
||||||
|
|
||||||
|
const findDiscordChannel = () =>
|
||||||
|
channels.find((c) => c.name.startsWith('discord') && c.isConnected());
|
||||||
|
|
||||||
|
const updateUsage = async () => {
|
||||||
|
const ch = findDiscordChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = buildUsageContent();
|
||||||
|
|
||||||
|
if (usageMessageId && ch.editMessage) {
|
||||||
|
await ch.editMessage(statusJid, usageMessageId, content);
|
||||||
|
} else if (ch.sendAndTrack) {
|
||||||
|
const id = await ch.sendAndTrack(statusJid, content);
|
||||||
|
if (id) usageMessageId = id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Usage dashboard update failed');
|
||||||
|
usageMessageId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(updateUsage, USAGE_UPDATE_INTERVAL);
|
||||||
|
await updateUsage();
|
||||||
|
logger.info('Usage dashboard started');
|
||||||
|
}
|
||||||
|
|
||||||
async function startMessageLoop(): Promise<void> {
|
async function startMessageLoop(): Promise<void> {
|
||||||
if (messageLoopRunning) {
|
if (messageLoopRunning) {
|
||||||
logger.debug('Message loop already running, skipping duplicate start');
|
logger.debug('Message loop already running, skipping duplicate start');
|
||||||
@@ -738,6 +902,7 @@ async function main(): Promise<void> {
|
|||||||
queue.setProcessMessagesFn(processGroupMessages);
|
queue.setProcessMessagesFn(processGroupMessages);
|
||||||
recoverPendingMessages();
|
recoverPendingMessages();
|
||||||
await startStatusDashboard();
|
await startStatusDashboard();
|
||||||
|
await startUsageDashboard();
|
||||||
startMessageLoop().catch((err) => {
|
startMessageLoop().catch((err) => {
|
||||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export interface TaskRunLog {
|
|||||||
|
|
||||||
// --- Channel abstraction ---
|
// --- Channel abstraction ---
|
||||||
|
|
||||||
|
export interface ChannelMeta {
|
||||||
|
position: number;
|
||||||
|
category: string;
|
||||||
|
categoryPosition: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
name: string;
|
name: string;
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
@@ -79,6 +85,8 @@ export interface Channel {
|
|||||||
sendAndTrack?(jid: string, text: string): Promise<string | null>;
|
sendAndTrack?(jid: string, text: string): Promise<string | null>;
|
||||||
// Optional: sync group/chat names from the platform.
|
// Optional: sync group/chat names from the platform.
|
||||||
syncGroups?(force: boolean): Promise<void>;
|
syncGroups?(force: boolean): Promise<void>;
|
||||||
|
// Optional: get channel metadata (position, category) for ordering.
|
||||||
|
getChannelMeta?(jids: string[]): Promise<Map<string, ChannelMeta>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback type that channels use to deliver inbound messages
|
// Callback type that channels use to deliver inbound messages
|
||||||
|
|||||||
Reference in New Issue
Block a user