@botcord/botcord 0.2.2 → 0.2.4-beta.20260329125010

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Structured tool result types and builder helpers.
3
+ *
4
+ * Provides a unified response envelope for all BotCord tools:
5
+ * - Success: { ok: true, ...data }
6
+ * - Failure: { ok: false, error: { type, code, message, hint? } }
7
+ * - DryRun: { ok: true, dry_run: true, request: { method, path, body? } }
8
+ */
9
+
10
+ // ── Error types ─────────────────────────────────────────────────
11
+
12
+ export type ToolErrorType = "config" | "auth" | "validation" | "api" | "network";
13
+
14
+ export interface ToolError {
15
+ type: ToolErrorType;
16
+ code: string;
17
+ message: string;
18
+ hint?: string;
19
+ }
20
+
21
+ // ── Result types ────────────────────────────────────────────────
22
+
23
+ export type ToolSuccess<T = Record<string, unknown>> = { ok: true } & T;
24
+ export type ToolFailure = { ok: false; error: ToolError };
25
+ export type ToolResult<T = Record<string, unknown>> = ToolSuccess<T> | ToolFailure;
26
+
27
+ export interface DryRunRequest {
28
+ method: string;
29
+ path: string;
30
+ body?: unknown;
31
+ query?: Record<string, string>;
32
+ }
33
+
34
+ export type DryRunResult = { ok: true; dry_run: true; request: DryRunRequest };
35
+
36
+ // ── Builder helpers ─────────────────────────────────────────────
37
+
38
+ export function success<T extends Record<string, unknown>>(data: T): ToolSuccess<T> {
39
+ return { ok: true, ...data };
40
+ }
41
+
42
+ export function fail(
43
+ type: ToolErrorType,
44
+ code: string,
45
+ message: string,
46
+ hint?: string,
47
+ ): ToolFailure {
48
+ return { ok: false, error: { type, code, message, ...(hint ? { hint } : {}) } };
49
+ }
50
+
51
+ export function configError(message: string, hint?: string): ToolFailure {
52
+ return fail("config", "NOT_CONFIGURED", message, hint);
53
+ }
54
+
55
+ export function validationError(message: string, hint?: string): ToolFailure {
56
+ return fail("validation", "INVALID_INPUT", message, hint);
57
+ }
58
+
59
+ export function apiError(code: string, message: string, hint?: string): ToolFailure {
60
+ return fail("api", code, message, hint);
61
+ }
62
+
63
+ export function dryRunResult(method: string, path: string, body?: unknown, query?: Record<string, string>): DryRunResult {
64
+ return {
65
+ ok: true,
66
+ dry_run: true,
67
+ request: { method, path, ...(body !== undefined ? { body } : {}), ...(query ? { query } : {}) },
68
+ };
69
+ }
70
+
71
+ // ── Error classifier ────────────────────────────────────────────
72
+
73
+ /**
74
+ * Classify a caught error into a structured ToolFailure.
75
+ * Recognises HTTP status codes attached by BotCordClient.
76
+ */
77
+ export function classifyError(err: unknown): ToolFailure {
78
+ if (!(err instanceof Error)) {
79
+ return fail("api", "UNKNOWN", String(err));
80
+ }
81
+
82
+ const status = (err as any).status as number | undefined;
83
+ const message = err.message;
84
+
85
+ // Network-level failures
86
+ if (
87
+ err.name === "AbortError" ||
88
+ message.includes("fetch failed") ||
89
+ message.includes("ECONNREFUSED") ||
90
+ message.includes("ENOTFOUND") ||
91
+ message.includes("network")
92
+ ) {
93
+ return fail("network", "CONNECTION_FAILED", message, "Check Hub URL and network connectivity");
94
+ }
95
+
96
+ if (!status) {
97
+ return fail("api", "UNKNOWN", message);
98
+ }
99
+
100
+ switch (status) {
101
+ case 401:
102
+ return fail("auth", "TOKEN_EXPIRED", message, "Token refresh may have failed — try again or re-register");
103
+ case 403: {
104
+ const code = message.includes("BLOCKED")
105
+ ? "BLOCKED"
106
+ : message.includes("NOT_IN_CONTACTS")
107
+ ? "NOT_IN_CONTACTS"
108
+ : "FORBIDDEN";
109
+ return fail("auth", code, message);
110
+ }
111
+ case 404:
112
+ return fail("api", "NOT_FOUND", message, "Verify the target ID exists via botcord_directory(action=\"resolve\")");
113
+ case 409:
114
+ return fail("api", "CONFLICT", message);
115
+ case 422:
116
+ return fail("validation", "UNPROCESSABLE", message);
117
+ case 429:
118
+ return fail("api", "RATE_LIMITED", message, "Throttle requests — 20 msg/min global, 10 msg/min per conversation");
119
+ default:
120
+ return fail("api", `HTTP_${status}`, message);
121
+ }
122
+ }
@@ -1,14 +1,8 @@
1
1
  /**
2
2
  * botcord_topics — Topic lifecycle management within rooms.
3
3
  */
4
- import {
5
- getSingleAccountModeError,
6
- resolveAccountConfig,
7
- isAccountConfigured,
8
- } from "../config.js";
9
- import { BotCordClient } from "../client.js";
10
- import { attachTokenPersistence } from "../credentials.js";
11
- import { getConfig as getAppConfig } from "../runtime.js";
4
+ import { withClient } from "./with-client.js";
5
+ import { validationError, dryRunResult } from "./tool-result.js";
12
6
 
13
7
  export function createTopicsTool() {
14
8
  return {
@@ -50,60 +44,66 @@ export function createTopicsTool() {
50
44
  enum: ["open", "completed", "failed", "expired"],
51
45
  description: "Topic status — for list (filter) or update (transition)",
52
46
  },
47
+ dry_run: {
48
+ type: "boolean" as const,
49
+ description: "Preview the request without executing. Returns the API call that would be made.",
50
+ },
53
51
  },
54
52
  required: ["action", "room_id"],
55
53
  },
56
54
  execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
57
- const cfg = getAppConfig();
58
- if (!cfg) return { error: "No configuration available" };
59
- const singleAccountError = getSingleAccountModeError(cfg);
60
- if (singleAccountError) return { error: singleAccountError };
61
-
62
- const acct = resolveAccountConfig(cfg);
63
- if (!isAccountConfigured(acct)) {
64
- return { error: "BotCord is not configured." };
65
- }
66
-
67
- const client = new BotCordClient(acct);
68
- attachTokenPersistence(client, acct);
55
+ return withClient(async (client) => {
56
+ // Dry-run for write operations
57
+ if (args.dry_run) {
58
+ switch (args.action) {
59
+ case "create":
60
+ if (!args.title) return validationError("title is required");
61
+ return dryRunResult("POST", `/hub/rooms/${args.room_id}/topics`, { title: args.title, description: args.description, goal: args.goal }) as any;
62
+ case "update":
63
+ if (!args.topic_id) return validationError("topic_id is required");
64
+ return dryRunResult("PATCH", `/hub/rooms/${args.room_id}/topics/${args.topic_id}`, { title: args.title, status: args.status, goal: args.goal }) as any;
65
+ case "delete":
66
+ if (!args.topic_id) return validationError("topic_id is required");
67
+ return dryRunResult("DELETE", `/hub/rooms/${args.room_id}/topics/${args.topic_id}`) as any;
68
+ default:
69
+ break;
70
+ }
71
+ }
69
72
 
70
- try {
71
73
  switch (args.action) {
72
74
  case "create":
73
- if (!args.title) return { error: "title is required" };
75
+ if (!args.title) return validationError("title is required");
74
76
  return await client.createTopic(args.room_id, {
75
77
  title: args.title,
76
78
  description: args.description,
77
79
  goal: args.goal,
78
- });
80
+ }) as any;
79
81
 
80
82
  case "list":
81
- return await client.listTopics(args.room_id, args.status);
83
+ return { topics: await client.listTopics(args.room_id, args.status) } as any;
82
84
 
83
85
  case "get":
84
- if (!args.topic_id) return { error: "topic_id is required" };
85
- return await client.getTopic(args.room_id, args.topic_id);
86
+ if (!args.topic_id) return validationError("topic_id is required");
87
+ return await client.getTopic(args.room_id, args.topic_id) as any;
86
88
 
87
89
  case "update":
88
- if (!args.topic_id) return { error: "topic_id is required" };
90
+ if (!args.topic_id) return validationError("topic_id is required");
89
91
  return await client.updateTopic(args.room_id, args.topic_id, {
90
92
  title: args.title,
91
93
  description: args.description,
92
94
  status: args.status,
93
95
  goal: args.goal,
94
- });
96
+ }) as any;
95
97
 
96
98
  case "delete":
97
- if (!args.topic_id) return { error: "topic_id is required" };
99
+ if (!args.topic_id) return validationError("topic_id is required");
98
100
  await client.deleteTopic(args.room_id, args.topic_id);
99
- return { ok: true, deleted: args.topic_id, room: args.room_id };
101
+ return { ok: true, deleted: args.topic_id, room: args.room_id } as any;
100
102
 
101
103
  default:
102
- return { error: `Unknown action: ${args.action}` };
104
+ return validationError(`Unknown action: ${args.action}`);
103
105
  }
104
- } catch (err: any) {
105
- return { error: `Topic action failed: ${err.message}` };
106
- }
106
+ });
107
107
  },
108
108
  };
109
109
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared tool wrapper that eliminates boilerplate across all BotCord tools.
3
+ *
4
+ * Handles: config check → single-account guard → account resolution →
5
+ * client creation → token persistence → try/catch with error classification.
6
+ */
7
+ import {
8
+ getSingleAccountModeError,
9
+ resolveAccountConfig,
10
+ isAccountConfigured,
11
+ } from "../config.js";
12
+ import { BotCordClient } from "../client.js";
13
+ import { attachTokenPersistence } from "../credentials.js";
14
+ import { getConfig as getAppConfig } from "../runtime.js";
15
+ import type { BotCordAccountConfig } from "../types.js";
16
+ import { configError, classifyError, type ToolFailure, type ToolSuccess } from "./tool-result.js";
17
+
18
+ /**
19
+ * Run a tool action with a fully-configured BotCordClient.
20
+ *
21
+ * The callback receives the client and resolved account config.
22
+ * If it returns a plain object, it is automatically wrapped in `{ ok: true, ... }`.
23
+ * If it returns an object that already has `ok` set, it is passed through as-is.
24
+ */
25
+ export async function withClient<T extends Record<string, unknown>>(
26
+ fn: (client: BotCordClient, acct: BotCordAccountConfig) => Promise<T | ToolSuccess<T> | ToolFailure>,
27
+ ): Promise<ToolSuccess<T> | ToolFailure> {
28
+ const cfg = getAppConfig();
29
+ if (!cfg) {
30
+ return configError("No configuration available", "Run /botcord_healthcheck to diagnose");
31
+ }
32
+
33
+ const singleErr = getSingleAccountModeError(cfg);
34
+ if (singleErr) {
35
+ return configError(singleErr);
36
+ }
37
+
38
+ const acct = resolveAccountConfig(cfg);
39
+ if (!isAccountConfigured(acct)) {
40
+ return configError(
41
+ "BotCord is not configured.",
42
+ "Run botcord-register to create an identity or botcord-import to restore one",
43
+ );
44
+ }
45
+
46
+ try {
47
+ const client = new BotCordClient(acct);
48
+ attachTokenPersistence(client, acct);
49
+ const result = await fn(client, acct);
50
+
51
+ // If the callback already returned a structured result, pass through
52
+ if (result && typeof result === "object" && "ok" in result) {
53
+ return result as ToolSuccess<T> | ToolFailure;
54
+ }
55
+
56
+ // Otherwise wrap in success envelope
57
+ return { ok: true, ...result } as ToolSuccess<T>;
58
+ } catch (err: unknown) {
59
+ return classifyError(err);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Lightweight version that only checks config availability (no client creation).
65
+ * Used by tools that don't need a BotCordClient (e.g. register, notify).
66
+ */
67
+ export async function withConfig<T extends Record<string, unknown>>(
68
+ fn: (cfg: any, acct: BotCordAccountConfig) => Promise<T | ToolSuccess<T> | ToolFailure>,
69
+ ): Promise<ToolSuccess<T> | ToolFailure> {
70
+ const cfg = getAppConfig();
71
+ if (!cfg) {
72
+ return configError("No configuration available", "Run /botcord_healthcheck to diagnose");
73
+ }
74
+
75
+ const singleErr = getSingleAccountModeError(cfg);
76
+ if (singleErr) {
77
+ return configError(singleErr);
78
+ }
79
+
80
+ const acct = resolveAccountConfig(cfg);
81
+
82
+ try {
83
+ const result = await fn(cfg, acct);
84
+
85
+ if (result && typeof result === "object" && "ok" in result) {
86
+ return result as ToolSuccess<T> | ToolFailure;
87
+ }
88
+
89
+ return { ok: true, ...result } as ToolSuccess<T>;
90
+ } catch (err: unknown) {
91
+ return classifyError(err);
92
+ }
93
+ }