UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

247 lines • 13.8 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { typedError } from "./error-envelope.js"; import { formatAsText, formatSuccess, formatValidationError, } from "@withpica/mcp-utils"; export class TelegramTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_telegram_status", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Check if the user has Telegram connected. Use this before promising to send Telegram notifications. " + "Returns connection status, username, and notification preferences. " + "If not connected, suggest the user visit /connect to set up Telegram. " + "Pair from here with pica_telegram_connect; change which events notify with pica_telegram_config.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: {}, }, }, executor: this.getStatus.bind(this), }, { definition: { name: "pica_notify_user", scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Send a notification to the user's connected Telegram. Use for long-running tasks: " + "enrichment, exports, duplicate merges, bulk operations. The message should be concise " + "and actionable — tell them what finished and what to do next. " + "Fails gracefully if Telegram is not connected (returns error, does not throw). " + "This reaches the ACCOUNT OWNER, now. Use pica_notify_person instead to reach " + "someone in the catalog (a writer, a producer), and pica_notify_user_at to " + "schedule this same message for later.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { message: { type: "string", description: "Notification message (markdown supported). Keep it short — 1-3 sentences. " + "Include what completed and any action needed.", }, }, required: ["message"], }, }, executor: this.notifyUser.bind(this), }, { definition: { name: "pica_telegram_connect", scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Pair the user's Telegram account with withPICA so the agent can send " + "notifications via pica_notify_user. Returns a short magic code the " + "user sends to @withpica_bot, then call pica_telegram_status on the " + "next turn to confirm the connection landed. If the user is already " + "paired, short-circuits and returns { already_connected: true }. " + "Idempotent — repeated calls within 10 minutes return the same code. " + "Rate-limited to 3 new codes per user per rolling hour. " + "Use when the user asks the agent to send them anything via Telegram " + "but pica_telegram_status reports not connected.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: {}, }, }, executor: this.connect.bind(this), }, { definition: { name: "pica_telegram_config", // Scoped admin like its siblings pica_notify_user / // pica_telegram_connect: the backing routes live under // /api/admin/settings/telegram/* and every /api/admin/settings/** // bearer WRITE floors to "admin" (ADMIN_SEGMENT_WRITE_FLOOR // shares the "settings" segment across the whole settings // surface, not just telegram) — no scope below admin can satisfy // that floor, so this can't be loosened per-tool without // widening every other /settings write at the same time. scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Get or set the user's Telegram notification preferences (per-event-type on/off), " + "grouped by category (catalog, payments, agreements, audio, invoices, expenses, tasks, " + "team, documents, performances, royalties, system). " + "Omit event_type/is_enabled/bulk_config to just read current preferences — the response " + "lists every event type with its enabled state and category. " + "Set event_type + is_enabled to toggle one event type, or bulk_config to set several at " + "once. Requires Telegram to already be connected (pica_telegram_connect). " + "→ then: pica_telegram_status (confirm the connection is still active)", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { event_type: { type: "string", description: "Single event type to toggle (pairs with is_enabled). Read the current preferences first (call with no arguments) to see the full list of event types.", }, is_enabled: { type: "boolean", description: "Enable or disable event_type. Required if event_type is set.", }, bulk_config: { type: "array", items: { type: "object", properties: { event_type: { type: "string" }, is_enabled: { type: "boolean" }, }, required: ["event_type", "is_enabled"], }, description: "Set multiple event types at once.", }, }, }, }, executor: this.config.bind(this), }, ]; } async getStatus(_args) { const result = await this.pica.telegram.getStatus(); const data = result.data || result; if (!data.connected) { return formatAsText({ connected: false, message: "Telegram is not connected. The user can set it up at /connect.", bot_username: data.botUsername, }); } return formatSuccess(`Telegram connected as @${data.telegramUsername || data.telegramFirstName}. You can send notifications via pica_notify_user.`, { connected: true, telegram_username: data.telegramUsername, telegram_first_name: data.telegramFirstName, preferences: data.preferences, }); } async notifyUser(args) { if (!args.message?.trim()) { return formatValidationError({ message: "message is required" }); } try { const result = await this.pica.telegram.sendNotification(args.message.trim()); const data = result.data || result; return formatSuccess(`Notification sent to @${data.telegramUsername || "user"} on Telegram.`, { sent: true, telegram_username: data.telegramUsername }); } catch (err) { // Graceful failure — don't throw, return a typed error envelope. It // still carries isError so the client can't read a failed send as a // delivered one (A6, 2026-08-24). const message = err?.message || String(err); if (message.includes("not connected") || message.includes("400")) { return typedError("TELEGRAM_NOT_CONNECTED", "Telegram is not connected. The user can set it up at /connect.", { sent: false }); } return typedError("TELEGRAM_SEND_FAILED", `Failed to send notification: ${message}`, { sent: false }); } } async connect(_args) { try { const result = await this.pica.telegram.connect(); const data = result.data || result; if (data.already_connected) { return formatSuccess(`Telegram is already paired as @${data.telegram_username || "user"}. You can send notifications via pica_notify_user.`, { already_connected: true, telegram_username: data.telegram_username, telegram_first_name: data.telegram_first_name, }); } return formatSuccess(data.instructions || `Open ${data.bot_url} and send the code ${data.code}. Call pica_telegram_status on the next turn to confirm.`, { already_connected: false, code: data.code, bot_url: data.bot_url, expires_at: data.expires_at, instructions: data.instructions, completion_hints: data.completion_hints, }); } catch (err) { const message = err?.message || String(err); if (message.includes("rate_limited") || message.includes("429")) { return typedError("RATE_LIMITED", "Too many Telegram pairing attempts. Try again in an hour."); } return typedError("TELEGRAM_PAIRING_FAILED", `Failed to generate pairing code: ${message}`); } } /** * pica_telegram_config — read the current notification preferences when * no write fields are given, otherwise apply the single-toggle or * bulk_config write. Mirrors the retired bespoke assistant's * handleGetTelegramNotificationConfig / handleConfigureTelegramNotifications * pair as one action-inferred tool (read-by-default, opt-in write). */ async config(args) { // A write is attempted whenever event_type or bulk_config is present at // all — event_type without is_enabled is still a write ATTEMPT (just an // invalid one), not a fall-through to the read path. const isWrite = args.bulk_config !== undefined || args.event_type !== undefined; if (!isWrite) { const result = await this.pica.telegram.getPreferences(); const data = result.data || result; return formatSuccess("Telegram notification preferences", data); } if (args.event_type !== undefined && args.is_enabled === undefined) { return formatValidationError({ is_enabled: "is_enabled is required when event_type is set", }); } try { const result = await this.pica.telegram.setPreferences({ event_type: args.event_type, is_enabled: args.is_enabled, bulk_config: args.bulk_config, }); const data = result.data || result; return formatSuccess((data && data.message) || "Telegram notification preferences updated", data); } catch (err) { const message = err?.message || String(err); if (message.includes("not connected") || message.includes("400")) { return typedError("TELEGRAM_NOT_CONNECTED", "Telegram is not connected. Use pica_telegram_connect first."); } return typedError("TELEGRAM_CONFIG_FAILED", `Failed to update Telegram notification preferences: ${message}`); } } } //# sourceMappingURL=telegram.js.map