Sync infra config: HA/Zabbix relocation, Immich storage move, new services
Accumulated uncommitted infra changes: - Caddyfile: repoint HA/Zabbix to 192.168.1.4/.3, add ~20 new site routes - Immich: move media to /mnt/smsg, enable CUDA ML, mem limits, rewrite backup.sh - Add service stacks: agap-mcp, anki, family, freshrss, iperf3, kanboard, linkwarden, qbittorrent, radicale, syncthing, vikunja, windows - openwebui: enable API keys; ollama: drop CPU fallback - seafile/zabbix: extra_hosts entries; matrix: add user juris - Remove pihole stack and stale wiki/migrate.py - Ignore marketplace-mcp (standalone repo) and linkwarden runtime data Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
215
agap-mcp/src/kanboard.js
Normal file
215
agap-mcp/src/kanboard.js
Normal file
@@ -0,0 +1,215 @@
|
||||
const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800';
|
||||
const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude';
|
||||
|
||||
let _token = null;
|
||||
let _botId = null;
|
||||
|
||||
export function initKanboard(token) {
|
||||
_token = token;
|
||||
console.log('Kanboard: ready');
|
||||
}
|
||||
|
||||
function token() {
|
||||
if (!_token) throw new Error('Kanboard not initialized');
|
||||
return _token;
|
||||
}
|
||||
|
||||
// JSON-RPC over the app-wide token (login "jsonrpc:<token>") — full access to all projects.
|
||||
async function rpc(method, params = {}) {
|
||||
const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64');
|
||||
const res = await fetch(`${BASE()}/jsonrpc.php`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`);
|
||||
return json.result;
|
||||
}
|
||||
|
||||
// Resolve + cache the bot user's id (default author for writes, filter for "my tasks").
|
||||
async function botUserId() {
|
||||
if (_botId != null) return _botId;
|
||||
const user = await rpc('getUserByName', { username: BOT_USER() });
|
||||
if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`);
|
||||
_botId = parseInt(user.id);
|
||||
return _botId;
|
||||
}
|
||||
|
||||
const STATUS = { open: 1, closed: 0 };
|
||||
|
||||
// --- Read / monitor ---
|
||||
|
||||
export async function kbListProjects() {
|
||||
const projects = await rpc('getAllProjects');
|
||||
return projects.map(p => ({
|
||||
id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active),
|
||||
is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function kbGetProject(projectId) {
|
||||
const [project, columns, swimlanes] = await Promise.all([
|
||||
rpc('getProjectById', { project_id: projectId }),
|
||||
rpc('getColumns', { project_id: projectId }),
|
||||
rpc('getAllSwimlanes', { project_id: projectId }),
|
||||
]);
|
||||
if (!project) throw new Error(`Project not found: ${projectId}`);
|
||||
return {
|
||||
id: parseInt(project.id), name: project.name, description: project.description,
|
||||
is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id),
|
||||
columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })),
|
||||
swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })),
|
||||
};
|
||||
}
|
||||
|
||||
function compactTask(t) {
|
||||
return {
|
||||
id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id),
|
||||
column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id),
|
||||
owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active),
|
||||
color_id: t.color_id, priority: parseInt(t.priority || 0),
|
||||
date_due: parseInt(t.date_due) || 0, reference: t.reference || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function kbListTasks(projectId, status = 'open') {
|
||||
let tasks = [];
|
||||
if (status === 'all') {
|
||||
const [open, closed] = await Promise.all([
|
||||
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }),
|
||||
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }),
|
||||
]);
|
||||
tasks = [...open, ...closed];
|
||||
} else {
|
||||
tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open });
|
||||
}
|
||||
return tasks.map(compactTask);
|
||||
}
|
||||
|
||||
// Tasks assigned to the bot across every project. status: open | closed | all
|
||||
export async function kbMyTasks(status = 'open') {
|
||||
const botId = await botUserId();
|
||||
const projects = await rpc('getAllProjects');
|
||||
const out = [];
|
||||
for (const p of projects) {
|
||||
const tasks = await kbListTasks(parseInt(p.id), status);
|
||||
for (const t of tasks) {
|
||||
if (t.owner_id === botId) out.push({ ...t, project_name: p.name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function kbGetTask(taskId) {
|
||||
const task = await rpc('getTask', { task_id: taskId });
|
||||
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||
const [subtasks, comments] = await Promise.all([
|
||||
rpc('getAllSubtasks', { task_id: taskId }),
|
||||
rpc('getAllComments', { task_id: taskId }),
|
||||
]);
|
||||
return {
|
||||
task,
|
||||
subtasks: (subtasks || []).map(s => ({
|
||||
id: parseInt(s.id), title: s.title, status: parseInt(s.status),
|
||||
status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null,
|
||||
})),
|
||||
comments: (comments || []).map(c => ({
|
||||
id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username,
|
||||
date: parseInt(c.date_creation), content: c.comment,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function kbSearchTasks(projectId, query) {
|
||||
const tasks = await rpc('searchTasks', { project_id: projectId, query });
|
||||
return (tasks || []).map(compactTask);
|
||||
}
|
||||
|
||||
export async function kbListUsers() {
|
||||
const users = await rpc('getAllUsers');
|
||||
return users.map(u => ({
|
||||
id: parseInt(u.id), username: u.username, name: u.name,
|
||||
role: u.role, is_active: parseInt(u.is_active), email: u.email,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function kbProjectActivity(projectId) {
|
||||
return rpc('getProjectActivity', { project_id: projectId });
|
||||
}
|
||||
|
||||
// --- Write (creation/comments authored by the bot user) ---
|
||||
|
||||
export async function kbCreateTask(params) {
|
||||
const body = { ...params };
|
||||
if (body.creator_id == null) body.creator_id = await botUserId();
|
||||
const id = await rpc('createTask', body);
|
||||
if (!id) throw new Error('createTask returned false (check required fields: title, project_id)');
|
||||
return { task_id: parseInt(id) };
|
||||
}
|
||||
|
||||
export async function kbUpdateTask(params) {
|
||||
const ok = await rpc('updateTask', params);
|
||||
return { updated: !!ok };
|
||||
}
|
||||
|
||||
// Move a card. project_id and swimlane_id are resolved from the task when omitted.
|
||||
export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) {
|
||||
if (project_id == null || swimlane_id == null) {
|
||||
const task = await rpc('getTask', { task_id });
|
||||
if (!task) throw new Error(`Task not found: ${task_id}`);
|
||||
if (project_id == null) project_id = parseInt(task.project_id);
|
||||
if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id);
|
||||
}
|
||||
const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id });
|
||||
return { moved: !!ok };
|
||||
}
|
||||
|
||||
export async function kbChangeTaskStatus(taskId, action) {
|
||||
const method = action === 'close' ? 'closeTask' : 'openTask';
|
||||
const ok = await rpc(method, { task_id: taskId });
|
||||
return { [action === 'close' ? 'closed' : 'opened']: !!ok };
|
||||
}
|
||||
|
||||
// Assign a task to a user (username or numeric id).
|
||||
export async function kbAssignTask(taskId, owner) {
|
||||
let ownerId = owner;
|
||||
if (typeof owner === 'string' && !/^\d+$/.test(owner)) {
|
||||
const user = await rpc('getUserByName', { username: owner });
|
||||
if (!user || !user.id) throw new Error(`User not found: ${owner}`);
|
||||
ownerId = parseInt(user.id);
|
||||
}
|
||||
const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) });
|
||||
return { assigned: !!ok, owner_id: parseInt(ownerId) };
|
||||
}
|
||||
|
||||
export async function kbAddComment(taskId, content, userId) {
|
||||
const uid = userId != null ? userId : await botUserId();
|
||||
const id = await rpc('createComment', { task_id: taskId, user_id: uid, content });
|
||||
if (!id) throw new Error('createComment returned false');
|
||||
return { comment_id: parseInt(id) };
|
||||
}
|
||||
|
||||
export async function kbCreateSubtask(params) {
|
||||
const body = { ...params };
|
||||
if (body.user_id == null) body.user_id = await botUserId();
|
||||
const id = await rpc('createSubtask', body);
|
||||
if (!id) throw new Error('createSubtask returned false (required: task_id, title)');
|
||||
return { subtask_id: parseInt(id) };
|
||||
}
|
||||
|
||||
export async function kbUpdateSubtask(params) {
|
||||
const ok = await rpc('updateSubtask', params);
|
||||
return { updated: !!ok };
|
||||
}
|
||||
|
||||
export async function kbRemoveTask(taskId) {
|
||||
const ok = await rpc('removeTask', { task_id: taskId });
|
||||
return { removed: !!ok };
|
||||
}
|
||||
|
||||
export async function kbRemoveComment(commentId) {
|
||||
const ok = await rpc('removeComment', { comment_id: commentId });
|
||||
return { removed: !!ok };
|
||||
}
|
||||
Reference in New Issue
Block a user