@dobby.ai/dobby 0.1.0 → 0.1.1

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.
Files changed (76) hide show
  1. package/.env.example +0 -1
  2. package/AGENTS.md +7 -7
  3. package/README.md +64 -32
  4. package/config/gateway.example.json +10 -6
  5. package/dist/plugins/connector-discord/src/mapper.js +75 -0
  6. package/dist/src/cli/commands/doctor.js +81 -2
  7. package/dist/src/cli/commands/extension.js +3 -1
  8. package/dist/src/cli/commands/init.js +43 -173
  9. package/dist/src/cli/commands/topology.js +38 -14
  10. package/dist/src/cli/program.js +15 -131
  11. package/dist/src/cli/shared/config-io.js +3 -31
  12. package/dist/src/cli/shared/config-mutators.js +33 -9
  13. package/dist/src/cli/shared/configure-sections.js +52 -12
  14. package/dist/src/cli/shared/init-catalog.js +89 -46
  15. package/dist/src/cli/shared/local-extension-specs.js +85 -0
  16. package/dist/src/cli/shared/schema-prompts.js +26 -2
  17. package/dist/src/cli/tests/config-io.test.js +5 -5
  18. package/dist/src/cli/tests/discord-mapper.test.js +90 -0
  19. package/dist/src/cli/tests/doctor.test.js +145 -0
  20. package/dist/src/cli/tests/init-catalog.test.js +108 -61
  21. package/dist/src/cli/tests/program-options.test.js +14 -28
  22. package/dist/src/cli/tests/routing-config.test.js +59 -4
  23. package/dist/src/core/gateway.js +3 -1
  24. package/dist/src/core/routing.js +53 -38
  25. package/dist/src/main.js +0 -0
  26. package/dist/src/shared/dobby-repo.js +40 -0
  27. package/docs/RUNBOOK.md +28 -27
  28. package/package.json +3 -2
  29. package/plugins/connector-discord/package-lock.json +2 -2
  30. package/plugins/connector-discord/package.json +1 -1
  31. package/plugins/connector-discord/src/connector.ts +0 -5
  32. package/plugins/connector-discord/src/mapper.ts +3 -4
  33. package/plugins/connector-feishu/package-lock.json +2 -2
  34. package/plugins/connector-feishu/package.json +1 -1
  35. package/plugins/plugin-sdk/package-lock.json +2 -2
  36. package/plugins/plugin-sdk/package.json +1 -1
  37. package/plugins/provider-claude/package-lock.json +2 -2
  38. package/plugins/provider-claude/package.json +1 -1
  39. package/plugins/provider-claude-cli/package-lock.json +2 -2
  40. package/plugins/provider-claude-cli/package.json +1 -1
  41. package/plugins/provider-pi/package-lock.json +2 -2
  42. package/plugins/provider-pi/package.json +1 -1
  43. package/plugins/provider-pi/src/contribution.ts +139 -9
  44. package/src/cli/commands/doctor.ts +103 -2
  45. package/src/cli/commands/extension.ts +3 -1
  46. package/src/cli/commands/init.ts +45 -230
  47. package/src/cli/commands/topology.ts +48 -16
  48. package/src/cli/program.ts +16 -167
  49. package/src/cli/shared/config-io.ts +3 -35
  50. package/src/cli/shared/config-mutators.ts +39 -9
  51. package/src/cli/shared/config-types.ts +10 -2
  52. package/src/cli/shared/configure-sections.ts +55 -11
  53. package/src/cli/shared/init-catalog.ts +126 -66
  54. package/src/cli/shared/local-extension-specs.ts +108 -0
  55. package/src/cli/shared/schema-prompts.ts +30 -1
  56. package/src/cli/tests/config-io.test.ts +5 -5
  57. package/src/cli/tests/discord-mapper.test.ts +128 -0
  58. package/src/cli/tests/doctor.test.ts +149 -0
  59. package/src/cli/tests/init-catalog.test.ts +112 -64
  60. package/src/cli/tests/program-options.test.ts +14 -32
  61. package/src/cli/tests/routing-config.test.ts +76 -4
  62. package/src/core/gateway.ts +3 -1
  63. package/src/core/routing.ts +70 -45
  64. package/src/core/types.ts +8 -2
  65. package/src/shared/dobby-repo.ts +48 -0
  66. package/config/models.custom.example.json +0 -27
  67. package/dist/src/agent/tests/event-forwarder.test.js +0 -113
  68. package/dist/src/cli/shared/config-path.js +0 -207
  69. package/dist/src/cli/shared/init-models-file.js +0 -65
  70. package/dist/src/cli/shared/presets.js +0 -86
  71. package/dist/src/cli/tests/config-path.test.js +0 -21
  72. package/dist/src/cli/tests/discord-config.test.js +0 -23
  73. package/dist/src/cli/tests/presets.test.js +0 -41
  74. package/dist/src/cli/tests/routing-legacy.test.js +0 -191
  75. package/dist/src/core/tests/gateway-update-strategy.test.js +0 -167
  76. package/src/cli/shared/init-models-file.ts +0 -77
@@ -1,44 +1,54 @@
1
- import type { RawBindingConfig, RawRouteProfile } from "./config-types.js";
1
+ import type {
2
+ RawBindingConfig,
3
+ RawDefaultBindingConfig,
4
+ RawRouteDefaults,
5
+ RawRouteProfile,
6
+ } from "./config-types.js";
2
7
  import {
8
+ DEFAULT_DISCORD_BOT_NAME,
3
9
  DEFAULT_DISCORD_CONNECTOR_INSTANCE_ID,
4
10
  DISCORD_CONNECTOR_CONTRIBUTION_ID,
5
11
  } from "./discord-config.js";
6
12
 
7
13
  export type InitProviderChoiceId = "provider.pi" | "provider.claude-cli";
8
- export type InitConnectorChoiceId = "connector.discord";
14
+ export type InitConnectorChoiceId = "connector.discord" | "connector.feishu";
15
+
16
+ export const DEFAULT_INIT_ROUTE_ID = "main";
17
+ export const DEFAULT_INIT_PROJECT_ROOT = "./REPLACE_WITH_PROJECT_ROOT";
9
18
 
10
19
  interface ProviderCatalogEntry {
11
20
  id: InitProviderChoiceId;
12
21
  label: string;
13
- extensionPackage: string;
22
+ package: string;
14
23
  instanceId: string;
15
24
  contributionId: string;
16
- config: Record<string, unknown>;
25
+ defaultConfig: Record<string, unknown>;
26
+ }
27
+
28
+ interface ConnectorBindingTemplate {
29
+ sourceType: RawBindingConfig["source"]["type"];
30
+ sourceId: string;
17
31
  }
18
32
 
19
33
  interface ConnectorCatalogEntry {
20
34
  id: InitConnectorChoiceId;
21
35
  label: string;
22
- extensionPackage: string;
36
+ package: string;
23
37
  instanceId: string;
24
38
  contributionId: string;
39
+ defaultConfig: Record<string, unknown>;
40
+ bindingTemplate: ConnectorBindingTemplate;
25
41
  }
26
42
 
27
43
  export interface InitSelectionContext {
28
- routeId: string;
29
- projectRoot: string;
30
- allowAllMessages: boolean;
31
- botName: string;
32
- botToken: string;
33
- channelId: string;
34
44
  routeProviderChoiceId: InitProviderChoiceId;
45
+ defaultProjectRoot?: string;
35
46
  }
36
47
 
37
48
  export interface InitSelectionResult {
38
49
  providerChoiceIds: InitProviderChoiceId[];
39
50
  routeProviderChoiceId: InitProviderChoiceId;
40
- providerChoiceId: InitProviderChoiceId;
41
- connectorChoiceId: InitConnectorChoiceId;
51
+ connectorChoiceIds: InitConnectorChoiceId[];
42
52
  extensionPackages: string[];
43
53
  providerInstances: Array<{
44
54
  choiceId: InitProviderChoiceId;
@@ -46,38 +56,43 @@ export interface InitSelectionResult {
46
56
  contributionId: string;
47
57
  config: Record<string, unknown>;
48
58
  }>;
59
+ connectorInstances: Array<{
60
+ choiceId: InitConnectorChoiceId;
61
+ instanceId: string;
62
+ contributionId: string;
63
+ config: Record<string, unknown>;
64
+ }>;
49
65
  providerInstanceId: string;
50
- providerContributionId: string;
51
- providerConfig: Record<string, unknown>;
52
- connectorInstanceId: string;
53
- connectorContributionId: string;
54
- connectorConfig: Record<string, unknown>;
66
+ routeId: string;
67
+ routeDefaults: RawRouteDefaults;
55
68
  routeProfile: RawRouteProfile;
56
- bindingId: string;
57
- bindingConfig: RawBindingConfig;
69
+ defaultBinding?: RawDefaultBindingConfig;
70
+ bindings: Array<{
71
+ id: string;
72
+ config: RawBindingConfig;
73
+ }>;
58
74
  }
59
75
 
60
76
  const PROVIDER_CATALOG: Record<InitProviderChoiceId, ProviderCatalogEntry> = {
61
77
  "provider.pi": {
62
78
  id: "provider.pi",
63
79
  label: "Pi provider",
64
- extensionPackage: "@dobby.ai/provider-pi",
80
+ package: "@dobby.ai/provider-pi",
65
81
  instanceId: "pi.main",
66
82
  contributionId: "provider.pi",
67
- config: {
68
- provider: "custom-openai",
69
- model: "example-model",
70
- thinkingLevel: "off",
71
- modelsFile: "./models.custom.json",
83
+ defaultConfig: {
84
+ model: "REPLACE_WITH_PROVIDER_MODEL_ID",
85
+ baseUrl: "REPLACE_WITH_PROVIDER_BASE_URL",
86
+ apiKey: "REPLACE_WITH_PROVIDER_API_KEY_OR_ENV",
72
87
  },
73
88
  },
74
89
  "provider.claude-cli": {
75
90
  id: "provider.claude-cli",
76
91
  label: "Claude CLI provider",
77
- extensionPackage: "@dobby.ai/provider-claude-cli",
92
+ package: "@dobby.ai/provider-claude-cli",
78
93
  instanceId: "claude-cli.main",
79
94
  contributionId: "provider.claude-cli",
80
- config: {
95
+ defaultConfig: {
81
96
  model: "claude-sonnet-4-5",
82
97
  maxTurns: 20,
83
98
  command: "claude",
@@ -93,12 +108,56 @@ const CONNECTOR_CATALOG: Record<InitConnectorChoiceId, ConnectorCatalogEntry> =
93
108
  "connector.discord": {
94
109
  id: "connector.discord",
95
110
  label: "Discord connector",
96
- extensionPackage: "@dobby.ai/connector-discord",
111
+ package: "@dobby.ai/connector-discord",
97
112
  instanceId: DEFAULT_DISCORD_CONNECTOR_INSTANCE_ID,
98
113
  contributionId: DISCORD_CONNECTOR_CONTRIBUTION_ID,
114
+ defaultConfig: {
115
+ botName: DEFAULT_DISCORD_BOT_NAME,
116
+ botToken: "REPLACE_WITH_DISCORD_BOT_TOKEN",
117
+ reconnectStaleMs: 60_000,
118
+ reconnectCheckIntervalMs: 10_000,
119
+ },
120
+ bindingTemplate: {
121
+ sourceType: "channel",
122
+ sourceId: "YOUR_DISCORD_CHANNEL_ID",
123
+ },
124
+ },
125
+ "connector.feishu": {
126
+ id: "connector.feishu",
127
+ label: "Feishu connector",
128
+ package: "@dobby.ai/connector-feishu",
129
+ instanceId: "feishu.main",
130
+ contributionId: "connector.feishu",
131
+ defaultConfig: {
132
+ appId: "REPLACE_WITH_FEISHU_APP_ID",
133
+ appSecret: "REPLACE_WITH_FEISHU_APP_SECRET",
134
+ domain: "feishu",
135
+ messageFormat: "card_markdown",
136
+ replyMode: "direct",
137
+ downloadAttachments: true,
138
+ },
139
+ bindingTemplate: {
140
+ sourceType: "chat",
141
+ sourceId: "YOUR_FEISHU_CHAT_ID",
142
+ },
99
143
  },
100
144
  };
101
145
 
146
+ function dedupeChoiceIds<T extends string>(choiceIds: T[]): T[] {
147
+ const dedupedChoiceIds: T[] = [];
148
+ const seenChoiceIds = new Set<T>();
149
+
150
+ for (const choiceId of choiceIds) {
151
+ if (seenChoiceIds.has(choiceId)) {
152
+ continue;
153
+ }
154
+ seenChoiceIds.add(choiceId);
155
+ dedupedChoiceIds.push(choiceId);
156
+ }
157
+
158
+ return dedupedChoiceIds;
159
+ }
160
+
102
161
  export function listInitProviderChoices(): ProviderCatalogEntry[] {
103
162
  return Object.values(PROVIDER_CATALOG);
104
163
  }
@@ -117,22 +176,19 @@ export function isInitConnectorChoiceId(value: string): value is InitConnectorCh
117
176
 
118
177
  export function createInitSelectionConfig(
119
178
  providerChoiceIds: InitProviderChoiceId[],
120
- connectorChoiceId: InitConnectorChoiceId,
179
+ connectorChoiceIds: InitConnectorChoiceId[],
121
180
  context: InitSelectionContext,
122
181
  ): InitSelectionResult {
123
- const dedupedProviderChoiceIds: InitProviderChoiceId[] = [];
124
- const seenProviderChoiceIds = new Set<InitProviderChoiceId>();
125
- for (const providerChoiceId of providerChoiceIds) {
126
- if (!seenProviderChoiceIds.has(providerChoiceId)) {
127
- seenProviderChoiceIds.add(providerChoiceId);
128
- dedupedProviderChoiceIds.push(providerChoiceId);
129
- }
130
- }
131
-
182
+ const dedupedProviderChoiceIds = dedupeChoiceIds(providerChoiceIds);
132
183
  if (dedupedProviderChoiceIds.length === 0) {
133
184
  throw new Error("At least one provider choice is required");
134
185
  }
135
186
 
187
+ const dedupedConnectorChoiceIds = dedupeChoiceIds(connectorChoiceIds);
188
+ if (dedupedConnectorChoiceIds.length === 0) {
189
+ throw new Error("At least one connector choice is required");
190
+ }
191
+
136
192
  if (!dedupedProviderChoiceIds.includes(context.routeProviderChoiceId)) {
137
193
  throw new Error(
138
194
  `route provider choice '${context.routeProviderChoiceId}' must be one of selected providers: ${dedupedProviderChoiceIds.join(", ")}`,
@@ -140,50 +196,54 @@ export function createInitSelectionConfig(
140
196
  }
141
197
 
142
198
  const providerChoices = dedupedProviderChoiceIds.map((providerChoiceId) => PROVIDER_CATALOG[providerChoiceId]);
199
+ const connectorChoices = dedupedConnectorChoiceIds.map((connectorChoiceId) => CONNECTOR_CATALOG[connectorChoiceId]);
143
200
  const primaryProviderChoice = PROVIDER_CATALOG[context.routeProviderChoiceId];
144
- const connectorChoice = CONNECTOR_CATALOG[connectorChoiceId];
145
201
 
146
202
  return {
147
203
  providerChoiceIds: dedupedProviderChoiceIds,
148
204
  routeProviderChoiceId: primaryProviderChoice.id,
149
- providerChoiceId: primaryProviderChoice.id,
150
- connectorChoiceId,
205
+ connectorChoiceIds: dedupedConnectorChoiceIds,
151
206
  extensionPackages: [
152
- ...new Set([...providerChoices.map((item) => item.extensionPackage), connectorChoice.extensionPackage]),
207
+ ...new Set([
208
+ ...providerChoices.map((item) => item.package),
209
+ ...connectorChoices.map((item) => item.package),
210
+ ]),
153
211
  ],
154
212
  providerInstances: providerChoices.map((providerChoice) => ({
155
213
  choiceId: providerChoice.id,
156
214
  instanceId: providerChoice.instanceId,
157
215
  contributionId: providerChoice.contributionId,
158
- config: structuredClone(providerChoice.config),
216
+ config: structuredClone(providerChoice.defaultConfig),
217
+ })),
218
+ connectorInstances: connectorChoices.map((connectorChoice) => ({
219
+ choiceId: connectorChoice.id,
220
+ instanceId: connectorChoice.instanceId,
221
+ contributionId: connectorChoice.contributionId,
222
+ config: structuredClone(connectorChoice.defaultConfig),
159
223
  })),
160
224
  providerInstanceId: primaryProviderChoice.instanceId,
161
- providerContributionId: primaryProviderChoice.contributionId,
162
- providerConfig: structuredClone(primaryProviderChoice.config),
163
- connectorInstanceId: connectorChoice.instanceId,
164
- connectorContributionId: connectorChoice.contributionId,
165
- connectorConfig: {
166
- botName: context.botName,
167
- botToken: context.botToken,
168
- reconnectStaleMs: 60_000,
169
- reconnectCheckIntervalMs: 10_000,
170
- },
171
- routeProfile: {
172
- projectRoot: context.projectRoot,
225
+ routeId: DEFAULT_INIT_ROUTE_ID,
226
+ routeDefaults: {
227
+ projectRoot: context.defaultProjectRoot ?? DEFAULT_INIT_PROJECT_ROOT,
173
228
  tools: "full",
174
- systemPromptFile: "",
175
- mentions: context.allowAllMessages ? "optional" : "required",
229
+ mentions: "required",
176
230
  provider: primaryProviderChoice.instanceId,
177
231
  sandbox: "host.builtin",
178
232
  },
179
- bindingId: `${connectorChoice.instanceId}.${context.routeId}`,
180
- bindingConfig: {
181
- connector: connectorChoice.instanceId,
182
- source: {
183
- type: "channel",
184
- id: context.channelId,
185
- },
186
- route: context.routeId,
233
+ routeProfile: {},
234
+ defaultBinding: {
235
+ route: DEFAULT_INIT_ROUTE_ID,
187
236
  },
237
+ bindings: connectorChoices.map((connectorChoice) => ({
238
+ id: `${connectorChoice.instanceId}.${DEFAULT_INIT_ROUTE_ID}`,
239
+ config: {
240
+ connector: connectorChoice.instanceId,
241
+ source: {
242
+ type: connectorChoice.bindingTemplate.sourceType,
243
+ id: connectorChoice.bindingTemplate.sourceId,
244
+ },
245
+ route: DEFAULT_INIT_ROUTE_ID,
246
+ },
247
+ })),
188
248
  };
189
249
  }
@@ -0,0 +1,108 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { readdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { findDobbyRepoRoot } from "../../shared/dobby-repo.js";
5
+
6
+ interface LocalExtensionPackage {
7
+ packageName: string;
8
+ packageDir: string;
9
+ }
10
+
11
+ function isExplicitInstallSpec(value: string): boolean {
12
+ return value.startsWith("file:")
13
+ || value.startsWith("git+")
14
+ || value.startsWith("http://")
15
+ || value.startsWith("https://")
16
+ || value.startsWith("./")
17
+ || value.startsWith("../")
18
+ || value.startsWith("/");
19
+ }
20
+
21
+ async function listRepoLocalExtensionPackages(repoRoot: string): Promise<Map<string, LocalExtensionPackage>> {
22
+ const pluginsRoot = resolve(repoRoot, "plugins");
23
+ const entries = await readdir(pluginsRoot, { withFileTypes: true });
24
+ const packages = new Map<string, LocalExtensionPackage>();
25
+
26
+ for (const entry of entries) {
27
+ if (!entry.isDirectory() || entry.name === "plugin-sdk") {
28
+ continue;
29
+ }
30
+
31
+ const packageDir = resolve(pluginsRoot, entry.name);
32
+ const packageJsonPath = resolve(packageDir, "package.json");
33
+ const manifestPath = resolve(packageDir, "dobby.manifest.json");
34
+
35
+ try {
36
+ await access(packageJsonPath);
37
+ await access(manifestPath);
38
+ const raw = await readFile(packageJsonPath, "utf-8");
39
+ const parsed = JSON.parse(raw) as { name?: unknown };
40
+ if (typeof parsed.name !== "string" || parsed.name.trim().length === 0) {
41
+ continue;
42
+ }
43
+
44
+ packages.set(parsed.name, {
45
+ packageName: parsed.name,
46
+ packageDir,
47
+ });
48
+ } catch {
49
+ continue;
50
+ }
51
+ }
52
+
53
+ return packages;
54
+ }
55
+
56
+ async function assertLocalExtensionBuildReady(localPackage: LocalExtensionPackage): Promise<void> {
57
+ const manifestPath = resolve(localPackage.packageDir, "dobby.manifest.json");
58
+ const rawManifest = await readFile(manifestPath, "utf-8");
59
+ const parsed = JSON.parse(rawManifest) as {
60
+ contributions?: Array<{ id?: unknown; entry?: unknown }>;
61
+ };
62
+
63
+ for (const contribution of parsed.contributions ?? []) {
64
+ if (typeof contribution.entry !== "string" || contribution.entry.trim().length === 0) {
65
+ continue;
66
+ }
67
+
68
+ const entryPath = resolve(localPackage.packageDir, contribution.entry);
69
+ try {
70
+ await access(entryPath);
71
+ } catch {
72
+ const contributionId = typeof contribution.id === "string" ? contribution.id : "unknown";
73
+ throw new Error(
74
+ `Local extension '${localPackage.packageName}' is not built for contribution '${contributionId}'. `
75
+ + `Missing '${entryPath}'. Run 'npm run build --prefix ${localPackage.packageDir}' first.`,
76
+ );
77
+ }
78
+ }
79
+ }
80
+
81
+ export async function resolveExtensionInstallSpecs(packageSpecs: string[], cwd = process.cwd()): Promise<string[]> {
82
+ const repoRoot = findDobbyRepoRoot(cwd);
83
+ if (!repoRoot) {
84
+ return packageSpecs;
85
+ }
86
+
87
+ const repoPackages = await listRepoLocalExtensionPackages(repoRoot);
88
+ const resolvedSpecs: string[] = [];
89
+
90
+ for (const rawSpec of packageSpecs) {
91
+ const packageSpec = rawSpec.trim();
92
+ if (packageSpec.length === 0 || isExplicitInstallSpec(packageSpec)) {
93
+ resolvedSpecs.push(packageSpec);
94
+ continue;
95
+ }
96
+
97
+ const localPackage = repoPackages.get(packageSpec);
98
+ if (!localPackage) {
99
+ resolvedSpecs.push(packageSpec);
100
+ continue;
101
+ }
102
+
103
+ await assertLocalExtensionBuildReady(localPackage);
104
+ resolvedSpecs.push(`file:${localPackage.packageDir}`);
105
+ }
106
+
107
+ return resolvedSpecs;
108
+ }
@@ -4,6 +4,7 @@ import {
4
4
  isCancel,
5
5
  multiselect,
6
6
  note,
7
+ password,
7
8
  select,
8
9
  text,
9
10
  } from "@clack/prompts";
@@ -95,13 +96,17 @@ function shouldPromptInMinimalMode(field: FieldPromptDescriptor): boolean {
95
96
  return true;
96
97
  }
97
98
 
98
- if (!field.hasDefault && field.existingValue === undefined) {
99
+ if (field.existingValue !== undefined) {
99
100
  return true;
100
101
  }
101
102
 
102
103
  return false;
103
104
  }
104
105
 
106
+ function isSensitiveStringField(key: string): boolean {
107
+ return /(token|secret|api[-_]?key)$/i.test(key);
108
+ }
109
+
105
110
  async function promptNumberField(params: {
106
111
  message: string;
107
112
  required: boolean;
@@ -306,6 +311,30 @@ async function promptFieldValue(params: {
306
311
  });
307
312
  }
308
313
 
314
+ if (isSensitiveStringField(key)) {
315
+ while (true) {
316
+ const result = await password({
317
+ message,
318
+ mask: "*",
319
+ });
320
+ if (isCancel(result)) {
321
+ cancel("Configuration cancelled.");
322
+ throw new Error("Configuration cancelled.");
323
+ }
324
+
325
+ const raw = String(result ?? "").trim();
326
+ if (raw.length === 0) {
327
+ if (required && existingValue === undefined) {
328
+ await note("This field is required.", "Validation");
329
+ continue;
330
+ }
331
+ return existingValue;
332
+ }
333
+
334
+ return raw;
335
+ }
336
+ }
337
+
309
338
  while (true) {
310
339
  const result = await text({
311
340
  message,
@@ -21,8 +21,8 @@ test("resolveConfigPath detects local dobby repository config path", async () =>
21
21
  await mkdir(resolve(repoRoot, "scripts"), { recursive: true });
22
22
  await mkdir(resolve(repoRoot, "src", "cli"), { recursive: true });
23
23
 
24
- await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
25
- await writeFile(resolve(repoRoot, "config", "gateway.json"), "{}", "utf-8");
24
+ await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
25
+ await writeFile(resolve(repoRoot, "config", "gateway.example.json"), "{}\n", "utf-8");
26
26
  await writeFile(resolve(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
27
27
 
28
28
  assert.equal(
@@ -38,8 +38,8 @@ test("resolveConfigPath prioritizes DOBBY_CONFIG_PATH over repository detection"
38
38
  const repoRoot = await mkdtemp(resolve(tmpdir(), "dobby-config-path-env-priority-"));
39
39
  await mkdir(resolve(repoRoot, "config"), { recursive: true });
40
40
  await mkdir(resolve(repoRoot, "scripts"), { recursive: true });
41
- await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
42
- await writeFile(resolve(repoRoot, "config", "gateway.json"), "{}", "utf-8");
41
+ await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
42
+ await writeFile(resolve(repoRoot, "config", "gateway.example.json"), "{}\n", "utf-8");
43
43
  await writeFile(resolve(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
44
44
 
45
45
  const customPath = resolve(tmpdir(), "dobby-custom-gateway.json");
@@ -77,7 +77,7 @@ test("resolveDataRootDir uses repo root for repo-local config/gateway.json", asy
77
77
  await mkdir(resolve(repoRoot, "config"), { recursive: true });
78
78
  await mkdir(resolve(repoRoot, "scripts"), { recursive: true });
79
79
 
80
- await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "dobby" }), "utf-8");
80
+ await writeFile(resolve(repoRoot, "package.json"), JSON.stringify({ name: "@dobby.ai/dobby" }), "utf-8");
81
81
  await writeFile(resolve(repoRoot, "config", "gateway.json"), "{}", "utf-8");
82
82
  await writeFile(resolve(repoRoot, "scripts", "local-extensions.mjs"), "#!/usr/bin/env node\n", "utf-8");
83
83
 
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert/strict";
2
+ import { access, mkdtemp, readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import pino from "pino";
7
+ import { mapDiscordMessage } from "../../../plugins/connector-discord/src/mapper.js";
8
+
9
+ function createMessage(overrides?: {
10
+ id?: string;
11
+ content?: string;
12
+ attachments?: Map<string, unknown>;
13
+ }): unknown {
14
+ return {
15
+ id: overrides?.id ?? "msg-1",
16
+ content: overrides?.content ?? "hello",
17
+ author: {
18
+ id: "user-1",
19
+ username: "alice",
20
+ bot: false,
21
+ },
22
+ attachments: overrides?.attachments ?? new Map(),
23
+ mentions: {
24
+ users: {
25
+ has: () => false,
26
+ },
27
+ },
28
+ guildId: "guild-1",
29
+ channelId: "channel-1",
30
+ channel: {
31
+ isThread: () => false,
32
+ },
33
+ createdTimestamp: 1_700_000_000_000,
34
+ toJSON: () => ({ ok: true }),
35
+ };
36
+ }
37
+
38
+ async function pathExists(path: string): Promise<boolean> {
39
+ try {
40
+ await access(path);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ const logger = pino({ enabled: false });
48
+
49
+ test("mapDiscordMessage does not create attachment directory when message has no attachments", async () => {
50
+ const root = await mkdtemp(join(tmpdir(), "dobby-discord-mapper-empty-"));
51
+ const message = createMessage();
52
+
53
+ const envelope = await mapDiscordMessage(
54
+ message as never,
55
+ "discord.main",
56
+ "bot-1",
57
+ "source-1",
58
+ root,
59
+ logger,
60
+ );
61
+
62
+ assert.ok(envelope);
63
+ assert.deepEqual(envelope.attachments, []);
64
+ assert.equal(await pathExists(join(root, "source-1", "msg-1")), false);
65
+ });
66
+
67
+ test("mapDiscordMessage only creates attachment directory when a download succeeds", async (t) => {
68
+ t.mock.method(globalThis, "fetch", async () => new Response("file-body", { status: 200 }));
69
+
70
+ const root = await mkdtemp(join(tmpdir(), "dobby-discord-mapper-file-"));
71
+ const message = createMessage({
72
+ attachments: new Map([
73
+ ["att-1", {
74
+ id: "att-1",
75
+ name: "hello.png",
76
+ contentType: "image/png",
77
+ size: 9,
78
+ url: "https://example.test/hello.png",
79
+ }],
80
+ ]),
81
+ });
82
+
83
+ const envelope = await mapDiscordMessage(
84
+ message as never,
85
+ "discord.main",
86
+ "bot-1",
87
+ "source-1",
88
+ root,
89
+ logger,
90
+ );
91
+
92
+ assert.ok(envelope);
93
+ assert.equal(envelope.attachments.length, 1);
94
+ assert.equal(await pathExists(join(root, "source-1", "msg-1")), true);
95
+ assert.equal(envelope.attachments[0]?.localPath, join(root, "source-1", "msg-1", "hello.png"));
96
+ assert.equal(await readFile(join(root, "source-1", "msg-1", "hello.png"), "utf-8"), "file-body");
97
+ });
98
+
99
+ test("mapDiscordMessage does not leave an empty attachment directory when download fails", async (t) => {
100
+ t.mock.method(globalThis, "fetch", async () => new Response("nope", { status: 500 }));
101
+
102
+ const root = await mkdtemp(join(tmpdir(), "dobby-discord-mapper-fail-"));
103
+ const message = createMessage({
104
+ attachments: new Map([
105
+ ["att-1", {
106
+ id: "att-1",
107
+ name: "broken.png",
108
+ contentType: "image/png",
109
+ size: 9,
110
+ url: "https://example.test/broken.png",
111
+ }],
112
+ ]),
113
+ });
114
+
115
+ const envelope = await mapDiscordMessage(
116
+ message as never,
117
+ "discord.main",
118
+ "bot-1",
119
+ "source-1",
120
+ root,
121
+ logger,
122
+ );
123
+
124
+ assert.ok(envelope);
125
+ assert.equal(envelope.attachments.length, 1);
126
+ assert.equal(envelope.attachments[0]?.localPath, undefined);
127
+ assert.equal(await pathExists(join(root, "source-1", "msg-1")), false);
128
+ });